diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index ad2fb78ab1..7f3a5ece44 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-modules", - "description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)", + "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", "version": "0.0.1", "private": true, "type": "module", @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -18,13 +22,25 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, + "dshClient": { + "platform": "web", + "inject": [], + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "license": "BSD-3-Clause", "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", + "lib/client.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts new file mode 100644 index 0000000000..c734ffb8f7 --- /dev/null +++ b/packages/client/modules/src/client/index.ts @@ -0,0 +1,34 @@ +/** + * Browser half (the standard `./client` export): the module-system class and + * wire contract, plus the enrollment plugin face. The module system itself is + * built by the shell kernel BEFORE cordis exists (the bootstrap exception, + * design §4.7 — the mechanism that loads plugins cannot arrive through + * itself); the plugin face only enrolls that pre-existing instance by + * providing it as `ctx.modules`. The kernel statically registers this module, + * so the graph row for this package never triggers a real fetch — arrival is + * a no-op against the already-registered entry. + * @module @deepseek-ai/dsh-client-modules/client + */ +import type { Context } from 'cordis' +import type { DshWindow } from './manifest.ts' + +export { ClientModuleSystem } from './system.ts' +export { parseBootManifest } from './manifest.ts' +export type { + BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord, + ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph, +} from './manifest.ts' + +/** + * Enroll the kernel-built module system as `ctx.modules`. + * @param ctx - client root context. + */ +export function apply(ctx: Context): void { + const modules = (globalThis as DshWindow).__DSH_MODULES__ + // The kernel writes the slot right after constructing the instance, before + // any cordis entry exists — a missing slot means the kernel sequencing broke. + if (modules === undefined) { + throw new Error('client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot') + } + ctx.reflect.provide('modules', modules) +} diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts new file mode 100644 index 0000000000..6b8ff35548 --- /dev/null +++ b/packages/client/modules/src/client/manifest.ts @@ -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//client.js?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//client.js?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 + 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 + 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 +} + +/** 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 `