|
|
@@ -8,7 +8,7 @@ import { stripClientSuffix } from './manifest.ts'
|
|
|
import { ClientEntries } from './entries.ts'
|
|
|
import { removeOwnedStyles } from './entry-lifecycle.ts'
|
|
|
import type {
|
|
|
- BootManifest, BootModuleRow, ClientBundleRegistration, ClientModuleLoader, ClientModuleRecord,
|
|
|
+ BootManifest, BootModuleRow, ClientBundleRegistration, ClientBundleRequire, ClientModuleLoader, ClientModuleRecord,
|
|
|
ClientModuleSystemOptions,
|
|
|
} from './manifest.ts'
|
|
|
|
|
|
@@ -36,6 +36,28 @@ function atRevision(url: string, rev: string): string {
|
|
|
return url.replace(/([?&]rev=)[^&#]*/, `$1${encodeURIComponent(rev)}`)
|
|
|
}
|
|
|
|
|
|
+const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
|
|
|
+
|
|
|
+/** Internal module-table key for one package-local chunk. */
|
|
|
+function chunkId(ownerId: string, fileName: string): string {
|
|
|
+ return `${ownerId}/${fileName}`
|
|
|
+}
|
|
|
+
|
|
|
+/** Resolve a sibling chunk against the package's one-resource URL and current revision. */
|
|
|
+function chunkUrl(row: BootModuleRow, fileName: string, rev: string): string {
|
|
|
+ const url = atRevision(row.url, rev)
|
|
|
+ const marker = '/??'
|
|
|
+ const resourceStart = url.indexOf(marker)
|
|
|
+ const revisionStart = url.indexOf('&rev=', resourceStart + marker.length)
|
|
|
+ const resource = resourceStart < 0 || revisionStart < 0
|
|
|
+ ? undefined
|
|
|
+ : url.slice(resourceStart + marker.length, revisionStart)
|
|
|
+ if (resource !== `${row.id}/client.js`) {
|
|
|
+ throw new Error(`client-modules: cannot resolve chunk ${JSON.stringify(fileName)} from bundle URL ${url}`)
|
|
|
+ }
|
|
|
+ return `${url.slice(0, resourceStart)}/${row.id}/${fileName}?${url.slice(revisionStart + 1)}`
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Claim and inventory the <style> tags a factory injected during
|
|
|
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
|
|
|
@@ -122,13 +144,18 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
|
|
|
|
|
/** Register one bundle factory, rejecting a script that executes twice without invalidation. */
|
|
|
private register(registration: ClientBundleRegistration): void {
|
|
|
- const id = stripClientSuffix(registration.id)
|
|
|
+ const ownerId = stripClientSuffix(registration.id)
|
|
|
+ if (registration.chunk !== undefined && !CLIENT_CHUNK.test(registration.chunk)) {
|
|
|
+ throw new Error(`client-modules: invalid package-local chunk ${JSON.stringify(registration.chunk)}`)
|
|
|
+ }
|
|
|
+ const id = registration.chunk === undefined ? ownerId : chunkId(ownerId, registration.chunk)
|
|
|
if (this.bootstrapIds.has(id) || this.factories.has(id)) {
|
|
|
- throw new Error(`client-modules: duplicate factory registration for "${registration.id}" (bundle executed twice without invalidate?)`)
|
|
|
+ const registrationName = registration.chunk === undefined ? registration.id : id
|
|
|
+ throw new Error(`client-modules: duplicate factory registration for "${registrationName}" (bundle executed twice without invalidate?)`)
|
|
|
}
|
|
|
this.factories.set(id, {
|
|
|
factory: registration.factory,
|
|
|
- rev: this.reloadTargets.get(id)?.rev ?? this.graphRows.get(id)?.rev,
|
|
|
+ rev: this.reloadTargets.get(ownerId)?.rev ?? this.graphRows.get(ownerId)?.rev,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -183,7 +210,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
|
|
}
|
|
|
|
|
|
/** Materialize a registered factory (synchronous; memoized in loadCache). */
|
|
|
- private materialize(id: string): ClientModuleRecord {
|
|
|
+ private materialize(id: string, ownerId = id): ClientModuleRecord {
|
|
|
const existing = this.loadCache.get(id)
|
|
|
if (existing !== undefined) return existing
|
|
|
const registered = this.factories.get(id)
|
|
|
@@ -195,26 +222,21 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
|
|
this.materializing.add(id)
|
|
|
try {
|
|
|
const edges = new Set<string>()
|
|
|
- const exports = registered.factory(this.makeRequire(edges))
|
|
|
- const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
|
|
|
+ const exports = registered.factory(this.makeRequire(ownerId, edges))
|
|
|
+ const record: ClientModuleRecord = { id, exports, styles: claimStyles(ownerId), edges }
|
|
|
this.loadCache.set(id, record)
|
|
|
return record
|
|
|
} catch (error) {
|
|
|
- removeOwnedStyles(id)
|
|
|
+ removeOwnedStyles(ownerId)
|
|
|
throw error
|
|
|
} finally {
|
|
|
this.materializing.delete(id)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * The synchronous require answered to factories: seed → memoized record →
|
|
|
- * registered factory. Fetching is async and therefore unreachable
|
|
|
- * from here; an external dynamic package must have arrived before its
|
|
|
- * consumer materializes.
|
|
|
- */
|
|
|
- private makeRequire(edges: Set<string>): (spec: string) => unknown {
|
|
|
- return (spec: string): unknown => {
|
|
|
+ /** Build the synchronous module-table require and its asynchronous chunk operation. */
|
|
|
+ private makeRequire(ownerId: string, edges: Set<string>): ClientBundleRequire {
|
|
|
+ const require = (spec: string): unknown => {
|
|
|
edges.add(spec)
|
|
|
if (this.seed.has(spec)) return this.seed.get(spec)
|
|
|
const id = stripClientSuffix(spec)
|
|
|
@@ -226,6 +248,39 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
|
|
+ 'and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)',
|
|
|
)
|
|
|
}
|
|
|
+ require.async = async (spec: string): Promise<unknown> => {
|
|
|
+ edges.add(spec)
|
|
|
+ if (!spec.startsWith('./')) return await this.import(spec)
|
|
|
+ const fileName = spec.slice(2)
|
|
|
+ if (!CLIENT_CHUNK.test(fileName)) {
|
|
|
+ throw new Error(`client-modules: invalid relative chunk request ${JSON.stringify(spec)}`)
|
|
|
+ }
|
|
|
+ return await this.importChunk(ownerId, fileName)
|
|
|
+ }
|
|
|
+ return require
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Load, register, and materialize one package-local dynamic chunk. */
|
|
|
+ private async importChunk(ownerId: string, fileName: string): Promise<unknown> {
|
|
|
+ const id = chunkId(ownerId, fileName)
|
|
|
+ const existing = this.loadCache.get(id)
|
|
|
+ if (existing !== undefined) return existing.exports
|
|
|
+ if (!this.factories.has(id)) {
|
|
|
+ const row = this.graphRows.get(ownerId)
|
|
|
+ if (row === undefined) throw new Error(`client-modules: chunk owner "${ownerId}" is not a boot graph entry`)
|
|
|
+ const revision = this.factories.get(ownerId)?.rev ?? this.reloadTargets.get(ownerId)?.rev ?? row.rev
|
|
|
+ const url = chunkUrl(row, fileName, revision)
|
|
|
+ let transport = this.pendingArrival.get(url)
|
|
|
+ if (transport === undefined) {
|
|
|
+ transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url) })
|
|
|
+ this.pendingArrival.set(url, transport)
|
|
|
+ }
|
|
|
+ await transport
|
|
|
+ if (!this.factories.has(id)) {
|
|
|
+ throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return this.materialize(id, ownerId).exports
|
|
|
}
|
|
|
|
|
|
async import(specifier: string): Promise<unknown> {
|
|
|
@@ -302,7 +357,11 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
|
|
const revision = rev ?? row.rev
|
|
|
this.reloadTargets.set(normalized, { url: atRevision(row.url, revision), rev: revision })
|
|
|
} else this.reloadTargets.delete(normalized)
|
|
|
- this.factories.delete(normalized)
|
|
|
- this.loadCache.delete(normalized)
|
|
|
+ for (const key of this.factories.keys()) {
|
|
|
+ if (key === normalized || key.startsWith(`${normalized}/client.`)) this.factories.delete(key)
|
|
|
+ }
|
|
|
+ for (const key of this.loadCache.keys()) {
|
|
|
+ if (key === normalized || key.startsWith(`${normalized}/client.`)) this.loadCache.delete(key)
|
|
|
+ }
|
|
|
}
|
|
|
}
|