Skip to content

feat: serve asset bundles in preview through an abgen sidecar - #1498

Closed
eordano wants to merge 33 commits into
mainfrom
feat/abgen-preview
Closed

feat: serve asset bundles in preview through an abgen sidecar#1498
eordano wants to merge 33 commits into
mainfrom
feat/abgen-preview

Conversation

@eordano

@eordano eordano commented Jul 22, 2026

Copy link
Copy Markdown
Member

What

sdk-commands start --asset-bundles runs an abgen sidecar: an ab-cdn-compatible server that JIT-converts the scene being previewed into Unity asset bundles, reading it through the preview's own /content endpoints. The CLI prints the sidecar URL and adds optimized-assets-url to the Desktop Explorer deep link. Off by default — the flag opts in.

  • Binary resolution: $ABGEN_BIN → cached copy of the pinned release (v0.11.3) → abgen on the PATH → only then a one-time, sha256-verified download → graceful degradation with a warning.
  • Converted bundles persist in .dcl-optimized-assets/ next to scene.json (inside the default dcl-ignore).
  • Preview content ids switch to base64url so they stay safe in URL paths and asset-bundle directory names.

Review findings addressed since #1448

  • exec() double settlement: added a settled guard so error + close can't both settle the promise.
  • Concurrent-download race: releases extract into a per-call staging dir and publish via atomic rename; a losing resolver detects the winner and proceeds.
  • tar hardening: --no-absolute-names was suggested but is rejected by both GNU tar 1.35 and bsdtar 3.8.8 (verified locally — it would break extraction everywhere). Both tars already strip absolute paths and refuse .. members unless -P is passed; documented in place.
  • Round-trip coverage: extracted b64UrlHashDecodingFunction (now used by the /content/contents/:hash route) with encode→decode round-trip tests covering base64url-specific characters (-, _) and unicode paths.
  • Weak negative assertions: expect.arrayContaining([expect.not.stringContaining(…)])expect.not.arrayContaining([expect.stringContaining(…)]) in explorer-alpha.spec.ts (all three occurrences).
  • Lint: fixed every outstanding lint finding across packages (repo lint is now fully clean).

Cross-component P1s (companion PRs)

The base64url switch needs consumers to accept -/_ in b64- hashes:

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 22, 2026

Copy link
Copy Markdown

Deploying js-sdk-toolchain with  Cloudflare Pages  Cloudflare Pages

Latest commit: b197017
Status: ✅  Deploy successful!
Preview URL: https://03d467f4.js-sdk-toolchain.pages.dev
Branch Preview URL: https://feat-abgen-preview.js-sdk-toolchain.pages.dev

View logs

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Test this pull request

  • The @dcl/sdk package can be tested in scenes by running

    npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/abgen-preview/dcl-sdk-7.25.1-30937970686.commit-d7e18c3.tgz"
  • The @dcl/js-runtime package can be tested in scenes by running

    npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/abgen-preview/@dcl/js-runtime/dcl-js-runtime-7.25.1-30937970686.commit-d7e18c3.tgz"
  • To test with npx init

    export SDK_COMMANDS="https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/abgen-preview/dcl-sdk-commands-7.25.1-30937970686.commit-d7e18c3.tgz"
    npx $SDK_COMMANDS init
  • The /changerealm command to test test in-world

    /changerealm https://sdk-team-cdn.decentraland.org/ipfs/feat/abgen-preview-e2e
    
  • You can preview this build entering:
    https://playground.decentraland.org/?sdk-branch=feat/abgen-preview

`sdk-commands start --asset-bundles` runs an abgen sidecar: an
ab-cdn-compatible server that JIT-converts the scene into Unity asset
bundles, reading it through the preview's own /content endpoints. The CLI
prints the sidecar URL and adds `optimized-assets-url` to the Desktop
Explorer deep link. Off by default.

The sidecar binary resolves from ABGEN_BIN, then a cached copy of the
pinned release (v0.11.3), then abgen on the PATH; only when none exist is
the release downloaded (sha256-verified). When everything fails the preview
keeps working with a warning. Releases extract into a staging dir and
publish via atomic rename so concurrent resolvers never race. Converted
bundles land in .dcl-optimized-assets/ next to scene.json, durable across
preview restarts and inside the default dcl-ignore.

The preview content server gets base64url id helpers (encode + decode, with
round-trip tests) so its ids stay safe inside URL paths and asset-bundle
directory names. exec() learns shell-less spawning with single-settlement
error handling, so a missing binary fails fast instead of hanging the promise.

Also fixes all outstanding lint findings across packages and inverted
negative assertions in explorer-alpha.spec.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eordano
eordano force-pushed the feat/abgen-preview branch from 707a353 to 8a4ebd0 Compare July 22, 2026 16:03
…for remote entities, host-platform builds, local-ab deeplink flag

Four defaults tuned from end-to-end testing against a client with
local-scene bundle support (unity-explorer#9459) and the Creator Hub
toggle (creator-hub#1396); every one remains overridable via env:

- Prefer port 5147 (abgen's default and the Explorer's default
  optimized-assets-url): a connected Unity Editor finds the sidecar with
  no configuration; scans upward when taken.
- Remote entities (wearables/emotes) stream prebuilt from the production
  ab-cdn (ABGEN_UPSTREAM_AB_CDN) instead of being converted locally per
  scene: the worlds content fallback is set to 'off', so only the
  previewed scene is ever built and .dcl-optimized-assets contains only
  scene artifacts. NOTE: needs the abgen release carrying
  decentraland/abgen#20 (upstream lane); with the current v0.11.3 pin,
  wearable requests would 404 through the sidecar.
- Eager registry builds restricted to the host platform
  (ABGEN_INDEX_BUILD_PLATFORMS): a local sidecar never serves the other
  platform, halving wearable/emote index work.
- Deeplink gains local-ab=true next to optimized-assets-url: the flag
  that makes the scene itself load bundles in local-scene mode
  (unity-explorer#9459); the url alone only re-bases wearable/registry
  traffic.
dalkia added a commit to decentraland/creator-hub that referenced this pull request Jul 22, 2026
…les)

sdk-commands now owns the abgen sidecar (decentraland/js-sdk-toolchain#1498):
it resolves/downloads the binary, boots it against its own preview server,
and injects local-ab + optimized-assets-url into the deeplink it fires. The
hub's job shrinks to mapping the "Optimized Assets" toggle to the opt-in
--asset-bundles flag (feature-detected in the scene's installed sdk-commands,
skipped with a warning when unsupported).

Deleted: the hub-owned sidecar (abgen.ts), the <userData>/abgen binary
location, the preview-port pre-picking, and the deeplink re-fire after
capture. Option changes on a running preview now only flip the local-ab
param (and strip the url when toggled off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dalkia added 6 commits July 22, 2026 14:59
Carries decentraland/abgen#20: content revalidation (auto-on for loopback
content servers), upstream ab-cdn read-through, and bounded storage names
(fixes silently dropped assets on macOS creator-hub scene paths, which
overflowed NAME_MAX with v0.11.3). Completes the CDN-passthrough default
from the previous commit.
The sidecar writes into .dcl-optimized-assets on every manifest request
(content revalidation), and chokidar tests absolute paths against the
ignore patterns, so the bare '.*' entry never prunes the directory. A
watched write there loops forever: reload → manifest request →
revalidation write → SCENE_UPDATE → reload. Reproduced on a 70-parcel
scene with a connected client; zero reload messages after the fix.
The sidecar-cache test replaced the existing *.map coverage instead of
extending it; this brings back the original three tests alongside it.
The explorer re-bases its ab-cdn registry POSTs (/entities/active) to the
sidecar, which resolves the wearable/emote pointers through the preview
server (it proxies unknown pointers to a real catalyst) and then eagerly
tries to JIT-build each one — but the preview server only serves the local
scene's files, so every build fails with a write-back 404. Those entities
stream prebuilt from the production CDN via the upstream read-through, so
the eager lane can never do useful work here; turn it off. The previewed
scene still converts on its manifest request.
Brings the upstream-first dispatch (no doomed JIT probes for remote
entities) and the asset-bundle registry pass-through (registry records
answer with the versions the production CDN actually serves, so
wearables and emotes load instead of 404ing on fabricated URLs).
dalkia added 7 commits July 23, 2026 09:15
A repo-wide prettier run swept pre-existing formatting drift on main
into the base commit; restored to main's state so the diff is only the
feature.
First-time conversion of a large scene could outlive the explorer's
manifest request timeout, degrading the first preview to raw GLTFs
until a re-run. The sidecar holds a manifest request open until the
conversion finishes, so start now awaits one — with a progress
heartbeat — before printing the deeplink: the explorer's own manifest
fetch is always a cache hit. Conversions persist in
.dcl-optimized-assets, so only the first run of a scene pays the wait
(Genesis Plaza central-plaza: 91s cold, 3s warm).
The sidecar's per-file ABGEN_BUILD telemetry and INFO tracing drowned
the progress lines: the sidecar now runs silent with warn-level logging
by default, and the heartbeat prints through the styled progress
logger. Exporting RUST_LOG restores the full sidecar output.
… sidecar dies

A single held manifest request can die a transport death minutes into a
long conversion, so the pre-warm now retries until its deadline — the
conversion keeps running server-side and a retried request attaches to
the in-flight build or hits the finished cache. If the sidecar itself
is gone (process exited or readyz unreachable) retrying is pointless:
warn and let the explorer take the raw-GLTF degrade instead of burning
the full 15-minute budget against a dead port.
silent: true left the child's stdout/stderr pipes unread, so a chatty
child (the abgen sidecar prints a telemetry line per converted file)
filled the ~64KB pipe buffer and blocked on its next write — the
conversion wedged at 0% CPU with readyz still answering. Resume the
streams so silent output is discarded instead of accumulating.
Polls the sidecar's /progress/{entity} route and prints
'[n/total] converting <file>' instead of a blind elapsed-time
heartbeat; sidecars without the route fall back to the old heartbeat.
runExplorerAlpha opened the client unconditionally, so a wrapper that
spawns start --no-browser to pre-convert in the background (Creator
Hub's warmup) had the explorer pop open on its own the moment
conversion finished. The deeplink line still prints — wrappers rely on
capturing it.
@dalkia

dalkia commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Pushed the first-load-timeout work discussed in review, plus fixes it surfaced:

  • 730f4825 / 9657c221 — pre-conversion on start: --asset-bundles now converts the scene before printing the deeplink (the sidecar holds a manifest request until conversion completes, so this is the corpus build over HTTP — no abgen-corpus needed). Explorer's manifest fetch is always warm; the first-load timeout is gone. Dropped connections retry and re-attach to the in-flight build; a dead sidecar bails to the raw-GLTF degrade instead of burning the 15-minute budget. Measured on Genesis Plaza central-plaza (1009 files): 91s cold, 3s warm.
  • 99361e9c / 46e1fa59 — readable output: the sidecar now runs quiet by default so its per-file telemetry doesn't drown the progress lines (exporting RUST_LOG restores full sidecar logs). Silencing exposed a latent exec bug: silent children's pipes were never drained, so a chatty child filled the 64KB pipe buffer and blocked at 0% CPU (readyz still green). exec now drains silent pipes; regression-tested.
  • e3ed9c8d — per-asset progress: polls the sidecar's /progress/{entity} route (serve: live per-entity build progress at /progress/{entity} abgen#25) and prints [340/1008] converting <file> instead of a blind heartbeat; sidecars without the route fall back gracefully.
  • 257aca2c / c7f7e5c1 — hub sessions never self-open the client: Creator Hub always captures the printed Desktop client: deeplink, adjusts it, and fires it itself, so the sdk's own launch under --hub was at best a double open with unadjusted params — and with CH's background conversions (feat: optimized assets preview option (local asset bundles via abgen) creator-hub#1396) it opened the explorer nobody asked for. Gated on --hub (first attempted via --no-browser, reworked in review since that flag should keep meaning the web browser); the deeplink line still prints, non-hub CLI behavior unchanged.

dalkia added 4 commits July 23, 2026 16:16
…browser gate)

Creator Hub always captures the printed deeplink, adjusts it, and fires
it itself — the sdk self-launch under --hub was at best a double open
with unadjusted params, at worst an explorer popping up during a
background conversion. Gating on --hub keeps --no-browser meaning what
it says (the web browser) and needs no new flag.
Brings the /progress/{entity} route, so the pre-conversion progress
lines ([n/total] converting <file>) now come from the pinned release
rather than only from ABGEN_BIN builds.
Lookups are exact on the pinned version, so old release dirs are dead
weight (~35MB each) that accumulated forever; they are removed once the
pinned one resolves. Staging dirs are left alone (a concurrent resolver
may be mid-extract), and pruning failures never affect the resolve.
The pruning spec hardcoded the darwin-arm64 dist as the kept entry, so
on CI's linux-x64 it counted as superseded and the assertion failed.
Brings the precise xn- prune: hot-reload edits reconvert only the
changed assets instead of sweeping every collapsed-name entry (half the
corpus on scenes with deep paths).

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: feat: serve asset bundles in preview through an abgen sidecar

Verdict: Approve — No P0 or P1 issues found. Well-constructed PR with strong security posture and thorough error handling.

Summary

This PR adds an opt-in --asset-bundles flag to sdk-commands start that spawns an abgen sidecar for JIT asset-bundle conversion during preview. The implementation is clean: sha256-verified binary downloads, shell: false execution, localhost-only binding, atomic staging for concurrent safety, graceful degradation at every failure point, and a prewarm step that eliminates first-load timeouts.

CI Status

All 8 checks pass (lint, build, test, docs, CLI E2E, deployment notification, dev dep check, Cloudflare Pages).

Security Review

No security issues found. Positive highlights:

  • sha256 pinning against hardcoded per-platform hashes before extraction
  • shell: false for all new spawns — eliminates shell injection; args array is empty []
  • tar extraction without -P — both GNU and BSD tar refuse absolute/.. paths by default
  • 127.0.0.1 binding — sidecar is not network-reachable
  • Path traversal guard on /content/contents/:hash via path.resolve() + startsWith(project.workingDirectory)
  • No secrets or credentials in any changed file

Consumer Impact

The base64url encoding switch affects content IDs served by the preview server. Since the same process encodes and decodes, the preview server is self-consistent. External consumers (explorer clients) need to accept -/_ in b64- hashes — companion PRs are documented and in progress:

b64HashingFunction (standard base64) is intentionally retained — it's still used for analytics projectHash in 5 commands (start, deploy, build, pack-smart-wearable, export-static).

Git Conventions

PR title (feat: serve asset bundles in preview through an abgen sidecar) and branch name (feat/abgen-preview) follow ADR-6 semantic commit format.

Findings (P2 only — none blocking)

[P2] Heartbeat async IIFE missing .catch()asset-bundles.ts:129
The void (async () => { ... })() inside setInterval suppresses the lint rule but doesn't handle errors. While fetchBuildProgress has internal try/catch, the printProgressStep/printProgressInfo calls are unguarded. If either threw, it would become an unhandled rejection. Consider appending .catch(() => {}).

[P2] close event code typed as number instead of number | nullexec.ts:59
Node.js close event signature is (code: number | null, signal: string | null). When a child is killed by signal, code is null, producing the message "exited with code null". The rejection behavior is correct, just the message is confusing. Pre-existing pattern made more relevant by the new shell: false codepath.

[P2] Test coverage gaps in error/recovery paths
The new tests establish good baseline coverage for the resolve-order, sidecar wiring, and b64url round-trip. Paths with missing coverage (all non-blocking — the code itself is correct):

  • exec.ts double-settlement guard (no test fires both error + close)
  • downloadRelease happy path (sha256-matched archive → extract → rename)
  • Prewarm retry loop (transport failure, sidecar death, timeout, non-zero exitCode)
  • asset-bundles.spec.ts:74 — weak expect(logger.warn).toHaveBeenCalled() assertion would pass from the exec .catch warn alone

[P2] ABGEN_BIN env var bypasses sha256 verificationabgen-binary.ts:61
By design for developer override. The threat model (local workstation) makes this acceptable. A log line when the override is active would be a nice-to-have.


Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni [Dalkia] (<@U03JSUQ5Z7U>) via Slack

@nicoecheza

Copy link
Copy Markdown
Member

Review

Reviewed together with decentraland/creator-hub#1396. This half is in good shape: pinned + sha256-verified download (checked before anything touches disk, with a test asserting no write/extract on mismatch), staged extract with atomic rename and loser-detection, shell-less spawn, loopback-only bind. The exec() fixes are real bug fixes beyond this feature — the silent-pipe drain (64KB backpressure deadlock) and the settled guard — both tested. The base64url switch is decode-back-compatible: Node's base64url decoder accepts standard base64 (+, /, padding), verified empirically, so old-style b64- hashes still resolve in /content/contents/:hash. Leaving the analytics/export-static b64HashingFunction callers untouched is the right call.

P1 — old hub + new sdk: first Preview press opens nothing

The isHub no-self-open change assumes the hub fires the deeplink itself — but shipped Creator Hub versions only fire it on the reuse path, never after a fresh spawn (they relied on sdk-commands self-opening). A scene that bumps to this sdk while the user runs an older hub: first Preview press boots the server and no client opens; only the second press works. Hub auto-update narrows the window, but this should be a conscious accept — or key the no-self-open on a new hub-passed flag instead of isHub (--no-client doesn't work as that marker since it also suppresses the deeplink print the hub needs).

(The mirror case — new hub + old sdk double-launch — is flagged on the hub PR.)

P2 — orphaned sidecar on ready-timeout

If /readyz doesn't answer within 15s, runAssetBundlesSidecar warns and returns undefined, but the spawned abgen keeps running — converting and holding the port until CLI exit — and exec() returns no handle to kill it. Slow first boots (e.g. Windows Defender scanning a freshly downloaded binary) hit exactly this. Consider a kill handle on exec for shell: false, or a longer timeout.

P3 — pruneStaleReleases vs concurrent versions

Two scenes pinning different sdk-commands (hence different abgen pins) previewing concurrently: each resolve prunes the other's release dir, yanking template//shader/ assets out from under a running sidecar (POSIX: binary survives, assets vanish). Rare but confusing when it hits; an mtime guard (don't prune recently-touched dirs) would cover it.

Nit — cross-repo log-format contract

Creator Hub parses printProgressStep's [n/total] <log> shape and the literal still converting... (Ns) string for its progress UI. The hub side documents the coupling; this side doesn't. A one-line comment at the heartbeat/printProgressStep call sites would prevent an innocent reformat from silently degrading hub progress (it fails open, but silently).

Verdict

Approve with the above as follow-ups; only the version-skew point needs an explicit decision before merge.

…he --no-browser gate)"

This reverts commit c7f7e5c.

Product call: Creator Hub drops the background (toggle-time) asset
conversion — conversion now runs inline with the Preview press, so
there is no longer a deeplink to hold and the hub no longer fires it
on fresh spawns. The sdk self-opening the client at the end of start
is again the single launch path on every version, which also removes
the version-skew double-open between old/new sdk-commands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dalkia dalkia self-assigned this Jul 28, 2026
This reverts commit 257aca2.

The gate existed solely so Creator Hub's background warmup could
pre-convert with the client held closed; the hub dropped that flow
(conversion now runs inline with the Preview press) and no longer
passes --no-browser to start. Dropping the gate returns runApp's
launch path to exactly main's behavior — the PR's delta is now just
the sidecar — and --no-browser goes back to meaning what it documents
(the web browser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@nicoecheza nicoecheza left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at b402ade2.

The two reverts (4d270edf, b402ade2) resolve the version-skew P1: runApp self-opens the client unconditionally again, so this PR no longer changes who launches the explorer for any hub or non-hub session. Combined with creator-hub#1396 dropping its fresh-spawn dclDeepLink fire, the launch contract is now exactly what shipped before — no double-launch, no first-press-opens-nothing, in either direction of version skew. That was the only blocker.

Everything else I checked before still holds up: sha256 verified before anything touches disk, staged extract + atomic rename, shell-less spawn, loopback bind, the exec() pipe-drain and settled fixes, and base64url decode staying back-compatible with existing b64- hashes.

The three follow-ups from my earlier pass are unchanged and still worth doing at some point, none blocking:

  • Orphaned sidecar on ready-timeout — after the 15s /readyz deadline, runAssetBundlesSidecar warns and returns undefined, but the spawned abgen keeps converting and holding its port until CLI exit, since exec() hands back no kill handle. A slow first boot (Windows Defender scanning a freshly downloaded binary) is the realistic trigger.
  • pruneStaleReleases vs concurrent versions — two scenes on different sdk-commands pins previewing at once will each prune the other's release dir, pulling template//shader/ out from under a running sidecar. An mtime guard would cover it.
  • Log-format coupling — Creator Hub parses printProgressStep's [n/total] <log> shape and the literal still converting... (Ns) string. A comment at those call sites would stop an innocent reformat from silently degrading hub progress.

Approving.

dalkia and others added 8 commits July 31, 2026 10:04
… lane

Explorer requests v49+ scene bundles by their digest-bearing file name under
the CDN's shared {version}/assets/ prefix (unity-explorer#9442). abgen serves
those files through its flat /assets/{file} lane (bundle-index lookup) but has
no version-prefixed route, so the proxy strips the version segment on the way
through. Drop once abgen serves GET /{version}/assets/{file} natively.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…IT lane

The flat /assets/{file} lane only serves bundles already in the sidecar's
index: anything rebuilt or not yet built after a scene edit 404s, since only
the legacy {version}/{entity}/{file} lane resolves digests tolerantly and
JIT-builds on miss. Rewrite there instead, deriving the entity id the same
way the preview content server does and dropping the deps digest from the
file name (bare b64 hashes never contain '_').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lane

The per-entity lane resolves the exact digest-bearing name straight from
disk (immune to the stale boot-time bundle index that broke the flat
/assets/ rewrite) and JIT-builds on miss — so only the entity segment
needs rewriting. Preserving the digest keeps the served bundle exactly
the one the manifest advertised instead of whatever same-hash build is
on disk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dalkia

dalkia commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Deprecated in favour of decentraland/unity-explorer#9704

@dalkia dalkia closed this Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants