From ce84953e462aed211e4fcd7bc18a367f1d2da9c7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:28:24 +0800 Subject: [PATCH] feat(client): serve plugin package assets by URL instead of data URLs --- apps/web/tests/desktop-onboarding.e2e.ts | 25 +- apps/web/tests/reasoning-preview.e2e.ts | 10 +- docs/subsystems/client-modules.md | 12 +- docs/subsystems/client-modules.zh.md | 12 +- docs/web-styling.md | 1 + packages/client/AGENTS.md | 5 +- packages/client/modules/README.md | 7 + packages/client/modules/src/assets.ts | 83 ++++ packages/client/modules/src/index.ts | 115 ++++- .../modules/tests/assets.client.spec.ts | 84 ++++ .../modules/tests/node-half.client.spec.ts | 2 +- .../tests/package-assets.client.spec.ts | 272 +++++++++++ packages/client/tsdown.client.ts | 231 ++++++++-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/package.json | 2 + .../{chat => assets}/running-whale@2x.png | Bin .../src/client/chat/ChatView.module.css | 2 +- .../tests/running-whale-tail.client.spec.tsx | 2 +- packages/client/ui-chat/tsdown.config.ts | 21 +- packages/client/ui-settings-account/README.md | 2 +- .../client/ui-settings-account/package.json | 2 + .../src/client/assets.d.ts | 9 - .../extensions/tool-cordis/src/api-catalog.ts | 6 +- scripts/check-workspace-constraints.spec.ts | 61 +++ scripts/check-workspace-constraints.ts | 11 +- scripts/client-asset-types.spec.ts | 32 ++ scripts/client-bundle-css.spec.ts | 434 ++++++++++++++---- scripts/types/client-assets/index.d.ts | 30 ++ scripts/verify-client-packages.spec.ts | 163 +++++++ scripts/verify-client-packages.ts | 210 ++++++++- scripts/verify-package-dependencies.spec.ts | 30 ++ scripts/verify-package-dependencies.ts | 9 +- tsconfig.base.client.json | 5 +- 33 files changed, 1678 insertions(+), 214 deletions(-) create mode 100644 packages/client/modules/src/assets.ts create mode 100644 packages/client/modules/tests/assets.client.spec.ts create mode 100644 packages/client/modules/tests/package-assets.client.spec.ts rename packages/client/ui-chat/src/client/{chat => assets}/running-whale@2x.png (100%) delete mode 100644 packages/client/ui-settings-account/src/client/assets.d.ts create mode 100644 scripts/client-asset-types.spec.ts create mode 100644 scripts/types/client-assets/index.d.ts diff --git a/apps/web/tests/desktop-onboarding.e2e.ts b/apps/web/tests/desktop-onboarding.e2e.ts index 8148ca3b00..24c3ada01e 100644 --- a/apps/web/tests/desktop-onboarding.e2e.ts +++ b/apps/web/tests/desktop-onboarding.e2e.ts @@ -19,6 +19,7 @@ const MODE = webSnapshotMode() const EXPECTED = fileURLToPath(new URL('./expected/desktop-onboarding', import.meta.url)) const NS = 'ui-settings-account' const INITIAL = { version: 1, step: 'welcome', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false } +const ACCOUNT_ASSET = /\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-[a-z-]+-[0-9a-f]{12}\.(?:svg|webp)$/ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => { let root: string @@ -124,15 +125,18 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => async function snapshot(name: string): Promise { await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false') const hasIllustration = name.startsWith('welcome') || name.startsWith('credit') - if (hasIllustration) { - const decoded = await page.locator('[class*="illustration"] > img').evaluateAll(async elements => - Promise.all(elements.map(async (element) => { - const image = element as HTMLImageElement - await image.decode() - return image.naturalWidth > 0 && image.naturalHeight > 0 - }))) - expect(decoded).toEqual([true, true]) - } + // Illustrations and card icons are package assets fetched after the step renders. + const images = await page.locator('[data-desktop-onboarding] img').evaluateAll(async elements => + Promise.all(elements.map(async (element) => { + const image = element as HTMLImageElement + await image.decode() + return { + source: image.currentSrc, illustration: image.matches('[class*="illustration"] > img'), + loaded: image.naturalWidth > 0 && image.naturalHeight > 0, + } + }))) + expect(images.filter(image => !image.loaded || !ACCOUNT_ASSET.test(image.source)), `${name}: images outside loaded package assets`).toEqual([]) + expect(images.filter(image => image.illustration), `${name}: illustration images`).toHaveLength(hasIllustration ? 2 : 0) const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd) await mkdir(EXPECTED, { recursive: true }) await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE) @@ -424,7 +428,8 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => expect(await page.getByRole('button', { name: 'Next', exact: true }) .evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(72, 73, 76)') await snapshot('credit-en-dark') - expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage)).toContain('data:image/svg+xml') + expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage)) + .toMatch(/^url\("[^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-back-[0-9a-f]{12}\.svg"\)$/) await page.getByRole('button', { name: 'Next', exact: true }).click() await page.getByRole('button', { name: 'Got it', exact: true }).click() await page.getByRole('checkbox', { name: 'Coding & development', exact: true }).locator('..').click() diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts index 15fa2a0988..7f7a34c8e7 100644 --- a/apps/web/tests/reasoning-preview.e2e.ts +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -12,6 +12,8 @@ import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeCo const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}` const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail'] const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url)) +/** Computed mask of the animated whale: the ui-chat package asset URL, whose `@` is percent-encoded. */ +const WHALE_MASK = /^url\("([^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-chat\/assets\/running-whale%402x-[0-9a-f]{12}\.png)"\)$/ class PausedReasoningAdapter extends LlmAdapter { override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] } @@ -96,14 +98,14 @@ it('shows completed paragraph first lines across blank lines with a right-edge f await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' }) await expect.poll(() => animatedWhale.isVisible()).toBe(true) expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha') - const imageSize = await animatedWhale.evaluate(async (element) => { - const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1] - if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL') + const imageSize = await animatedWhale.evaluate(async (element, mask) => { + const source = getComputedStyle(element).maskImage.match(new RegExp(mask))?.[1] + if (source === undefined) throw new Error('Running whale mask must use the ui-chat package asset URL') const image = new Image() image.src = source await image.decode() return [image.naturalWidth, image.naturalHeight] - }) + }, WHALE_MASK.source) expect(imageSize).toEqual([28, 28]) expect(await restingWhale.isVisible()).toBe(false) const firstWhaleFrame = await whale.screenshot({ animations: 'allow' }) diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index 0f9bb5b068..6a856d60e4 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.clientModules` — `ClientModuleRegistry` -The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). +The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). ```ts cordis-catalog /** @@ -150,10 +150,12 @@ graph(): WebBootGraph clientPath(id: string): string | undefined /** - * Serve an advertised revisioned bundle or source map without a Web server. - * Unknown URLs return 404, unsupported methods return 405, and `HEAD` - * returns the same immutable headers without materializing a body. Each body - * is built once on its first `GET`; script construction never reads maps. + * Serve an advertised revisioned bundle, source map, chunk, or package asset + * without a Web server. Unknown URLs return 404, unsupported methods return + * 405, and `HEAD` returns the same immutable headers without materializing a + * body. Each bundle body is built once on its first `GET`; script + * construction never reads maps. A package asset is read on every request + * and answers 304 without a body when `If-None-Match` equals its entity tag. * @param request - shell-carrier request for a `/plugins` resource. * @returns the exact response also exposed by the optional Web route. */ diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index c2d0d31e5c..71b7623b0f 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.clientModules` — `ClientModuleRegistry` -The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). +The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). ```ts cordis-catalog /** @@ -150,10 +150,12 @@ graph(): WebBootGraph clientPath(id: string): string | undefined /** - * Serve an advertised revisioned bundle or source map without a Web server. - * Unknown URLs return 404, unsupported methods return 405, and `HEAD` - * returns the same immutable headers without materializing a body. Each body - * is built once on its first `GET`; script construction never reads maps. + * Serve an advertised revisioned bundle, source map, chunk, or package asset + * without a Web server. Unknown URLs return 404, unsupported methods return + * 405, and `HEAD` returns the same immutable headers without materializing a + * body. Each bundle body is built once on its first `GET`; script + * construction never reads maps. A package asset is read on every request + * and answers 304 without a body when `If-None-Match` equals its entity tag. * @param request - shell-carrier request for a `/plugins` resource. * @returns the exact response also exposed by the optional Web route. */ diff --git a/docs/web-styling.md b/docs/web-styling.md index 564801d476..4710e09e39 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -20,6 +20,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Pair font sizes with line heights and use the theme typography variables when an existing role matches. - Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors. - Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches. +- A relative `url()` in a plugin stylesheet points to a file in the package's `src/client/assets/`, and the build rewrites it to the [package asset](../packages/client/AGENTS.md#dependency-declaration) URL. `data:`, `#fragment`, `/`-rooted, scheme, and empty `url('')` references stay unchanged; stylesheets from dependencies are not rewritten. - Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls. - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing. - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 7697a5cfd3..f23db967f9 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -69,7 +69,8 @@ Npm sections describe installation and development relationships; each build fac 5. **Only Host runtime libraries require installation dependencies.** Browser-only third-party implementations belong in `devDependencies`, whether inlined into `lib/client.js` or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure ([reference](../../.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md)). 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. -8. **Only the root manifest declares React type packages.** `@types/react` and `@types/react-dom` belong only in the root `package.json` `devDependencies`, and `verify-package-dependencies` rejects them in every manifest under `apps/*`, `packages/*/*`, and `vendor/*`; packages still declare the `react` and `react-dom` they import. Each package-local pnpm link is one more location TypeScript scans when it computes module specifiers for every JSX file ([rationale](../../.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md#1-only-the-root-manifest-declares-react-type-packages)). +8. **Package images live under `src/client/assets/`.** Client code references one by relative `import` and the package's own CSS by relative `url()`; supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. The package declares `"./assets/*": "./lib/assets/*"` in `exports` and `lib/assets` in `files`; the build preset emits each referenced file under `lib/assets/`, and the browser loads it through the [client-modules asset route](modules/README.md). +9. **Only the root manifest declares React type packages.** `@types/react` and `@types/react-dom` belong only in the root `package.json` `devDependencies`, and `verify-package-dependencies` rejects them in every manifest under `apps/*`, `packages/*/*`, and `vendor/*`; packages still declare the `react` and `react-dom` they import. Each package-local pnpm link is one more location TypeScript scans when it computes module specifiers for every JSX file ([rationale](../../.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md#1-only-the-root-manifest-declares-react-type-packages)). ## Build-time browser environment @@ -143,7 +144,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json` plus `./assets/*` for [package images](#dependency-declaration), `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the computed runtime resolution, which is installed as an interception on Node's resolvers — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph). 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index f37f5d21be..89ee929335 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -33,6 +33,10 @@ Use it when you compose or build a browser client plugin: the package turns a pa A browser plugin package declares `dsh.client` in its `package.json` with `platform: 'web'`, exports a `./client` bundle, and lists any non-baseline module requests under `dsh.client.external`. The host half turns each declaration into a served bundle under `/plugins`, ordered so dynamic providers load before their consumers. +### Package assets + +The same `/plugins` route serves the images a client package publishes through its `"./assets/*": "./lib/assets/*"` export. The build emits each referenced asset with its content revision, the first 12 hexadecimal digits of the SHA-1 digest of its bytes, inserted before the extension, and turns the import into the document-relative reference `plugins//assets/`, so one URL always names one content. Any `/plugins//assets/` path is answered by the asset route alone: for an active client row, the Host percent-decodes the path once and resolves `/assets/` with Node's `require.resolve` from the package's own manifest, so the package's exports decide which files exist. A path that does not resolve, a resolved file whose extension is not `.svg`, `.png`, `.webp`, `.jpg`, or `.jpeg`, a malformed escape, and an inactive package answer 404; the query selects nothing. A served asset streams from disk on every request without caching and carries its media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag: ""`, and `x-content-type-options: nosniff`; an SVG additionally carries `content-security-policy: script-src 'none'`, so an SVG opened directly runs no script. An `If-None-Match` equal to that tag answers 304 without opening the file, and `HEAD` answers without a body. `fetchBundle()` returns the same responses, with a streamed body that closes the file when cancelled. + ### What the browser loads The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client..js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. A combo whose `\n') +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]) +const WEBP = Buffer.from('RIFF....WEBP') + +let root: string | undefined +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + root = undefined + opened.length = 0 +}) + +/** Own one fresh temporary directory per test. */ +function fixtureRoot(): string { + root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-assets-'))) + return root +} + +/** Write one file, creating its parent directories. */ +function writeFile(path: string, contents: string | Buffer): string { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + return path +} + +/** Create a built client package below the fixture's `node_modules`; returns its `lib/assets` directory. */ +function writeClientPackage(packageName: string, exports: Record): string { + const pkgRoot = join(fixtureRoot(), 'node_modules', ...packageName.split('/')) + writeFile(join(pkgRoot, 'package.json'), JSON.stringify({ + name: packageName, + exports: { '.': './lib/index.js', './client': './lib/client.js', './package.json': './package.json', ...exports }, + dsh: { client: { platform: 'web' } }, + })) + writeFile(join(pkgRoot, 'lib', 'index.js'), 'export default {}\n') + writeFile(join(pkgRoot, 'lib', 'client.js'), 'module.exports = {}\n') + writeFile(join(pkgRoot, 'secret.png'), PNG) + return join(pkgRoot, 'lib', 'assets') +} + +/** Create the active, inactive, and export-less packages; returns the active package's `lib/assets`. */ +function writeFixture(): string { + const assets = writeClientPackage(ASSETS_ID, { './assets/private/*': null, './assets/*': './lib/assets/*' }) + writeFile(join(assets, 'icon.svg'), SVG) + writeFile(join(assets, 'photo.png'), PNG) + writeFile(join(assets, 'nested', 'a b.webp'), WEBP) + writeFile(join(assets, 'notes.txt'), 'text\n') + writeFile(join(assets, 'folder.png', 'inner.png'), PNG) + writeFile(join(assets, 'private', 'hidden.png'), PNG) + // `a#b.png` resolves here because Node reads `#` in the subpath as a fragment. + writeFile(join(assets, 'a'), PNG) + writeFile(join(writeClientPackage(NO_EXPORT_ID, {}), 'photo.png'), PNG) + writeFile(join(writeClientPackage(INACTIVE_ID, { './assets/*': './lib/assets/*' }), 'photo.png'), PNG) + return assets +} + +/** One route answer reduced to the parts the asset route owns. */ +interface Answer { + status: number + headers: Partial> + body: Buffer +} + +function assetHeaders(read: (name: string) => string | string[] | null | undefined): Answer['headers'] { + const headers: Answer['headers'] = {} + for (const name of ASSET_HEADERS) { + const value = read(name) + if (typeof value === 'string') headers[name] = value + } + return headers +} + +interface AssetRequestInit { + method?: string + headers?: Record +} + +/** Composed Host: the modules service and a requester through both carriers. */ +interface Host { + service: ClientModuleRegistry + /** Send one raw request target through the Web route and the shell carrier and require identical answers. */ + both: (path: string, init?: AssetRequestInit) => Promise +} + +/** Compose the real Web server and the modules service over a Loader double listing the active rows. */ +async function composeHost(): Promise { + const internal = ModuleLoader.fromInternal() + if (internal === undefined) throw new Error('package-asset tests require the Node module loader internals') + const context = new Context() + contexts.push(context) + const baseUrl = pathToFileURL(fixtureRoot()).href + '/' + context.baseUrl = baseUrl + context.provide('loader', { + internal, + *entries() { + for (const name of [NO_EXPORT_ID, ASSETS_ID]) { + yield { options: { name }, fiber: {}, disabled: false, parent: { tree: { ctx: { baseUrl } } } } + } + }, + }) + await context.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + await context.plugin(ClientModuleRegistry) + const service = context.get('clientModules')! + const port = context.webServer.port! + const http = (path: string, init: AssetRequestInit = {}): Promise => new Promise((resolve, reject) => { + const outgoing = request({ host: '127.0.0.1', port, path, method: init.method ?? 'GET', headers: init.headers, agent: false }, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + res.on('error', reject) + res.on('end', () => { + resolve({ status: res.statusCode!, headers: assetHeaders(name => res.headers[name]), body: Buffer.concat(chunks) }) + }) + }) + outgoing.on('error', reject) + outgoing.end() + }) + const both = async (path: string, init?: AssetRequestInit): Promise => { + const answer = await http(path, init) + const response = await service.fetchBundle(new Request(`http://localhost${path}`, init)) + expect({ + status: response.status, + headers: assetHeaders(name => response.headers.get(name)), + body: Buffer.from(await response.arrayBuffer()), + }).toEqual(answer) + return answer + } + // The Web route registers once the injected webServer is ready. + await expect.poll(async () => (await http(assetUrl(ASSETS_ID, 'photo.png'))).status).toBe(200) + return { service, both } +} + +/** Absolute route the browser requests for the document-relative build reference to an emitted file. */ +function assetUrl(packageName: string, emittedPath: string): string { + return `/${packageAssetReference(packageName, emittedPath)}` +} + +describe('package asset route', () => { + it('streams exported assets with immutable typed headers and the path as entity tag', async () => { + writeFixture() + const { both } = await composeHost() + + expect(await both(assetUrl(ASSETS_ID, 'icon.svg'))).toEqual({ + status: 200, + headers: { + 'content-type': 'image/svg+xml', + 'cache-control': IMMUTABLE, + etag: '"icon.svg"', + 'x-content-type-options': 'nosniff', + 'content-security-policy': "script-src 'none'", + }, + body: SVG, + }) + expect(await both(assetUrl(ASSETS_ID, 'photo.png'))).toEqual({ + status: 200, + headers: { + 'content-type': 'image/png', + 'cache-control': IMMUTABLE, + etag: '"photo.png"', + 'x-content-type-options': 'nosniff', + }, + body: PNG, + }) + const nested = await both(assetUrl(ASSETS_ID, 'nested/a b.webp')) + expect([nested.status, nested.headers.etag, nested.body]).toEqual([200, '"nested/a%20b.webp"', WEBP]) + // The revision lives in the emitted name; a query does not select content. + expect((await both(`${assetUrl(ASSETS_ID, 'photo.png')}?rev=anything`)).body).toEqual(PNG) + }) + + it('answers HEAD with the same headers and no body, and rejects other methods', async () => { + writeFixture() + const { both } = await composeHost() + const url = assetUrl(ASSETS_ID, 'icon.svg') + const get = await both(url) + + expect(await both(url, { method: 'HEAD' })).toEqual({ ...get, body: Buffer.alloc(0) }) + expect((await both(url, { method: 'POST' })).status).toBe(405) + }) + + it('answers 304 without opening the file only when If-None-Match equals the entity tag', async () => { + const assets = writeFixture() + const { both } = await composeHost() + const url = assetUrl(ASSETS_ID, 'icon.svg') + const get = await both(url) + const opens = opened.filter(entry => entry.path === join(assets, 'icon.svg')).length + + expect(await both(url, { headers: { 'if-none-match': '"icon.svg"' } })) + .toEqual({ status: 304, headers: get.headers, body: Buffer.alloc(0) }) + expect(opened.filter(entry => entry.path === join(assets, 'icon.svg'))).toHaveLength(opens) + expect(await both(url, { headers: { 'if-none-match': '"photo.png"' } })).toEqual(get) + }) + + it('streams an asset spanning several read chunks intact through both carriers', async () => { + const assets = writeFixture() + // Several default 64 KiB read-stream chunks; small enough for the deep body comparison. + const large = Buffer.alloc(160 * 1024, 7) + writeFile(join(assets, 'large.png'), large) + const { both } = await composeHost() + + const answer = await both(assetUrl(ASSETS_ID, 'large.png')) + expect(answer.status).toBe(200) + expect(answer.body.equals(large)).toBe(true) + }) + + it('closes the file stream when a shell-carrier body is cancelled', async () => { + const assets = writeFixture() + const { service } = await composeHost() + const response = await service.fetchBundle(new Request(`http://localhost${assetUrl(ASSETS_ID, 'photo.png')}`)) + const body = opened.findLast(entry => entry.path === join(assets, 'photo.png'))?.stream + + await response.body?.cancel() + expect(body?.destroyed).toBe(true) + }) + + const base = `/plugins/${ASSETS_ID}/assets` + it.each([ + ['an excluded export subpath', `${base}/private/hidden.png`], + ['a package without the asset export', `/plugins/${NO_EXPORT_ID}/assets/photo.png`], + ['a missing file', `${base}/missing.png`], + ['`..` leaving the asset prefix', `${base}/../../secret.png`], + ['`%2e%2e` leaving the asset prefix', `${base}/%2e%2e/%2e%2e/secret.png`], + ['an encoded `..` path', `${base}/..%2f..%2fsecret.png`], + ['an empty asset path', `${base}/`], + ['a malformed percent-escape', `${base}/photo%zz.png`], + ['a decoded `%`', `${base}/100%25.png`], + ['a decoded `#` that Node reads as a fragment', `${base}/a%23b.png`], + ['an unsupported extension', `${base}/notes.txt`], + ['a directory', `${base}/folder.png`], + ['an inactive package', `/plugins/${INACTIVE_ID}/assets/photo.png`], + ])('answers 404 for %s', async (_case, url) => { + writeFixture() + const { both } = await composeHost() + + expect(await both(url)).toEqual({ status: 404, headers: {}, body: Buffer.alloc(0) }) + }) +}) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index fc361a7135..05931c980f 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -6,18 +6,33 @@ * lightningcss inside the bundle: `x.module.css` yields its hashed class map * and injects a tagged style at factory execution, while `x.css?inline` * exports compiled text for a plugin-owned lifecycle effect. The virtual - * loaders register each real stylesheet as a watch dependency. SVG, PNG, and - * WebP imports become watched base64 data URLs inside the bundle. + * loaders register each real stylesheet as a watch dependency. Images under + * `src/client/assets/`, imported from Client code or referenced by relative + * `url()` in the package's own stylesheets, are emitted under `lib/assets/` + * with their content revision in the file name and become document-relative + * URLs served by the + * client-modules asset route; such a package exports `./assets/*` as + * `./lib/assets/*`, and its Node Client face rejects asset imports. * Non-experimental client outputs reject experimental module and stylesheet * inputs, including origins recorded by chained source maps. */ -import { readFile, stat, utimes } from 'node:fs/promises' -import { existsSync, readFileSync } from 'node:fs' +import { readFile, readdir, rm, rmdir, stat, utimes } from 'node:fs/promises' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { createRequire, isBuiltin } from 'node:module' -import { basename, dirname, extname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path' +import { basename, dirname, isAbsolute, join, posix, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { Rolldown, type TsdownPlugin, type UserConfig } from 'tsdown' -import { transform } from 'lightningcss' +import { transform, type CustomAtRules, type Visitor } from 'lightningcss' +import { + PACKAGE_ASSET_EXPORT, + PACKAGE_ASSET_EXPORT_TARGET, + PACKAGE_ASSET_FILES_ENTRY, + PACKAGE_ASSET_MEDIA_TYPES, + packageAssetMediaType, + packageAssetReference, + packageAssetRevision, + packageAssetRevisionedPath, +} from './modules/src/assets.ts' import { optionalStringArray } from './modules/src/client/manifest.ts' import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts' import { clientBuildEnvironmentDefines, clientBuildValueDefines } from '../../scripts/client-build-environment.ts' @@ -35,9 +50,6 @@ const INLINE_CSS_VIRTUAL_PREFIX = '\0dsh-inline-css:' const CSS_VIRTUAL_SUFFIX = '.mjs' const INLINE_CSS_QUERY = '?inline' -/** Image imports become base64 data URLs embedded in the plugin's own bundle. */ -const IMAGE_MEDIA_TYPES = new Map([['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.webp', 'image/webp']]) - /** Emit one plugin-owned style injector and an optional CSS Modules export. */ function styleInjectionModule( id: string, @@ -106,7 +118,9 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * original source content, into the standalone plugin map. * @param packageDir - the package's own directory, normally the config's * `import.meta.dirname`. Its `package.json` name is the plugin id stamped into - * the __ModuleLoader__.load handoff and onto the injected style tags. + * the __ModuleLoader__.load handoff and onto the injected style tags, and the + * owner segment of its asset URLs; its `src/client/assets/` holds the only + * images the browser bundle may reference. * @param libEntry - node-half entries. * @param options - phase placement, lib overrides, optional independently bundled Node Client, companion configs, and per-file Client banner. * @returns ENV-selected tsdown config for the current build face. @@ -122,7 +136,7 @@ export function clientBundle( return ({ env }) => { const face = buildFace(env?.DSH_BUILD_FACE) const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js' - const client = clientConfig(manifest, clientEntry, options.clientBanner, options.dualClient === true) + const client = clientConfig(manifest, packageDir, clientEntry, options.clientBanner, options.dualClient === true) const node = [lib, ...(options.companions ?? [])] const clientFaces = options.dualClient === true ? [nodeClientConfig(manifest, clientEntry), client] @@ -277,6 +291,11 @@ interface AssetEmitter { }): string } +/** The slice of the rolldown plugin context that emits and watches one package asset. */ +interface AssetContext extends AssetEmitter { + addWatchFile(id: string): void +} + function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig { const emitted = new Set() const isolation = clientInputIsolation(id) @@ -358,6 +377,8 @@ interface PackageManifest { readonly dependencies?: Record readonly peerDependencies?: Record readonly optionalDependencies?: Record + /** Package exports; the browser face emits assets only while `./assets/*` publishes `lib/assets`. */ + readonly exports?: Readonly> readonly dsh?: { readonly client?: { readonly external?: unknown } } } @@ -478,6 +499,170 @@ function asyncChunkRequirePlugin(): TsdownPlugin { } } +/** Emitted asset directory below the browser config's `lib` outDir, published as {@link PACKAGE_ASSET_FILES_ENTRY}. */ +const ASSET_OUTPUT_DIR = posix.relative('lib', PACKAGE_ASSET_FILES_ENTRY) + +/** Characters the asset route rejects in a decoded path segment, because Node resolves export subpaths as URL text. */ +const UNSERVABLE_ASSET_SEGMENT = /[%#?\\\0]/ + +/** A `url()` value with a scheme, such as `data:` or `https:`, which names no package file. */ +const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i + +/** Browser-face package assets: the import plugin and the stylesheet `url()` rewrite share one per-build emission record. */ +interface PackageAssets { + /** `dsh-package-asset`: resolves asset imports, emits referenced files, and prunes stale `lib/assets` files after writing. */ + readonly plugin: TsdownPlugin + /** + * Select the lightningcss options that rewrite one stylesheet's relative `url()` values. + * @param context - plugin context of the stylesheet loader, which emits each referenced asset. + * @param stylesheet - physical stylesheet path. + * @returns a `url()` visitor when the stylesheet lies inside the package's own `src`, otherwise no options. + * @throws {Error} from the visitor when a relative `url()` does not name a publishable package asset. + */ + urlRewrite(context: AssetContext, stylesheet: string): { readonly visitor?: Visitor } +} + +/** + * Build the package-asset rules of one dynamic browser bundle. A referenced + * file must lie under the package's own `src/client/assets/`, have a supported + * extension, and be published by the `./assets/*` export; it is emitted once + * per build under `lib/assets/` at its relative path with the content revision + * inserted before the extension, and the reference becomes its document-relative URL. + * @param manifest - the package's own manifest, naming the URL owner and declaring the asset export. + * @param packageDir - the package's own directory. + * @returns the plugin and stylesheet visitor sharing the per-build emission record. + */ +function packageAssets(manifest: PackageManifest, packageDir: string): PackageAssets { + const name = manifest.name + const sourceDir = resolvePath(packageDir, 'src') + const assetsDir = resolvePath(sourceDir, 'client', 'assets') + /** Reference of every asset emitted in the current build, keyed by its real path. */ + const references = new Map() + + const reference = (context: AssetContext, file: string): string => { + let realPath: string + try { + realPath = realpathSync(file) + } catch (error) { + throw new Error(`tsdown: ${name}: cannot resolve asset ${file}`, { cause: error }) + } + const emitted = references.get(realPath) + if (emitted !== undefined) return emitted + const below = pathBelow(realDirectory(assetsDir), realPath) + if (below === undefined) throw new Error(`tsdown: ${name}: ${file} is outside src/client/assets; move the asset there`) + const assetPath = below.split(sep).join('/') + if (assetPath.split('/').some(segment => UNSERVABLE_ASSET_SEGMENT.test(segment))) { + throw new Error(`tsdown: ${name}: ${file} has a path segment containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`) + } + if (manifest.exports?.[PACKAGE_ASSET_EXPORT] !== PACKAGE_ASSET_EXPORT_TARGET) { + throw new Error( + `tsdown: ${name}: ${file} needs package.json exports ${JSON.stringify({ [PACKAGE_ASSET_EXPORT]: PACKAGE_ASSET_EXPORT_TARGET })} ` + + `and ${JSON.stringify(PACKAGE_ASSET_FILES_ENTRY)} in files`, + ) + } + if (packageAssetMediaType(assetPath) === undefined) { + throw new Error(`tsdown: ${name}: ${file} is not a supported asset; supported extensions: ${[...PACKAGE_ASSET_MEDIA_TYPES.keys()].join(', ')}`) + } + const bytes = readFileSync(realPath) + const emittedPath = packageAssetRevisionedPath(assetPath, packageAssetRevision(bytes)) + context.emitFile({ type: 'asset', fileName: `${ASSET_OUTPUT_DIR}/${emittedPath}`, source: bytes, originalFileName: realPath }) + context.addWatchFile(realPath) + const url = packageAssetReference(name, emittedPath) + references.set(realPath, url) + return url + } + + return { + plugin: { + name: 'dsh-package-asset', + buildStart() { references.clear() }, + resolveId(source, importer) { + if (importer === undefined || packageAssetMediaType(source) === undefined) return null + return sourceAssetPath(source, importer) + }, + load(id) { + if (!isAbsolute(id) || packageAssetMediaType(id) === undefined) return null + return `export default ${JSON.stringify(reference(this, id))};` + }, + async writeBundle(outputOptions, bundle) { + if (outputOptions.dir === undefined) return + const outDir = outputOptions.dir + const assets = resolvePath(outDir, ASSET_OUTPUT_DIR) + if (!existsSync(assets)) return + await pruneDirectory(assets, file => relative(outDir, file).split(sep).join('/') in bundle) + }, + }, + urlRewrite(context, stylesheet) { + if (pathBelow(realDirectory(sourceDir), realpathSync(stylesheet)) === undefined) return {} + const base = dirname(stylesheet) + return { + visitor: { + Url(url) { + // Empty values, fragments, root-relative paths, and schemes such as `data:` name no package file. + if (url.url === '' || url.url.startsWith('#') || url.url.startsWith('/') || URL_SCHEME.test(url.url)) return + return { ...url, url: reference(context, resolvePath(base, url.url)) } + }, + }, + } + }, + } +} + +/** Real path of an existing directory, or undefined when it does not exist. */ +function realDirectory(directory: string): string | undefined { + return existsSync(directory) ? realpathSync(directory) : undefined +} + +/** + * Locate a real path strictly inside a real directory. + * @param root - real directory path, or undefined when the directory does not exist. + * @param file - real file path. + * @returns the platform-separated path of `file` below `root`, or undefined when it is not inside. + */ +function pathBelow(root: string | undefined, file: string): string | undefined { + if (root === undefined) return undefined + const path = relative(root, file) + return path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) ? undefined : path +} + +/** + * Delete every file a predicate rejects below a directory, then every directory left empty. + * @param directory - directory to prune; removed itself when nothing remains. + * @param keep - whether a file path survives. + * @returns whether the directory was removed. + */ +async function pruneDirectory(directory: string, keep: (file: string) => boolean): Promise { + let remaining = 0 + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + if (!(await pruneDirectory(path, keep))) remaining++ + } else if (keep(path)) { + remaining++ + } else { + await rm(path) + } + } + if (remaining > 0) return false + await rmdir(directory) + return true +} + +/** + * Reject asset imports in a Node Client face, which has no asset route. + * @param id - package name, used in diagnostics. + * @returns a plugin whose resolveId throws for every supported asset extension. + */ +function browserOnlyAssets(id: string): TsdownPlugin { + return { + name: 'dsh-package-asset-browser-only', + resolveId(source) { + if (packageAssetMediaType(source) === undefined) return null + throw new Error(`tsdown: ${id}: asset imports are browser-only (${source})`) + }, + } +} + /** Bundle the Client entry for plain Node independently of the Host entry and browser factory. */ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig { const isolation = clientInputIsolation(manifest.name) @@ -486,7 +671,7 @@ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig entry: { 'client.node': entry }, sourcemap: true, inputOptions: { resolve: { conditionNames: ['node', 'import', 'module', 'default'] } }, - plugins: [tscSourceMapPlugin(), isolation.plugin], + plugins: [browserOnlyAssets(manifest.name), tscSourceMapPlugin(), isolation.plugin], outputOptions: { entryFileNames: 'client.node.js', chunkFileNames: 'client.node.[name].js', @@ -498,6 +683,7 @@ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig function clientConfig( manifest: PackageManifest, + packageDir: string, entry: string, clientBanner?: (fileName: string) => string | undefined, dualClient = false, @@ -506,6 +692,7 @@ function clientConfig( let requested: ReadonlySet | undefined const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier) const isolation = clientInputIsolation(id) + const assets = packageAssets(manifest, packageDir) return { name: `${id}/client`, entry: { client: entry }, @@ -594,6 +781,7 @@ function clientConfig( code: source, cssModules: { pattern: '[hash]_[local]' }, minify: true, + ...assets.urlRewrite(this, fileId), }) const classMap: Record = {} const exportEntries = Object.entries(cssExports ?? {}) @@ -614,7 +802,7 @@ function clientConfig( const fileId = virtualId.slice(INLINE_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length) this.addWatchFile(fileId) const source = await readFile(fileId) - const { code } = transform({ filename: fileId, code: source, minify: true }) + const { code } = transform({ filename: fileId, code: source, minify: true, ...assets.urlRewrite(this, fileId) }) return `export default ${JSON.stringify(code.toString())};` }, }, { @@ -629,23 +817,10 @@ function clientConfig( const fileId = virtualId.slice(GLOBAL_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length) this.addWatchFile(fileId) const source = await readFile(fileId) - const { code } = transform({ filename: fileId, code: source, minify: true }) + const { code } = transform({ filename: fileId, code: source, minify: true, ...assets.urlRewrite(this, fileId) }) return styleInjectionModule(id, fileId, code.toString()) }, - }, { - name: 'dsh-image-inline', - resolveId(source: string, importer: string | undefined) { - if (importer === undefined || !IMAGE_MEDIA_TYPES.has(extname(source))) return null - return sourceAssetPath(source, importer) - }, - async load(fileId: string) { - const mediaType = IMAGE_MEDIA_TYPES.get(extname(fileId)) - if (mediaType === undefined) return null - this.addWatchFile(fileId) - const data = await readFile(fileId) - return `export default ${JSON.stringify(`data:${mediaType};base64,${data.toString('base64')}`)};` - }, - }], + }, assets.plugin], outputOptions: { entryFileNames: dualClient ? 'client.browser.js' : 'client.js', // The imported source basename becomes the published chunk name; package diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 1ca395fd17..f52fe1e4ba 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -111,7 +111,7 @@ The process group uses a stable `div` layout box, a scroll body, and an uncapped Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it. -The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The build embeds the image in the plugin stylesheet; React does not pass image data through DOM styles. +The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The APNG is a [package asset](../AGENTS.md#dependency-declaration) referenced by the plugin stylesheet; React does not pass image data through DOM styles. Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect. diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 1d0b8c744c..d43d47801c 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -22,6 +22,7 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./assets/*": "./lib/assets/*", "./src/*": "./src/*", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" @@ -101,6 +102,7 @@ "locale/*.json", "lib/index.js", "lib/client.js", + "lib/assets", "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-chat/src/client/chat/running-whale@2x.png b/packages/client/ui-chat/src/client/assets/running-whale@2x.png similarity index 100% rename from packages/client/ui-chat/src/client/chat/running-whale@2x.png rename to packages/client/ui-chat/src/client/assets/running-whale@2x.png diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index a4f38a857d..74cc41351e 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -327,7 +327,7 @@ .runningWhaleAnimated { display: block; background: currentColor; - mask: url('./running-whale@2x.png') center / 100% 100% no-repeat; + mask: url('../assets/running-whale@2x.png') center / 100% 100% no-repeat; mask-mode: alpha; /* Keep animated-mask repaints on an icon-sized composited layer. */ will-change: transform; diff --git a/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx b/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx index 5d1bbc0c99..988715cb07 100644 --- a/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx +++ b/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx @@ -28,7 +28,7 @@ describe('RunningWhaleTail', () => { }) it('ships a 28px alpha APNG with sixty 50ms frames and infinite playback', () => { - const png = readFileSync(resolve(import.meta.dirname, '../src/client/chat/running-whale@2x.png')) + const png = readFileSync(resolve(import.meta.dirname, '../src/client/assets/running-whale@2x.png')) expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]) const chunks: { type: string; data: Buffer }[] = [] for (let offset = 8; offset < png.length;) { diff --git a/packages/client/ui-chat/tsdown.config.ts b/packages/client/ui-chat/tsdown.config.ts index 0364805433..7aa03cebc1 100644 --- a/packages/client/ui-chat/tsdown.config.ts +++ b/packages/client/ui-chat/tsdown.config.ts @@ -1,22 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js']) -const stylesheet = fileURLToPath(new URL('./src/client/chat/ChatView.module.css', import.meta.url)).replaceAll('\\', '/') -const whaleImage = fileURLToPath(new URL('./src/client/chat/running-whale@2x.png', import.meta.url)) - -export default ((options) => bundle(options).map(config => ({ - ...config, - plugins: [...(config.plugins ?? []), { - name: 'chat-whale-image', - async transform(code: string, id: string) { - if (id.replaceAll('\\', '/') !== `\0dsh-css:${stylesheet}.mjs`) return null - const imageUrl = './running-whale@2x.png' - if (!code.includes(imageUrl)) return null - this.addWatchFile(whaleImage) - const image = await readFile(whaleImage) - return { code: code.replaceAll(imageUrl, `data:image/png;base64,${image.toString('base64')}`), map: null } - }, - }], -}))) satisfies typeof bundle +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index d79f8ac0f8..2b02a9d990 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -68,7 +68,7 @@ The welcome page requires Get started. The credit page always appears. A positiv The Account section appears first in Settings only while signed in; it is hidden before account state loads and after sign-out. The plugin owns one Host snapshot stream shared through framework hooks by settings.section and settings.launcher. The launcher opens Settings and offers Sign out only while an account credential is stored. Platform failures leave the menu available for retry. It maintains no independent credential state. -The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared Coding Tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Onboarding choices compact, standard, and detailed apply directly to the corresponding Chat work-detail modes. API-key entry and explicit skip select standard; onboarding does not select verbose. Complete Figma illustration layers are bundled as transparent, lossless 3× WebP assets; welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [historical onboarding decision](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs. +The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared Coding Tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Onboarding choices compact, standard, and detailed apply directly to the corresponding Chat work-detail modes. API-key entry and explicit skip select standard; onboarding does not select verbose. Complete Figma illustration layers ship as transparent, lossless 3× WebP [package assets](../AGENTS.md#dependency-declaration); welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [historical onboarding decision](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs. ## Further Exploration diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index 80697ffbe8..681232a393 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -22,6 +22,7 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./assets/*": "./lib/assets/*", "./src/*": "./src/*", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" @@ -75,6 +76,7 @@ "locale/*.json", "lib/index.js", "lib/client.js", + "lib/assets", "lib/types/**/*.d.ts" ], "dependencies": { diff --git a/packages/client/ui-settings-account/src/client/assets.d.ts b/packages/client/ui-settings-account/src/client/assets.d.ts deleted file mode 100644 index db768fc288..0000000000 --- a/packages/client/ui-settings-account/src/client/assets.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Illustration imports are embedded in the account client bundle. */ -declare module '*.webp' { - const url: string - export default url -} -declare module '*.svg' { - const url: string - export default url -} diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 1f51ea8eab..d86467a595 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -601,8 +601,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'clientModules', - summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows.', - description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).', + summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows.', + description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).', methods: [ { signature: 'graph(): WebBootGraph', @@ -618,7 +618,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async fetchBundle(request: Request): Promise', - description: 'Serve an advertised revisioned bundle or source map without a Web server. Unknown URLs return 404, unsupported methods return 405, and `HEAD` returns the same immutable headers without materializing a body. Each body is built once on its first `GET`; script construction never reads maps.', + description: 'Serve an advertised revisioned bundle, source map, chunk, or package asset without a Web server. Unknown URLs return 404, unsupported methods return 405, and `HEAD` returns the same immutable headers without materializing a body. Each bundle body is built once on its first `GET`; script construction never reads maps. A package asset is read on every request and answers 304 without a body when `If-None-Match` equals its entity tag.', parameters: [{ name: 'request', description: 'shell-carrier request for a `/plugins` resource.' }], returns: 'the exact response also exposed by the optional Web route.', }, diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index ebe8a57643..b908524315 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -442,6 +442,67 @@ describe('package payload constraints', () => { const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) }) + + it.each([ + { './assets/*': './lib/assets/*' }, + { './assets/*': { default: './lib/assets/*' } }, + ])('publishes built package assets after the browser Client entries: %j', (assets) => { + expect(expectedDshPackageFiles({ + exports: { + './client': { types: './lib/types/client/index.d.ts', node: './lib/client.node.js', default: './lib/client.js' }, + './loader': './lib/loader.js', + ...assets, + }, + })).toEqual([ + 'lib/index.js', 'lib/client.js', 'lib/client.node.js', 'lib/assets', 'lib/loader.js', 'lib/types/**/*.d.ts', + ]) + }) + + it.each([ + {}, + { './assets/*': './assets/*' }, + { './assets/*': { types: './lib/assets/*' } }, + { './assets/extra': './lib/assets/*' }, + ])('does not publish package assets without the exact asset export target: %j', (assets) => { + expect(expectedDshPackageFiles({ exports: { './client': './lib/client.js', ...assets } })) + .toEqual(['lib/index.js', 'lib/client.js', 'lib/types/**/*.d.ts']) + }) + + it('rejects a package-asset export whose payload omits the built assets', () => { + const dir = 'packages/client/probe' + const manifest = { + name: '@deepseek-ai/dsh-client-probe', + type: 'module', + main: 'lib/index.js', + types: 'lib/types/index.d.ts', + exports: { + '.': { types: './lib/types/index.d.ts', default: './lib/index.js' }, + './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' }, + './assets/*': './lib/assets/*', + }, + files: ['lib/index.js', 'lib/client.js', 'lib/assets', 'lib/types/**/*.d.ts'], + peerDependencies: { '@deepseek-ai/cordis': 'workspace:~' }, + devDependencies: { '@deepseek-ai/cordis': 'workspace:~' }, + } satisfies WorkspaceManifest['manifest'] + const payloadErrors = (candidate: WorkspaceManifest['manifest']): string[] => + checkWorkspaceManifest({ dir, manifest: candidate }).filter(error => error.includes('package.json files must be')) + expect(payloadErrors(manifest)).toEqual([]) + expect(payloadErrors({ ...manifest, files: manifest.files.filter(file => file !== 'lib/assets') })).toEqual([ + expect.stringContaining('@deepseek-ai/dsh-client-probe: package.json files must be ' + + '["lib/index.js","lib/client.js","lib/assets","lib/types/**/*.d.ts"]'), + ]) + }) + + it.each(['ui-chat', 'ui-plugin-manager', 'ui-settings-account'])( + 'accepts the published package assets for %s and rejects their omission', (name) => { + const dir = `packages/client/${name}` + const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) + expect(checkWorkspaceManifest({ dir, manifest: { + ...manifest, files: manifest.files!.filter(file => file !== 'lib/assets'), + } })).toEqual([expect.stringContaining('package.json files must be')]) + }, + ) }) it('publishes CLI runtime declarations and rejects a payload that omits them', () => { diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index a82a49b6c7..15d32af306 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -21,6 +21,11 @@ import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publica import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts' import { detailPath } from '../packages/util/plugin-detail/src/document.ts' import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts' +import { + PACKAGE_ASSET_EXPORT, + PACKAGE_ASSET_EXPORT_TARGET, + PACKAGE_ASSET_FILES_ENTRY, +} from '../packages/client/modules/src/assets.ts' import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' const root = resolve(import.meta.dirname, '..') @@ -285,10 +290,11 @@ export function detailDocumentFiles(packageDir: string): readonly string[] { } /** - * Compute canonical publication patterns, including declared and exported icon paths, exported locale JSON resources, and detail documents. + * Compute canonical publication patterns, including declared and exported icon paths, exported locale JSON resources, + * detail documents, and the built package-asset directory when `exports["./assets/*"]` targets it. * @param manifest - workspace package manifest. * @param detailFiles - entries from {@link detailDocumentFiles}. - * @returns the icon, deduplicated locale targets, and detail documents followed by runtime and declaration payloads. + * @returns the icon, deduplicated locale targets, and detail documents followed by runtime, package-asset, and declaration payloads. */ export function expectedDshPackageFiles(manifest: PackageManifest, detailFiles: readonly string[] = []): readonly string[] { const localeFiles = new Set() @@ -327,6 +333,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest, detailFiles: ...browserClient === './lib/client.js' ? ['lib/client.js'] : [], ...browserClient === './lib/client.browser.js' ? ['lib/client.browser.js'] : [], ...exportCondition(manifest, './client', 'node') === './lib/client.node.js' ? ['lib/client.node.js'] : [], + ...exportDefault(manifest, PACKAGE_ASSET_EXPORT) === PACKAGE_ASSET_EXPORT_TARGET ? [PACKAGE_ASSET_FILES_ENTRY] : [], // runtime's shell-held loader subpath ships as its own bundle beside the client half. ...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [], // A store subpath ships its own bundle (single-entry builds; no shared chunk). diff --git a/scripts/client-asset-types.spec.ts b/scripts/client-asset-types.spec.ts new file mode 100644 index 0000000000..e2e0564c23 --- /dev/null +++ b/scripts/client-asset-types.spec.ts @@ -0,0 +1,32 @@ +/** + * The type roots declare one wildcard image module per supported package + * asset extension, each with a string default export. + */ +import { join } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' +import { PACKAGE_ASSET_MEDIA_TYPES } from '../packages/client/modules/src/assets.ts' + +const DECLARATIONS = join(import.meta.dirname, 'types/client-assets/index.d.ts') + +describe('client asset type declarations', () => { + it('declare a string default export for exactly the supported extensions', () => { + const program = ts.createProgram({ + rootNames: [DECLARATIONS], + options: { noEmit: true, noLib: true, types: [] }, + }) + const checker = program.getTypeChecker() + const declarations = program.getSourceFile(DECLARATIONS) + if (declarations === undefined) throw new Error(`${DECLARATIONS} is not in the program`) + const modules = checker.getAmbientModules() + .filter(module => module.declarations?.some(declaration => declaration.getSourceFile() === declarations)) + + expect(modules.map(module => JSON.parse(module.getName()) as string).sort()) + .toEqual([...PACKAGE_ASSET_MEDIA_TYPES.keys()].map(extension => `*${extension}`).sort()) + for (const module of modules) { + const defaultExport = checker.getExportsOfModule(module).find(symbol => symbol.getName() === 'default') + if (defaultExport === undefined) throw new Error(`${module.getName()} declares no default export`) + expect(checker.typeToString(checker.getTypeOfSymbol(defaultExport)), module.getName()).toBe('string') + } + }) +}) diff --git a/scripts/client-bundle-css.spec.ts b/scripts/client-bundle-css.spec.ts index 29037d9f4a..f0eb694ea0 100644 --- a/scripts/client-bundle-css.spec.ts +++ b/scripts/client-bundle-css.spec.ts @@ -1,124 +1,382 @@ /** - * Stylesheets and images enter client bundles through plugin loaders, which - * must register their physical files as watch dependencies. + * Stylesheets and package assets enter client bundles through plugin loaders, + * which register their physical files as watch dependencies. Package assets + * are emitted under `lib/assets/` with revisioned file names and referenced by document-relative URLs. */ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { dirname, join } from 'node:path' +import type { UserConfig } from 'tsdown' +import { afterEach, describe, expect, it } from 'vitest' +import { packageAssetRevision } from '../packages/client/modules/src/assets.ts' import { clientBundle } from '../packages/client/tsdown.client.ts' +interface EmittedAsset { + type: 'asset' + fileName: string + source: Uint8Array + originalFileName: string +} + +interface PluginContext { + emitFile(file: EmittedAsset): string + addWatchFile(id: string): void +} + interface BundlePlugin { name: string + buildStart?: (this: PluginContext) => void resolveId?: (source: string, importer?: string) => string | null - load?: (this: { addWatchFile(id: string): void }, id: string) => Promise + load?: (this: PluginContext, id: string) => string | null | Promise + writeBundle?: (outputOptions: { dir?: string }, bundle: Record) => Promise } -function bundlePlugin(name: 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline' | 'dsh-image-inline'): BundlePlugin { - const configs = clientBundle( - join(import.meta.dirname, '../packages/client/ui-sidebar'), - ['lib/types/index.js'], - )({ env: { DSH_BUILD_FACE: 'client' } }) - const client = configs.find(config => config.platform === 'browser') - if (client === undefined) throw new Error('client config missing') - const plugins = (client as { plugins: BundlePlugin[] }).plugins - const plugin = plugins.find(candidate => candidate.name === name) - if (plugin === undefined) throw new Error(`${name} missing from client config`) +type PluginName = 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline' | 'dsh-package-asset' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-asset-fixture' +const ASSET_EXPORTS = { './client': './lib/client.js', './assets/*': './lib/assets/*' } +const WHALE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]) +const WHALE_REV = packageAssetRevision(WHALE) +const WHALE_URL = `plugins/@deepseek-ai/dsh-client-asset-fixture/assets/whale-${WHALE_REV}.png` + +const temporaryRoots: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +/** Create a spec-owned temporary directory, addressed by its real path. */ +async function temporaryDirectory(prefix: string): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), prefix))) + temporaryRoots.push(root) + return root +} + +async function writeFiles(root: string, files: Readonly>): Promise { + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }) + await writeFile(join(root, path), content) + } +} + +/** Create a client package fixture whose manifest declares the given exports. */ +async function assetPackage( + files: Readonly>, + exports: Readonly> = ASSET_EXPORTS, +): Promise { + const root = await temporaryDirectory('dsh-client-asset-') + await writeFiles(root, { 'package.json': JSON.stringify({ name: PACKAGE_NAME, exports }), ...files }) + return root +} + +/** Client-face configs of one package, as its tsdown.config.ts would return them. */ +function clientFaceConfigs(packageDir: string, dualClient = false): UserConfig[] { + return clientBundle(packageDir, ['lib/types/index.js'], { dualClient })({ env: { DSH_BUILD_FACE: 'client' } }) +} + +function configPlugin(config: UserConfig | undefined, name: string): BundlePlugin { + if (config === undefined) throw new Error('config missing') + const plugin = (config as { plugins: BundlePlugin[] }).plugins.find(candidate => candidate.name === name) + if (plugin === undefined) throw new Error(`${name} missing from ${String(config.name)}`) return plugin } +/** Look up plugins of one browser config, so stylesheet and asset plugins share its per-build state. */ +function browserPlugins(packageDir: string): (name: PluginName) => BundlePlugin { + const client = clientFaceConfigs(packageDir).find(config => config.platform === 'browser') + return name => configPlugin(client, name) +} + +function bundlePlugin(name: PluginName): BundlePlugin { + return browserPlugins(join(import.meta.dirname, '../packages/client/ui-sidebar'))(name) +} + +interface RecordingContext extends PluginContext { + readonly emitted: EmittedAsset[] + readonly watched: string[] +} + +function recordingContext(): RecordingContext { + const emitted: EmittedAsset[] = [] + const watched: string[] = [] + return { + emitted, + watched, + emitFile(file) { + emitted.push(file) + return file.fileName + }, + addWatchFile(id) { watched.push(id) }, + } +} + +/** Emitted files with hex sources, so Buffer and Uint8Array bytes compare equal. */ +function emittedFiles(context: { readonly emitted: readonly EmittedAsset[] }): object[] { + return context.emitted.map(file => ({ ...file, source: Buffer.from(file.source).toString('hex') })) +} + +function hooks(plugin: BundlePlugin): Required> { + if (plugin.resolveId === undefined || plugin.load === undefined) throw new Error(`${plugin.name} hooks are incomplete`) + return { resolveId: plugin.resolveId, load: plugin.load } +} + +/** Resolve and load one import through a plugin. */ +async function loadImport(plugin: BundlePlugin, context: PluginContext, source: string, importer: string): Promise { + const { resolveId, load } = hooks(plugin) + const id = resolveId(source, importer) + if (typeof id !== 'string') throw new Error(`${plugin.name} did not resolve ${source}`) + return await load.call(context, id) +} + describe('client bundle CSS Modules', () => { it('registers the source stylesheet as a watch dependency', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-client-css-watch-')) - try { - const stylesheet = join(root, 'Fixture.module.css') - const importer = join(root, 'index.ts') - await writeFile(stylesheet, '.root { color: red; }\n') - const plugin = bundlePlugin('dsh-css-modules-inline') - const virtualId = plugin.resolveId?.('./Fixture.module.css', importer) - if (typeof virtualId !== 'string' || plugin.load === undefined) { - throw new Error('CSS Modules plugin hooks are incomplete') - } - const watched: string[] = [] + const root = await temporaryDirectory('dsh-client-css-watch-') + const stylesheet = join(root, 'Fixture.module.css') + await writeFile(stylesheet, '.root { color: red; }\n') + const context = recordingContext() - const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId) + const output = await loadImport(bundlePlugin('dsh-css-modules-inline'), context, './Fixture.module.css', join(root, 'index.ts')) - expect(watched).toEqual([stylesheet]) - expect(output).toContain('data-plugin-css') - } finally { - await rm(root, { recursive: true, force: true }) - } + expect(context.watched).toEqual([stylesheet]) + expect(output).toContain('data-plugin-css') }) }) describe('client bundle global CSS', () => { it('compiles a side-effect stylesheet into a watched style injector', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-client-global-css-watch-')) - try { - const stylesheet = join(root, 'base.css') - const importer = join(root, 'index.ts') - await writeFile(stylesheet, 'body { color: red; }\n') - const plugin = bundlePlugin('dsh-css-global-inline') - const virtualId = plugin.resolveId?.('./base.css', importer) - if (typeof virtualId !== 'string' || plugin.load === undefined) { - throw new Error('global CSS plugin hooks are incomplete') - } - const watched: string[] = [] + const root = await temporaryDirectory('dsh-client-global-css-watch-') + const stylesheet = join(root, 'base.css') + await writeFile(stylesheet, 'body { color: red; }\n') + const context = recordingContext() - const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId) + const output = await loadImport(bundlePlugin('dsh-css-global-inline'), context, './base.css', join(root, 'index.ts')) - expect(watched).toEqual([stylesheet]) - expect(output).toContain('data-plugin-css') - expect(output).toContain('body{color:red}') - } finally { - await rm(root, { recursive: true, force: true }) - } + expect(context.watched).toEqual([stylesheet]) + expect(output).toContain('data-plugin-css') + expect(output).toContain('body{color:red}') }) it('compiles inline stylesheets as watched text without a module side effect', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-client-inline-css-watch-')) - try { - const stylesheet = join(root, 'base.css') - const importer = join(root, 'index.ts') - await writeFile(stylesheet, 'body { color: red; }\n') - const plugin = bundlePlugin('dsh-css-text-inline') - const virtualId = plugin.resolveId?.('./base.css?inline', importer) - if (typeof virtualId !== 'string' || plugin.load === undefined) { - throw new Error('inline CSS plugin hooks are incomplete') - } - const watched: string[] = [] + const root = await temporaryDirectory('dsh-client-inline-css-watch-') + const stylesheet = join(root, 'base.css') + await writeFile(stylesheet, 'body { color: red; }\n') + const context = recordingContext() - const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId) + const output = await loadImport(bundlePlugin('dsh-css-text-inline'), context, './base.css?inline', join(root, 'index.ts')) - expect(watched).toEqual([stylesheet]) - expect(output).toContain('export default "body{color:red}"') - expect(output).not.toContain('data-plugin-css') - } finally { - await rm(root, { recursive: true, force: true }) - } + expect(context.watched).toEqual([stylesheet]) + expect(output).toContain('export default "body{color:red}"') + expect(output).not.toContain('data-plugin-css') }) }) -describe('client bundle images', () => { - it('embeds an imported image as a watched data URL and ignores other modules', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-client-image-watch-')) - try { - const image = join(root, 'preview.svg') - const importer = join(root, 'index.ts') - await writeFile(image, '') - const plugin = bundlePlugin('dsh-image-inline') - const id = plugin.resolveId?.('./preview.svg', importer) - if (typeof id !== 'string' || plugin.load === undefined) throw new Error('image plugin hooks are incomplete') - const watched: string[] = [] +describe('client bundle package asset imports', () => { + it('emits an imported asset once per build and exports its revisioned URL', async () => { + const root = await assetPackage({ 'src/client/assets/icons/whale@2x.png': WHALE }) + const plugin = browserPlugins(root)('dsh-package-asset') + const { resolveId, load } = hooks(plugin) + const asset = join(root, 'src/client/assets/icons/whale@2x.png') + const context = recordingContext() - const output = await plugin.load.call({ addWatchFile: file => watched.push(file) }, id) + const id = resolveId('./assets/icons/whale@2x.png', join(root, 'lib/types/client/index.js')) + if (id === null) throw new Error('asset import did not resolve') + const first = await load.call(context, id) + const second = await load.call(context, id) - expect(watched).toEqual([image]) - expect(output).toBe(`export default "data:image/svg+xml;base64,${Buffer.from('').toString('base64')}";`) - expect(plugin.resolveId?.('./index.ts', importer)).toBeNull() - expect(await plugin.load.call({ addWatchFile: file => watched.push(file) }, importer)).toBeNull() - } finally { - await rm(root, { recursive: true, force: true }) - } + const expected = `export default "plugins/@deepseek-ai/dsh-client-asset-fixture/assets/icons/whale%402x-${WHALE_REV}.png";` + expect(id).toBe(asset) + expect([first, second]).toEqual([expected, expected]) + expect(emittedFiles(context)).toEqual([{ + type: 'asset', + fileName: `assets/icons/whale@2x-${WHALE_REV}.png`, + source: Buffer.from(WHALE).toString('hex'), + originalFileName: asset, + }]) + expect(context.watched).toEqual([asset]) + + plugin.buildStart?.call(context) + expect(await load.call(context, id)).toBe(expected) + expect(context.emitted).toHaveLength(2) + }) + + it('ignores imports and modules that are not supported assets', async () => { + const root = await assetPackage({ 'src/client/index.ts': '', 'src/client/assets/font.woff2': 'font' }) + const { resolveId, load } = hooks(browserPlugins(root)('dsh-package-asset')) + const importer = join(root, 'src/client/index.ts') + const context = recordingContext() + + expect(resolveId('./index.ts', importer)).toBeNull() + expect(resolveId('./assets/font.woff2', importer)).toBeNull() + expect(resolveId('./assets/whale.png', undefined)).toBeNull() + expect(await load.call(context, importer)).toBeNull() + expect(await load.call(context, 'whale.png')).toBeNull() + expect(context.emitted).toEqual([]) + }) + + it('rejects files outside the package src/client/assets directory', async () => { + const other = await assetPackage({ 'src/client/assets/whale.png': WHALE }) + const root = await assetPackage({ + 'src/client/whale.png': WHALE, + 'node_modules/dependency/package.json': JSON.stringify({ name: 'dependency' }), + 'node_modules/dependency/whale.png': WHALE, + }) + const plugin = browserPlugins(root)('dsh-package-asset') + const { load } = hooks(plugin) + const importer = join(root, 'src/client/index.ts') + const context = recordingContext() + + await expect(loadImport(plugin, context, './whale.png', importer)) + .rejects.toThrow(`${join(root, 'src/client/whale.png')} is outside src/client/assets; move the asset there`) + expect(() => load.call(context, join(other, 'src/client/assets/whale.png'))).toThrow('is outside src/client/assets') + await expect(loadImport(plugin, context, 'dependency/whale.png', importer)).rejects.toThrow('is outside src/client/assets') + expect(context.emitted).toEqual([]) + }) + + it.each([ + ['is missing', { './client': './lib/client.js' }], + ['targets another directory', { './assets/*': './src/client/assets/*' }], + ])('rejects an asset while the asset export %s', async (_case, exports) => { + const root = await assetPackage({ 'src/client/assets/whale.png': WHALE }, exports) + const { load } = hooks(browserPlugins(root)('dsh-package-asset')) + + expect(() => load.call(recordingContext(), join(root, 'src/client/assets/whale.png'))) + .toThrow('needs package.json exports {"./assets/*":"./lib/assets/*"} and "lib/assets" in files') + }) + + it('rejects a missing asset file by name', async () => { + const root = await assetPackage({}) + const asset = join(root, 'src/client/assets/missing.png') + const { load } = hooks(browserPlugins(root)('dsh-package-asset')) + + expect(() => load.call(recordingContext(), asset)).toThrow(`${PACKAGE_NAME}: cannot resolve asset ${asset}`) + }) + + it.each(['src/client/assets/100%.png', 'src/client/assets/icons#1/whale.png'])('rejects %s, which the asset route cannot serve', async (path) => { + const root = await assetPackage({ [path]: WHALE }) + const { load } = hooks(browserPlugins(root)('dsh-package-asset')) + + expect(() => load.call(recordingContext(), join(root, path))) + .toThrow(`${join(root, path)} has a path segment containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`) + }) + + it('rejects asset imports in the Node Client face', async () => { + const root = await assetPackage({}) + const node = clientFaceConfigs(root, true).find(config => config.name === `${PACKAGE_NAME}/client-node`) + const { resolveId } = configPlugin(node, 'dsh-package-asset-browser-only') + if (resolveId === undefined) throw new Error('browser-only guard has no resolveId hook') + const importer = join(root, 'lib/types/client/index.js') + + expect(() => resolveId('./assets/whale.png', importer)).toThrow(`${PACKAGE_NAME}: asset imports are browser-only (./assets/whale.png)`) + expect(resolveId('./index.js', importer)).toBeNull() + }) +}) + +describe('client bundle stylesheet url()', () => { + it('rewrites relative CSS Modules urls and keeps urls that name no package file', async () => { + const root = await assetPackage({ + 'src/client/assets/whale.png': WHALE, + 'src/client/View.module.css': [ + '.whale { mask: url(\'./assets/whale.png\') center / 100% 100% no-repeat; }', + '@supports (mask-image: url(\'\')) { .empty { background: url(\'\'); } }', + '.data { background: url("data:image/svg+xml,%3Csvg%3E"); }', + '.fragment { mask: url(#mask); }', + '.rooted { background: url(/static/a.png); }', + '.remote { background: url(https://example.com/a.png); }', + ].join('\n'), + }) + const context = recordingContext() + + const output = await loadImport(browserPlugins(root)('dsh-css-modules-inline'), context, './View.module.css', join(root, 'src/client/index.ts')) + + expect(output).toContain(`url(${WHALE_URL})`) + for (const preserved of ['(mask-image:url(\'\'))', 'background:url()', 'url(data:image/svg+xml,%3Csvg%3E)', 'url(#mask)', 'url(/static/a.png)', 'url(https://example.com/a.png)']) { + expect(output).toContain(preserved) + } + expect(emittedFiles(context)).toEqual([{ + type: 'asset', + fileName: `assets/whale-${WHALE_REV}.png`, + source: Buffer.from(WHALE).toString('hex'), + originalFileName: join(root, 'src/client/assets/whale.png'), + }]) + }) + + it.each([ + ['dsh-css-global-inline', './theme.css'], + ['dsh-css-text-inline', './theme.css?inline'], + ] as const)('rewrites relative urls through %s', async (name, source) => { + const root = await assetPackage({ + 'src/client/assets/whale.png': WHALE, + 'src/client/theme.css': 'body { background: url(./assets/whale.png); }', + }) + const context = recordingContext() + + const output = await loadImport(browserPlugins(root)(name), context, source, join(root, 'src/client/index.ts')) + + expect(output).toContain(`url(${WHALE_URL})`) + expect(context.emitted.map(file => file.fileName)).toEqual([`assets/whale-${WHALE_REV}.png`]) + }) + + it('leaves stylesheets outside the package src untouched', async () => { + const root = await assetPackage({ + 'node_modules/@fortune-sheet/react/dist/index.css': '.sheet { background: url("EwaAntH.gif"); }', + }) + const context = recordingContext() + + const output = await loadImport( + browserPlugins(root)('dsh-css-global-inline'), + context, + '../../node_modules/@fortune-sheet/react/dist/index.css', + join(root, 'src/client/index.ts'), + ) + + expect(output).toContain('url(EwaAntH.gif)') + expect(context.emitted).toEqual([]) + }) + + it.each([ + ['./assets/font.woff2', 'is not a supported asset; supported extensions: .svg, .png, .webp, .jpg, .jpeg'], + ['./whale.png', 'is outside src/client/assets; move the asset there'], + ['./assets/missing.png', 'cannot resolve asset'], + ])('rejects url(%s)', async (url, message) => { + const root = await assetPackage({ + 'src/client/whale.png': WHALE, + 'src/client/assets/font.woff2': 'font', + 'src/client/View.module.css': `.x { background: url(${url}); }`, + }) + + await expect(loadImport(browserPlugins(root)('dsh-css-modules-inline'), recordingContext(), './View.module.css', join(root, 'src/client/index.ts'))) + .rejects.toThrow(message) + }) +}) + +describe('client bundle asset output', () => { + it('prunes lib/assets files this build did not emit and the directories they leave empty', async () => { + const root = await assetPackage({ + 'lib/client.js': '', + 'lib/assets/whale.png': WHALE, + 'lib/assets/icons/kept.svg': '', + 'lib/assets/icons/stale.svg': '', + 'lib/assets/old/deep/stale.webp': 'webp', + }) + const { writeBundle } = browserPlugins(root)('dsh-package-asset') + if (writeBundle === undefined) throw new Error('asset plugin has no writeBundle hook') + const out = join(root, 'lib') + + await writeBundle({ dir: out }, { 'client.js': {}, 'assets/whale.png': {}, 'assets/icons/kept.svg': {} }) + + expect(['client.js', 'assets/whale.png', 'assets/icons/kept.svg', 'assets/icons/stale.svg', 'assets/old'] + .map(path => [path, existsSync(join(out, path))])) + .toEqual([ + ['client.js', true], + ['assets/whale.png', true], + ['assets/icons/kept.svg', true], + ['assets/icons/stale.svg', false], + ['assets/old', false], + ]) + + await writeBundle({ dir: out }, { 'client.js': {} }) + expect([existsSync(join(out, 'client.js')), existsSync(join(out, 'assets'))]).toEqual([true, false]) + await writeBundle({ dir: out }, { 'client.js': {} }) }) }) diff --git a/scripts/types/client-assets/index.d.ts b/scripts/types/client-assets/index.d.ts new file mode 100644 index 0000000000..b676e3e5cc --- /dev/null +++ b/scripts/types/client-assets/index.d.ts @@ -0,0 +1,30 @@ +/** + * Package images under `src/client/assets/`: the client build preset replaces + * each import with the asset's document-relative, content-revisioned URL. + * The module names mirror the supported extensions of the client-modules + * asset format. + */ +declare module '*.svg' { + const url: string + export default url +} + +declare module '*.png' { + const url: string + export default url +} + +declare module '*.webp' { + const url: string + export default url +} + +declare module '*.jpg' { + const url: string + export default url +} + +declare module '*.jpeg' { + const url: string + export default url +} diff --git a/scripts/verify-client-packages.spec.ts b/scripts/verify-client-packages.spec.ts index 8b8cf2e243..84a543d862 100644 --- a/scripts/verify-client-packages.spec.ts +++ b/scripts/verify-client-packages.spec.ts @@ -10,9 +10,12 @@ import { collectRuntimeLocalSourceSpecifiers, collectRuntimeSourcePackageUses, collectRuntimeSourceSpecifiers, + collectSourceAssetSpecifiers, collectSourcePackageUses, + collectStylesheetAssetUrls, fixClientPackageManifests, readClientDeclarations, + readStylesheetAssetReferences, type ClientDeclaration, type ClientPackage, type ClientPackageFacts, @@ -33,10 +36,13 @@ function declaration( name: short.startsWith('@') ? short : '@deepseek-ai/dsh-client-' + short, manifest: 'packages/client/' + short.replace(/^.*\//, '') + '/package.json', dynamic: true, + platform: 'web', external: [], inject: [], runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, + assetExport: undefined, + assetReferences: [], ...fields, } } @@ -285,6 +291,163 @@ describe('module requests', () => { }) }) +describe('package assets', () => { + const art = { file: 'packages/client/ui/src/client/View.tsx', reference: '"./assets/art.webp"' } + const mask = { file: 'packages/client/ui/src/client/View.module.css', reference: 'url("../assets/mask.png")' } + + it('collects relative runtime module specifiers with a package-asset extension', () => { + expect([...collectSourceAssetSpecifiers('View.tsx', [ + "import art from './assets/art.webp'", + "import back from '../assets/icons/back.SVG'", + "import './assets/side-effect.png'", + "export { default as photo } from './assets/photo.jpeg'", + "const lazy = () => import('./assets/lazy.jpg')", + "const legacy = require('./assets/legacy.svg')", + "import type Art from './assets/type-only.webp'", + "import styles from './View.module.css'", + "import font from './assets/font.woff2'", + "import raw from './assets/raw.svg?raw'", + "import shared from '@deepseek-ai/dsh-client-other/assets/shared.svg'", + "import rooted from '/assets/rooted.svg'", + ].join('\n'))]).toEqual([ + './assets/art.webp', + '../assets/icons/back.SVG', + './assets/side-effect.png', + './assets/photo.jpeg', + './assets/lazy.jpg', + './assets/legacy.svg', + ]) + }) + + it.each(['View.module.css', 'global.css'])('collects relative url() values from %s', (path) => { + expect(collectStylesheetAssetUrls(path, [ + ".plain { background: url('./x.png'); }", + '.parent { mask: url("../assets/x.png") center / 100% no-repeat; }', + '.bare { background: url(./bare.svg); }', + ".empty { background: url(''); }", + '.inline { background: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E"); }', + ".fragment { filter: url('#frag'); }", + ".rooted { background: url('/abs/x.png'); }", + '.network { background: url(//cdn.example.com/x.png); }', + ".remote { background: url('https://example.com/x.png'); }", + "@supports (mask-image: url('')) { .nested { mask: url(./nested.svg); } }", + ].join('\n'))).toEqual(['./x.png', '../assets/x.png', './bare.svg', './nested.svg']) + }) + + it('rejects an unparsable stylesheet with its path', () => { + expect(() => collectStylesheetAssetUrls('src/View.module.css', '.a { composes: b; } .c .d { composes: a; }')) + .toThrow('verify-client-packages: cannot parse stylesheet src/View.module.css: The `composes` property') + }) + + it('reads url() references only from package stylesheets that Client sources import', () => { + const root = mkdtempSync(join(tmpdir(), 'client-package-assets-')) + roots.push(root) + const files: Record = { + 'packages/client/ui/src/client/View.module.css': ".view { mask: url('../assets/mask.png'); background: url(data:,x); }", + 'packages/client/ui/src/client/global.css': '.global { background: url(./assets/global.svg); }', + 'packages/client/ui/src/client/text.css': '.text { background: url("./assets/text.webp"); }', + 'packages/client/ui/src/styles/static.css': '@font-face { src: url(./static.woff2); }', + 'packages/client/ui/styles/outside.css': '.outside { background: url(./outside.png); }', + } + for (const [path, value] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), value) + } + + expect(readStylesheetAssetReferences(root, 'packages/client/ui', new Map([ + ['packages/client/ui/src/client/View.tsx', ['./View.module.css', './global.css', './assets/art.webp']], + ['packages/client/ui/src/client/lazy.ts', ['./text.css?inline', '../../styles/outside.css', './View.module.css']], + ['packages/client/ui/src/index.ts', ['katex/dist/katex.css', './client/missing.css.ts']], + ]))).toEqual({ + stylesheets: [ + 'packages/client/ui/src/client/View.module.css', + 'packages/client/ui/src/client/global.css', + 'packages/client/ui/src/client/text.css', + ], + references: [ + { file: 'packages/client/ui/src/client/View.module.css', reference: 'url("../assets/mask.png")' }, + { file: 'packages/client/ui/src/client/global.css', reference: 'url("./assets/global.svg")' }, + { file: 'packages/client/ui/src/client/text.css', reference: 'url("./assets/text.webp")' }, + ], + }) + }) + + it('reads the Web platform and the raw asset export from manifests', () => { + const root = mkdtempSync(join(tmpdir(), 'client-package-asset-manifests-')) + roots.push(root) + const files: Record = { + 'packages/g/web/package.json': { + name: '@f/web', dsh: { client: { platform: 'web' } }, exports: { './assets/*': './lib/assets/*' }, + }, + 'packages/g/conditional/package.json': { + name: '@f/conditional', exports: { './assets/*': { default: './lib/assets/*' } }, + }, + 'packages/g/none/package.json': { name: '@f/none', dsh: { client: { platform: 1 } }, exports: './lib/index.js' }, + } + for (const [path, value] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), JSON.stringify(value)) + } + + expect(readClientDeclarations(root).declarations.map(entry => [entry.name, entry.platform, entry.assetExport])) + .toEqual([ + ['@f/conditional', undefined, { default: './lib/assets/*' }], + ['@f/none', undefined, undefined], + ['@f/web', 'web', './lib/assets/*'], + ]) + }) + + it('accepts referenced assets with the export and unreferenced packages without it', () => { + expect(collectClientPackageViolations(facts([], { + declarations: [ + declaration('ui', { assetExport: './lib/assets/*', assetReferences: [mask, art] }), + declaration('plain'), + declaration('shell', { dynamic: false, platform: undefined }), + ], + }))).toEqual([]) + }) + + it.each([ + ['./lib/assets/**', '"./lib/assets/**"'], + [{ default: './lib/assets/*' }, '{"default":"./lib/assets/*"}'], + [null, 'null'], + ])('rejects the asset export target %j', (target, shown) => { + const ui = declaration('ui', { assetExport: target, assetReferences: [art] }) + expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([ + ui.manifest + ': exports["./assets/*"] is ' + shown + '; it must be exactly "./lib/assets/*"', + ]) + }) + + it.each([ + ['a statically linked package', { dynamic: false, platform: undefined }], + ['a row for another platform', { platform: 'desktop' }], + ['a row without a platform', { platform: undefined }], + ] as const)('rejects the asset export on %s', (_label, fields) => { + const ui = declaration('ui', { ...fields, assetExport: './lib/assets/*', assetReferences: [art] }) + expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([ + ui.manifest + ': ' + ui.name + ' declares exports["./assets/*"] without a dsh.client row for platform "web";' + + ' only dynamic Web Client packages publish package assets', + ]) + }) + + it('rejects a referenced asset without the export and names the first reference', () => { + const ui = declaration('ui', { assetReferences: [mask, art] }) + expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([ + ui.manifest + ': ' + ui.name + ' references package asset url("../assets/mask.png") in ' + + 'packages/client/ui/src/client/View.module.css but declares no exports["./assets/*"];' + + ' add "./assets/*": "./lib/assets/*" and publish "lib/assets"', + ]) + }) + + it('rejects an unused asset export', () => { + const ui = declaration('ui', { assetExport: './lib/assets/*' }) + expect(collectClientPackageViolations(facts([], { declarations: [ui] }))).toEqual([ + ui.manifest + ': ' + ui.name + ' declares an unused ./assets/* export;' + + ' no Client source or stylesheet it imports references a package asset', + ]) + }) +}) + describe('manifest declarations', () => { it('reports malformed arrays without hiding other packages', () => { const root = mkdtempSync(join(tmpdir(), 'client-packages-')) diff --git a/scripts/verify-client-packages.ts b/scripts/verify-client-packages.ts index cd6145f8a3..a32bf85c46 100644 --- a/scripts/verify-client-packages.ts +++ b/scripts/verify-client-packages.ts @@ -1,11 +1,18 @@ /** - * Verify client package modes and the synchronous browser module-request graph. + * Verify client package modes, the synchronous browser module-request graph, and package-asset publication. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, resolve, sep } from 'node:path' +import { dirname, posix, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' +import { transform } from 'lightningcss' import ts from 'typescript' +import { + PACKAGE_ASSET_EXPORT, + PACKAGE_ASSET_EXPORT_TARGET, + PACKAGE_ASSET_FILES_ENTRY, + packageAssetMediaType, +} from '../packages/client/modules/src/assets.ts' import { TypeScriptProject } from './ts-project.ts' const GATE = 'verify-client-packages' @@ -16,18 +23,38 @@ const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts' const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts' const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts' const CORDIS = '@deepseek-ai/cordis' +/** Stylesheet requests the dynamic build preset compiles: CSS Modules, global sheets, and `?inline` text. */ +const STYLESHEET_REQUEST = /\.css(?:\?inline)?$/u +const INLINE_STYLESHEET_QUERY = /\?inline$/u + +/** One source location whose reference makes the dynamic build preset emit a package asset. */ +export interface AssetReference { + /** Repository-relative, `/`-separated source or stylesheet path. */ + readonly file: string + /** The JSON-quoted module specifier, or `url("")` for a stylesheet reference. */ + readonly reference: string +} /** One workspace package's browser-module declaration. */ export interface ClientDeclaration { readonly name: string readonly manifest: string readonly dynamic: boolean + /** String `dsh.client.platform`; undefined without a row or a string platform. */ + readonly platform: string | undefined readonly external: readonly string[] readonly runtimeSourceUses: Readonly> /** Exact runtime specifiers used to validate `dsh.client.external` declarations. */ readonly runtimeSourceSpecifiers: Readonly> /** Informational package dependencies declared by the row. */ readonly inject: readonly string[] + /** Raw `exports["./assets/*"]` value; undefined when the manifest declares no such subpath. */ + readonly assetExport: unknown + /** + * Package-asset references in a dynamic Web row's Client sources under `src/` and in the package stylesheets they + * import, sorted by file and then reference; empty for every other package. + */ + readonly assetReferences: readonly AssetReference[] } /** One package directly under packages/client. */ @@ -112,6 +139,87 @@ export function collectRuntimeLocalSourceSpecifiers(path: string, source: string return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative) } +/** + * Collect package-asset module specifiers retained by one production source file. + * @param path - File path used to select TypeScript's parser mode. + * @param source - Source text to inspect. + * @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type. + */ +export function collectSourceAssetSpecifiers(path: string, source: string): Set { + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) + return new Set([...collectSourceFileUses(sourceFile, true, 'local')].filter(isAssetSpecifier)) +} + +function isAssetSpecifier(specifier: string): boolean { + return packageAssetMediaType(specifier) !== undefined +} + +/** + * Collect the relative `url()` values of one stylesheet, which the dynamic build preset resolves to package assets. + * @param path - Stylesheet path; a `.module.css` name enables CSS Modules syntax, as in the build preset. + * @param source - Stylesheet text. + * @returns `url()` values in source order, excluding empty values, fragments, root-relative paths, and URLs with a + * scheme such as `data:` or `https:`. + * @throws when lightningcss cannot parse the stylesheet; the message names `path`. + */ +export function collectStylesheetAssetUrls(path: string, source: string): string[] { + const urls: string[] = [] + try { + transform({ + filename: path, + code: Buffer.from(source), + cssModules: path.endsWith('.module.css'), + visitor: { + Url(url) { + if (isRelativeStylesheetUrl(url.url)) urls.push(url.url) + }, + }, + }) + } catch (error) { + throw new Error( + GATE + ': cannot parse stylesheet ' + path + ': ' + (error instanceof Error ? error.message : String(error)), + { cause: error }, + ) + } + return urls +} + +function isRelativeStylesheetUrl(url: string): boolean { + return url !== '' && !url.startsWith('#') && !url.startsWith('/') && !/^[a-z][a-z\d+.-]*:/iu.test(url) +} + +/** + * Read package-asset references from the package stylesheets that Client sources import. The dynamic build preset + * compiles exactly these stylesheets; a stylesheet no Client source imports, such as a copied static payload, keeps + * its `url()` values unchanged. + * @param root - Absolute repository root. + * @param packageDirectory - Repository-relative, `/`-separated package directory. + * @param importers - Repository-relative, `/`-separated Client source paths and their runtime local module specifiers. + * @returns Imported `.css` and `.css?inline` stylesheets under the package's `src/`, in path order, and their references. + */ +export function readStylesheetAssetReferences( + root: string, + packageDirectory: string, + importers: ReadonlyMap>, +): { readonly stylesheets: readonly string[]; readonly references: readonly AssetReference[] } { + const sourcePrefix = packageDirectory + '/src/' + const imported = new Set() + for (const [file, specifiers] of importers) { + for (const specifier of specifiers) { + if (!specifier.startsWith('.') || !STYLESHEET_REQUEST.test(specifier)) continue + const stylesheet = posix.join(posix.dirname(file), specifier.replace(INLINE_STYLESHEET_QUERY, '')) + if (stylesheet.startsWith(sourcePrefix)) imported.add(stylesheet) + } + } + const stylesheets = [...imported].sort() + return { + stylesheets, + references: stylesheets.flatMap(file => + collectStylesheetAssetUrls(file, readFileSync(resolve(root, file), 'utf8')) + .map(url => ({ file, reference: 'url(' + JSON.stringify(url) + ')' }))), + } +} + function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean { const clause = node.importClause if (clause === undefined) return true @@ -190,7 +298,7 @@ export function readClientDeclarations(root: string): ClientDeclarations { /** * Return every client package policy violation. - * @param facts - Package modes, manifests, source uses, and platform module lists. + * @param facts - Package modes, manifests, source uses, package-asset references, and platform module lists. * @returns Stable self-contained diagnostics. */ export function collectClientPackageViolations(facts: ClientPackageFacts): string[] { @@ -198,6 +306,7 @@ export function collectClientPackageViolations(facts: ClientPackageFacts): strin ...facts.malformed, ...collectModeViolations(facts), ...collectModuleViolations(facts), + ...collectAssetViolations(facts), ].sort((left, right) => left.localeCompare(right)) } @@ -440,9 +549,51 @@ function formatCycle( return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '') } +/** Whether the dynamic build preset builds this package's browser face, the only face that emits package assets. */ +function buildsDynamicWebClient(declaration: ClientDeclaration): boolean { + return declaration.dynamic && declaration.platform === 'web' +} + +function collectAssetViolations(facts: ClientPackageFacts): string[] { + const violations: string[] = [] + const exportKey = 'exports[' + JSON.stringify(PACKAGE_ASSET_EXPORT) + ']' + for (const pkg of facts.declarations) { + const declared = pkg.assetExport !== undefined + const covered = buildsDynamicWebClient(pkg) + if (declared && pkg.assetExport !== PACKAGE_ASSET_EXPORT_TARGET) { + violations.push( + pkg.manifest + ': ' + exportKey + ' is ' + JSON.stringify(pkg.assetExport) + + '; it must be exactly ' + JSON.stringify(PACKAGE_ASSET_EXPORT_TARGET), + ) + } + if (declared && !covered) { + violations.push( + pkg.manifest + ': ' + pkg.name + ' declares ' + exportKey + ' without a dsh.client row for platform "web";' + + ' only dynamic Web Client packages publish package assets', + ) + } + if (!covered) continue + const first = pkg.assetReferences[0] + if (!declared && first !== undefined) { + violations.push( + pkg.manifest + ': ' + pkg.name + ' references package asset ' + first.reference + ' in ' + first.file + + ' but declares no ' + exportKey + '; add ' + JSON.stringify(PACKAGE_ASSET_EXPORT) + ': ' + + JSON.stringify(PACKAGE_ASSET_EXPORT_TARGET) + ' and publish ' + JSON.stringify(PACKAGE_ASSET_FILES_ENTRY), + ) + } else if (declared && first === undefined) { + violations.push( + pkg.manifest + ': ' + pkg.name + ' declares an unused ' + PACKAGE_ASSET_EXPORT + ' export;' + + ' no Client source or stylesheet it imports references a package asset', + ) + } + } + return violations +} + interface Manifest { name?: unknown dsh?: unknown + exports?: unknown dependencies?: Record peerDependencies?: Record devDependencies?: Record @@ -457,27 +608,27 @@ function readDeclaration( if (typeof manifest.name !== 'string') return undefined const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined const rawClient = dsh?.client - if (rawClient === undefined) { - return { - name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [], - runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, - } - } + const assetExport = isRecord(manifest.exports) ? manifest.exports[PACKAGE_ASSET_EXPORT] : undefined + const rowless = { + name: manifest.name, manifest: manifestPath, dynamic: false, platform: undefined, external: [], inject: [], + runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, assetExport, assetReferences: [], + } satisfies ClientDeclaration + if (rawClient === undefined) return rowless if (!isRecord(rawClient)) { malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object') - return { - name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [], - runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, - } + return rowless } return { name: manifest.name, manifest: manifestPath, dynamic: true, + platform: typeof rawClient.platform === 'string' ? rawClient.platform : undefined, external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed), inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed), runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, + assetExport, + assetReferences: [], } } @@ -563,9 +714,13 @@ async function readFacts(root: string): Promise { const staticLinkedPackages = await readStaticLinkedRoster(root) const project = new TypeScriptProject(root, 'client') const sourceFiles = project.sourceFiles() + let webStylesheets = 0 const declarations = bareDeclarations.map((declaration): ClientDeclaration => { const runtimeSourceUses = new Map>() const runtimeSourceSpecifiers = new Map>() + const assetReferences: AssetReference[] = [] + const scansAssets = buildsDynamicWebClient(declaration) + const importers = new Map>() const sourcePrefix = dirname(declaration.manifest) + '/src/' for (const sourceFile of sourceFiles) { if (sourceFile.isDeclarationFile) continue @@ -581,6 +736,24 @@ async function readFacts(root: string): Promise { locations.add(file) runtimeSourceSpecifiers.set(specifier, locations) } + if (!scansAssets) continue + const localSpecifiers = collectSourceFileUses(sourceFile, true, 'local') + importers.set(file, localSpecifiers) + for (const specifier of localSpecifiers) { + if (isAssetSpecifier(specifier)) assetReferences.push({ file, reference: JSON.stringify(specifier) }) + } + } + if (scansAssets) { + // A Web row always has a browser half, so an empty source set means the Client program stopped covering it. + if (importers.size === 0) { + throw new Error( + GATE + ': the Client program loads no source under ' + sourcePrefix + ' for dynamic Web row ' + + declaration.name + '; its package-asset references cannot be read', + ) + } + const stylesheets = readStylesheetAssetReferences(root, dirname(declaration.manifest), importers) + webStylesheets += stylesheets.stylesheets.length + assetReferences.push(...stylesheets.references) } return { ...declaration, @@ -592,8 +765,13 @@ async function readFacts(root: string): Promise { [...runtimeSourceSpecifiers].sort(([left], [right]) => left.localeCompare(right)) .map(([specifier, locations]) => [specifier, [...locations].sort()]), ), + assetReferences: assetReferences.sort((left, right) => + left.file.localeCompare(right.file) || left.reference.localeCompare(right.reference)), } }) + if (webStylesheets === 0) { + throw new Error(GATE + ': no dynamic Web row imports a stylesheet from its src/ directory; the stylesheet scan read an empty corpus') + } const byManifest = new Map(declarations.map(entry => [entry.manifest, entry])) const packages: ClientPackage[] = [] @@ -701,10 +879,12 @@ async function main(): Promise { const dynamic = facts.packages.filter(pkg => pkg.dynamic).length const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0) + const assetExports = facts.declarations.filter(pkg => pkg.assetExport !== undefined).length console.log( GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, ' - + String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode and module-request rules; ' - + String(requests) + ' explicit external request(s).', + + String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode, module-request, and ' + + 'package-asset rules; ' + String(requests) + ' explicit external request(s), ' + String(assetExports) + + ' package-asset export(s).', ) } diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index ca5f646337..aa17d85fea 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -707,6 +707,36 @@ describe('face-aware source classification', () => { })).rejects.toThrow('Host export ./node cannot map ./other/node.js to a source entry') }) + it.each(['./lib/assets/*', { default: './lib/assets/*.js' }])( + 'does not classify the package-asset export %j as Host code', async (target) => { + const root = writeWorkspace({ + 'packages/client/ui-probe/package.json': { + name: '@deepseek-ai/dsh-client-ui-probe', + dsh: { client: { platform: 'web' } }, + exports: { + '.': { types: './lib/types/index.d.ts', default: './lib/index.js' }, + './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' }, + './assets/*': target, + }, + ...CORDIS_PEER, + }, + 'packages/client/ui-probe/src/index.ts': 'export function apply() {}\n', + 'packages/client/ui-probe/src/client/index.ts': "import art from './assets/art.webp'\nexport const image = art\n", + }) + + const state = await readPackageDependencyState(root, policy()) + + expect(state.facts.map(fact => fact.manifestPath)).toEqual(['packages/client/ui-probe/package.json']) + expect(collectPackageDependencyViolations(state)).toEqual([]) + }, + ) + + it('scans other subpaths below ./assets as Host exports', async () => { + await expect(sourceFacts({ 'src/index.ts': 'export function apply() {}' }, { + exports: { './assets/index': './lib/assets/index.js' }, + })).rejects.toThrow('Host export ./assets/index has no source entry') + }) + it('fails when a managed Host package has no Host entry', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-package-missing-host-')) roots.push(root) diff --git a/scripts/verify-package-dependencies.ts b/scripts/verify-package-dependencies.ts index a479c5bc5f..f73ae4ee0c 100644 --- a/scripts/verify-package-dependencies.ts +++ b/scripts/verify-package-dependencies.ts @@ -10,6 +10,7 @@ import { dirname, extname, join, normalize, relative, resolve, sep } from 'node: import { pathToFileURL } from 'node:url' import ts from 'typescript' import { WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts' +import { PACKAGE_ASSET_EXPORT } from '../packages/client/modules/src/assets.ts' import { writeModuleGraph } from './gen-module-graph.ts' import { hasClientDeclaration, @@ -363,7 +364,10 @@ function generatedHostSources(root: string, packages: readonly WorkspacePackageM return sources } -/** Source-backed Node exports; generated Typert artifacts have no standalone source entry. */ +/** + * Source-backed Node exports. Generated Typert artifacts have no standalone source entry; `./client`, `./src`, and + * package-asset subpaths publish no Host code. + */ function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[] { const entry = resolve(root, pkg.dir, 'src/index.ts') if (!existsSync(entry)) { @@ -373,7 +377,8 @@ function hostSourceEntries(root: string, pkg: WorkspacePackageManifest): string[ const exports = pkg.manifest.exports if (exports === null || typeof exports !== 'object') return [...entries] for (const [subpath, declaration] of Object.entries(exports as Record)) { - if (subpath === '.' || subpath === './package.json' || /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue + if (subpath === '.' || subpath === './package.json' || subpath === PACKAGE_ASSET_EXPORT + || /^\.\/(?:client|src)(?:\/|$)/.test(subpath)) continue const types = declaration !== null && typeof declaration === 'object' && 'types' in declaration ? declaration.types : undefined diff --git a/tsconfig.base.client.json b/tsconfig.base.client.json index 6af2056c90..0f1dbfcfe7 100644 --- a/tsconfig.base.client.json +++ b/tsconfig.base.client.json @@ -1,12 +1,13 @@ { // Client-side compiler shape shared by tsconfig.client.json and every - // packages/client/* package: browser library API, React JSX, no ambient + // packages/client/* package: browser library API, React JSX, build-time + // environment values and package-image module declarations, no ambient // node types (packages that need them override locally). "extends": "./tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", "lib": ["ES2024", "DOM", "DOM.Iterable", "ESNext.Disposable"], "typeRoots": ["./scripts/types", "./node_modules/@types"], - "types": ["client-build-environment"] + "types": ["client-build-environment", "client-assets"] } }