|
|
@@ -2,11 +2,12 @@
|
|
|
* 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
|
|
|
- * `/plugins/<id>/client.js` and its source map, contributes the boot manifest
|
|
|
- * plus the parser-blocking bootstrap preloads to the webserver's index
|
|
|
- * injection table, and provides the `clientModuleHost` service (the HMR node
|
|
|
- * half's registration/notification face).
|
|
|
+ * in `./client/manifest.ts`) in module-graph order, serves two initial-load
|
|
|
+ * batches plus revisioned per-plugin HMR scripts and their source maps,
|
|
|
+ * contributes the registration facade, application preload, bootstrap script,
|
|
|
+ * and graph to the webserver's index injection table, and provides the
|
|
|
+ * `clientModuleHost` 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
|
|
|
@@ -21,9 +22,8 @@
|
|
|
* @module @deepseek-ai/dsh-client-modules
|
|
|
*/
|
|
|
|
|
|
-import { createHash } from 'node:crypto'
|
|
|
-import { readFileSync } from 'node:fs'
|
|
|
-import { readFile } from 'node:fs/promises'
|
|
|
+import { createHash, randomBytes } from 'node:crypto'
|
|
|
+import { readFileSync, statSync, type Stats } from 'node:fs'
|
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
import { createRequire } from 'node:module'
|
|
|
import { dirname, join } from 'node:path'
|
|
|
@@ -32,11 +32,11 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
|
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
|
|
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
|
|
|
-import type { WebBootEntry, WebBootGraph } 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, WebBootEntry, WebBootGraph,
|
|
|
+ BootManifest, BootModuleRow, BootPluginRow, WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph,
|
|
|
} from './client/manifest.ts'
|
|
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
|
@@ -50,7 +50,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
interface DshClientDeclaration {
|
|
|
inject?: string[]
|
|
|
platform: string
|
|
|
- /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
|
|
|
+ /** Boot phase-one registration barrier; absent rows still ride the shared application batch. */
|
|
|
immediately?: boolean
|
|
|
/**
|
|
|
* Exact module-table requests beyond the implicit client baseline. Any
|
|
|
@@ -70,6 +70,20 @@ interface WebBootRowFields {
|
|
|
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
|
|
|
+ /** Source-map modification time, or null when no map was observable. */
|
|
|
+ readonly mapMtimeMs: number | null
|
|
|
+ /** Source-map size in bytes, or null when no map was observable. */
|
|
|
+ readonly mapSize: number | null
|
|
|
+}
|
|
|
+
|
|
|
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
|
|
|
interface PkgMeta extends WebBootRowFields {
|
|
|
clientPath: string
|
|
|
@@ -120,8 +134,29 @@ class ClientPackageCompositionError extends AggregateError {
|
|
|
interface WebPluginRecord {
|
|
|
entry: WebBootEntry
|
|
|
meta: PkgMeta
|
|
|
+ /** Exact build artifact included in the startup batches. */
|
|
|
+ bundle: Buffer
|
|
|
+ /** Pre-read filesystem baseline handed to the HMR watcher. */
|
|
|
+ baseline: ClientArtifactBaseline
|
|
|
+ /** Revision-stamped individual response used after HMR invalidation. */
|
|
|
+ individualBundle: Buffer
|
|
|
+ /** Optional parsed and original source map snapshot for immutable delivery. */
|
|
|
+ sourceMap?: { body: Buffer; parsed: Record<string, unknown> }
|
|
|
+}
|
|
|
+
|
|
|
+/** One generated initial-load response and its wire descriptor. */
|
|
|
+interface BatchArtifact {
|
|
|
+ descriptor: WebBootBatch
|
|
|
+ script: Buffer
|
|
|
+ sourceMap?: Buffer
|
|
|
}
|
|
|
|
|
|
+/** Versioned code is immutable; mismatched revisions are rejected instead of serving newer bytes. */
|
|
|
+const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
|
|
|
+
|
|
|
+/** Source-map trailer emitted by tsdown at the end of every client bundle. */
|
|
|
+const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
|
|
|
+
|
|
|
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
|
|
|
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
|
|
|
if (value === undefined) return undefined
|
|
|
@@ -158,11 +193,118 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi
|
|
|
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 (bundle rev / graph rev). */
|
|
|
+/** sha1 content hash shortened to 12 hex chars (batch / graph / rebuilt-artifact rev). */
|
|
|
function shortHash(input: string | Buffer): string {
|
|
|
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
|
|
}
|
|
|
|
|
|
+/** 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, 12)
|
|
|
+}
|
|
|
+
|
|
|
+/** Hash every byte served after HMR observes one artifact change. */
|
|
|
+function artifactRevision(bundle: Buffer, sourceMap: WebPluginRecord['sourceMap']): string {
|
|
|
+ return framedHash('individual', sourceMap === undefined ? [bundle] : [bundle, sourceMap.body])
|
|
|
+}
|
|
|
+
|
|
|
+/** Remove a bundle-local source-map trailer and retain one final newline. */
|
|
|
+function withoutSourceMapTrailer(input: Buffer): string {
|
|
|
+ const stripped = input.toString('utf8').replace(SOURCE_MAP_TRAILER, '')
|
|
|
+ return stripped.endsWith('\n') ? stripped : `${stripped}\n`
|
|
|
+}
|
|
|
+
|
|
|
+/** Stamp an individual bundle's map request with the same immutable revision. */
|
|
|
+function individualBundle(input: Buffer, rev: string, hasSourceMap: boolean): Buffer {
|
|
|
+ const source = withoutSourceMapTrailer(input)
|
|
|
+ return Buffer.from(hasSourceMap ? `${source}//# sourceMappingURL=client.js.map?rev=${rev}\n` : source)
|
|
|
+}
|
|
|
+
|
|
|
+/** Parse an optional source-map artifact; missing maps do not prevent plugin execution. */
|
|
|
+function sourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
|
|
|
+ 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 { body, 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 relocation into a batch. */
|
|
|
+function batchSectionMap(record: WebPluginRecord): Record<string, unknown> {
|
|
|
+ const original = record.sourceMap?.parsed
|
|
|
+ /* v8 ignore next -- callers add sections only for records with a source map. */
|
|
|
+ if (original === undefined) throw new Error(`client-modules: source map missing for ${record.entry.id}`)
|
|
|
+ const sourcePaths = original.sources as string[]
|
|
|
+ const sourceRoot = typeof original.sourceRoot === 'string' ? original.sourceRoot : ''
|
|
|
+ const base = new URL(`/plugins/${record.entry.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
|
|
|
+}
|
|
|
+
|
|
|
+/** Concatenate factory registrations and compose their maps as indexed sections. */
|
|
|
+function buildBatch(phase: WebBootBatchPhase, records: readonly WebPluginRecord[]): BatchArtifact {
|
|
|
+ let source = ''
|
|
|
+ const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
|
|
|
+ let line = 0
|
|
|
+ for (const record of records) {
|
|
|
+ if (record.sourceMap !== undefined) {
|
|
|
+ sections.push({ offset: { line, column: 0 }, map: batchSectionMap(record) })
|
|
|
+ }
|
|
|
+ const bundle = `${withoutSourceMapTrailer(record.bundle)};\n`
|
|
|
+ source += bundle
|
|
|
+ line += newlineCount(bundle)
|
|
|
+ }
|
|
|
+ const sourceMap = sections.length === 0
|
|
|
+ ? undefined
|
|
|
+ : Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
|
|
|
+ if (sourceMap !== undefined) source += '//# sourceMappingURL=client.js.map\n'
|
|
|
+ const script = Buffer.from(source)
|
|
|
+ const rev = framedHash('batch', sourceMap === undefined ? [script] : [script, sourceMap])
|
|
|
+ return {
|
|
|
+ descriptor: {
|
|
|
+ phase,
|
|
|
+ url: `/plugins/_batch/${phase}/${rev}/client.js`,
|
|
|
+ rev,
|
|
|
+ entries: records.map(record => record.entry.id),
|
|
|
+ },
|
|
|
+ script,
|
|
|
+ ...(sourceMap === undefined ? {} : { sourceMap }),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/** 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 {
|
|
|
@@ -222,13 +364,13 @@ export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEnt
|
|
|
/** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
|
|
|
const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
|
|
|
|
|
-/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
|
|
|
+/** 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 blocking classic script for modules' ordinary `lib/client.js`
|
|
|
- * artifact. Its `create()` method materializes the modules
|
|
|
+ * 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.
|
|
|
@@ -259,14 +401,17 @@ window.__ModuleLoader__={
|
|
|
}
|
|
|
}
|
|
|
})()`
|
|
|
- const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
|
|
|
- .filter((entry): entry is WebBootEntry => entry !== undefined)
|
|
|
- .map((entry): IndexInjection => ({ kind: 'script-src', placement: 'head', src: entry.url }))
|
|
|
- return [
|
|
|
- { kind: 'script', placement: 'head', text: queue },
|
|
|
- ...preload,
|
|
|
- { kind: 'global', name: '__DSH_BOOT__', value: graph },
|
|
|
- ]
|
|
|
+ const bootstrap = graph.batches.find(batch => batch.phase === 'bootstrap')
|
|
|
+ const application = graph.batches.find(batch => batch.phase === 'application')
|
|
|
+ const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: queue }]
|
|
|
+ if (application !== undefined) {
|
|
|
+ rows.push({ kind: 'script-preload', src: application.url })
|
|
|
+ }
|
|
|
+ if (bootstrap !== undefined) {
|
|
|
+ rows.push({ kind: 'script-src', placement: 'head', src: bootstrap.url })
|
|
|
+ }
|
|
|
+ rows.push({ kind: 'global', name: '__DSH_BOOT__', value: graph })
|
|
|
+ return rows
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -288,6 +433,11 @@ export class ClientModuleRegistry extends Service {
|
|
|
private readonly graphListeners = new Set<() => void>()
|
|
|
private readonly dirty = new Set<string>()
|
|
|
private readonly resolvePkgJson: (spec: string) => string
|
|
|
+ private readonly initialRevisionNonce = randomBytes(8).toString('hex')
|
|
|
+ private nextInitialRevision = 0
|
|
|
+ private batchResponses = new Map<string, { body: Buffer; contentType: string }>()
|
|
|
+ /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
|
|
|
+ private previousBatchResponses = new Map<string, { body: Buffer; contentType: string }>()
|
|
|
private flushQueued = false
|
|
|
private composed: WebBootGraph
|
|
|
|
|
|
@@ -359,6 +509,19 @@ export class ClientModuleRegistry extends Service {
|
|
|
return this.table.get(id)?.meta.clientPath
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 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 }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Re-hash one bundle (the HMR watch's registration hook — the only entry
|
|
|
* point through which bundle content changes reach the graph).
|
|
|
@@ -368,9 +531,17 @@ export class ClientModuleRegistry extends Service {
|
|
|
rebuilt(id: string): string | undefined {
|
|
|
const record = this.table.get(id)
|
|
|
if (record === undefined) return undefined
|
|
|
- const rev = shortHash(readFileSync(record.meta.clientPath))
|
|
|
+ const baseline = this.captureArtifactBaseline(record.meta.clientPath)
|
|
|
+ const bundle = readFileSync(record.meta.clientPath)
|
|
|
+ const sourceMap = this.readSourceMapSnapshot(record.meta.clientPath)
|
|
|
+ const rev = artifactRevision(bundle, sourceMap)
|
|
|
+ record.baseline = baseline
|
|
|
if (rev === record.entry.rev) return rev
|
|
|
record.entry = graphRow(id, rev, record.meta)
|
|
|
+ record.bundle = bundle
|
|
|
+ record.individualBundle = individualBundle(bundle, rev, sourceMap !== undefined)
|
|
|
+ if (sourceMap === undefined) delete record.sourceMap
|
|
|
+ else record.sourceMap = sourceMap
|
|
|
this.composed = this.compose()
|
|
|
for (const notify of this.rebuildListeners) {
|
|
|
// Containment: rebuilt() runs inside the HMR watch callback — a
|
|
|
@@ -408,7 +579,35 @@ export class ClientModuleRegistry extends Service {
|
|
|
|
|
|
private compose(): WebBootGraph {
|
|
|
const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
|
|
|
- return { rev: shortHash(JSON.stringify(entries)), entries }
|
|
|
+ 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[] = []
|
|
|
+ if (bootstrap.length > 0) artifacts.push(buildBatch('bootstrap', bootstrap))
|
|
|
+ if (application.length > 0) artifacts.push(buildBatch('application', application))
|
|
|
+
|
|
|
+ const batchResponses = new Map<string, { body: Buffer; contentType: string }>()
|
|
|
+ for (const artifact of artifacts) {
|
|
|
+ batchResponses.set(artifact.descriptor.url, {
|
|
|
+ body: artifact.script,
|
|
|
+ contentType: 'text/javascript; charset=utf-8',
|
|
|
+ })
|
|
|
+ if (artifact.sourceMap !== undefined) {
|
|
|
+ batchResponses.set(`${artifact.descriptor.url}.map`, {
|
|
|
+ body: artifact.sourceMap,
|
|
|
+ contentType: 'application/json; charset=utf-8',
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.previousBatchResponses = this.batchResponses
|
|
|
+ this.batchResponses = batchResponses
|
|
|
+ const batches = artifacts.map(artifact => artifact.descriptor)
|
|
|
+ return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }
|
|
|
}
|
|
|
|
|
|
private notifyGraphChanged(): void {
|
|
|
@@ -459,22 +658,63 @@ export class ClientModuleRegistry extends Service {
|
|
|
return meta
|
|
|
}
|
|
|
|
|
|
+ /** Capture the bundle and optional-map stats before reading their bytes. */
|
|
|
+ private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
|
|
|
+ const bundle = statSync(clientPath)
|
|
|
+ let sourceMap: Stats | undefined
|
|
|
+ try {
|
|
|
+ sourceMap = statSync(`${clientPath}.map`)
|
|
|
+ } catch {
|
|
|
+ // Optional map metadata only seeds HMR; the following map read reports
|
|
|
+ // malformed or inaccessible bytes and a later stat change self-heals.
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ path: clientPath,
|
|
|
+ mtimeMs: bundle.mtimeMs,
|
|
|
+ size: bundle.size,
|
|
|
+ mapMtimeMs: sourceMap?.mtimeMs ?? null,
|
|
|
+ mapSize: sourceMap?.size ?? null,
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Allocate an opaque initial row revision without inspecting artifact bytes. */
|
|
|
+ private allocateInitialRevision(): string {
|
|
|
+ return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
- * Read the activation-time bundle revision.
|
|
|
+ * Read the activation-time bundle and optional source-map snapshots.
|
|
|
* @param pkgName - package that declares the client bundle.
|
|
|
* @param clientPath - absolute path of the built client artifact.
|
|
|
- * @returns the bundle content's short hash for use as its revision.
|
|
|
+ * @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 initialBundleRevision(pkgName: string, clientPath: string): string {
|
|
|
+ private initialBundleSnapshot(pkgName: string, clientPath: string): {
|
|
|
+ bundle: Buffer
|
|
|
+ baseline: ClientArtifactBaseline
|
|
|
+ sourceMap?: WebPluginRecord['sourceMap']
|
|
|
+ } {
|
|
|
try {
|
|
|
- return shortHash(readFileSync(clientPath))
|
|
|
+ const baseline = this.captureArtifactBaseline(clientPath)
|
|
|
+ const bundle = readFileSync(clientPath)
|
|
|
+ const sourceMap = this.readSourceMapSnapshot(clientPath)
|
|
|
+ return { bundle, baseline, ...(sourceMap === undefined ? {} : { sourceMap }) }
|
|
|
} 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 unmapped artifact revision. */
|
|
|
+ private readSourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
|
|
|
+ try {
|
|
|
+ return sourceMapSnapshot(clientPath)
|
|
|
+ } catch (error) {
|
|
|
+ this.ctx.logger.warn(error)
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
|
|
|
private processOne(entryName: string): boolean {
|
|
|
let qualifies = false
|
|
|
@@ -488,10 +728,18 @@ export class ClientModuleRegistry extends Service {
|
|
|
if (this.table.has(entryName)) return false
|
|
|
const meta = this.resolveMeta(entryName)
|
|
|
if (meta === null) return false
|
|
|
- // The rev rides the row from here on: a fiber restart reuses the row (and
|
|
|
- // its rev) untouched; only rebuilt() re-reads the bundle.
|
|
|
- const rev = this.initialBundleRevision(entryName, meta.clientPath)
|
|
|
- this.table.set(entryName, { entry: graphRow(entryName, rev, meta), meta })
|
|
|
+ // The opaque initial rev rides the row until HMR observes a file change;
|
|
|
+ // a fiber restart reuses the existing row without inspecting bytes.
|
|
|
+ const snapshot = this.initialBundleSnapshot(entryName, meta.clientPath)
|
|
|
+ const rev = this.allocateInitialRevision()
|
|
|
+ this.table.set(entryName, {
|
|
|
+ entry: graphRow(entryName, rev, meta),
|
|
|
+ meta,
|
|
|
+ bundle: snapshot.bundle,
|
|
|
+ baseline: snapshot.baseline,
|
|
|
+ individualBundle: individualBundle(snapshot.bundle, rev, snapshot.sourceMap !== undefined),
|
|
|
+ ...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
|
|
|
+ })
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
@@ -523,14 +771,24 @@ export class ClientModuleRegistry extends Service {
|
|
|
this.notifyGraphChanged()
|
|
|
}
|
|
|
|
|
|
- private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
|
+ private readonly serveBundle = (req: IncomingMessage, res: ServerResponse): void => {
|
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
|
res.writeHead(405)
|
|
|
res.end()
|
|
|
return
|
|
|
}
|
|
|
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
|
|
- const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
|
|
|
+ const requestUrl = new URL(req.url ?? '/', 'http://x')
|
|
|
+ const pathname = decodeURIComponent(requestUrl.pathname)
|
|
|
+ const batch = this.batchResponses.get(pathname) ?? this.previousBatchResponses.get(pathname)
|
|
|
+ if (batch !== undefined) {
|
|
|
+ res.writeHead(200, {
|
|
|
+ 'content-type': batch.contentType,
|
|
|
+ 'cache-control': IMMUTABLE_CACHE,
|
|
|
+ })
|
|
|
+ res.end(req.method === 'HEAD' ? undefined : batch.body)
|
|
|
+ return
|
|
|
+ }
|
|
|
// The id may contain a scope slash. Anything else under /plugins (including
|
|
|
// /plugins/events when the HMR row is absent) is an unknown resource.
|
|
|
const prefix = '/plugins/'
|
|
|
@@ -538,27 +796,26 @@ export class ClientModuleRegistry extends Service {
|
|
|
const bundleSuffix = '/client.js'
|
|
|
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
|
|
|
const suffix = isSourceMap ? mapSuffix : bundleSuffix
|
|
|
- const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
|
|
|
- ? this.clientPath(pathname.slice(prefix.length, -suffix.length))
|
|
|
+ const id = pathname.startsWith(prefix) && pathname.endsWith(suffix)
|
|
|
+ ? pathname.slice(prefix.length, -suffix.length)
|
|
|
: undefined
|
|
|
- const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
|
|
|
- if (path === undefined) {
|
|
|
+ const record = id === undefined ? undefined : this.table.get(id)
|
|
|
+ if (record === undefined || requestUrl.searchParams.get('rev') !== record.entry.rev) {
|
|
|
res.writeHead(404)
|
|
|
res.end()
|
|
|
return
|
|
|
}
|
|
|
- try {
|
|
|
- const body = await readFile(path)
|
|
|
- res.writeHead(200, {
|
|
|
- 'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
|
|
|
- 'cache-control': 'no-cache',
|
|
|
- })
|
|
|
- res.end(body)
|
|
|
- } catch {
|
|
|
- // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
|
|
+ const body = isSourceMap ? record.sourceMap?.body : record.individualBundle
|
|
|
+ if (body === undefined) {
|
|
|
res.writeHead(404)
|
|
|
res.end()
|
|
|
+ return
|
|
|
}
|
|
|
+ res.writeHead(200, {
|
|
|
+ 'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
|
|
|
+ 'cache-control': IMMUTABLE_CACHE,
|
|
|
+ })
|
|
|
+ res.end(req.method === 'HEAD' ? undefined : body)
|
|
|
}
|
|
|
}
|
|
|
|