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.
This commit is contained in:
imccyu
2026-08-20 16:13:04 +08:00
parent e483c9e30e
commit 156bd075a9
21 changed files with 533 additions and 201 deletions
@@ -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
@@ -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.
@@ -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.htmlhead 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。行是纯 JSON 数据,这是双端等价的纪律。
`tapIndex`/`applyIndexTaps` 保留为原始 HTML 变换的逃生口,在行渲染之后执行;内部消费者全部迁走。
## Consequences
- client-modules 与 ui-theme 不再各自正则改 HTMLworker 侧 `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
View File
@@ -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
View File
@@ -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 {
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,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
View File
@@ -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
View File
@@ -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
View File
@@ -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)))
})
}
@@ -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)
})
})
@@ -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
View File
@@ -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) => {
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
@@ -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