Browse Source

feat(webserver): structured index injection table and the client boot seams

Replace per-plugin tapIndex regex edits with pure-data IndexInjection rows
collected fresh per render over one webserver/index-inject event. One table,
two renderers: the served form renders rows into index.html; a static worker
form ships the same rows over its boot payload. tapIndex survives as the
raw-HTML escape hatch, applied after row rendering; client-modules and
ui-theme move to the event, and the manifest global renders as
globalThis["__DSH_BOOT__"].

The client boot chain gains the seams a pre-injected transport needs: the
module loader takes loadBundle from the transport global by default, HTTP
prefetch stands down when a transport owns bundle bytes, the web-app bundle
can decline frontend serving, the gateway client installs a namespace's
whole method group inside its fiber apply so a parked dependent never
observes the service without its methods, and the dynamic-code precheck
gates through new Function so hosts without a real node:vm keep the
define-time parse gate.
imccyu 1 month ago
parent
commit
156bd075a9

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md
+2026-08-19-web-index-injection-table.md: a4c21974ef6770320b61cd7c2b19fc20372e8111
+2026-08-19-web-index-injection-table.zh.md: 4ccfa8afe6353e9af7e341ce73f5fb3213257ba2

+ 30 - 0
.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md

@@ -0,0 +1,30 @@
+# Agent Note: structured index injection table (webserver/index-inject)
+
+Status: implemented
+
+English | [中文](2026-08-19-web-index-injection-table.zh.md)
+
+## Problem
+
+The web shell's boot HTML needs three kinds of injection: client-modules' boot protocol (the `__ModuleLoader__` registration queue inline script, the parser-blocking preload `<script src>` tags, the `__DSH_BOOT__` graph global) and ui-theme's first-paint theme script. The old mechanism was `webServer.tapIndex(html => html)` string transforms: each registrant regex-located `<head>`/`<body>` and spliced HTML on its own. The static worker deployment (the page is a build artifact; the host tree runs in a Web Worker) has no serve-HTML step at all, so the worker side hand-copied the same data into its `/__boot__` payload (`graph` + `theme` via `ctx.get`), and the page side re-implemented what the taps did (a facade installer, a theme applier, a preload loop) — one boot semantics, three implementations.
+
+## Decision
+
+Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`style`/`html`, `head|body` placement). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber.
+
+One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). Rows are pure JSON data — that is the both-ends-equivalent discipline.
+
+`tapIndex`/`applyIndexTaps` survive as the raw-HTML escape hatch, applied after row rendering; every internal consumer moved to the event.
+
+## Consequences
+
+- client-modules and ui-theme no longer regex-edit HTML; the worker's `readBootPayload` service-poking (`clientModules`, `settings`, theme constants through `loader.load`) is deleted; the page-side `installModuleLoaderFacade`, `applyBootTheme`, and `PARSER_PRELOAD_IDS` re-implementations retire.
+- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → preloads → global.
+- The served rendering of the manifest global changed from `window.__DSH_BOOT__ =` to `globalThis["__DSH_BOOT__"] =`; snapshot expectations carrying that text need re-recording.
+- New model-visible or page-visible boot inputs extend the row union; no new tap consumers.
+
+## Alternatives considered
+
+- **Keep tap functions, add a worker-side renderer that re-runs them over a fake document** — rejected: taps are opaque `html => html` closures, so the worker cannot serialize or replay them without shipping a DOM emulation into the boot path.
+- **A registration-style table (`registerInjection(row): dispose`)** — rejected for the two problems the event dissolves: rows staled against live state (theme preference, module graph) unless every producer re-registered on change, and every producer owned one more disposer. The per-emit pull reads fresh state with fiber-scoped cleanup for free.
+- **Deleting `tapIndex` outright** — rejected: an escape hatch for raw HTML transforms costs nothing while the table is young, and external compositions may have transforms no row kind expresses yet.

+ 30 - 0
.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md

@@ -0,0 +1,30 @@
+# Agent Note: 结构化 index 注入表(webserver/index-inject 事件)
+
+Status: implemented
+
+[English](2026-08-19-web-index-injection-table.md) | 中文
+
+## Problem
+
+Web 壳的启动 HTML 需要三类注入:client-modules 的引导协议(`__ModuleLoader__` 注册队列内联脚本、parser 阻塞的 preload `<script src>`、`__DSH_BOOT__` 全局图)与 ui-theme 的首帧主题脚本。旧机制是 `webServer.tapIndex(html => html)` 字符串变换:每个注册方各自用正则找 `<head>`/`<body>` 改 HTML。静态 worker 部署(页面是构建产物、host 树在 Web Worker 里)没有「服 HTML」这一步,于是 worker 侧只能在 `/__boot__` 载荷里手工重抄同一批数据(graph + theme,经 `ctx.get` 硬掏),页面侧再用手写代码(facade 安装、theme 应用、preload 循环)把 tap 干的事重演一遍——同一份启动语义存在三份实现。
+
+## Decision
+
+注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection`(`global`/`script`/`script-src`/`style`/`html`,`head|body` 定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。
+
+一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.html(head 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。行是纯 JSON 数据,这是双端等价的纪律。
+
+`tapIndex`/`applyIndexTaps` 保留为原始 HTML 变换的逃生口,在行渲染之后执行;内部消费者全部迁走。
+
+## Consequences
+
+- client-modules 与 ui-theme 不再各自正则改 HTML;worker 侧 `readBootPayload` 的 `ctx.get` 手掏(clientModules、settings、theme 常量 loader.load)删除;页面侧 `installModuleLoaderFacade`、`applyBootTheme`、`PARSER_PRELOAD_IDS` 三份重抄退役。
+- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证 队列→preload→全局 三行有序。
+- `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;含此文本的快照期望需重录。
+- 新的模型可见/页面可见注入一律走行类型扩展,不再新增 tap 消费者。
+
+## Alternatives considered
+
+- **保留 tap 函数、worker 侧对假 document 重放**——否决:tap 是不透明的 `html => html` 闭包,worker 无法序列化或重放,除非把 DOM 仿真塞进启动链。
+- **注册表式(`registerInjection(row): dispose`)**——否决于事件天然化解的两个问题:行数据会相对活状态(主题偏好、模块图)过期,除非每个生产者变更时重注册;且每个生产者多背一个 disposer。按次 emit 的拉取免费获得新鲜读取与 fiber 级清理。
+- **直接删除 `tapIndex`**——否决:表还年轻,原始 HTML 变换的逃生口零成本,外部组合可能还有行类型暂不能表达的变换。

+ 4 - 5
apps/web/tests/assembled-boot.ts

@@ -13,7 +13,7 @@ import { dirname, join, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { act, cleanup } from '@testing-library/react'
 import { afterEach, beforeEach, vi } from 'vitest'
-import { injectBootManifest, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
+import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
 import type { ClientModuleLoaderTarget, WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
 import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 
@@ -194,10 +194,9 @@ export function mountAssembledApp(search = '?fixture'): void {
   root.id = 'root'
   document.body.appendChild(root)
   win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
-  const html = injectBootManifest('<head></head>', win.__DSH_BOOT__)
-  const facadeSource = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
-  if (facadeSource === undefined) throw new Error('missing injected ModuleLoader facade')
-  ;(0, eval)(facadeSource)
+  const [facadeRow] = bootInjections(win.__DSH_BOOT__)
+  if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row')
+  ;(0, eval)(facadeRow.text)
   // Mirror the blocking Host-injected scripts before the Vite entry calls create().
   for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) {
     const plugin = PLUGINS.find(candidate => candidate.id === id)

+ 103 - 58
packages/api/gateway/src/client/index.ts

@@ -52,6 +52,14 @@ interface RemoteNamespaceHandle {
   readonly dispose: TypertDisposer
 }
 
+/** One descriptor's mounted variants, for the group disposer to unwind. */
+interface InstalledMethod {
+  readonly descriptor: InvocationDescriptor
+  readonly token: MountToken
+  direct: boolean
+  scoped: boolean
+}
+
 /** Typed Remote service augmented by generated direct namespaces. */
 export type ClientRemote = TypertClientRemote
 
@@ -177,9 +185,17 @@ class ClientRemoteService extends Service implements TypertClientRemote {
   ): Promise<TypertDisposer> {
     this.validateContribution(contribution)
     const disposeRemote = callerCtx.typert.remotes.register(contribution)
+    const groups = new Map<string, InvocationDescriptor[]>()
+    for (const descriptor of contribution.descriptors) {
+      const group = groups.get(descriptor.namespace)
+      if (group === undefined) groups.set(descriptor.namespace, [descriptor])
+      else group.push(descriptor)
+    }
     const installed: TypertDisposer[] = []
     try {
-      for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
+      for (const [namespace, descriptors] of groups) {
+        installed.push(await this.installNamespace(namespace, descriptors))
+      }
     } catch (error) {
       for (const dispose of installed.reverse()) await dispose()
       await disposeRemote()
@@ -235,66 +251,47 @@ class ClientRemoteService extends Service implements TypertClientRemote {
     }
   }
 
-  private async install(descriptor: InvocationDescriptor): Promise<TypertDisposer> {
-    const token: MountToken = { active: true, abort: new AbortController() }
-    const installed: TypertDisposer[] = []
-    try {
-      if (descriptor.invocation.kind === 'direct') {
-        installed.push(await this.installDirect(descriptor, token))
-      }
-      const projection = scopedProjection(descriptor)
-      if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
-    } catch (error) {
-      token.active = false
-      token.abort.abort()
-      for (const dispose of installed.reverse()) await dispose()
-      throw error
-    }
-    return async () => {
-      /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
-      if (!token.active) return
-      token.active = false
-      token.abort.abort()
-      for (const dispose of installed.reverse()) await dispose()
-    }
-  }
-
-  private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypertDisposer> {
-    const namespace = await this.namespace(descriptor.namespace)
-    try {
-      namespace.service.installDirect(descriptor, token)
-    } catch (error) {
-      await this.disposeNamespace(descriptor.namespace, namespace)
-      throw error
-    }
-    return async () => {
-      namespace.service.remove('direct', descriptor.method, token)
-      await this.disposeNamespace(descriptor.namespace, namespace)
-    }
-  }
-
-  private async installScoped(
-    descriptor: InvocationDescriptor,
-    projection: ScopedProjection,
-    token: MountToken,
+  /**
+   * Mount one namespace's descriptor group with no visibility gap: a fresh
+   * namespace installs its whole group synchronously inside its fiber's
+   * apply, so a plugin parked on the namespace service never observes it
+   * without the methods the same contribution carries; an existing namespace
+   * takes the group in one synchronous step.
+   * @param name - Remote namespace.
+   * @param descriptors - Every contribution descriptor naming that namespace.
+   * @returns disposer unmounting the group and the namespace once empty.
+   */
+  private async installNamespace(
+    name: string,
+    descriptors: readonly InvocationDescriptor[],
   ): Promise<TypertDisposer> {
-    const namespace = await this.namespace(descriptor.namespace)
-    try {
-      namespace.service.installScoped(descriptor, projection, token)
-    } catch (error) {
-      await this.disposeNamespace(descriptor.namespace, namespace)
-      throw error
+    let namespace = this.namespaces.get(name)
+    let installed: InstalledMethod[]
+    if (namespace === undefined) {
+      ({ namespace, installed } = await this.createNamespace(name, descriptors))
+    } else {
+      installed = installMethods(namespace.service, descriptors)
     }
+    const handle = namespace
     return async () => {
-      namespace.service.remove('scoped', descriptor.method, token)
-      await this.disposeNamespace(descriptor.namespace, namespace)
+      for (const method of [...installed].reverse()) {
+        /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
+        if (!method.token.active) continue
+        method.token.active = false
+        method.token.abort.abort()
+        if (method.scoped) handle.service.remove('scoped', method.descriptor.method, method.token)
+        if (method.direct) handle.service.remove('direct', method.descriptor.method, method.token)
+      }
+      await this.disposeNamespace(name, handle)
     }
   }
 
-  private async namespace(name: string): Promise<RemoteNamespaceHandle> {
-    let namespace = this.namespaces.get(name)
-    if (namespace !== undefined) return namespace
+  private async createNamespace(
+    name: string,
+    descriptors: readonly InvocationDescriptor[],
+  ): Promise<{ namespace: RemoteNamespaceHandle; installed: InstalledMethod[] }> {
     let service: RemoteNamespaceService | undefined
+    let installed: InstalledMethod[] | undefined
     const fiber = this.ownerCtx.plugin({
       name: remoteServiceKey(name),
       apply: (ctx: Context) => {
@@ -303,6 +300,9 @@ class ClientRemoteService extends Service implements TypertClientRemote {
           name,
           (direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
         )
+        // Same synchronous window as the service registration: a dependent the
+        // new service unparks runs only after the methods exist.
+        installed = installMethods(service, descriptors)
       },
     })
     try {
@@ -311,11 +311,13 @@ class ClientRemoteService extends Service implements TypertClientRemote {
       await fiber.dispose()
       throw error
     }
-    /* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
-    if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
-    namespace = { service, dispose: fiber.dispose }
+    /* v8 ignore next 3 -- a settled namespace fiber synchronously constructs its Service and installs the group. */
+    if (service === undefined || installed === undefined) {
+      throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
+    }
+    const namespace = { service, dispose: fiber.dispose }
     this.namespaces.set(name, namespace)
-    return namespace
+    return { namespace, installed }
   }
 
   private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
@@ -504,6 +506,49 @@ class RemoteNamespaceService extends Service {
   }
 }
 
+/**
+ * Install one descriptor group on a namespace service, unwinding the partial
+ * group when a descriptor is refused.
+ * @param service - Namespace service taking the methods.
+ * @param descriptors - Descriptor group of one contribution.
+ * @returns per-descriptor records for the group disposer.
+ */
+function installMethods(
+  service: RemoteNamespaceService,
+  descriptors: readonly InvocationDescriptor[],
+): InstalledMethod[] {
+  const installed: InstalledMethod[] = []
+  try {
+    for (const descriptor of descriptors) {
+      const method: InstalledMethod = {
+        descriptor,
+        token: { active: true, abort: new AbortController() },
+        direct: false,
+        scoped: false,
+      }
+      installed.push(method)
+      if (descriptor.invocation.kind === 'direct') {
+        service.installDirect(descriptor, method.token)
+        method.direct = true
+      }
+      const projection = scopedProjection(descriptor)
+      if (projection !== undefined) {
+        service.installScoped(descriptor, projection, method.token)
+        method.scoped = true
+      }
+    }
+  } catch (error) {
+    for (const method of [...installed].reverse()) {
+      method.token.active = false
+      method.token.abort.abort()
+      if (method.scoped) service.remove('scoped', method.descriptor.method, method.token)
+      if (method.direct) service.remove('direct', method.descriptor.method, method.token)
+    }
+    throw error
+  }
+  return installed
+}
+
 const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
 
 function remoteServiceKey(namespace: string): string {

+ 1 - 1
packages/bundle/web-app/tests/web-app.spec.ts

@@ -78,7 +78,7 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
       fallback = handler
       return () => { fallback = undefined }
     },
-    applyIndexTaps: (html: string) => html,
+    renderIndex: (html: string) => html,
   } as unknown as WebServer
   return { server, seat: () => fallback }
 }

+ 29 - 3
packages/client/connection/src/client/index.ts

@@ -8,7 +8,7 @@ import type { HostDescription, IApiClient } from './api.ts'
 import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
 import { FixtureApiClient } from './fixture.ts'
 import { WebApiClient } from './web-api-client.ts'
-import { createWebConnectionRpc } from './rpc.ts'
+import { createWebConnectionRpc, type RpcFetch } from './rpc.ts'
 import { isLoopbackHostname } from '../loopback-hostname.ts'
 import type { ClientConnectionRpc } from '../rpc.ts'
 
@@ -40,6 +40,7 @@ export {
 // controller remains package-internal.
 export type { ConnectionConfig, ConnectionSinks, ConnectionState }
 export type { ClientConnectionRpc } from '../rpc.ts'
+export type { RpcFetch } from './rpc.ts'
 
 /** Observable Host description published by each completed connection handshake. */
 export interface HostDescriptionSource {
@@ -52,6 +53,30 @@ export interface HostDescriptionSource {
 /** Required services (none — this is the wire root). */
 export const inject: string[] = []
 
+/**
+ * Carrier override installed on the page global before plugin boot. The served
+ * web app leaves it unset and gets HTTP + WebSocket; a shell that owns a
+ * different physical transport (the worker preview's postMessage tunnel)
+ * provides both halves here instead of forking this plugin.
+ */
+export interface ClientTransportHooks {
+  /** Build the API carrier: unary calls plus the two downstream event streams. */
+  createApiClient(): IApiClient
+  /** Transport for generic unary RPC channels (the Typert gateway). */
+  fetch: RpcFetch
+  /**
+   * Bundle transport for the module system, present when the carrier also owns
+   * bundle bytes (the worker tunnel). Absent in the served web app, whose
+   * bundles load over HTTP.
+   */
+  loadBundle?(url: string): Promise<void>
+}
+
+/** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */
+interface ClientTransportGlobal {
+  __DSH_TRANSPORT__?: ClientTransportHooks
+}
+
 /**
  * The ctx.connection service API: the API client plus a one-shot
  * controller starter (the runtime plugin supplies sinks when its object layer
@@ -85,8 +110,9 @@ export function apply(ctx: Context): void {
   const pageLocation = typeof location === 'undefined' ? undefined : location
   const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
   const fixtureClient = fixture ? new FixtureApiClient() : undefined
-  const api: IApiClient = fixtureClient ?? new WebApiClient()
-  const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
+  const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
+  const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
+  const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch)
   let started = false
   let description: HostDescription | undefined
   const descriptionListeners = new Set<() => void>()

+ 7 - 2
packages/client/connection/src/client/rpc.ts

@@ -12,11 +12,16 @@ const INTERNAL_BASE = 'http://dsh.internal'
 const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
 const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
 
+/** Transport this caller posts through; same signature as the global `fetch`. */
+export type RpcFetch = (input: URL, init: RequestInit) => Promise<Response>
+
 /**
  * Create the browser-backed generic RPC caller.
+ * @param doFetch - transport override; defaults to the page's global fetch.
  * @returns caller that owns request correlation and response-envelope validation.
  */
-export function createWebConnectionRpc(): ClientConnectionRpc {
+export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc {
+  const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init))
   return {
     async call(channel, endpoint, payload, signal) {
       assertTarget(channel, endpoint)
@@ -27,7 +32,7 @@ export function createWebConnectionRpc(): ClientConnectionRpc {
         method: endpoint,
         payload,
       }
-      const response = await globalThis.fetch(
+      const response = await send(
         new URL(`${channel}/${endpoint}`, resolveBase()),
         {
           method: 'POST',

+ 23 - 37
packages/client/modules/src/index.ts

@@ -3,10 +3,10 @@
  * 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, taps the index render to
- * inject the boot manifest plus the parser-blocking bootstrap preloads, and
- * provides the `clientModuleHost` service (the HMR node half's
- * registration/notification face).
+ * `/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).
  *
  * Scanning is incremental per package — there is no full-rescan code path.
  * Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -30,7 +30,7 @@ import { dirname, join } from 'node:path'
 import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/cordis-plugin-loader'
-import type {} from '@deepseek-ai/dsh-host-webserver'
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
 import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
 import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
 
@@ -228,31 +228,19 @@ const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
 /** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
 const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_ID] as const
 
-/** Escape a graph URL before placing it in a quoted HTML attribute. */
-function escapeHtmlAttribute(value: string): string {
-  return value
-    .replaceAll('&', '&amp;')
-    .replaceAll('"', '&quot;')
-    .replaceAll('<', '&lt;')
-    .replaceAll('>', '&gt;')
-}
-
 /**
- * Inject the boot protocol into index.html. The inline registration queue precedes
- * blocking classic scripts for modules' and runtime's ordinary
+ * The boot protocol as index injection rows. The inline registration queue
+ * precedes blocking classic scripts for modules' and runtime's ordinary
  * `lib/client.js` artifacts. Its `create()` method materializes the modules
  * bundle, delegates construction to that bundle, and leaves the same facade
- * in live-registration mode. The graph script follows before the shell reads
- * it. `<` is escaped in JSON so a plugin-controlled string cannot break out
- * of the script element.
- * @param html - the index.html source.
+ * in live-registration mode. The graph global follows before the shell reads
+ * it.
  * @param graph - the composed entry graph.
- * @returns the html with the graph script injected.
+ * @returns head rows in execution order: queue script, preload scripts, graph global.
  */
-export function injectBootManifest(html: string, graph: WebBootGraph): string {
-  const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
+export function bootInjections(graph: WebBootGraph): IndexInjection[] {
   const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
-  const queue = `<script>(()=>{
+  const queue = `(()=>{
 const pendingQueue=[]
 window.__ModuleLoader__={
   mode:"queue",
@@ -273,21 +261,20 @@ window.__ModuleLoader__={
     return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
   }
 }
-})()</script>`
+})()`
   const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
     .filter((entry): entry is WebBootEntry => entry !== undefined)
-    .map(entry => `<script src="${escapeHtmlAttribute(entry.url)}"></script>`)
-    .join('')
-  const script = `${queue}${preload}<script>window.__DSH_BOOT__ = ${json}</script>`
-  const head = html.indexOf('<head>')
-  if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
-  // Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
-  return `${script}${html}`
+    .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 },
+  ]
 }
 
 /**
  * The web plugin table service: incremental `dsh.client` scan + wire composition
- * + bundle route + index tap. Construction runs the activation scan
+ * + bundle route + index injection rows. Construction runs the activation scan
  * synchronously — a malformed declaration or missing bundle among the
  * already-loaded entries aggregates into one loud throw (FAILED fiber; the
  * boot activation audit reports it).
@@ -353,10 +340,9 @@ export class ClientModuleRegistry extends Service {
       () => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
       'client-modules: bundle route',
     )
-    ctx.effect(
-      () => ctx.webServer.tapIndex(html => injectBootManifest(html, this.composed)),
-      'client-modules: boot manifest injection',
-    )
+    ctx.on('webserver/index-inject', (table) => {
+      table.push(...bootInjections(this.composed))
+    })
   }
 
   /**

+ 8 - 5
packages/client/modules/tests/node-half.client.spec.ts

@@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url'
 import { runInNewContext } from 'node:vm'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it } from 'vitest'
-import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
+import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
 import * as modulesClient from '../src/client/index.ts'
-import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts'
+import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
 import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
 
 const MODULES_ID = '@deepseek-ai/dsh-client-modules'
@@ -81,9 +81,12 @@ function construct(packageNames: string[]): ClientModuleRegistry {
   return constructWithRoute(packageNames).service
 }
 
-/** Execute the exact first inline script emitted by the Host HTML transform. */
+/** Execute the exact first inline script emitted by the Host boot rows. */
 function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
-  const html = injectBootManifest('<html><head></head><body><script type="module" src="/index.js"></script></body></html>', graph)
+  const html = renderIndexInjections(
+    '<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
+    bootInjections(graph),
+  )
   const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
   if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
   const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
@@ -107,7 +110,7 @@ describe('HTML bootstrap facade', () => {
     const facadeAt = html.indexOf('window.__ModuleLoader__=')
     const modulesAt = html.indexOf('<script src="/plugins/modules.js?rev=m"></script>')
     const runtimeAt = html.indexOf('<script src="/plugins/runtime.js?rev=r"></script>')
-    const graphAt = html.indexOf('window.__DSH_BOOT__ = ')
+    const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
     const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
     expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([
       facadeAt, modulesAt, runtimeAt, graphAt, entryAt,

+ 3 - 3
packages/client/ui-theme/package.json

@@ -57,6 +57,7 @@
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -65,10 +66,9 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@types/react": "~18.3.1",
-    "react": "^18.2.0",
     "@deepseek-ai/dsh-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-connection": "workspace:^"
+    "@types/react": "~18.3.1",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 14 - 20
packages/client/ui-theme/src/boot-theme.ts

@@ -1,15 +1,16 @@
 /**
- * Host-rendered theme bootstrap for the browser's pre-plugin interval. Each
- * index response embeds the current durable built-in preference; the browser
- * resolves only `system`, then writes the same DOM fields ui-layout's
- * ThemePresenter owns after the client plugin tree activates.
+ * Theme bootstrap row for the browser's pre-plugin interval. Each index
+ * render embeds the current durable built-in preference; the browser resolves
+ * only `system`, then writes the same DOM fields ui-layout's ThemePresenter
+ * owns after the client plugin tree activates.
  */
 
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
 import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts'
 
-/** Build the inline script for one schema-validated built-in preference. */
+/** Build the inline script body for one schema-validated built-in preference. */
 function bootThemeScript(preference: ThemePreference): string {
-  return `<script>(() => {
+  return `(() => {
   const preference = ${JSON.stringify(preference)}
   const systemDark = preference === 'system'
     && typeof matchMedia !== 'undefined'
@@ -17,24 +18,17 @@ function bootThemeScript(preference: ThemePreference): string {
   const dark = preference === 'dark' || systemDark
   document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
   document.body.toggleAttribute('data-ds-dark-theme', dark)
-})()</script>`
+})()`
 }
 
 /**
- * Insert the theme bootstrap immediately after the opening body tag, before
- * the shell mount and module script. Body-less fragments receive it at the
- * end, where the HTML parser has already synthesized a body.
- * @param html - Raw application index HTML.
+ * The theme bootstrap as an injection row: an inline script immediately after
+ * the opening body tag, before the shell mount and module script.
  * @param preference - Current Host-backed built-in preference.
- * @returns HTML containing the theme bootstrap.
+ * @returns the body script row.
  */
-export function injectBootTheme(
-  html: string,
+export function bootThemeInjection(
   preference: ThemePreference = DEFAULT_PREFERENCE,
-): string {
-  const script = bootThemeScript(preference)
-  const body = /<body(?:\s[^>]*)?>/i.exec(html)
-  if (body === null) return `${html}${script}`
-  const at = body.index + body[0].length
-  return `${html.slice(0, at)}${script}${html.slice(at)}`
+): IndexInjection {
+  return { kind: 'script', placement: 'body', text: bootThemeScript(preference) }
 }

+ 7 - 9
packages/client/ui-theme/src/index.ts

@@ -3,7 +3,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-host-webserver'
 import { settingsNamespace } from '@deepseek-ai/dsh-settings'
-import { injectBootTheme } from './boot-theme.ts'
+import { bootThemeInjection } from './boot-theme.ts'
 import {
   DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema,
   type ThemePreference, type ThemeSettings,
@@ -26,18 +26,16 @@ function readPreference(ctx: Context): ThemePreference {
 }
 
 /**
- * Register the durable theme section and initial-theme index transform when
- * their optional Host services are composed.
- * @param ctx - Host context that may acquire settings and HTTP services.
+ * Register the durable theme section when the optional settings service is
+ * composed, and answer every index injection collection with the current
+ * theme bootstrap row.
+ * @param ctx - Host context that may acquire the settings service.
  */
 export function apply(ctx: Context): void {
   ctx.inject(['settings'], (settingsCtx) => {
     settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema)
   })
-  ctx.inject(['webServer'], (httpCtx) => {
-    httpCtx.effect(
-      () => httpCtx.webServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))),
-      'client-ui-theme: initial theme bootstrap',
-    )
+  ctx.on('webserver/index-inject', (table) => {
+    table.push(bootThemeInjection(readPreference(ctx)))
   })
 }

+ 11 - 21
packages/client/ui-theme/tests/boot-theme.client.spec.ts

@@ -1,8 +1,8 @@
 // @vitest-environment jsdom
-/** Host index injection and the resulting pre-plugin browser theme. */
+/** The theme bootstrap injection row and the resulting pre-plugin browser theme. */
 import { runInNewContext } from 'node:vm'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { injectBootTheme } from '../src/boot-theme.ts'
+import { bootThemeInjection } from '../src/boot-theme.ts'
 import type { ThemePreference } from '../src/theme-settings.ts'
 
 const DARK_ATTRIBUTE = 'data-ds-dark-theme'
@@ -11,15 +11,10 @@ function mockSystemDark(matches: boolean): void {
   vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
 }
 
-function executeBootstrap(
-  preference?: ThemePreference,
-  html = '<html><body><div id="root"></div><script type="module"></script></body></html>',
-): string {
-  const injected = injectBootTheme(html, preference)
-  const source = /<script>([\s\S]*?)<\/script>/.exec(injected)?.[1]
-  if (source === undefined) throw new Error('theme bootstrap script missing')
-  runInNewContext(source, { document, matchMedia: globalThis.matchMedia })
-  return injected
+function executeBootstrap(preference?: ThemePreference): void {
+  const row = bootThemeInjection(preference)
+  if (row.kind !== 'script') throw new Error('theme bootstrap row is not a script')
+  runInNewContext(row.text, { document, matchMedia: globalThis.matchMedia })
 }
 
 afterEach(() => {
@@ -29,12 +24,12 @@ afterEach(() => {
   document.body.removeAttribute(DARK_ATTRIBUTE)
 })
 
-describe('theme boot index transform', () => {
-  it('runs immediately inside the body before the shell mount', () => {
+describe('theme bootstrap row', () => {
+  it('is a body script row, so it runs before the shell mount', () => {
     mockSystemDark(false)
-    const html = executeBootstrap('dark', '<html><body class="app"><div id="root"></div></body></html>')
-    expect(html.indexOf('<script>')).toBeGreaterThan(html.indexOf('<body class="app">'))
-    expect(html.indexOf('<script>')).toBeLessThan(html.indexOf('<div id="root">'))
+    const row = bootThemeInjection('dark')
+    expect(row).toMatchObject({ kind: 'script', placement: 'body' })
+    executeBootstrap('dark')
     expect(document.documentElement.style.colorScheme).toBe('dark')
     expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
   })
@@ -63,9 +58,4 @@ describe('theme boot index transform', () => {
     expect(document.documentElement.style.colorScheme).toBe('light')
     expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
   })
-
-  it('appends the script to a body-less fragment', () => {
-    const html = injectBootTheme('<main>loading</main>', 'dark')
-    expect(html.startsWith('<main>loading</main><script>')).toBe(true)
-  })
 })

+ 23 - 23
packages/client/ui-theme/tests/host.client.spec.ts

@@ -1,6 +1,6 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it } from 'vitest'
-import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
 import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
 import {
   DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
@@ -14,6 +14,19 @@ class MemorySettings extends SettingsProvider {
   }
 }
 
+/** Collect the injection table the way an index render or boot payload does. */
+function collect(ctx: Context): IndexInjection[] {
+  const table: IndexInjection[] = []
+  ctx.emit('webserver/index-inject', table)
+  return table
+}
+
+/** Narrow the theme row and return its script body. */
+function scriptText(row: IndexInjection | undefined): string {
+  if (row?.kind !== 'script') throw new Error('expected a script row')
+  return row.text
+}
+
 describe('ui-theme host', () => {
   it('registers, validates, and disposes the durable theme namespace with its fiber', async () => {
     const ctx = new Context()
@@ -29,37 +42,24 @@ describe('ui-theme host', () => {
     expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
   })
 
-  it('renders the current durable preference and disposes the index transform', async () => {
+  it('answers each collection with the current durable preference until disposal', async () => {
     const ctx = new Context()
     await ctx.plugin(MemorySettings).await()
-    let transform: ((html: string) => string) | undefined
-    let disposed = false
-    ctx.provide('webServer', {
-      tapIndex: (next: (html: string) => string) => {
-        transform = next
-        return () => { disposed = true }
-      },
-    } as WebServer)
     const fiber = ctx.plugin({ apply })
     await fiber.await()
-    expect(transform?.('<body></body>')).toContain('const preference = "system"')
+    const rows = collect(ctx)
+    expect(rows).toHaveLength(1)
+    expect(rows[0]).toMatchObject({ kind: 'script', placement: 'body' })
+    expect(scriptText(rows[0])).toContain('const preference = "system"')
     await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' })
-    expect(transform?.('<body></body>')).toContain('const preference = "dark"')
+    expect(scriptText(collect(ctx)[0])).toContain('const preference = "dark"')
     await fiber.dispose()
-    expect(disposed).toBe(true)
-    expect(transform?.('<body></body>')).toContain('const preference = "system"')
+    expect(collect(ctx)).toEqual([])
   })
 
-  it('uses the system preference when only an HTTP server exists', async () => {
+  it('uses the system preference without a settings provider', async () => {
     const ctx = new Context()
-    let transform: ((html: string) => string) | undefined
-    ctx.provide('webServer', {
-      tapIndex: (next: (html: string) => string) => {
-        transform = next
-        return () => undefined
-      },
-    } as WebServer)
     await ctx.plugin({ apply }).await()
-    expect(transform?.('<body></body>')).toContain('const preference = "system"')
+    expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"')
   })
 })

+ 9 - 0
packages/client/web/src/boot.ts

@@ -50,9 +50,15 @@ export class AppWebEntry {
       if (moduleLoader === undefined) {
         throw new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing')
       }
+      // A pre-injected transport (the worker preview page) owns bundle bytes;
+      // its loadBundle is the default and explicit seams still win.
+      const transport = (globalThis as {
+        __DSH_TRANSPORT__?: { loadBundle?: ClientModuleCreateOptions['loadBundle'] }
+      }).__DSH_TRANSPORT__
       this.modules = moduleLoader.create({
         boot: win.__DSH_BOOT__,
         staticModules: getStaticModules(),
+        ...transport?.loadBundle === undefined ? {} : { loadBundle: transport.loadBundle },
         ...this.seams,
       })
       this.manifest = this.modules.manifest
@@ -86,6 +92,9 @@ export class AppWebEntry {
 
   /** Prefetch stage-one bundles; their import path owns any eventual failure. */
   private async prefetchImmediateTier(): Promise<void> {
+    // A pre-injected transport owns bundle bytes; HTTP prefetch against the
+    // static deployment answers nothing.
+    if ((globalThis as { __DSH_TRANSPORT__?: unknown }).__DSH_TRANSPORT__ !== undefined) return
     await Promise.all(this.manifest.plugins
       .filter(row => row.immediately)
       .map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => {

+ 29 - 6
packages/extensions/cordis-host-runner/src/sandbox.ts

@@ -196,23 +196,46 @@ export function parseErrorMessage(half: 'code.host' | 'code.client', context: st
 /**
  * Parse one half's source without running it: the define-time precheck that
  * keeps unparseable code out of the registry, so a model fixes it and defines
- * again instead of discovering the failure at run time. Compiling through `vm`
- * rather than `new Function` is what makes the two agree — same wrapper, same
- * compiler, and the same source-line-and-caret prelude in the failure.
+ * again instead of discovering the failure at run time. `new Function` is the
+ * gate — hosts without a real `node:vm` (the browser worker) still refuse
+ * unparseable code — and `vm.Script` is only the best-effort prettifier: on a
+ * Node host its failure carries the source-line-and-caret prelude the
+ * teaching text builds on, and where the vm is a stub the message stays bare.
  * @param code - the model-written function body.
  * @param half - which define argument carried it, for the error text.
  * @throws when the body does not parse, with the offending line and a teaching hint.
  */
 export function precheckCode(code: string, half: 'code.host' | 'code.client'): void {
+  const wrapped = `(async () => {\n${code}\n})()`
   try {
-    // Compile-only: constructing the Script parses the source and runs nothing.
-    new Script(`(async () => {\n${code}\n})()`, { filename: `cordis-dyn-${half}.js` })
+    // Compile-only: constructing the function parses the source and runs nothing.
+    // eslint-disable-next-line @typescript-eslint/no-implied-eval -- parse gate over model-written code; nothing is invoked
+    new Function(wrapped)
   } catch (error) {
     if (!isSyntaxError(error)) throw error
-    throw new Error(parseErrorMessage(half, syntaxErrorContext(error)))
+    throw new Error(parseErrorMessage(half, prettyParseContext(wrapped, half, error)))
   }
 }
 
+/**
+ * Best-effort vm recompile of a body `new Function` already refused, for the
+ * source-line-and-caret prelude only.
+ * @param wrapped - the wrapped source that failed to parse.
+ * @param half - which define argument carried it, for the vm filename.
+ * @param refusal - the gate's own `SyntaxError`, the fallback context source.
+ * @returns the vm prelude when a real vm produced one, else the bare refusal.
+ */
+function prettyParseContext(wrapped: string, half: 'code.host' | 'code.client', refusal: Error): string {
+  try {
+    new Script(wrapped, { filename: `cordis-dyn-${half}.js` })
+  } catch (vmError) {
+    if (isSyntaxError(vmError)) return syntaxErrorContext(vmError)
+    // A stubbed vm (the browser worker) refuses Script itself; the gate's
+    // error is the only context there is.
+  }
+  return String(refusal)
+}
+
 /**
  * Evaluate a host half as the body of an async function inside the sandbox. `vmTimeoutMs` only
  * bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's

+ 4 - 4
packages/host/frontend-static/src/index.ts

@@ -4,8 +4,8 @@
  * Web shell locked at step1 — traversal outside the dist root is 403, any
  * miss falls back to index.html with HTTP 200 (SPA routing), unknown
  * extensions ship as octet-stream, non-GET/HEAD is 405. Every index response
- * runs through the webserver's registered index taps (boot-manifest
- * injection). The dist location is workspace knowledge of the composing
+ * runs through the webserver's index render (structured injection rows, then
+ * raw taps). The dist location is workspace knowledge of the composing
  * application, so `distIndex` is typically supplied through a `!!js`
  * expression, never hardcoded by a deployment.
  * @module @deepseek-ai/dsh-host-frontend-static
@@ -50,7 +50,7 @@ const MIME: Record<string, string> = {
  * @param res - the node:http response to write.
  * @param distRoot - absolute dist root directory (resolved by the caller).
  * @param distIndex - absolute path of index.html inside distRoot.
- * @param renderIndex - produces the index.html body (index-tap injection) for
+ * @param renderIndex - produces the index.html body (injection rendering) for
  * `/` and every SPA fallback.
  */
 export async function serveStatic(
@@ -94,7 +94,7 @@ export function apply(ctx: Context, config: Config): void {
   const distIndex = config.distIndex
   const distRoot = dirname(distIndex)
   const renderIndex = async (): Promise<string> =>
-    ctx.webServer.applyIndexTaps(await readFile(distIndex, 'utf8'))
+    ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'))
   ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => {
     // Non-GET/HEAD without a matching named route is 405 (fallback-only
     // semantics: named routes own their method handling).

+ 39 - 3
packages/host/webserver/src/index.ts

@@ -1,8 +1,8 @@
 /**
  * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
- * server plus the `webServer` service (HTTP and upgrade route registries,
- * index transform taps, and the single fallback seat for everything no route
- * claims). Knows no harness concepts and serves no files; the composing
+ * server plus the `webServer` service (HTTP and upgrade route registries, the
+ * structured index injection table with raw transform taps behind it, and the
+ * single fallback seat for everything no route claims). Knows no harness concepts and serves no files; the composing
  * application's frontend plugin owns dist serving through the fallback hook.
  * Web shape only — Electron loads dist over file:// and carries fetch over an
  * IPC bridge. This package never prints: the URL line belongs to the shell.
@@ -14,11 +14,25 @@ import type { AddressInfo } from 'node:net'
 import type { Duplex } from 'node:stream'
 import { Context, Service } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
+import { renderIndexInjections, type IndexInjection } from './injections.ts'
+
+export { renderIndexInjections } from './injections.ts'
+export type { IndexInjection, IndexInjectionPlacement } from './injections.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
     webServer: WebServer
   }
+  interface Events {
+    /**
+     * Collect the structured index injection table. Emitted on every index
+     * render and every worker boot-payload request; listeners push their
+     * current rows, so a row's data is read fresh at emit time.
+     * @param table - Mutable row table; listeners append in activation order.
+     * @mode emit
+     */
+    'webserver/index-inject'(table: IndexInjection[]): void
+  }
 }
 
 /** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
@@ -261,6 +275,28 @@ export class WebServer extends Service {
     for (const transform of this.indexTaps) out = transform(out)
     return out
   }
+
+  /**
+   * Gather the structured injection table: one `webserver/index-inject` emit,
+   * every subscriber pushes its current rows. Fresh per call, so subscribers
+   * read live state (module graph, theme preference) at emit time.
+   * @returns rows in subscriber activation order.
+   */
+  collectIndexInjections(): IndexInjection[] {
+    const table: IndexInjection[] = []
+    this.ctx.emit('webserver/index-inject', table)
+    return table
+  }
+
+  /**
+   * Render one index.html body: the structured injection table first, then
+   * the raw `tapIndex` transforms over the result.
+   * @param html - the raw index.html body.
+   * @returns the transformed body.
+   */
+  renderIndex(html: string): string {
+    return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections()))
+  }
 }
 
 export default WebServer

+ 104 - 0
packages/host/webserver/src/injections.ts

@@ -0,0 +1,104 @@
+/**
+ * Structured index injections: the typed rows plugins contribute to the boot
+ * HTML instead of raw `tapIndex` string transforms. Rows are pure
+ * JSON-serializable data because one table feeds two renderers: the served
+ * form renders rows into the index.html text ({@link renderIndexInjections}),
+ * and a static worker deployment ships the same rows over its boot payload
+ * for a page-side interpreter. Anything not expressible as a row stays on
+ * `tapIndex`, which runs after row rendering.
+ */
+
+/** Document region a rendered row lands in: after the opening head or body tag. */
+export type IndexInjectionPlacement = 'head' | 'body'
+
+/** One structured index injection row. */
+export type IndexInjection =
+  /** Assign a JSON-serializable value to a `globalThis` property, ahead of later script rows. */
+  | { kind: 'global'; name: string; value: unknown }
+  /** Inline classic script. `text` must not contain `</script`, which would close the element early. */
+  | { kind: 'script'; placement: IndexInjectionPlacement; text: string }
+  /**
+   * External classic script, executed in table order: a parser-blocking tag
+   * when served, an awaited fetch-and-execute in the worker form (whose
+   * loader resolves worker-only URLs such as `/plugins/...`).
+   */
+  | { kind: 'script-src'; placement: IndexInjectionPlacement; src: string }
+  /** A `<style>` element in the head. */
+  | { kind: 'style'; text: string }
+  /** Raw markup fragment. */
+  | { kind: 'html'; placement: IndexInjectionPlacement; html: string }
+
+/** Escape a row value before placing it in a quoted HTML attribute. */
+function escapeHtmlAttribute(value: string): string {
+  return value
+    .replaceAll('&', '&amp;')
+    .replaceAll('"', '&quot;')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;')
+}
+
+function assertNever(row: never): never {
+  throw new Error(`webserver: unknown index injection row ${JSON.stringify(row)}`)
+}
+
+/** Render one row to markup with its placement. */
+function renderRow(row: IndexInjection): { placement: IndexInjectionPlacement; markup: string } {
+  switch (row.kind) {
+    case 'global': {
+      // `<` is escaped in JSON so a row-controlled string cannot break out of
+      // the script element.
+      const name = JSON.stringify(row.name).replaceAll('<', '\\u003c')
+      const value = row.value === undefined
+        ? 'undefined'
+        : JSON.stringify(row.value).replaceAll('<', '\\u003c')
+      return { placement: 'head', markup: `<script>globalThis[${name}] = ${value}</script>` }
+    }
+    case 'script':
+      return { placement: row.placement, markup: `<script>${row.text}</script>` }
+    case 'script-src':
+      return { placement: row.placement, markup: `<script src="${escapeHtmlAttribute(row.src)}"></script>` }
+    case 'style':
+      return { placement: 'head', markup: `<style>${row.text}</style>` }
+    case 'html':
+      return { placement: row.placement, markup: row.html }
+    default:
+      return assertNever(row)
+  }
+}
+
+/** Insert `markup` into `html` at `at`. */
+function splice(html: string, at: number, markup: string): string {
+  return `${html.slice(0, at)}${markup}${html.slice(at)}`
+}
+
+/**
+ * Render rows into an index.html body: head rows immediately after the
+ * opening head tag, body rows immediately after the opening body tag, each
+ * group in table order.
+ * @param html - the raw index.html body.
+ * @param rows - the collected injection table.
+ * @returns the html with every row rendered.
+ */
+export function renderIndexInjections(html: string, rows: readonly IndexInjection[]): string {
+  let head = ''
+  let body = ''
+  for (const row of rows) {
+    const rendered = renderRow(row)
+    if (rendered.placement === 'head') head += rendered.markup
+    else body += rendered.markup
+  }
+  let out = html
+  if (head !== '') {
+    const open = /<head(?:\s[^>]*)?>/i.exec(out)
+    // Headless fixture pages may lack <head>; prepending keeps the rows ahead
+    // of every document script.
+    out = open === null ? `${head}${out}` : splice(out, open.index + open[0].length, head)
+  }
+  if (body !== '') {
+    const open = /<body(?:\s[^>]*)?>/i.exec(out)
+    // Body-less fragments receive the rows at the end, where the HTML parser
+    // has already synthesized a body.
+    out = open === null ? `${out}${body}` : splice(out, open.index + open[0].length, body)
+  }
+  return out
+}

+ 49 - 1
packages/host/webserver/tests/webserver.spec.ts

@@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Include from '@deepseek-ai/cordis-plugin-include'
-import HttpServer from '../src/index.ts'
+import HttpServer, { renderIndexInjections } from '../src/index.ts'
 
 let root: string | undefined
 let context: Context | undefined
@@ -200,6 +200,54 @@ describe('real Loader composition', () => {
     await expect(request(port, '/probe')).rejects.toThrow()
   })
 
+  it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => {
+    const loaded = await loadComposition()
+    const server = loaded.webServer
+    let flag = 'dark'
+    loaded.on('webserver/index-inject', (table) => {
+      table.push(
+        { kind: 'script', placement: 'head', text: 'window.__Q__=1' },
+        { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=<y>' },
+        { kind: 'global', name: '__DSH_BOOT__', value: { rev: '</script><b>' } },
+        { kind: 'style', text: 'body{margin:0}' },
+        { kind: 'html', placement: 'head', html: '<meta name="probe">' },
+        { kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` },
+      )
+    })
+
+    const html = server.renderIndex('<html><head></head><body>shell</body></html>')
+    // Head rows land right after the opening head tag in table order; the body
+    // row lands right after the opening body tag.
+    const order = [
+      '<head>',
+      '<script>window.__Q__=1</script>',
+      '<script src="/plugins/a.js?rev=&quot;1&quot;&amp;x=&lt;y&gt;"></script>',
+      'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}',
+      '<style>body{margin:0}</style>',
+      '<meta name="probe">',
+      '<body>',
+      '<script>window.__P__="dark"</script>',
+      'shell',
+    ].map(part => html.indexOf(part))
+    expect(order).toEqual([...order].sort((a, b) => a - b))
+    expect(order.every(at => at !== -1)).toBe(true)
+
+    // Fresh collection per render: the listener reads live state at emit time.
+    flag = 'light'
+    expect(server.renderIndex('<head></head><body></body>')).toContain('window.__P__="light"')
+
+    // Raw taps still run, over the already-rendered rows.
+    const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2'))
+    expect(server.renderIndex('<head></head><body></body>')).toContain('window.__Q__=2')
+    untap()
+
+    // Tag-less fragments: head rows prepend, body rows append.
+    expect(renderIndexInjections('<main>x</main>', [
+      { kind: 'script', placement: 'head', text: 'H' },
+      { kind: 'script', placement: 'body', text: 'B' },
+    ])).toBe('<script>H</script><main>x</main><script>B</script>')
+  })
+
   it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
     const first = await loadComposition()
     const takenPort = first.webServer.port