fix(client-modules): reread package manifests when Loader entries restart

This commit is contained in:
imccyu committed 2026-10-20 02:13:37 +08:00
1 parent ce84953e46
commit 0ecc1dec41
3 files changed
+85 -26

No files matched your search

+2 -1
View File
@@ -77,7 +77,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
### Incremental composition
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Each reconciliation reads the package manifest and bundle again, so an entry that restarts after its package was reinstalled, upgraded, or moved publishes its current `dsh.client` declaration and client export; an unchanged bundle keeps its revision, so the browser keeps the loaded plugin. The resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads that script through the asynchronous file API, so a filesystem that materializes files on first asynchronous read can supply it, and caches it; its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial publication and HMR derive per-plugin revisions from the entry's `mtimeMs`, `ctimeMs`, and size, without hashing artifact contents. Status-change time distinguishes rewrites that preserve mtime and size. Unchanged artifacts therefore retain their revisions across Host restarts, so SSE reconnection does not replace their browser plugins. The shared preset stamps `client.js` after every package output is written, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
@@ -143,6 +143,7 @@ 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.
- **A manifest edit alone is not observed** — the Host reads a package manifest when its Loader entry starts or restarts; editing only `package.json` takes effect at the next entry restart or Host restart.
- **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.
+19 -25
View File
@@ -15,12 +15,12 @@
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
* against the live loader entries. The activation pass seeds the same dirty
* set with all current entries and flushes synchronously, so first scan and
* steady state share one implementation. Package metadata (including the
* negative "not a client package" verdict) is cached per Loader specifier and
* owning-tree base URL until restart. The manifest package name identifies
* the browser module; distinct active Loader sources for that package are a
* composition error. Bundle content changes reach the graph only through
* {@link ClientModuleRegistry.rebuilt}.
* steady state share one implementation. Each reconciliation reads the
* package manifest and bundle again, so an entry that restarts after its
* package was reinstalled or moved publishes them as they are now. The
* manifest package name identifies the browser module; distinct active Loader
* sources for that package are a composition error. Bundle content changes
* reach the graph only through {@link ClientModuleRegistry.rebuilt}.
* @module @deepseek-ai/dsh-client-modules
*/
@@ -33,6 +33,7 @@ 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 { isDeepStrictEqual } from 'node:util'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
@@ -73,7 +74,7 @@ export interface ClientArtifactBaseline {
readonly size: number
}
/** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
/** Package metadata read from the manifest of one active Loader source. */
interface PkgMeta extends WebBootRowFields {
clientPath: string
/** The package's own manifest; package-asset requests resolve against it as a self-reference. */
@@ -634,9 +635,6 @@ export class ClientModuleRegistry extends Service {
private readonly release: WebBootRelease
private readonly table = new Map<string, WebPluginRecord>()
private readonly sources = new Map<string, ClientPackageSource>()
// Resolution is entry-local: the same specifier can resolve differently in
// separate config trees. Negative verdicts remain stable until restart.
private readonly pkgMeta = new Map<string, ResolvedPkgMeta | null>()
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
private readonly graphListeners = new Set<() => void>()
private readonly dirty = new Set<string>()
@@ -869,14 +867,10 @@ export class ClientModuleRegistry extends Service {
}
private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null {
const sourceKey = this.sourceKey(loaderName, baseUrl)
const cached = this.pkgMeta.get(sourceKey)
if (cached !== undefined) return cached
const located = this.locatePkgJson(loaderName, baseUrl)
if (located === undefined) {
// Not a resolvable package root: loader builtins (cordis:include) and
// subpath entries (…/gateway) land here — permanently not a client row.
this.pkgMeta.set(sourceKey, null)
// subpath entries (…/gateway) land here — not a client row.
return null
}
const { packageName, path: pkgPath } = located
@@ -886,10 +880,7 @@ export class ClientModuleRegistry extends Service {
packageName,
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
)
if (decl === undefined || decl.platform !== 'web') {
this.pkgMeta.set(sourceKey, null)
return null
}
if (decl === undefined || decl.platform !== 'web') return null
const clientRel = clientExportOf(packageName, pkg.exports)
if (clientRel === undefined) {
throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
@@ -901,9 +892,7 @@ export class ClientModuleRegistry extends Service {
external: decl.external ?? [],
immediately: decl.immediately === true,
}
const resolved = { packageName, meta }
this.pkgMeta.set(sourceKey, resolved)
return resolved
return { packageName, meta }
}
/**
@@ -948,7 +937,7 @@ export class ClientModuleRegistry extends Service {
: internal.resolveSync(loaderName, baseUrl, {}).url
} catch {
// The Loader cannot resolve the name: its row cannot have imported, so
// the name is permanently not a client row.
// it is not a client row.
return undefined
}
return this.nearestPackage(moduleUrl, expectedPackageName)
@@ -1082,12 +1071,17 @@ export class ClientModuleRegistry extends Service {
}
const source = sources[0]
if (source === undefined) return this.table.delete(packageName)
if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
// Startup and HMR share revisions so unchanged artifacts survive a server restart.
const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
const rev = artifactRevision(snapshot.baseline)
const entry = graphRow(packageName, rev, source.meta)
const current = this.table.get(packageName)
// A restart that reads the same manifest and bundle leaves the published graph as it is.
const unchanged = current?.sourceKey === source.sourceKey
&& isDeepStrictEqual(current.meta, source.meta) && isDeepStrictEqual(current.entry, entry)
if (unchanged) return false
this.table.set(packageName, {
entry: graphRow(packageName, rev, source.meta),
entry,
loaderName: source.loaderName,
sourceKey: source.sourceKey,
meta: source.meta,
@@ -1245,3 +1245,67 @@ describe('module graph order', () => {
.toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
})
})
describe('package manifest refresh', () => {
/** Rewrite a fixture package's manifest; its client bundle is left as it is. */
const writeManifest = (packageName: string, manifest: Record<string, unknown>): void => {
writeFileSync(join(root!, 'node_modules', ...packageName.split('/'), 'package.json'), JSON.stringify({ name: packageName, ...manifest }))
}
const clientManifest = (client: Record<string, unknown>, clientExport = './lib/client.js'): Record<string, unknown> => ({
exports: { './client': clientExport, './package.json': './package.json' },
dsh: { client: { platform: 'web', ...client } },
})
it('publishes a reinstalled declaration when its Loader entry restarts, and nothing for an unchanged restart', async () => {
const packageName = '@fixture/refresh-declaration'
writeBuiltPackage(packageName, { external: ['react'] })
const { context, service } = constructWithRoute([packageName])
const before = service.graph().entries[0]!
const changed = vi.fn()
service.onGraphChanged(changed)
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(changed).not.toHaveBeenCalled()
writeManifest(packageName, clientManifest({ external: ['scheduler'], inject: ['@fixture/slots'] }))
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(changed).toHaveBeenCalledTimes(1)
expect(service.graph().entries[0]).toMatchObject({ rev: before.rev, external: ['scheduler'], inject: ['@fixture/slots'] })
})
it('reads a moved client export as a new bundle generation when its Loader entry restarts', async () => {
const packageName = '@fixture/refresh-moved'
writeBuiltPackage(packageName, {})
const { context, service } = constructWithRoute([packageName])
const before = service.graph().entries[0]!
const moved = join(root!, 'node_modules', '@fixture', 'refresh-moved', 'dist', 'client.js')
mkdirSync(dirname(moved), { recursive: true })
writeFileSync(moved, 'module.exports = { moved: true }\n')
writeManifest(packageName, clientManifest({}, './dist/client.js'))
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(service.clientPath(packageName)).toBe(moved)
expect(service.graph().entries[0]!.rev).not.toBe(before.rev)
})
it('adds or removes the row when a restarted package gains or loses its client declaration', async () => {
const packageName = '@fixture/refresh-membership'
writeBuiltPackage(packageName, {})
writeManifest(packageName, { exports: { './package.json': './package.json' } })
const { context, service } = constructWithRoute([packageName])
expect(service.graph().entries).toEqual([])
writeManifest(packageName, clientManifest({}))
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
writeManifest(packageName, { exports: { './package.json': './package.json' } })
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(service.graph().entries).toEqual([])
})
})