Skip to content

Watch admin static_root and serve assets via dev server WebSocket#7152

Open
melissaluu wants to merge 4 commits intomainfrom
ml-add-staticRoot-ext-websocket
Open

Watch admin static_root and serve assets via dev server WebSocket#7152
melissaluu wants to merge 4 commits intomainfrom
ml-add-staticRoot-ext-websocket

Conversation

@melissaluu
Copy link
Copy Markdown

Summary

  • Adds app-level asset directory watching and serving to the UI extensions dev server
  • When an admin extension has static_root configured in its TOML, the dev server watches that directory for file changes, serves its contents via HTTP, and broadcasts timestamp updates to all WebSocket clients
  • Introduces a generic app.assets field on the WebSocket payload, keyed by asset type (currently staticRoot), each with a url and lastUpdated timestamp

How it works

Payload model (models.ts, ui-extensions-server-kit/types.ts): Added optional app.assets to both server-side and client-side types — a Record<string, { url, lastUpdated }>.

Payload store (store.ts): Accepts appAssets in options to populate app.assets on init. Exposes updateAppAssetTimestamp(key) to bump a single asset's timestamp and emit an update, and updateAppAssets(appAssets, url) to fully rebuild or remove the assets map (used on app reload).

HTTP serving (middlewares.ts, server.ts): Parameterized route at /extensions/assets/:assetKey/**:filePath serves files from the resolved directory, using the existing fileServerMiddleware helper.

File watching (extension.ts): Creates a dedicated chokidar watcher per asset directory with debounced updates. Watchers and debounce timers are cleaned up on abort and restarted on app reload when the admin extension config changes.

Plumbing (previewable-extension.ts): Resolves admin extension's static_root into the appAssets map at process setup time via shared resolveAppAssets() helper.

End-to-end flow

File changed in admin static_root directory
  → chokidar watcher (debounced)
  → payloadStore.updateAppAssetTimestamp("staticRoot")
  → emits Update event (extensionIds=[])
  → WebSocket broadcasts { app: { assets: { staticRoot: { url, lastUpdated } } }, extensions: [] }
  → clients receive updated timestamp

Test plan

  • extension.test.tsresolveAppAssets() returns correct map for admin extensions with/without static_root; appAssets passed through to HTTP server
  • store.test.tsupdateAppAssetTimestamp updates timestamp and emits; updateAppAssets rebuilds or removes assets; raw payload populates app.assets when configured
  • middlewares.test.tsgetAppAssetsMiddleware serves files for valid keys, returns 404 for unknown keys

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 1, 2026 23:02
@melissaluu melissaluu requested a review from a team as a code owner April 1, 2026 23:02
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

We detected some changes at packages/*/src and there are no updates in the .changeset.
If the changes are user-facing, run pnpm changeset add to track your changes and include them in the next release CHANGELOG.

Caution

DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds support to the UI extensions dev server for app-level “asset directories” (starting with admin extension static_root), including HTTP serving and WebSocket payload updates so clients can invalidate/reload assets when files change.

Changes:

  • Extend dev server WebSocket payload/types with optional app.assets (keyed map of {url, lastUpdated}).
  • Serve app-level assets from /extensions/assets/:assetKey/**:filePath when configured.
  • Watch configured asset directories and broadcast lastUpdated timestamp bumps on changes; re-resolve assets on app reload.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
packages/ui-extensions-server-kit/src/types.ts Adds App.assets to the shared server-kit type surface.
packages/app/src/cli/services/dev/processes/previewable-extension.ts Resolves and passes appAssets into the dev server process options.
packages/app/src/cli/services/dev/extension/server/middlewares.ts Introduces getAppAssetsMiddleware for serving app-level assets.
packages/app/src/cli/services/dev/extension/server/middlewares.test.ts Adds middleware tests for serving app assets + unknown key 404.
packages/app/src/cli/services/dev/extension/server.ts Wires the new assets route into the HTTP server when appAssets is present.
packages/app/src/cli/services/dev/extension/payload/store.ts Adds appAssets to payload initialization and new store update APIs.
packages/app/src/cli/services/dev/extension/payload/store.test.ts Adds unit tests for app asset timestamp updates + payload initialization behavior.
packages/app/src/cli/services/dev/extension/payload/models.ts Adds app.assets to the server-side payload model interface.
packages/app/src/cli/services/dev/extension.ts Implements resolveAppAssets, chokidar watchers, and app reload plumbing.
packages/app/src/cli/services/dev/extension.test.ts Adds tests for resolveAppAssets and passing appAssets through to HTTP server setup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +45 to +54
if (options.appAssets) {
const assets: Record<string, {url: string; lastUpdated: number}> = {}
for (const assetKey of Object.keys(options.appAssets)) {
assets[assetKey] = {
url: new URL(`/extensions/assets/${assetKey}/`, options.url).toString(),
lastUpdated: Date.now(),
}
}
payload.app.assets = assets
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

getExtensionsPayloadStoreRawPayload sets payload.app.assets whenever options.appAssets is truthy, even if it’s an empty object. That produces app.assets = {} whereas updateAppAssets treats an empty map as “no assets” and deletes the field. Consider aligning the behavior by checking Object.keys(options.appAssets).length > 0 before setting payload.app.assets.

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +145
const {assetKey = '', filePath = ''} = getRouterParams(event)

const directory = appAssets[assetKey]

if (!directory) {
return sendError(event, {statusCode: 404, statusMessage: `No app assets configured for key: ${assetKey}`})
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

const directory = appAssets[assetKey] reads a URL-controlled key from a plain object. Keys like __proto__/constructor can resolve to inherited properties (non-string values) and bypass the !directory guard. Use an own-property check (e.g., Object.hasOwn(appAssets, assetKey)) and/or construct appAssets with a null prototype to avoid prototype-chain lookups.

Copilot uses AI. Check for mistakes.
… leak

- Changed appAssets from a captured reference to a getAppAssets() callback
  in setupHTTPServer and getAppAssetsMiddleware, so the middleware always
  reads fresh asset config after app reloads
- Replaced unbounded debounceTimers array with a Map<string, setTimeout>
  keyed by assetKey, so each key has at most one timer entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

2 participants