Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/host/runtime/src/api-proxy.ts
#	packages/host/runtime/src/boot.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-24 16:29:03 +08:00
503 changed files with 19038 additions and 3597 deletions

View File

@@ -13,12 +13,14 @@ import { readFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import { dirname } from 'node:path'
import { serveStatic } from './static.ts'
import type { HostWebPluginRegistry } from './web-plugins.ts'
import { createPluginEventChannel } from './plugin-events.ts'
import type { HostWebPluginRegistry, WebBootGraph } from './web-plugins.ts'
export { createHostWebPluginRegistry } from './web-plugins.ts'
export type {
HostWebPluginRegistry, LoaderEntryView, LoaderView, WebPluginBootEntry, WebPluginRegistryDeps,
HostWebPluginRegistry, LoaderEntryView, LoaderView, WebBootEntry, WebBootGraph, WebPluginRegistryDeps,
} from './web-plugins.ts'
export type { PluginEventChannel, PluginEventFrame } from './plugin-events.ts'
/** Options for startWebServer. */
export interface WebServerOptions {
@@ -36,11 +38,14 @@ export interface WebServerOptions {
/** Maximum buffered bytes accepted for one `/api/*` request body. */
maxRequestBodyBytes: number
/**
* Web plugin table. When present, every index.html response carries a
* `window.__DSH_BOOT__` manifest script and `/plugins/<id>/client.js` serves
* each plugin's client bundle. Absent = both surfaces off (carrier-only use).
* Web plugin table. When present, every index.html response carries the
* `window.__DSH_BOOT__` entry graph script, `/plugins/<id>/client.js` serves
* each fetch entry's client bundle, and `GET /plugins/events` streams graph/
* rebuilt frames (SSE) — rebuilt frames ride the registry's own bundle-watch
* notifications (`onRebuilt`). Absent = all three surfaces off (carrier-only
* use).
*/
webPlugins?: Pick<HostWebPluginRegistry, 'snapshot' | 'clientPath'>
webPlugins?: Pick<HostWebPluginRegistry, 'graph' | 'clientPath' | 'onRebuilt'>
}
/** Listening web server handle. */
@@ -75,8 +80,14 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
const distRoot = dirname(distIndex)
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
const html = await readFile(distIndex, 'utf8')
return injectBootManifest(html, webPlugins.snapshot())
return injectBootManifest(html, webPlugins.graph())
}
const pluginEvents = webPlugins === undefined ? undefined : createPluginEventChannel()
// Rebuilt frames come from the registry's own bundle watch (dev mode); a
// prod registry without watching simply never notifies.
const unsubscribeRebuilt = webPlugins !== undefined && pluginEvents !== undefined
? webPlugins.onRebuilt((id, rev) => { pluginEvents.broadcast({ type: 'rebuilt', id, rev }) })
: undefined
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
@@ -91,6 +102,10 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
res.end()
return
}
if (webPlugins !== undefined && pluginEvents !== undefined && rawPath === '/plugins/events') {
pluginEvents.connect(res, webPlugins.graph())
return
}
if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) {
await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins)
return
@@ -115,6 +130,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
let closing: Promise<void> | undefined
const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
unsubscribeRebuilt?.()
server.close(() => { resolveClose() })
server.closeAllConnections()
}))
@@ -130,15 +146,15 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
}
/**
* Inject the boot manifest into index.html: `window.__DSH_BOOT__` as the first
* script in <head> (before the shell bundle reads it). `<` is escaped in the
* JSON so plugin-controlled strings cannot break out of the script element.
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
* first script in <head> (before the shell bundle reads it). `<` is escaped in
* the JSON so plugin-controlled strings cannot break out of the script element.
* @param html - the index.html source.
* @param plugins - the manifest rows from the registry snapshot.
* @returns the html with the manifest script injected.
* @param graph - the composed entry graph from the registry.
* @returns the html with the graph script injected.
*/
export function injectBootManifest(html: string, plugins: readonly unknown[]): string {
const json = JSON.stringify({ plugins }).replaceAll('<', '\\u003c')
export function injectBootManifest(html: string, graph: WebBootGraph): string {
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
const script = `<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)}`
@@ -146,7 +162,12 @@ export function injectBootManifest(html: string, plugins: readonly unknown[]): s
return `${script}${html}`
}
/** Serve one plugin client bundle from the registry table (unknown id = 404; the id may contain a scope slash). */
/**
* Serve one plugin client bundle from the registry table (unknown id = 404;
* the id may contain a scope slash). The `?rev=` query is a cache-busting
* parameter only — serving ignores it; `no-cache` makes the browser revalidate
* so a stale rev never sticks.
*/
async function servePluginBundle(
pathname: string, res: ServerResponse, webPlugins: Pick<HostWebPluginRegistry, 'clientPath'>,
): Promise<void> {
@@ -159,7 +180,7 @@ async function servePluginBundle(
}
try {
const body = await readFile(path)
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
res.end(body)
} catch {
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.