feat(web): make dsh-client-modules dual-face with an incremental host scan
The node half is ClientModuleHostService (ctx.clientModuleHost): it composes the __DSH_BOOT__ graph by scanning loader entries for dshClient packages, serves /plugins/<id>/client.js, taps the index render, and exposes rebuilt/onRebuilt/onGraphChanged. Scanning is incremental per package — no full-rescan path exists: internal/plugin marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata (including negative verdicts) caches forever, and re-hashing is reachable only through rebuilt(id). The browser half moves wholesale to the standard ./client export (ClientModuleSystem, parseBootManifest with the dual-view BootManifest, and the adoption plugin face that reads the window.__DSH_MODULES__ slot and provides ctx.modules).
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Client module system: the browser peer of Node's internal ESM loader, built
|
||||
* as a lazy CJS table. The vendored cordis Loader consumes this object
|
||||
* through its `internal` seam (the only call site is `EntryTree.import` →
|
||||
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
|
||||
* waiting, update/refresh) entirely on the vendored side while this package
|
||||
* owns code arrival.
|
||||
*
|
||||
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
|
||||
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
|
||||
* side effect — including CSS injection — lives inside the factory closure
|
||||
* and runs at materialization, not at script execution. Materialization
|
||||
* (factory(require) → export surface) happens on first import/require and is
|
||||
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
|
||||
* another registered-but-unmaterialized module materializes it recursively,
|
||||
* so load order needs no external sequencing.
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → fetch + execute +
|
||||
* materialize; anything else → throw (loud — the runtime mirror of the
|
||||
* build-time bundle purity gate). The synchronous `require` handed to
|
||||
* factories walks the same order minus the fetch branch: fetching is async,
|
||||
* so only already-executed bundles can be required — and cross-plugin value
|
||||
* imports are a build error anyway.
|
||||
*
|
||||
* This file is the browser-safe contract face (zero node imports): the
|
||||
* `__DSH_BOOT__` wire types, the boot-manifest parser, and the seams around
|
||||
* {@link ClientModuleSystem}. The package root is the host-side service that
|
||||
* composes the wire.
|
||||
*/
|
||||
|
||||
import type {} from 'cordis'
|
||||
import type { ClientModuleSystem } from './system.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The client module system the web shell builds at boot (contract C5; provided by the `./client` wrapper plugin). */
|
||||
modules: ClientModuleLoader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One composed client entry pushed by the host (web2 §0 graph row). Wire
|
||||
* single source: the host node half (package root) produces this same shape.
|
||||
* `immediately` marks stage-one prefetch; `inject` is informational graph
|
||||
* metadata (the authoritative edges live in each package's dshClient
|
||||
* declaration and reach fibers through entry creation).
|
||||
*/
|
||||
export interface WebBootEntry {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
|
||||
url: string
|
||||
/** Bundle content hash (cache-busting consistency anchor). */
|
||||
rev: string
|
||||
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
|
||||
inject?: string[]
|
||||
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
|
||||
export interface WebBootGraph {
|
||||
/** Consistency anchor over the whole graph (content + bundle hashes). */
|
||||
rev: string
|
||||
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
|
||||
entries: WebBootEntry[]
|
||||
}
|
||||
|
||||
/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */
|
||||
export interface BootModuleRow {
|
||||
/** Entry name == package name (module-table key). */
|
||||
id: string
|
||||
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
|
||||
url: string
|
||||
/** Bundle content hash. */
|
||||
rev: string
|
||||
}
|
||||
|
||||
/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
|
||||
export interface BootPluginRow {
|
||||
/** Entry name == package name. */
|
||||
id: string
|
||||
/** Package-name dependency edges ([] when the wire omits them). */
|
||||
inject: string[]
|
||||
/** Stage-one prefetch tier (false when the wire omits it). */
|
||||
immediately: boolean
|
||||
}
|
||||
|
||||
/** The parsed boot manifest: one wire, two consumer views. */
|
||||
export interface BootManifest {
|
||||
/** Consistency anchor over the whole graph. */
|
||||
rev: string
|
||||
/** Rows as the module table consumes them. */
|
||||
modules: BootModuleRow[]
|
||||
/** Rows as entry composition consumes them. */
|
||||
plugins: BootPluginRow[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
|
||||
* a missing or malformed graph throws (the shell shows the loud failure —
|
||||
* a page without a valid manifest cannot boot anything).
|
||||
* @param wire - the raw `window.__DSH_BOOT__` value.
|
||||
* @returns the manifest with optional plugin-view fields normalized.
|
||||
*/
|
||||
export function parseBootManifest(wire: unknown): BootManifest {
|
||||
if (typeof wire !== 'object' || wire === null) {
|
||||
throw new Error('client-modules: window.__DSH_BOOT__ is missing or not an object')
|
||||
}
|
||||
const graph = wire as Record<string, unknown>
|
||||
if (typeof graph.rev !== 'string') {
|
||||
throw new Error('client-modules: boot manifest rev must be a string')
|
||||
}
|
||||
if (!Array.isArray(graph.entries)) {
|
||||
throw new Error('client-modules: boot manifest entries must be an array')
|
||||
}
|
||||
const modules: BootModuleRow[] = []
|
||||
const plugins: BootPluginRow[] = []
|
||||
for (const value of graph.entries as unknown[]) {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('client-modules: boot manifest entry is not an object')
|
||||
}
|
||||
const row = value as Record<string, unknown>
|
||||
const where = typeof row.id === 'string' ? `"${row.id}"` : JSON.stringify(row)
|
||||
if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') {
|
||||
throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`)
|
||||
}
|
||||
if (row.inject !== undefined && (!Array.isArray(row.inject) || row.inject.some(i => typeof i !== 'string'))) {
|
||||
throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`)
|
||||
}
|
||||
if (row.immediately !== undefined && typeof row.immediately !== 'boolean') {
|
||||
throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`)
|
||||
}
|
||||
modules.push({ id: row.id, url: row.url, rev: row.rev })
|
||||
plugins.push({
|
||||
id: row.id,
|
||||
inject: row.inject === undefined ? [] : [...row.inject as string[]],
|
||||
immediately: row.immediately === true,
|
||||
})
|
||||
}
|
||||
return { rev: graph.rev, modules, plugins }
|
||||
}
|
||||
|
||||
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
|
||||
export interface ClientPluginHandoff {
|
||||
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
|
||||
id: string
|
||||
/**
|
||||
* Closure factory holding the whole bundle body: receives the synchronous
|
||||
* require bound to the module table and returns the bundle's export
|
||||
* surface. Runs once, at materialization.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the kernel handoff slot. */
|
||||
export interface DshWindow {
|
||||
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
|
||||
__DSH_BOOT__?: unknown
|
||||
/** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor (contract C6). */
|
||||
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
|
||||
/**
|
||||
* Kernel handoff slot: the shell kernel stores the instance here right
|
||||
* after construction (before cordis exists) so the `./client` wrapper
|
||||
* plugin can provide it as `ctx.modules`. Missing slot at wrapper apply
|
||||
* time = kernel sequencing bug, thrown loud.
|
||||
*/
|
||||
__DSH_MODULES__?: ClientModuleSystem
|
||||
}
|
||||
|
||||
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
|
||||
export interface ClientModuleRecord {
|
||||
/** Module id (entry name / package name). */
|
||||
id: string
|
||||
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
|
||||
surface: unknown
|
||||
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
|
||||
styles: string[]
|
||||
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
|
||||
edges: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal-seam subset the vendored Loader and the client HMR plugin
|
||||
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
|
||||
* as `ctx.modules` (contract C5).
|
||||
*/
|
||||
export interface ClientModuleLoader {
|
||||
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
|
||||
version: 'client'
|
||||
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
|
||||
loadCache: Map<string, ClientModuleRecord>
|
||||
/**
|
||||
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
|
||||
* `specifier` through the branch order documented on the module, fetching
|
||||
* and executing a bundle when needed.
|
||||
* @param specifier - module specifier (entry name or table word).
|
||||
* @param parentURL - importer URL (unused — the client module graph is flat).
|
||||
* @param attrs - import attributes (unused; interface parity with Node's seam).
|
||||
* @returns the module's export surface.
|
||||
*/
|
||||
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
* Register a shell-own module (app-shell — code that ships inside the shell
|
||||
* bundle and never arrives as a plugin bundle).
|
||||
* @param id - entry name (shell-owned pseudo id).
|
||||
* @param module - the statically imported module namespace.
|
||||
*/
|
||||
registerStatic(id: string, module: unknown): void
|
||||
/**
|
||||
* Stage-one arrival: fetch the entry's bundle and execute it, registering
|
||||
* its factory (no materialization — module side effects wait for import).
|
||||
* No-op for static-registered ids and ids whose factory is already
|
||||
* registered; concurrent calls share one in-flight task. To force a fresh
|
||||
* fetch (HMR), {@link invalidate} first.
|
||||
* @param id - graph entry name.
|
||||
*/
|
||||
prefetch(id: string): Promise<void>
|
||||
/**
|
||||
* Full reset of one module: drop its registered factory, its materialized
|
||||
* record, and any consumed bundle text, so the next prefetch/import
|
||||
* refetches and re-executes (the HMR invalidation hook).
|
||||
* @param id - entry name to invalidate.
|
||||
*/
|
||||
invalidate(id: string): void
|
||||
}
|
||||
|
||||
/** Options for {@link ClientModuleSystem} (assembled by the web shell kernel at boot). */
|
||||
export interface ClientModuleSystemOptions {
|
||||
/** Boot rows in the module-table view (from {@link parseBootManifest}). */
|
||||
modules: BootModuleRow[]
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (synchronously performs the load() registration).
|
||||
* Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
}
|
||||
Reference in New Issue
Block a user