mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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:
@@ -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.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 变换的逃生口零成本,外部组合可能还有行类型暂不能表达的变换。
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
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="1"&x=<y>"></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
|
||||
|
||||
Reference in New Issue
Block a user