From 156bd075a9c8cdbd7fcfb075e15807e961add968 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:54:03 +0800 Subject: [PATCH] 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. --- ...-08-19-web-index-injection-table.i18n.yaml | 6 + .../2026-08-19-web-index-injection-table.md | 30 ++++ ...2026-08-19-web-index-injection-table.zh.md | 30 ++++ apps/web/tests/assembled-boot.ts | 9 +- packages/api/gateway/src/client/index.ts | 161 +++++++++++------- packages/bundle/web-app/tests/web-app.spec.ts | 2 +- .../client/connection/src/client/index.ts | 32 +++- packages/client/connection/src/client/rpc.ts | 9 +- packages/client/modules/src/index.ts | 60 +++---- .../modules/tests/node-half.client.spec.ts | 13 +- packages/client/ui-theme/package.json | 6 +- packages/client/ui-theme/src/boot-theme.ts | 34 ++-- packages/client/ui-theme/src/index.ts | 16 +- .../ui-theme/tests/boot-theme.client.spec.ts | 32 ++-- .../client/ui-theme/tests/host.client.spec.ts | 46 ++--- packages/client/web/src/boot.ts | 9 + .../cordis-host-runner/src/sandbox.ts | 35 +++- packages/host/frontend-static/src/index.ts | 8 +- packages/host/webserver/src/index.ts | 42 ++++- packages/host/webserver/src/injections.ts | 104 +++++++++++ .../host/webserver/tests/webserver.spec.ts | 50 +++++- 21 files changed, 533 insertions(+), 201 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md create mode 100644 .agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md create mode 100644 packages/host/webserver/src/injections.ts diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml new file mode 100644 index 0000000000..1c228865dc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md +2026-08-19-web-index-injection-table.md: a4c21974ef6770320b61cd7c2b19fc20372e8111 +2026-08-19-web-index-injection-table.zh.md: 4ccfa8afe6353e9af7e341ce73f5fb3213257ba2 diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md new file mode 100644 index 0000000000..a4c21974ef --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md @@ -0,0 +1,30 @@ +# Agent Note: structured index injection table (webserver/index-inject) + +Status: implemented + +English | [中文](2026-08-19-web-index-injection-table.zh.md) + +## Problem + +The web shell's boot HTML needs three kinds of injection: client-modules' boot protocol (the `__ModuleLoader__` registration queue inline script, the parser-blocking preload `` +})()` const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id)) .filter((entry): entry is WebBootEntry => entry !== undefined) - .map(entry => ``) - .join('') - const script = `${queue}${preload}` - const head = html.indexOf('
') - if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` - // Headless fixture pages may lack ; 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)) + }) } /** diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 0dc6ea0e3c..8577d2a57d 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url' import { runInNewContext } from 'node:vm' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' -import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver' import * as modulesClient from '../src/client/index.ts' -import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts' +import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts' import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts' const MODULES_ID = '@deepseek-ai/dsh-client-modules' @@ -81,9 +81,12 @@ function construct(packageNames: string[]): ClientModuleRegistry { return constructWithRoute(packageNames).service } -/** Execute the exact first inline script emitted by the Host HTML transform. */ +/** Execute the exact first inline script emitted by the Host boot rows. */ function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } { - const html = injectBootManifest('', graph) + const html = renderIndexInjections( + '', + bootInjections(graph), + ) const source = /') const runtimeAt = html.indexOf('') - const graphAt = html.indexOf('window.__DSH_BOOT__ = ') + const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ') const entryAt = html.indexOf('') expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([ facadeAt, modulesAt, runtimeAt, graphAt, entryAt, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index eb7d3f176c..dd969c69f2 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -57,6 +57,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -65,10 +66,9 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "react": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^" + "@types/react": "~18.3.1", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-theme/src/boot-theme.ts b/packages/client/ui-theme/src/boot-theme.ts index 89b81b9de1..e6430cc6b9 100644 --- a/packages/client/ui-theme/src/boot-theme.ts +++ b/packages/client/ui-theme/src/boot-theme.ts @@ -1,15 +1,16 @@ /** - * Host-rendered theme bootstrap for the browser's pre-plugin interval. Each - * index response embeds the current durable built-in preference; the browser - * resolves only `system`, then writes the same DOM fields ui-layout's - * ThemePresenter owns after the client plugin tree activates. + * Theme bootstrap row for the browser's pre-plugin interval. Each index + * render embeds the current durable built-in preference; the browser resolves + * only `system`, then writes the same DOM fields ui-layout's ThemePresenter + * owns after the client plugin tree activates. */ +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts' -/** Build the inline script for one schema-validated built-in preference. */ +/** Build the inline script body for one schema-validated built-in preference. */ function bootThemeScript(preference: ThemePreference): string { - return `` +})()` } /** - * 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 = /]*)?>/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) } } diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index c56c030e17..cc119d1825 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-host-webserver' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { injectBootTheme } from './boot-theme.ts' +import { bootThemeInjection } from './boot-theme.ts' import { DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, type ThemePreference, type ThemeSettings, @@ -26,18 +26,16 @@ function readPreference(ctx: Context): ThemePreference { } /** - * Register the durable theme section and initial-theme index transform when - * their optional Host services are composed. - * @param ctx - Host context that may acquire settings and HTTP services. + * Register the durable theme section when the optional settings service is + * composed, and answer every index injection collection with the current + * theme bootstrap row. + * @param ctx - Host context that may acquire the settings service. */ export function apply(ctx: Context): void { ctx.inject(['settings'], (settingsCtx) => { settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema) }) - ctx.inject(['webServer'], (httpCtx) => { - httpCtx.effect( - () => httpCtx.webServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))), - 'client-ui-theme: initial theme bootstrap', - ) + ctx.on('webserver/index-inject', (table) => { + table.push(bootThemeInjection(readPreference(ctx))) }) } diff --git a/packages/client/ui-theme/tests/boot-theme.client.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts index a8d0dff235..ab737059c1 100644 --- a/packages/client/ui-theme/tests/boot-theme.client.spec.ts +++ b/packages/client/ui-theme/tests/boot-theme.client.spec.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom -/** Host index injection and the resulting pre-plugin browser theme. */ +/** The theme bootstrap injection row and the resulting pre-plugin browser theme. */ import { runInNewContext } from 'node:vm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { injectBootTheme } from '../src/boot-theme.ts' +import { bootThemeInjection } from '../src/boot-theme.ts' import type { ThemePreference } from '../src/theme-settings.ts' const DARK_ATTRIBUTE = 'data-ds-dark-theme' @@ -11,15 +11,10 @@ function mockSystemDark(matches: boolean): void { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) } -function executeBootstrap( - preference?: ThemePreference, - html = '', -): string { - const injected = injectBootTheme(html, preference) - const source = /` 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: `` } + } + case 'script': + return { placement: row.placement, markup: `` } + case 'script-src': + return { placement: row.placement, markup: `` } + case 'style': + return { placement: 'head', markup: `` } + 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 = /]*)?>/i.exec(out) + // Headless fixture pages may lack ; 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 = /]*)?>/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 +} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 2cbd285856..ffe5b4648d 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import HttpServer from '../src/index.ts' +import HttpServer, { renderIndexInjections } from '../src/index.ts' let root: string | undefined let context: Context | undefined @@ -200,6 +200,54 @@ describe('real Loader composition', () => { await expect(request(port, '/probe')).rejects.toThrow() }) + it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => { + const loaded = await loadComposition() + const server = loaded.webServer + let flag = 'dark' + loaded.on('webserver/index-inject', (table) => { + table.push( + { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, + { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=