1160 lines
47 KiB
TypeScript
1160 lines
47 KiB
TypeScript
/**
|
|
* 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 } 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 status-change time in milliseconds, including writes that preserve mtime. */
|
|
readonly ctimeMs: 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 {
|
|
/** Absolute route URL of this response. */
|
|
url: string
|
|
rev: string
|
|
entries: string[]
|
|
/** Absolute route URL of the map response. */
|
|
sourceMapUrl: string
|
|
scriptBody: () => Promise<Buffer>
|
|
sourceMapBody: () => Promise<Buffer>
|
|
}
|
|
|
|
/**
|
|
* One generated initial-load response and its wire descriptor. The descriptor
|
|
* travels to the browser, so its URL is the document-relative reference while
|
|
* {@link ComboArtifact.url} is the route key.
|
|
*/
|
|
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 metadata hash shortened to 12 hex chars. */
|
|
function shortHash(input: string): 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 string[]): string {
|
|
const hash = createHash('sha1').update(domain).update('\0')
|
|
for (const part of parts) hash.update(`${String(Buffer.byteLength(part))}:`).update(part)
|
|
return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
|
|
}
|
|
|
|
/** Identify an entry's build from filesystem metadata without hashing its contents. */
|
|
function artifactRevision(baseline: ClientArtifactBaseline): string {
|
|
return framedHash('plugin-artifact', [String(baseline.mtimeMs), String(baseline.ctimeMs), String(baseline.size)])
|
|
}
|
|
|
|
/** Absolute route prefix serving every plugin resource. */
|
|
const PLUGIN_ROUTE = '/plugins'
|
|
|
|
/** 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(',')
|
|
return `??${resources}&rev=${rev}`
|
|
}
|
|
|
|
/** Absolute route URL for one combo resource. */
|
|
function comboUrl(ids: readonly string[], rev: string, sourceMap = false): string {
|
|
return `${PLUGIN_ROUTE}/${comboSearch(ids, rev, sourceMap)}`
|
|
}
|
|
|
|
/**
|
|
* Browser reference to one combo resource: app-owned browser routes are
|
|
* document-relative, so the route key's leading slash is stripped here, at the
|
|
* boundary between the two halves. The rule and its reasons are owned by
|
|
* .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md.
|
|
*/
|
|
function comboReference(ids: readonly string[], rev: string, sourceMap = false): string {
|
|
return comboUrl(ids, rev, sourceMap).slice(1)
|
|
}
|
|
|
|
/** Absolute route URL for one package-local chunk. */
|
|
function chunkUrl(id: string, fileName: string, rev: string, sourceMap = false): string {
|
|
return `${PLUGIN_ROUTE}/${id}/${fileName}${sourceMap ? '.map' : ''}?rev=${rev}`
|
|
}
|
|
|
|
/**
|
|
* Source-map reference stamped into one chunk script. A script's map reference
|
|
* resolves against that script's own directory rather than the document, so
|
|
* this is the bare map file name, not the document-relative route.
|
|
*/
|
|
function chunkMapReference(fileName: string, rev: string): string {
|
|
return `${fileName}.map?rev=${rev}`
|
|
}
|
|
|
|
/** Measure the longest browser-facing combo URL used to partition a startup resource list. */
|
|
function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
|
|
return Buffer.byteLength(comboReference(
|
|
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 source-map reference 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: unknown = JSON.parse(body.toString('utf8'))
|
|
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 => [
|
|
resource.id,
|
|
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)
|
|
// Trailer only: it resolves against this script's own directory, not the document.
|
|
return {
|
|
url,
|
|
rev,
|
|
entries,
|
|
sourceMapUrl,
|
|
scriptBody: lazyBody(() => buildComboScript(resources, comboSearch(entries, rev, true))),
|
|
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.slice(1),
|
|
rev: artifact.rev,
|
|
entries: artifact.entries,
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Graph row for one bundle rev (the reference carries the rev as its cache-busting query). */
|
|
function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
|
|
return {
|
|
id,
|
|
url: comboReference([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 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: PLUGIN_ROUTE, 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).
|
|
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
|
* @param id - entry id (package name).
|
|
* @returns the current artifact 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 rev = artifactRevision(baseline)
|
|
if (rev === record.entry.rev) return rev
|
|
const bundle = readFileSync(record.meta.clientPath)
|
|
record.baseline = baseline
|
|
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 artifact metadata changes 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) {
|
|
// The table is keyed by the absolute route the request arrives on, not by
|
|
// the document-relative reference the graph and descriptors carry.
|
|
batchResponses.set(artifact.url, this.responses.get(artifact.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,
|
|
ctimeMs: bundle.ctimeMs,
|
|
size: bundle.size,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
// 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)
|
|
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 = chunkMapReference(fileName, record.entry.rev)
|
|
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
|