mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(client): serve plugin package assets by URL instead of data URLs
This commit is contained in:
1 parent
cd2d7dea0c
commit
ce84953e46
33 files changed
+1678
-214
No files matched your search
@@ -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<void> {
|
||||
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()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<name>` 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-<name>`, 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-<name>`, 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.
|
||||
|
||||
@@ -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/<package>/assets/<emitted path>`, so one URL always names one content. Any `/plugins/<package>/assets/` path is answered by the asset route alone: for an active client row, the Host percent-decodes the path once and resolves `<package>/assets/<path>` 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: "<requested path>"`, 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.<name>.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 `<script>` fails to load is requested once more; a combo that loads without registering a row is never re-executed, because the batch script registers its packages in sequence and a replay would stop at the first duplicate registration. In both cases each still-missing row then loads its own one-resource combo URL, so one failed batch costs at most three requests per missing row and leaves the rows it did register untouched. The module system records the last import failure per row (transport, registration, dependency cascade, or factory execution); the Web boot audit reports that text per entry. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
|
||||
@@ -92,6 +96,7 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows |
|
||||
| [`src/assets.ts`](src/assets.ts) | Package-asset media types, export, content revision, revisioned file name, and browser reference shared by the build preset, the asset route, and gates |
|
||||
| [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment |
|
||||
| [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery |
|
||||
| [`src/client/entries.ts`](src/client/entries.ts) | Page entry reconciliation, retries and code replacement |
|
||||
@@ -138,6 +143,8 @@ These limits define what the module system does not do. They are current package
|
||||
- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
|
||||
- **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
|
||||
- **An unrequested prior-generation map reads the current map file** — combo revisions track executable bundles, not debug artifacts. If HMR rebuilds a map before the retained prior URL receives its first map `GET`, that response uses the current authored map with the prior bundle offsets; requesting the map before the rebuild fixes that URL's response.
|
||||
- **WebWorker preview cannot load package assets** — the preview reaches `/plugins` only through its tunnel, so an `<img>` or CSS `url()` request never reaches the Host running in the Worker; the WebWorker runtime owns serving them.
|
||||
- **Desktop re-reads package assets on every display** — the Desktop forwarder rewrites every `/plugins/` cache header to `no-store`, so the immutable asset headers do not reach its browser cache.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Package-asset format shared by the client build preset, the Host asset
|
||||
* route, and repository gates: the supported media types, the export that
|
||||
* publishes built assets, the content revision, and the document-relative
|
||||
* browser reference. Changing any value here changes a published URL or
|
||||
* package-manifest requirement, so the preset, the route, and the gates read
|
||||
* it from this one module.
|
||||
* @module @deepseek-ai/dsh-client-modules/src/assets
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** Route segment between a package name and its asset path. */
|
||||
export const PACKAGE_ASSET_SEGMENT = 'assets'
|
||||
|
||||
/** Media type served for each supported asset extension, keyed by the lowercase extension with its dot. */
|
||||
export const PACKAGE_ASSET_MEDIA_TYPES: ReadonlyMap<string, string> = new Map([
|
||||
['.svg', 'image/svg+xml'],
|
||||
['.png', 'image/png'],
|
||||
['.webp', 'image/webp'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
])
|
||||
|
||||
/** Package export key that publishes built assets. */
|
||||
export const PACKAGE_ASSET_EXPORT = './assets/*'
|
||||
|
||||
/** The only accepted target of {@link PACKAGE_ASSET_EXPORT}. */
|
||||
export const PACKAGE_ASSET_EXPORT_TARGET = './lib/assets/*'
|
||||
|
||||
/** The `files` entry that publishes {@link PACKAGE_ASSET_EXPORT_TARGET}. */
|
||||
export const PACKAGE_ASSET_FILES_ENTRY = 'lib/assets'
|
||||
|
||||
/** Hex digits kept from the content digest; the same length as client artifact revisions. */
|
||||
const PACKAGE_ASSET_REVISION_LENGTH = 12
|
||||
|
||||
/**
|
||||
* Look up the media type of an asset path.
|
||||
* @param assetPath - file path or URL path; only its final extension is read, case-insensitively.
|
||||
* @returns the served media type, or undefined when the extension is unsupported.
|
||||
*/
|
||||
export function packageAssetMediaType(assetPath: string): string | undefined {
|
||||
const name = assetPath.slice(Math.max(assetPath.lastIndexOf('/'), assetPath.lastIndexOf('\\')) + 1)
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot <= 0 ? undefined : PACKAGE_ASSET_MEDIA_TYPES.get(name.slice(dot).toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the content revision of one built asset. The build writes it into
|
||||
* the emitted file name, so a URL names exactly one content and a rebuilt
|
||||
* asset gets a new URL.
|
||||
* @param bytes - exact bytes written under `lib/assets/`.
|
||||
* @returns the first 12 hexadecimal digits of the SHA-1 digest.
|
||||
*/
|
||||
export function packageAssetRevision(bytes: Uint8Array): string {
|
||||
return createHash('sha1').update(bytes).digest('hex').slice(0, PACKAGE_ASSET_REVISION_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the emitted file of one asset by inserting its revision before the extension.
|
||||
* @param assetPath - `/`-separated path below `src/client/assets/`, such as `icons/whale@2x.png`.
|
||||
* @param rev - {@link packageAssetRevision} of the asset bytes.
|
||||
* @returns the path below `lib/assets/`, such as `icons/whale@2x-<rev>.png`.
|
||||
*/
|
||||
export function packageAssetRevisionedPath(assetPath: string, rev: string): string {
|
||||
const dot = assetPath.lastIndexOf('.')
|
||||
return dot > assetPath.lastIndexOf('/') + 1
|
||||
? `${assetPath.slice(0, dot)}-${rev}${assetPath.slice(dot)}`
|
||||
: `${assetPath}-${rev}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the browser reference to one built asset. The reference is relative
|
||||
* to the shell document, like every other app-owned route, so it resolves
|
||||
* under any document mount and from injected `<style>` text.
|
||||
* @param packageName - owning package name, emitted verbatim; a scoped name keeps its `/`.
|
||||
* @param emittedPath - {@link packageAssetRevisionedPath} result below `lib/assets/`; each segment is percent-encoded.
|
||||
* @returns `plugins/<packageName>/assets/<path>`.
|
||||
*/
|
||||
export function packageAssetReference(packageName: string, emittedPath: string): string {
|
||||
const path = emittedPath.split('/').map(segment => encodeURIComponent(segment)).join('/')
|
||||
return `plugins/${packageName}/${PACKAGE_ASSET_SEGMENT}/${path}`
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* the host Loader's entries for packages declaring `dsh.client`, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`) in module-graph order, serves one-or-more-plugin
|
||||
* combo scripts plus their source maps,
|
||||
* combo scripts plus their source maps, package-local chunks, and the
|
||||
* content-revisioned `./assets/*` exports of active client packages,
|
||||
* contributes the registration facade, application preloads, bootstrap scripts,
|
||||
* and graph to the webserver's index injection table, and provides the
|
||||
* `clientModules` service (the HMR node half's registration/notification
|
||||
@@ -24,16 +25,19 @@
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { PACKAGE_ASSET_SEGMENT, packageAssetMediaType } from './assets.ts'
|
||||
import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
|
||||
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph, WebBootRelease } from './client/manifest.ts'
|
||||
|
||||
@@ -72,6 +76,8 @@ export interface ClientArtifactBaseline {
|
||||
/** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
|
||||
interface PkgMeta extends WebBootRowFields {
|
||||
clientPath: string
|
||||
/** The package's own manifest; package-asset requests resolve against it as a self-reference. */
|
||||
manifestPath: string
|
||||
}
|
||||
|
||||
interface ResolvedPkgMeta {
|
||||
@@ -225,6 +231,24 @@ function artifactRevision(baseline: ClientArtifactBaseline): string {
|
||||
/** Absolute route prefix serving every plugin resource. */
|
||||
const PLUGIN_ROUTE = '/plugins'
|
||||
|
||||
/** `/plugins/<package>/assets/<path>`; a package name holds at most one `/`, after its `@scope`. */
|
||||
const PACKAGE_ASSET_PATH = /^\/plugins\/((?:@[^/]+\/)?[^/]+)\/assets\/(.*)$/
|
||||
|
||||
/** Expose a Node read stream as a shell-carrier Response body; cancelling the body closes the stream. */
|
||||
function transforStreamBody(stream: Readable): ReadableStream<Uint8Array> {
|
||||
const chunks: AsyncIterator<Uint8Array> = stream[Symbol.asyncIterator]()
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
const next = await chunks.next()
|
||||
if (next.done === true) controller.close()
|
||||
else controller.enqueue(next.value)
|
||||
},
|
||||
async cancel() {
|
||||
await chunks.return?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Combo query addressing one ordered plugin-file list. */
|
||||
function comboSearch(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
|
||||
@@ -595,8 +619,8 @@ window.__ModuleLoader__={
|
||||
|
||||
/**
|
||||
* 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
|
||||
* + 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).
|
||||
*/
|
||||
@@ -690,16 +714,19 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async fetchBundle(request: Request): Promise<Response> {
|
||||
const resource = await this.bundleResource(request.method, request.url)
|
||||
const body = resource.body === undefined ? null : Uint8Array.from(resource.body)
|
||||
const resource = await this.bundleResource(request.method, request.url, request.headers.get('if-none-match') ?? undefined)
|
||||
const body = resource.body === undefined ? null
|
||||
: resource.body instanceof Readable ? transforStreamBody(resource.body) : Uint8Array.from(resource.body)
|
||||
return new Response(body, {
|
||||
status: resource.status,
|
||||
...(resource.headers === undefined ? {} : { headers: resource.headers }),
|
||||
@@ -869,6 +896,7 @@ export class ClientModuleRegistry extends Service {
|
||||
}
|
||||
const meta: PkgMeta = {
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
manifestPath: pkgPath,
|
||||
...(decl.inject !== undefined ? { inject: decl.inject } : {}),
|
||||
external: decl.external ?? [],
|
||||
immediately: decl.immediately === true,
|
||||
@@ -1150,13 +1178,71 @@ export class ClientModuleRegistry extends Service {
|
||||
return response
|
||||
}
|
||||
|
||||
private async bundleResource(method: string | undefined, url: string): Promise<{
|
||||
/**
|
||||
* Answer one package-asset request. Node's `require.resolve` applies the
|
||||
* package's `./assets/*` export as a self-reference, so unexported,
|
||||
* `.`/`..`, missing, and directory paths answer 404. The build writes each
|
||||
* asset's content revision into its file name, so a served path names one
|
||||
* content: the body streams from disk on every request, and the path is the
|
||||
* entity tag.
|
||||
* @param method - request method.
|
||||
* @param record - the active row of the addressed package, or undefined when it is not active.
|
||||
* @param assetPath - percent-encoded path below the package's `assets/` route segment.
|
||||
* @param ifNoneMatch - request `If-None-Match` field.
|
||||
* @returns the route answer.
|
||||
*/
|
||||
private assetResponse(
|
||||
method: 'GET' | 'HEAD',
|
||||
record: WebPluginRecord | undefined,
|
||||
assetPath: string,
|
||||
ifNoneMatch: string | undefined,
|
||||
): {
|
||||
status: number
|
||||
headers?: Record<string, string>
|
||||
body?: Buffer
|
||||
body?: Readable
|
||||
} {
|
||||
if (record === undefined || assetPath === '') return { status: 404 }
|
||||
let file: string
|
||||
try {
|
||||
file = createRequire(record.meta.manifestPath)
|
||||
.resolve(`${record.entry.id}/${PACKAGE_ASSET_SEGMENT}/${decodeURIComponent(assetPath)}`)
|
||||
} catch {
|
||||
// Unresolvable or malformed paths name no asset.
|
||||
return { status: 404 }
|
||||
}
|
||||
// The resolved file decides the type: Node reads `#` and `?` in the subpath as URL syntax.
|
||||
const mediaType = packageAssetMediaType(file)
|
||||
if (mediaType === undefined) return { status: 404 }
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': mediaType,
|
||||
'cache-control': IMMUTABLE_CACHE,
|
||||
etag: `"${assetPath}"`,
|
||||
'x-content-type-options': 'nosniff',
|
||||
// A directly opened SVG document runs no script.
|
||||
...(mediaType === 'image/svg+xml' ? { 'content-security-policy': "script-src 'none'" } : {}),
|
||||
}
|
||||
if (ifNoneMatch === headers.etag) return { status: 304, headers }
|
||||
return { status: 200, headers, ...(method === 'HEAD' ? {} : { body: createReadStream(file) }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer one `/plugins` request. A `/plugins/<package>/assets/` path is
|
||||
* answered by the package-asset route alone; any other path is an advertised
|
||||
* combo or its map, a prior startup generation, or a current-revision
|
||||
* package-local chunk, in that order, and otherwise 404.
|
||||
*/
|
||||
private async bundleResource(method: string | undefined, url: string, ifNoneMatch: string | undefined): Promise<{
|
||||
status: number
|
||||
headers?: Record<string, string>
|
||||
body?: Buffer | Readable
|
||||
}> {
|
||||
if (method !== 'GET' && method !== 'HEAD') return { status: 405 }
|
||||
const requestUrl = new URL(url, 'http://x')
|
||||
const asset = PACKAGE_ASSET_PATH.exec(requestUrl.pathname)
|
||||
if (asset !== null) {
|
||||
const [, packageName = '', assetPath = ''] = asset
|
||||
return this.assetResponse(method, this.table.get(packageName), assetPath, ifNoneMatch)
|
||||
}
|
||||
const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
|
||||
const response = this.responses.get(resourceUrl)
|
||||
?? this.previousBatchResponses.get(resourceUrl)
|
||||
@@ -1175,9 +1261,10 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
||||
const response = await this.bundleResource(req.method, req.url ?? '/')
|
||||
const response = await this.bundleResource(req.method, req.url ?? '/', req.headers['if-none-match'])
|
||||
res.writeHead(response.status, response.headers)
|
||||
res.end(response.body)
|
||||
if (response.body instanceof Readable) await pipeline(response.body, res)
|
||||
else res.end(response.body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Package-asset format: media types, content revisions, revisioned file names, and document-relative references. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT,
|
||||
PACKAGE_ASSET_EXPORT_TARGET,
|
||||
PACKAGE_ASSET_FILES_ENTRY,
|
||||
PACKAGE_ASSET_SEGMENT,
|
||||
packageAssetMediaType,
|
||||
packageAssetReference,
|
||||
packageAssetRevision,
|
||||
packageAssetRevisionedPath,
|
||||
} from '../src/assets.ts'
|
||||
|
||||
describe('packageAssetMediaType', () => {
|
||||
it.each([
|
||||
['icon.svg', 'image/svg+xml'],
|
||||
['photo.png', 'image/png'],
|
||||
['nested/photo.webp', 'image/webp'],
|
||||
['photo.jpg', 'image/jpeg'],
|
||||
['photo.jpeg', 'image/jpeg'],
|
||||
['PHOTO.PNG', 'image/png'],
|
||||
['Icon.SvG', 'image/svg+xml'],
|
||||
['archive.txt.png', 'image/png'],
|
||||
['nested\\photo.png', 'image/png'],
|
||||
['C:\\assets\\photo.webp', 'image/webp'],
|
||||
])('serves %s as %s', (assetPath, mediaType) => {
|
||||
expect(packageAssetMediaType(assetPath)).toBe(mediaType)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['photo', 'no extension'],
|
||||
['.png', 'a dotfile'],
|
||||
['nested/.png', 'a nested dotfile'],
|
||||
['photo.png/readme', 'an extension on a parent segment only'],
|
||||
['photo.png\\readme', 'an extension before a backslash separator'],
|
||||
['photo.gif', 'an unsupported extension'],
|
||||
['photo.png.txt', 'an unsupported final extension'],
|
||||
['photo.', 'an empty extension'],
|
||||
])('serves nothing for %s (%s)', (assetPath) => {
|
||||
expect(packageAssetMediaType(assetPath)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetRevision', () => {
|
||||
it('keeps the first 12 hexadecimal digits of the SHA-1 content digest', () => {
|
||||
expect(packageAssetRevision(new TextEncoder().encode('abc'))).toBe('a9993e364706')
|
||||
expect(packageAssetRevision(new Uint8Array())).toBe('da39a3ee5e6b')
|
||||
})
|
||||
|
||||
it('changes with the asset bytes', () => {
|
||||
expect(packageAssetRevision(Uint8Array.of(1))).not.toBe(packageAssetRevision(Uint8Array.of(2)))
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetRevisionedPath', () => {
|
||||
it.each([
|
||||
['photo.png', 'photo-a9993e364706.png'],
|
||||
['icons/whale@2x.png', 'icons/whale@2x-a9993e364706.png'],
|
||||
['icons/badge.zh.svg', 'icons/badge.zh-a9993e364706.svg'],
|
||||
['icons.v2/photo', 'icons.v2/photo-a9993e364706'],
|
||||
['icons/.png', 'icons/.png-a9993e364706'],
|
||||
])('inserts the revision before the extension of %s', (assetPath, emitted) => {
|
||||
expect(packageAssetRevisionedPath(assetPath, 'a9993e364706')).toBe(emitted)
|
||||
})
|
||||
})
|
||||
|
||||
describe('packageAssetReference', () => {
|
||||
it('keeps a scoped package name verbatim and encodes each path segment', () => {
|
||||
expect(packageAssetReference('@scope/pkg', 'icons/a b#?%-a9993e364706.svg'))
|
||||
.toBe('plugins/@scope/pkg/assets/icons/a%20b%23%3F%25-a9993e364706.svg')
|
||||
})
|
||||
|
||||
it('addresses the document-relative form of the plugin asset route without a query', () => {
|
||||
const reference = packageAssetReference('plain', 'photo-a9993e364706.png')
|
||||
expect(reference).toBe('plugins/plain/assets/photo-a9993e364706.png')
|
||||
expect(reference.startsWith(`plugins/plain/${PACKAGE_ASSET_SEGMENT}/`)).toBe(true)
|
||||
})
|
||||
|
||||
it('pairs the asset export with its only accepted target and published files entry', () => {
|
||||
expect(PACKAGE_ASSET_EXPORT).toBe('./assets/*')
|
||||
expect(PACKAGE_ASSET_EXPORT_TARGET).toBe(`./${PACKAGE_ASSET_FILES_ENTRY}/*`)
|
||||
})
|
||||
})
|
||||
@@ -224,7 +224,7 @@ async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
await (await route).handler({ method, url } as IncomingMessage, response)
|
||||
await (await route).handler({ method, url, headers: {} } as IncomingMessage, response)
|
||||
return { status, headers, body }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/** Host package-asset route: exported `./assets/*` files streamed under their revisioned names. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { ReadStream } from 'node:fs'
|
||||
import { request } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { packageAssetReference } from '../src/assets.ts'
|
||||
import { ClientModuleRegistry } from '../src/index.ts'
|
||||
|
||||
/** File read streams the route opens, in order, to observe hashing and body streaming. */
|
||||
const opened = vi.hoisted(() => [] as { path: string; stream: ReadStream }[])
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
createReadStream: (path: Parameters<typeof actual.createReadStream>[0], options?: Parameters<typeof actual.createReadStream>[1]) => {
|
||||
const stream = actual.createReadStream(path, options)
|
||||
opened.push({ path: String(path), stream })
|
||||
return stream
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const ASSETS_ID = '@fixture/assets'
|
||||
const NO_EXPORT_ID = '@fixture/no-export'
|
||||
const INACTIVE_ID = '@fixture/inactive'
|
||||
/** Response headers the asset route owns. */
|
||||
const ASSET_HEADERS = ['content-type', 'cache-control', 'etag', 'x-content-type-options', 'content-security-policy'] as const
|
||||
const IMMUTABLE = 'public, max-age=31536000, immutable'
|
||||
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>\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, string | null>): 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<Record<typeof ASSET_HEADERS[number], string>>
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
/** 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<Answer>
|
||||
}
|
||||
|
||||
/** Compose the real Web server and the modules service over a Loader double listing the active rows. */
|
||||
async function composeHost(): Promise<Host> {
|
||||
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<Answer> => 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<Answer> => {
|
||||
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) })
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
const isolation = clientInputIsolation(id)
|
||||
@@ -358,6 +377,8 @@ interface PackageManifest {
|
||||
readonly dependencies?: Record<string, string>
|
||||
readonly peerDependencies?: Record<string, string>
|
||||
readonly optionalDependencies?: Record<string, string>
|
||||
/** Package exports; the browser face emits assets only while `./assets/*` publishes `lib/assets`. */
|
||||
readonly exports?: Readonly<Record<string, unknown>>
|
||||
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<CustomAtRules> }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>()
|
||||
|
||||
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<boolean> {
|
||||
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<string> | 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<string, string> = {}
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
File renamed without changes.
@@ -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;
|
||||
|
||||
@@ -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;) {
|
||||
|
||||
@@ -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'])
|
||||
@@ -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.
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Response>',
|
||||
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.',
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string>()
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string | null>
|
||||
load?: (this: PluginContext, id: string) => string | null | Promise<string | null>
|
||||
writeBundle?: (outputOptions: { dir?: string }, bundle: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
|
||||
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<string> {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), prefix)))
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function writeFiles(root: string, files: Readonly<Record<string, string | Uint8Array>>): Promise<void> {
|
||||
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<Record<string, string | Uint8Array>>,
|
||||
exports: Readonly<Record<string, string>> = ASSET_EXPORTS,
|
||||
): Promise<string> {
|
||||
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<Pick<BundlePlugin, 'resolveId' | 'load'>> {
|
||||
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<string | null> {
|
||||
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, '<svg/>')
|
||||
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('<svg/>').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': '<svg/>',
|
||||
'lib/assets/icons/stale.svg': '<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': {} })
|
||||
})
|
||||
})
|
||||
+30
@@ -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
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'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<string, unknown> = {
|
||||
'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-'))
|
||||
|
||||
@@ -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("<value>")` 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<Record<string, readonly string[]>>
|
||||
/** Exact runtime specifiers used to validate `dsh.client.external` declarations. */
|
||||
readonly runtimeSourceSpecifiers: Readonly<Record<string, readonly string[]>>
|
||||
/** 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<string> {
|
||||
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<string, Iterable<string>>,
|
||||
): { readonly stylesheets: readonly string[]; readonly references: readonly AssetReference[] } {
|
||||
const sourcePrefix = packageDirectory + '/src/'
|
||||
const imported = new Set<string>()
|
||||
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<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
@@ -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<ClientPackageFacts> {
|
||||
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<string, Set<string>>()
|
||||
const runtimeSourceSpecifiers = new Map<string, Set<string>>()
|
||||
const assetReferences: AssetReference[] = []
|
||||
const scansAssets = buildsDynamicWebClient(declaration)
|
||||
const importers = new Map<string, ReadonlySet<string>>()
|
||||
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<ClientPackageFacts> {
|
||||
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<ClientPackageFacts> {
|
||||
[...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<void> {
|
||||
|
||||
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).',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown>)) {
|
||||
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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user