|
|
@@ -58,7 +58,7 @@ interface WebBootRowFields {
|
|
|
|
|
|
/** Filesystem baseline captured before a client artifact snapshot is read. */
|
|
|
export interface ClientArtifactBaseline {
|
|
|
- /** Absolute path of the client bundle. */
|
|
|
+ /** Absolute path of the client entry bundle watched for package rebuilds. */
|
|
|
readonly path: string
|
|
|
/** Bundle modification time in milliseconds. */
|
|
|
readonly mtimeMs: number
|
|
|
@@ -137,6 +137,8 @@ interface WebPluginRecord {
|
|
|
meta: PkgMeta
|
|
|
/** Exact build artifact included in the startup batches. */
|
|
|
bundle: Buffer
|
|
|
+ /** Package-local chunks referenced by the entry or another captured chunk. */
|
|
|
+ chunks: Map<string, Buffer>
|
|
|
/** Pre-read filesystem baseline handed to the HMR watcher. */
|
|
|
baseline: ClientArtifactBaseline
|
|
|
}
|
|
|
@@ -146,6 +148,7 @@ interface ComboResource {
|
|
|
id: string
|
|
|
rev: string
|
|
|
clientPath: string
|
|
|
+ fileName: string
|
|
|
bundle: Buffer
|
|
|
}
|
|
|
|
|
|
@@ -179,6 +182,8 @@ const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
|
|
|
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)?$/
|
|
|
+/** Relative CJS requests emitted by tsdown for package-local dynamic imports. */
|
|
|
+const CLIENT_CHUNK_REQUIRE = /\brequire\((["'])\.\/(client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\1\)/g
|
|
|
|
|
|
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
|
|
|
function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
|
|
|
@@ -205,9 +210,10 @@ function framedHash(domain: string, parts: readonly Buffer[]): string {
|
|
|
return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
|
|
|
}
|
|
|
|
|
|
-/** Hash the executable artifact served after HMR observes one plugin change. */
|
|
|
-function artifactRevision(bundle: Buffer): string {
|
|
|
- return framedHash('plugin-artifact', [bundle])
|
|
|
+/** Hash the entry and every package-local chunk served under one plugin revision. */
|
|
|
+function artifactRevision(bundle: Buffer, chunks: ReadonlyMap<string, Buffer>): string {
|
|
|
+ return framedHash('plugin-artifact', [bundle, ...[...chunks].sort(([left], [right]) => left.localeCompare(right))
|
|
|
+ .flatMap(([name, body]) => [Buffer.from(name), body])])
|
|
|
}
|
|
|
|
|
|
/** Address one ordered plugin-file list through the shared combo route. */
|
|
|
@@ -216,6 +222,11 @@ function comboUrl(ids: readonly string[], rev: string, sourceMap = false): strin
|
|
|
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(
|
|
|
@@ -265,7 +276,7 @@ function prepareSource(resource: ComboResource): PreparedSource {
|
|
|
source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
|
|
|
if (!source.endsWith('\n')) source += '\n'
|
|
|
const fallbackSource = sourceUrl === undefined
|
|
|
- ? `/plugins/${resource.id}/client.js`
|
|
|
+ ? `/plugins/${resource.id}/${resource.fileName}`
|
|
|
: /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
|
|
|
return { source, fallbackSource }
|
|
|
}
|
|
|
@@ -311,7 +322,7 @@ function newlineCount(value: string): number {
|
|
|
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 base = new URL(`/plugins/${resource.id}/${resource.fileName}.map`, 'http://dsh.invalid')
|
|
|
const relocated = sourcePaths.map((source) => {
|
|
|
const separator = sourceRoot !== '' && !sourceRoot.endsWith('/') && !source.startsWith('/') ? '/' : ''
|
|
|
const resolved = new URL(`${sourceRoot}${separator}${source}`, base)
|
|
|
@@ -365,6 +376,7 @@ function buildComboScript(resources: readonly ComboResource[], sourceMapUrl: str
|
|
|
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
|
|
|
@@ -383,7 +395,7 @@ function buildComboSourceMap(
|
|
|
sections.push({ offset: { line, column: 0 }, map: section })
|
|
|
line += newlineCount(`${prepared.source};\n`)
|
|
|
}
|
|
|
- return Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
|
|
|
+ return Buffer.from(`${JSON.stringify({ version: 3, file: fileName, sections })}\n`)
|
|
|
}
|
|
|
|
|
|
/** Describe one combo and defer its executable and debug payloads independently. */
|
|
|
@@ -396,6 +408,7 @@ function buildCombo(
|
|
|
id: record.entry.id,
|
|
|
rev: record.entry.rev,
|
|
|
clientPath: record.meta.clientPath,
|
|
|
+ fileName: 'client.js',
|
|
|
bundle: record.bundle,
|
|
|
}))
|
|
|
const rev = revision ?? comboRevision(resources)
|
|
|
@@ -655,21 +668,21 @@ export class ClientModuleRegistry extends Service {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Re-hash one bundle (the HMR watch's registration hook — the only entry
|
|
|
- * point through which bundle content changes reach the graph).
|
|
|
+ * Re-snapshot one package's entry and chunks (the HMR watch's registration
|
|
|
+ * hook — the only entry point through which executable 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)
|
|
|
- record.baseline = baseline
|
|
|
+ const snapshot = this.artifactSnapshot(id, record.meta.clientPath)
|
|
|
+ const rev = artifactRevision(snapshot.bundle, snapshot.chunks)
|
|
|
+ record.baseline = snapshot.baseline
|
|
|
if (rev === record.entry.rev) return rev
|
|
|
record.entry = graphRow(id, rev, record.meta)
|
|
|
- record.bundle = bundle
|
|
|
+ record.bundle = snapshot.bundle
|
|
|
+ record.chunks = snapshot.chunks
|
|
|
this.composed = this.compose()
|
|
|
for (const notify of this.rebuildListeners) {
|
|
|
// Containment: rebuilt() runs inside the HMR watch callback — a
|
|
|
@@ -745,6 +758,20 @@ export class ClientModuleRegistry extends Service {
|
|
|
body: artifact.sourceMapBody,
|
|
|
contentType: 'application/json; charset=utf-8',
|
|
|
})
|
|
|
+ for (const [fileName, bundle] of record.chunks) {
|
|
|
+ const clientPath = join(dirname(record.meta.clientPath), fileName)
|
|
|
+ const resource = { id: record.entry.id, rev: record.entry.rev, clientPath, fileName, bundle }
|
|
|
+ const url = chunkUrl(record.entry.id, fileName, record.entry.rev)
|
|
|
+ const sourceMapUrl = chunkUrl(record.entry.id, fileName, record.entry.rev, true)
|
|
|
+ responses.set(url, this.responses.get(url) ?? {
|
|
|
+ body: lazyBody(() => buildComboScript([resource], sourceMapUrl)),
|
|
|
+ contentType: 'text/javascript; charset=utf-8',
|
|
|
+ })
|
|
|
+ responses.set(sourceMapUrl, this.responses.get(sourceMapUrl) ?? {
|
|
|
+ body: lazyBody(() => buildComboSourceMap([resource], this.readSourceMap, fileName)),
|
|
|
+ contentType: 'application/json; charset=utf-8',
|
|
|
+ })
|
|
|
+ }
|
|
|
}
|
|
|
this.previousBatchResponses = this.batchResponses
|
|
|
this.batchResponses = batchResponses
|
|
|
@@ -896,20 +923,33 @@ export class ClientModuleRegistry extends Service {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Read the activation-time bundle snapshot.
|
|
|
+ * Read the activation-time entry and recursively referenced chunk 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): {
|
|
|
+ private artifactSnapshot(pkgName: string, clientPath: string): {
|
|
|
bundle: Buffer
|
|
|
+ chunks: Map<string, Buffer>
|
|
|
baseline: ClientArtifactBaseline
|
|
|
} {
|
|
|
try {
|
|
|
const baseline = this.captureArtifactBaseline(clientPath)
|
|
|
const bundle = readFileSync(clientPath)
|
|
|
- return { bundle, baseline }
|
|
|
+ const chunks = new Map<string, Buffer>()
|
|
|
+ const pending = [bundle]
|
|
|
+ for (let index = 0; index < pending.length; index += 1) {
|
|
|
+ const source = pending[index]!.toString('utf8')
|
|
|
+ for (const match of source.matchAll(CLIENT_CHUNK_REQUIRE)) {
|
|
|
+ const fileName = match[2]!
|
|
|
+ if (chunks.has(fileName)) continue
|
|
|
+ const chunk = readFileSync(join(dirname(clientPath), fileName))
|
|
|
+ chunks.set(fileName, chunk)
|
|
|
+ pending.push(chunk)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { bundle, chunks, baseline }
|
|
|
} catch (error) {
|
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
|
throw new MissingClientBundleError(pkgName, clientPath, error)
|
|
|
@@ -985,7 +1025,7 @@ export class ClientModuleRegistry extends Service {
|
|
|
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 snapshot = this.artifactSnapshot(packageName, source.meta.clientPath)
|
|
|
const rev = this.allocateInitialRevision()
|
|
|
this.table.set(packageName, {
|
|
|
entry: graphRow(packageName, rev, source.meta),
|
|
|
@@ -993,6 +1033,7 @@ export class ClientModuleRegistry extends Service {
|
|
|
sourceKey: source.sourceKey,
|
|
|
meta: source.meta,
|
|
|
bundle: snapshot.bundle,
|
|
|
+ chunks: snapshot.chunks,
|
|
|
baseline: snapshot.baseline,
|
|
|
})
|
|
|
return true
|