| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122 |
- /**
- * Node half of the client module system (`dsh.client` dual-face package): scans
- * 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,
- * 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
- * face).
- *
- * Scanning is incremental per package — there is no full-rescan code path.
- * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
- * 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}.
- * @module @deepseek-ai/dsh-client-modules
- */
- import { createHash, randomBytes } from 'node:crypto'
- import { existsSync, readFileSync, statSync } from 'node:fs'
- import type { IncomingMessage, ServerResponse } from 'node:http'
- import { createRequire } from 'node:module'
- import { dirname, isAbsolute, join } from 'node:path'
- 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 { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
- import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
- export { stripClientSuffix } from './client/manifest.ts'
- export type {
- BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph,
- } from './client/manifest.ts'
- declare module '@deepseek-ai/cordis' {
- interface Context {
- /** The web plugin table (provided by the client-modules node half). */
- clientModules: ClientModuleRegistry
- }
- }
- /** The declared fields a graph row carries, normalized (absent array declarations become empty). */
- interface WebBootRowFields {
- inject?: string[]
- /** Module specifiers the package requests from the module table. */
- external: string[]
- immediately: boolean
- }
- /** Filesystem baseline captured before a client artifact snapshot is read. */
- export interface ClientArtifactBaseline {
- /** Absolute path of the client bundle. */
- readonly path: string
- /** Bundle modification time in milliseconds. */
- readonly mtimeMs: number
- /** Bundle size in bytes. */
- readonly size: number
- }
- /** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
- interface PkgMeta extends WebBootRowFields {
- clientPath: string
- }
- interface ResolvedPkgMeta {
- packageName: string
- meta: PkgMeta
- }
- /** One active Loader source and the browser package manifest it resolves to. */
- interface ClientPackageSource extends ResolvedPkgMeta {
- /** Loader specifier from the active row. */
- loaderName: string
- /** Resolution base of the config tree that owns the row. */
- baseUrl: string
- /** Stable cache and contribution key for this source. */
- sourceKey: string
- }
- /** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
- const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
- /** Missing built client export, retained as structured data for activation-error grouping. */
- class MissingClientBundleError extends Error {
- constructor(
- readonly packageName: string,
- readonly clientPath: string,
- cause: unknown,
- ) {
- super(
- [
- `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
- ` package: ${packageName}`,
- ` path: ${clientPath}`,
- ].join('\n'),
- { cause },
- )
- }
- }
- /** Activation failures grouped by actionable package-build errors and unrelated failures. */
- class ClientPackageCompositionError extends AggregateError {
- constructor(failures: Error[]) {
- const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
- const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
- const packageNoun = failures.length === 1 ? 'package' : 'packages'
- const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
- if (missingBundles.length > 0) {
- lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
- for (const error of missingBundles) {
- lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
- }
- }
- if (otherFailures.length > 0) {
- lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
- }
- super(failures, lines.join('\n'))
- }
- }
- /** One composed table row: the wire entry plus the resolved package metadata behind it. */
- interface WebPluginRecord {
- entry: WebBootEntry
- /** Loader specifier whose active row contributes this browser module. */
- loaderName: string
- /** Loader resolution input that selected this package instance. */
- sourceKey: string
- meta: PkgMeta
- /** Exact build artifact included in the startup batches. */
- bundle: Buffer
- /** Pre-read filesystem baseline handed to the HMR watcher. */
- baseline: ClientArtifactBaseline
- }
- /** Immutable inputs captured for one resource in a generated combo. */
- interface ComboResource {
- id: string
- rev: string
- clientPath: string
- fileName: string
- bundle: Buffer
- }
- /** One lazily materialized immutable response body. */
- interface LazyResponse {
- contentType: string
- body: () => Promise<Buffer>
- }
- /** Fields shared by every generated combo plan. */
- interface ComboArtifact {
- url: string
- rev: string
- entries: string[]
- sourceMapUrl: string
- scriptBody: () => Promise<Buffer>
- sourceMapBody: () => Promise<Buffer>
- }
- /** One generated initial-load response and its wire descriptor. */
- type BatchArtifact = ComboArtifact & { descriptor: WebBootBatch }
- /** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
- const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
- /** Generated request URLs stay below conservative browser and intermediary request-target limits. */
- const MAX_COMBO_URL_BYTES = 3 * 1024
- const HASH_REVISION_LENGTH = 12
- const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
- /** Source-map trailer emitted by tsdown at the end of every client bundle. */
- const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
- /** Debugger source name appended to page bundles in the WebWorker image. */
- const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
- /** Published package-local client chunk names accepted by the on-demand route. */
- const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
- /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
- function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
- if (typeof exportsField !== 'object' || exportsField === null) return undefined
- const client = (exportsField as Record<string, unknown>)['./client']
- if (client === undefined) return undefined
- if (typeof client === 'string') return client
- if (typeof client === 'object' && client !== null) {
- const fallback = (client as Record<string, unknown>).default
- if (typeof fallback === 'string') return fallback
- }
- throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
- }
- /** sha1 content hash shortened to 12 hex chars (combo / graph / rebuilt-artifact rev). */
- function shortHash(input: string | Buffer): string {
- return createHash('sha1').update(input).digest('hex').slice(0, HASH_REVISION_LENGTH)
- }
- /** Hash several response fields without allowing bytes to move across field boundaries. */
- function framedHash(domain: string, parts: readonly Buffer[]): string {
- const hash = createHash('sha1').update(domain).update('\0')
- for (const part of parts) hash.update(`${String(part.byteLength)}:`).update(part)
- return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
- }
- /** Hash one completed build generation observed through its entry artifact. */
- function artifactRevision(bundle: Buffer, baseline: ClientArtifactBaseline): string {
- return framedHash('plugin-artifact', [bundle, Buffer.from(String(baseline.mtimeMs))])
- }
- /** Address one ordered plugin-file list through the shared combo route. */
- function comboUrl(ids: readonly string[], rev: string, sourceMap = false): string {
- const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
- return `/plugins/??${resources}&rev=${rev}`
- }
- /** Address one package-local chunk through the same revision as its entry. */
- function chunkUrl(id: string, fileName: string, rev: string, sourceMap = false): string {
- return `/plugins/${id}/${fileName}${sourceMap ? '.map' : ''}?rev=${rev}`
- }
- /** Measure the longer map-form URL used to partition a startup resource list. */
- function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
- return Buffer.byteLength(comboUrl(
- records.map(record => record.entry.id),
- COMBO_REVISION_PLACEHOLDER,
- true,
- ))
- }
- /** Partition one phase in graph order without allowing a generated URL above the protocol limit. */
- function partitionComboRecords(records: readonly WebPluginRecord[]): WebPluginRecord[][] {
- const chunks: WebPluginRecord[][] = []
- let current: WebPluginRecord[] = []
- for (const record of records) {
- const candidate = [...current, record]
- if (projectedComboUrlBytes(candidate) <= MAX_COMBO_URL_BYTES) {
- current = candidate
- continue
- }
- if (current.length === 0) {
- throw new Error(
- `client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
- )
- }
- chunks.push(current)
- current = [record]
- if (projectedComboUrlBytes(current) > MAX_COMBO_URL_BYTES) {
- throw new Error(
- `client-modules: ${record.entry.id} exceeds the ${String(MAX_COMBO_URL_BYTES)}-byte combo URL limit`,
- )
- }
- }
- if (current.length > 0) chunks.push(current)
- return chunks
- }
- /** Executable source plus the generated-file name used when no authored map exists. */
- interface PreparedSource {
- source: string
- fallbackSource: string
- }
- /** Remove bundle-local debug directives and retain their stable generated-file name. */
- function prepareSource(resource: ComboResource): PreparedSource {
- let source = resource.bundle.toString('utf8')
- const sourceUrl = SOURCE_URL_TRAILER.exec(source)?.[1]
- source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
- if (!source.endsWith('\n')) source += '\n'
- const fallbackSource = sourceUrl === undefined
- ? `/plugins/${resource.id}/${resource.fileName}`
- : /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
- return { source, fallbackSource }
- }
- /** Stamp a combo script's absolute indexed-map URL onto its executable bytes. */
- function comboScript(input: string, sourceMapUrl?: string): Buffer {
- return Buffer.from(sourceMapUrl === undefined ? input : `${input}//# sourceMappingURL=${sourceMapUrl}\n`)
- }
- /** Read and parse an optional source map when its combo-map endpoint is requested. */
- function readSourceMap(clientPath: string): Record<string, unknown> | undefined {
- let body: Buffer
- try {
- body = readFileSync(`${clientPath}.map`)
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
- throw error
- }
- const value = JSON.parse(body.toString('utf8')) as unknown
- const parsed = typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined
- if (
- parsed === undefined
- || parsed.version !== 3
- || !Array.isArray(parsed.sources)
- || parsed.sources.some(source => typeof source !== 'string')
- || !Array.isArray(parsed.names)
- || parsed.names.some(name => typeof name !== 'string')
- || typeof parsed.mappings !== 'string'
- ) {
- throw new Error(`client-modules: ${clientPath}.map is not a regular Source Map v3 object`)
- }
- return parsed
- }
- /** Count generated lines while assembling indexed-map section offsets. */
- function newlineCount(value: string): number {
- let count = 0
- for (const char of value) if (char === '\n') count += 1
- return count
- }
- /** Resolve section sources against their original per-plugin map URL before combo relocation. */
- function comboSectionMap(resource: ComboResource, original: Record<string, unknown>): Record<string, unknown> {
- const sourcePaths = original.sources as string[]
- const sourceRoot = typeof original.sourceRoot === 'string' ? original.sourceRoot : ''
- const base = new URL(`/plugins/${resource.id}/client.js.map`, 'http://dsh.invalid')
- const relocated = sourcePaths.map((source) => {
- const separator = sourceRoot !== '' && !sourceRoot.endsWith('/') && !source.startsWith('/') ? '/' : ''
- const resolved = new URL(`${sourceRoot}${separator}${source}`, base)
- return resolved.origin === base.origin
- ? `${resolved.pathname}${resolved.search}${resolved.hash}`
- : resolved.href
- })
- const section: Record<string, unknown> = { ...original, sources: relocated }
- delete section.sourceRoot
- return section
- }
- /** Map each generated line to the same line in a bundled JavaScript source. */
- function identitySectionMap(source: string, sourceUrl: string): Record<string, unknown> {
- const mappings = Array.from({ length: newlineCount(source) }, (_, index) => index === 0 ? 'AAAA' : 'AACA')
- .join(';')
- return {
- version: 3,
- names: [],
- sources: [sourceUrl],
- sourcesContent: [source],
- mappings,
- }
- }
- /** Run one producer in the first requester's microtask, not off-thread, and share its settlement. */
- function lazyBody(produce: () => Buffer): () => Promise<Buffer> {
- let result: Promise<Buffer> | undefined
- return () => {
- result ??= Promise.resolve().then(produce)
- return result
- }
- }
- /** Derive one combo revision from the ordered immutable row revisions. */
- function comboRevision(resources: readonly ComboResource[]): string {
- return framedHash('combo', resources.flatMap(resource => [
- Buffer.from(resource.id),
- Buffer.from(resource.rev),
- ]))
- }
- /** Concatenate one or more factory registrations without reading or composing source maps. */
- function buildComboScript(resources: readonly ComboResource[], sourceMapUrl: string): Buffer {
- let source = ''
- for (const resource of resources) source += `${prepareSource(resource).source};\n`
- return comboScript(source, sourceMapUrl)
- }
- /** Compose one indexed map from source-map files read only for this request. */
- function buildComboSourceMap(
- resources: readonly ComboResource[],
- sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
- fileName = 'client.js',
- ): Buffer {
- const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
- let line = 0
- for (const resource of resources) {
- const prepared = prepareSource(resource)
- const sourceMap = sourceMapOf(resource.clientPath)
- let section = identitySectionMap(prepared.source, prepared.fallbackSource)
- if (sourceMap !== undefined) {
- try {
- section = comboSectionMap(resource, sourceMap)
- } catch {
- // An invalid authored source URL is a malformed map, so the generated
- // bundle remains debuggable through the same identity fallback.
- }
- }
- sections.push({ offset: { line, column: 0 }, map: section })
- line += newlineCount(`${prepared.source};\n`)
- }
- return Buffer.from(`${JSON.stringify({ version: 3, file: fileName, sections })}\n`)
- }
- /** Describe one combo and defer its executable and debug payloads independently. */
- function buildCombo(
- records: readonly WebPluginRecord[],
- sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
- revision?: string,
- ): ComboArtifact {
- const resources = records.map(record => ({
- id: record.entry.id,
- rev: record.entry.rev,
- clientPath: record.meta.clientPath,
- fileName: 'client.js',
- bundle: record.bundle,
- }))
- const rev = revision ?? comboRevision(resources)
- const entries = resources.map(resource => resource.id)
- const url = comboUrl(entries, rev)
- const sourceMapUrl = comboUrl(entries, rev, true)
- return {
- url,
- rev,
- entries,
- sourceMapUrl,
- scriptBody: lazyBody(() => buildComboScript(resources, sourceMapUrl)),
- sourceMapBody: lazyBody(() => buildComboSourceMap(resources, sourceMapOf)),
- }
- }
- /** Add initial-load scheduling metadata to a combo artifact. */
- function buildBatch(
- phase: WebBootBatchPhase,
- records: readonly WebPluginRecord[],
- sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
- ): BatchArtifact {
- const artifact = buildCombo(records, sourceMapOf)
- return {
- ...artifact,
- descriptor: { phase, url: artifact.url, rev: artifact.rev, entries: artifact.entries },
- }
- }
- /** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
- function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
- return {
- id,
- url: comboUrl([id], rev),
- rev,
- ...(fields.inject !== undefined ? { inject: fields.inject } : {}),
- ...(fields.immediately ? { immediately: true } : {}),
- ...(fields.external.length > 0 ? { external: fields.external } : {}),
- }
- }
- /**
- * Order composed rows so every requested dynamic package precedes its
- * consumers. An `external` specifier is either the package row it names
- * (`<pkg>/client` aliases the bare package) or a static-table name that adds no
- * graph edge.
- * @param entries - composed rows in scan order.
- * @returns the same rows reordered; scan order breaks every tie.
- * @throws {Error} when a row requests itself or when the module graph has a
- * cycle; the message lists the packages on it.
- */
- export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEntry[] {
- const rowsById = new Map<string, WebBootEntry>()
- for (const entry of entries) rowsById.set(entry.id, entry)
- const ordered: WebBootEntry[] = []
- const placed = new Set<string>()
- const open: string[] = []
- const visit = (entry: WebBootEntry): void => {
- if (placed.has(entry.id)) return
- const cycleStart = open.indexOf(entry.id)
- if (cycleStart !== -1) {
- throw new Error(
- `client-modules: module graph cycle ${[...open.slice(cycleStart), entry.id].join(' -> ')} `
- + '— a requested package row must precede its consumers, and factory-form CJS cannot deliver partial exports',
- )
- }
- open.push(entry.id)
- for (const name of entry.external ?? []) {
- const dependency = rowsById.get(name) ?? rowsById.get(stripClientSuffix(name))
- if (dependency === entry) {
- throw new Error(
- `client-modules: "${entry.id}" requests module "${name}" that it answers itself `
- + '— a row must not declare its own package in dsh.client.external',
- )
- }
- if (dependency !== undefined) visit(dependency)
- }
- open.pop()
- placed.add(entry.id)
- ordered.push(entry)
- }
- for (const entry of entries) visit(entry)
- return ordered
- }
- /** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
- const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
- /** Dynamic bundles grouped into the parser bootstrap batch before the Vite shell. */
- const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const
- /**
- * The boot protocol as index injection rows. The inline registration queue
- * precedes the application-batch preload and the blocking bootstrap batch. Its
- * `create()` method materializes the modules
- * bundle, delegates construction to that bundle, and leaves the same facade
- * in live-registration mode. The graph global follows before the shell reads
- * it.
- * @param graph - the composed entry graph.
- * @returns head rows in execution order: queue script, application preloads,
- * blocking bootstrap scripts, graph global.
- */
- export function bootInjections(graph: WebBootGraph): IndexInjection[] {
- const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
- const queue = `(()=>{
- const pendingQueue=[]
- window.__ModuleLoader__={
- mode:"queue",
- pendingQueue,
- load(registration){pendingQueue.push(registration)},
- create(options){
- if(this.mode!=="queue")throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot")
- const index=pendingQueue.findIndex(registration=>registration.id===${bootstrapId})
- const registration=pendingQueue[index]
- if(registration===undefined)throw new Error("client-modules: HTML did not preload ${CLIENT_MODULES_ID}/client.js")
- pendingQueue.splice(index,1)
- const exports=registration.factory(specifier=>{
- throw new Error('client-modules: ${CLIENT_MODULES_ID}/client.js requested external "'+specifier+'" before the module system existed')
- })
- if(typeof exports!=="object"||exports===null||typeof exports.createClientModuleSystem!=="function"||typeof exports.apply!=="function"){
- throw new Error("client-modules: ${CLIENT_MODULES_ID}/client.js did not export the bootstrap module face")
- }
- return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
- }
- }
- })()`
- const bootstrap = graph.batches.filter(batch => batch.phase === 'bootstrap')
- const application = graph.batches.filter(batch => batch.phase === 'application')
- const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }]
- for (const batch of application) {
- rows.push({ kind: 'script-preload', src: batch.url })
- }
- for (const batch of bootstrap) {
- rows.push({ kind: 'script-src', placement: 'head', src: batch.url })
- }
- rows.push({ kind: 'global', name: '__DSH_BOOT__', value: graph })
- return rows
- }
- /**
- * 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).
- */
- export class ClientModuleRegistry extends Service {
- static inject = ['loader']
- 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>()
- private readonly initialRevisionNonce = randomBytes(8).toString('hex')
- private nextInitialRevision = 0
- private responses = new Map<string, LazyResponse>()
- private batchResponses = new Map<string, LazyResponse>()
- /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
- private previousBatchResponses = new Map<string, LazyResponse>()
- private flushQueued = false
- private composed: WebBootGraph
- /**
- * Build the service: subscribe, seed, and run the activation flush.
- * Bundle routes follow the optional Web carrier's injected lifecycle.
- * @param ctx - plugin context carrying Loader and an optional Web carrier.
- */
- constructor(ctx: Context) {
- super(ctx, 'clientModules')
- // Subscribe before seeding so a fiber arriving mid-activation lands in the
- // same dirty set (Set idempotence makes the overlap harmless). An entry-less
- // fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
- ctx.on('internal/plugin', (fiber) => {
- const entryName = fiber.entry?.options.name
- if (entryName === undefined) return
- this.dirty.add(entryName)
- if (this.flushQueued) return
- this.flushQueued = true
- queueMicrotask(() => {
- this.flushQueued = false
- this.flush((err) => { ctx.logger.warn(err) })
- })
- })
- // Activation pass: the initial scan IS the incremental path over the
- // current entries, flushed synchronously (nothing async between subscribe,
- // seed, and flush).
- for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
- this.composed = this.compose()
- const failures: Error[] = []
- this.flush(err => failures.push(err))
- if (failures.length > 0) {
- throw new ClientPackageCompositionError(failures)
- }
- const registerWebCarrier = (webCtx: Context): void => {
- webCtx.effect(
- () => webCtx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
- 'client-modules: bundle route',
- )
- }
- ctx.inject(['webServer'], registerWebCarrier)
- ctx.on('webserver/index-inject', (table) => {
- table.push(...bootInjections(this.composed))
- })
- }
- /**
- * Current composed entry graph (stable object between changes).
- * @returns the graph served as `window.__DSH_BOOT__`.
- */
- graph(): WebBootGraph {
- return this.composed
- }
- /**
- * Absolute path of an entry's client bundle.
- * @param id - entry id (package name).
- * @returns the path, or undefined for an unknown id.
- */
- clientPath(id: string): string | undefined {
- return this.table.get(id)?.meta.clientPath
- }
- /**
- * 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.
- * @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)
- return new Response(body, {
- status: resource.status,
- ...(resource.headers === undefined ? {} : { headers: resource.headers }),
- })
- }
- /**
- * Filesystem baseline captured before an entry's current bytes were read.
- * HMR compares it with the live files when installing a watch, so a write
- * between startup composition and watch installation cannot disappear into
- * the watcher's initial state.
- * @param id - entry id (package name).
- * @returns the path and baseline, or undefined for an unknown id.
- */
- artifactBaseline(id: string): ClientArtifactBaseline | undefined {
- const baseline = this.table.get(id)?.baseline
- return baseline === undefined ? undefined : { ...baseline }
- }
- /**
- * Publish one completed bundle generation (the HMR watch's registration
- * hook — the only entry point through which build changes reach the graph).
- * @param id - entry id (package name).
- * @returns the new rev, or undefined for an unknown id.
- */
- rebuilt(id: string): string | undefined {
- const record = this.table.get(id)
- if (record === undefined) return undefined
- const baseline = this.captureArtifactBaseline(record.meta.clientPath)
- const bundle = readFileSync(record.meta.clientPath)
- const rev = artifactRevision(bundle, baseline)
- record.baseline = baseline
- if (rev === record.entry.rev) return rev
- record.entry = graphRow(id, rev, record.meta)
- record.bundle = bundle
- this.composed = this.compose()
- for (const notify of this.rebuildListeners) {
- // Containment: rebuilt() runs inside the HMR watch callback — a
- // throwing subscriber must not kill the poll or skip later subscribers.
- try {
- notify(id, rev)
- } catch (error) {
- this.ctx.logger.error(error)
- }
- }
- this.notifyGraphChanged()
- return rev
- }
- /**
- * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
- * @param listener - receives the entry id and its new bundle rev.
- * @returns the unsubscriber.
- */
- onRebuilt(listener: (id: string, rev: string) => void): () => void {
- this.rebuildListeners.add(listener)
- return () => { this.rebuildListeners.delete(listener) }
- }
- /**
- * Fires after any flush that recomposed the graph (row added/removed, or a
- * rebuilt rev change). Pull model: listeners re-read {@link graph}.
- * @param listener - notified with no payload.
- * @returns the unsubscriber.
- */
- onGraphChanged(listener: () => void): () => void {
- this.graphListeners.add(listener)
- return () => { this.graphListeners.delete(listener) }
- }
- private compose(): WebBootGraph {
- const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
- const bootstrap = PARSER_PRELOAD_IDS
- .map(id => this.table.get(id))
- .filter((record): record is WebPluginRecord => record !== undefined)
- const bootstrapIds = new Set(bootstrap.map(record => record.entry.id))
- const application = entries
- .filter(entry => !bootstrapIds.has(entry.id))
- .map(entry => this.table.get(entry.id))
- .filter((record): record is WebPluginRecord => record !== undefined)
- const artifacts: BatchArtifact[] = []
- for (const records of partitionComboRecords(bootstrap)) {
- artifacts.push(buildBatch('bootstrap', records, this.readSourceMap))
- }
- for (const records of partitionComboRecords(application)) {
- artifacts.push(buildBatch('application', records, this.readSourceMap))
- }
- const batchResponses = new Map<string, LazyResponse>()
- for (const artifact of artifacts) {
- batchResponses.set(artifact.descriptor.url, this.responses.get(artifact.descriptor.url) ?? {
- body: artifact.scriptBody,
- contentType: 'text/javascript; charset=utf-8',
- })
- batchResponses.set(artifact.sourceMapUrl, this.responses.get(artifact.sourceMapUrl) ?? {
- body: artifact.sourceMapBody,
- contentType: 'application/json; charset=utf-8',
- })
- }
- const responses = new Map(batchResponses)
- for (const record of this.table.values()) {
- const artifact = buildCombo([record], this.readSourceMap, record.entry.rev)
- responses.set(artifact.url, responses.get(artifact.url) ?? this.responses.get(artifact.url) ?? {
- body: artifact.scriptBody,
- contentType: 'text/javascript; charset=utf-8',
- })
- responses.set(artifact.sourceMapUrl, responses.get(artifact.sourceMapUrl) ?? this.responses.get(artifact.sourceMapUrl) ?? {
- body: artifact.sourceMapBody,
- contentType: 'application/json; charset=utf-8',
- })
- }
- for (const [resourceUrl, response] of this.responses) {
- if (this.chunkRequest(new URL(resourceUrl, 'http://x')) !== undefined) responses.set(resourceUrl, response)
- }
- this.previousBatchResponses = this.batchResponses
- this.batchResponses = batchResponses
- this.responses = responses
- const batches = artifacts.map(artifact => artifact.descriptor)
- return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }
- }
- private notifyGraphChanged(): void {
- for (const listener of this.graphListeners) {
- // A throwing subscriber must not skip later subscribers (or escape into
- // whatever triggered the flush — possibly an fs.watchFile callback).
- try {
- listener()
- } catch (error) {
- this.ctx.logger.error(error)
- }
- }
- }
- 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)
- return null
- }
- const { packageName, path: pkgPath } = located
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
- const dsh = pkg.dsh
- const decl = parseDshClient(
- 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
- }
- const clientRel = clientExportOf(packageName, pkg.exports)
- if (clientRel === undefined) {
- throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
- }
- const meta: PkgMeta = {
- clientPath: join(dirname(pkgPath), clientRel),
- ...(decl.inject !== undefined ? { inject: decl.inject } : {}),
- external: decl.external ?? [],
- immediately: decl.immediately === true,
- }
- const resolved = { packageName, meta }
- this.pkgMeta.set(sourceKey, resolved)
- return resolved
- }
- /**
- * Locate the manifest of the package the Loader mounts for a row. The row's
- * module location is authoritative: the specifier resolves through the same
- * Loader resolution that imported the row's host half — including any
- * active ESM hooks — and the nearest ancestor manifest declaring the name
- * owns the module. Tree-anchored `require` resolution remains only for
- * runtimes without Node internals.
- * @param loaderName - module specifier of the loader row.
- * @param baseUrl - resolution base of the tree that owns the row.
- * @returns the manifest path, or `undefined` when the name resolves to no package root.
- */
- private locatePkgJson(loaderName: string, baseUrl: string): { path: string; packageName: string } | undefined {
- if (loaderName.startsWith('cordis:')) return undefined
- const pathLike = loaderName.startsWith('.') || loaderName.startsWith('file:') || isAbsolute(loaderName)
- const expectedPackageName = pathLike ? undefined : exactPackageSpecifier(loaderName)
- if (!pathLike && expectedPackageName === undefined) return undefined
- const internal = this.ctx.loader.internal
- if (internal === undefined || typeof Reflect.get(internal, 'resolveSync') !== 'function') {
- if (expectedPackageName === undefined) {
- const moduleUrl = loaderName.startsWith('file:')
- ? loaderName
- : isAbsolute(loaderName) ? pathToFileURL(loaderName).href : new URL(loaderName, baseUrl).href
- return this.nearestPackage(moduleUrl)
- }
- try {
- return {
- path: createRequire(baseUrl).resolve(`${expectedPackageName}/package.json`),
- packageName: expectedPackageName,
- }
- } catch {
- // Without Node internals the owning tree is the only resolver; an
- // unresolvable name is classified exactly as below.
- return undefined
- }
- }
- let moduleUrl: string
- try {
- moduleUrl = internal.version === 'v2'
- ? internal.resolveSync(baseUrl, { specifier: loaderName, attributes: {} }).url
- : 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.
- return undefined
- }
- return this.nearestPackage(moduleUrl, expectedPackageName)
- }
- private nearestPackage(
- moduleUrl: string,
- expectedPackageName?: string,
- ): { path: string; packageName: string } | undefined {
- if (!moduleUrl.startsWith('file:')) return undefined
- let dir = dirname(fileURLToPath(moduleUrl))
- while (true) {
- const candidate = join(dir, 'package.json')
- if (existsSync(candidate)) {
- try {
- const name = (JSON.parse(readFileSync(candidate, 'utf8')) as { name?: unknown }).name
- if (typeof name === 'string' && (expectedPackageName === undefined || name === expectedPackageName)) {
- return { path: candidate, packageName: name }
- }
- } catch {
- // An unreadable or malformed intermediate manifest cannot own the
- // module; keep walking toward the declaring package root.
- }
- }
- const parent = dirname(dir)
- if (parent === dir) break
- dir = parent
- }
- return undefined
- }
- private sourceKey(loaderName: string, baseUrl: string): string {
- return `${baseUrl}\0${loaderName}`
- }
- /** Capture the bundle stats before reading its bytes. */
- private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
- const bundle = statSync(clientPath)
- return {
- path: clientPath,
- mtimeMs: bundle.mtimeMs,
- size: bundle.size,
- }
- }
- /** Allocate an opaque initial row revision without inspecting artifact bytes. */
- private allocateInitialRevision(): string {
- return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`
- }
- /**
- * Read the activation-time bundle snapshot.
- * @param pkgName - package that declares the client bundle.
- * @param clientPath - absolute path of the built client artifact.
- * @returns the immutable bytes plus the pre-read filesystem baseline.
- * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
- */
- private initialBundleSnapshot(pkgName: string, clientPath: string): {
- bundle: Buffer
- baseline: ClientArtifactBaseline
- } {
- try {
- const baseline = this.captureArtifactBaseline(clientPath)
- const bundle = readFileSync(clientPath)
- return { bundle, baseline }
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
- throw new MissingClientBundleError(pkgName, clientPath, error)
- }
- }
- /** Treat a missing, torn, or malformed development map as an identity section. */
- private readonly readSourceMap = (clientPath: string): Record<string, unknown> | undefined => {
- try {
- return readSourceMap(clientPath)
- } catch (error) {
- this.ctx.logger.warn(error)
- return undefined
- }
- }
- /** Reconcile one entry name against the live Loader sources. @returns whether the table changed. */
- private processOne(entryName: string, onError: (err: Error) => void): boolean {
- const nextSources = new Map<string, ClientPackageSource>()
- for (const entry of this.ctx.loader.entries()) {
- if (entry.options.name !== entryName || entry.fiber === undefined || entry.disabled) continue
- const source = this.resolveSource(entry)
- if (source !== undefined) nextSources.set(source.sourceKey, source)
- }
- const affectedPackages = new Set<string>()
- for (const [sourceKey, source] of this.sources) {
- if (source.loaderName !== entryName) continue
- affectedPackages.add(source.packageName)
- if (!nextSources.has(sourceKey)) this.sources.delete(sourceKey)
- }
- for (const [sourceKey, source] of nextSources) {
- affectedPackages.add(source.packageName)
- this.sources.set(sourceKey, source)
- }
- let changed = false
- for (const packageName of affectedPackages) {
- try {
- if (this.reconcilePackage(packageName)) changed = true
- } catch (error) {
- onError(error instanceof Error ? error : new Error(String(error)))
- }
- }
- return changed
- }
- private resolveSource(entry: Entry): ClientPackageSource | undefined {
- const loaderName = entry.options.name
- const baseUrl = entry.parent.tree.ctx.baseUrl
- if (baseUrl === undefined) {
- throw new Error(`client-modules: loader entry ${loaderName} has no resolution base URL`)
- }
- const resolved = this.resolveMeta(loaderName, baseUrl)
- if (resolved === null) return undefined
- return { ...resolved, loaderName, baseUrl, sourceKey: this.sourceKey(loaderName, baseUrl) }
- }
- private reconcilePackage(packageName: string): boolean {
- const sources: ClientPackageSource[] = []
- for (const source of this.sources.values()) {
- if (source.packageName === packageName) sources.push(source)
- }
- if (sources.length > 1) {
- const locations = sources
- .map(source => `${JSON.stringify(source.loaderName)} from ${source.baseUrl}`)
- .join(', ')
- throw new Error(
- `client-modules: package ${packageName} resolves from multiple active Loader sources: ${locations}; remove one entry`,
- )
- }
- const source = sources[0]
- if (source === undefined) return this.table.delete(packageName)
- if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
- // The opaque initial rev rides the row until HMR observes a file change;
- // a fiber restart from the same source reuses the existing row.
- const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
- const rev = this.allocateInitialRevision()
- this.table.set(packageName, {
- entry: graphRow(packageName, rev, source.meta),
- loaderName: source.loaderName,
- sourceKey: source.sourceKey,
- meta: source.meta,
- bundle: snapshot.bundle,
- baseline: snapshot.baseline,
- })
- return true
- }
- private flush(onError: (err: Error) => void): void {
- let changed = false
- for (const entryName of [...this.dirty]) {
- this.dirty.delete(entryName)
- try {
- if (this.processOne(entryName, onError)) changed = true
- } catch (error) {
- // Steady state: one broken package must not poison the others; the
- // activation pass aggregates these into a loud throw instead.
- onError(error instanceof Error ? error : new Error(String(error)))
- }
- }
- if (!changed) return
- let composed: WebBootGraph
- try {
- composed = this.compose()
- } catch (error) {
- // An unorderable module graph is a property of the whole table, not of
- // the arriving package, so it surfaces here: aggregated into the
- // activation throw, or warned in steady state while the last orderable
- // graph stays served.
- onError(error as Error)
- return
- }
- this.composed = composed
- this.notifyGraphChanged()
- }
- /** Match an exact current-revision package-local chunk URL without reading its file. */
- private chunkRequest(requestUrl: URL): {
- record: WebPluginRecord
- fileName: string
- sourceMap: boolean
- resourceUrl: string
- } | undefined {
- const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
- for (const record of this.table.values()) {
- const prefix = `/plugins/${record.entry.id}/`
- if (!requestUrl.pathname.startsWith(prefix)) continue
- const requested = requestUrl.pathname.slice(prefix.length)
- const sourceMap = requested.endsWith('.map')
- const fileName = sourceMap ? requested.slice(0, -'.map'.length) : requested
- if (!CLIENT_CHUNK.test(fileName)) return undefined
- if (resourceUrl !== chunkUrl(record.entry.id, fileName, record.entry.rev, sourceMap)) return undefined
- return { record, fileName, sourceMap, resourceUrl }
- }
- return undefined
- }
- /** Build a package-local chunk response only when its URL is requested. */
- private chunkResponse(requestUrl: URL): LazyResponse | undefined {
- const request = this.chunkRequest(requestUrl)
- if (request === undefined) return undefined
- const { record, fileName, sourceMap, resourceUrl } = request
- const clientPath = join(dirname(record.meta.clientPath), fileName)
- if (!existsSync(clientPath)) return undefined
- const sourceMapUrl = chunkUrl(record.entry.id, fileName, record.entry.rev, true)
- const resource = (): ComboResource => ({
- id: record.entry.id,
- rev: record.entry.rev,
- clientPath,
- fileName,
- bundle: readFileSync(clientPath),
- })
- const response: LazyResponse = sourceMap
- ? {
- body: lazyBody(() => buildComboSourceMap([resource()], this.readSourceMap, fileName)),
- contentType: 'application/json; charset=utf-8',
- }
- : {
- body: lazyBody(() => buildComboScript([resource()], sourceMapUrl)),
- contentType: 'text/javascript; charset=utf-8',
- }
- this.responses.set(resourceUrl, response)
- return response
- }
- private async bundleResource(method: string | undefined, url: string): Promise<{
- status: number
- headers?: Record<string, string>
- body?: Buffer
- }> {
- if (method !== 'GET' && method !== 'HEAD') return { status: 405 }
- const requestUrl = new URL(url, 'http://x')
- const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
- const response = this.responses.get(resourceUrl)
- ?? this.previousBatchResponses.get(resourceUrl)
- ?? this.chunkResponse(requestUrl)
- if (response !== undefined) {
- return {
- status: 200,
- headers: { 'content-type': response.contentType, 'cache-control': IMMUTABLE_CACHE },
- ...(method === 'HEAD' ? {} : { body: await response.body() }),
- }
- }
- // Anything else under /plugins (including unadvertised combinations and
- // /plugins/events when the HMR row is absent) is an unknown resource.
- return { status: 404 }
- }
- 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 ?? '/')
- res.writeHead(response.status, response.headers)
- res.end(response.body)
- }
- }
- export default ClientModuleRegistry
|