acrylblends 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +2017 -32
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +5 -3
- package/docs/framework/workshop/README.md +139 -153
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +87 -9
- package/lib/cordis/door.js +324 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +128 -24
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +14 -1
- package/plugins/acryl-door/src/index.ts +168 -30
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +47 -18
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +6 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +154 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +3 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/.gitattributes +2 -0
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page of a framework-hosted app. The document is ours: a minimal index (doctype, title, the root div) with the index taps applied per request,
|
|
3
|
+
* which is where the client-module row inserts `window.__DSH_BOOT__` and its registration scripts. DSH's built frontend is used only as the client
|
|
4
|
+
* runtime library the frame's client plugins load through: its own index is read for the entry script and stylesheet tags it genuinely needs, and its
|
|
5
|
+
* `dist/assets/` files are served beside our document. No DSH web server, web runtime or web-startup row is involved.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { dirname, extname, join, normalize, sep } from 'node:path'
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type {} from 'acryl-web-server'
|
|
13
|
+
|
|
14
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
15
|
+
export const name = 'acryl-page'
|
|
16
|
+
|
|
17
|
+
/** The page is served through the host's webServer port. */
|
|
18
|
+
export const inject = ['webServer']
|
|
19
|
+
|
|
20
|
+
export interface Config {
|
|
21
|
+
/** The document's title. */
|
|
22
|
+
title: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
26
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
27
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
28
|
+
'.css': 'text/css; charset=utf-8',
|
|
29
|
+
'.map': 'application/json; charset=utf-8',
|
|
30
|
+
'.json': 'application/json; charset=utf-8',
|
|
31
|
+
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
32
|
+
'.svg': 'image/svg+xml',
|
|
33
|
+
'.png': 'image/png',
|
|
34
|
+
'.jpg': 'image/jpeg',
|
|
35
|
+
'.jpeg': 'image/jpeg',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.ico': 'image/x-icon',
|
|
38
|
+
'.woff': 'font/woff',
|
|
39
|
+
'.woff2': 'font/woff2',
|
|
40
|
+
'.ttf': 'font/ttf',
|
|
41
|
+
'.wasm': 'application/wasm',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The frontend package's `dist` directory: anchored on its manifest, as DSH itself does. */
|
|
45
|
+
export function frontendDist(): string {
|
|
46
|
+
const require = createRequire(import.meta.url)
|
|
47
|
+
return join(dirname(require.resolve('@deepseek-ai/dsh-web-frontend/package.json')), 'dist')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The tags the built client runtime genuinely needs from the frontend's own index: the entry script, its preloads and its stylesheets. */
|
|
51
|
+
export function clientAssetTags(indexHtml: string): string {
|
|
52
|
+
const tags = [...indexHtml.matchAll(/<(?:script|link)\b[^>]*>/gu)]
|
|
53
|
+
.map((match) => match[0])
|
|
54
|
+
.filter(
|
|
55
|
+
(tag) =>
|
|
56
|
+
/^<script\b[^>]*\bsrc=/u.test(tag) ||
|
|
57
|
+
/^<link\b[^>]*\brel="(?:stylesheet|modulepreload)"/u.test(tag),
|
|
58
|
+
)
|
|
59
|
+
// A copied `<script src>` without its closing tag turns every tag after it (the stylesheet links) into its text: DSH's component stylesheet then never
|
|
60
|
+
// loads, and the frame draws its dialogs, buttons and inputs with browser defaults instead of the product's. The frontend's own script tags are empty,
|
|
61
|
+
// so the closing tag is this side's to add.
|
|
62
|
+
return tags.map((tag) => (tag.startsWith('<script') ? `${tag}</script>` : tag)).join('\n ')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Our document: minimal on purpose; the frame renders into `#root`, and the taps add the boot wire before `</head>`. */
|
|
66
|
+
export function pageDocument(tags: string, title: string, taps: (html: string) => string): string {
|
|
67
|
+
const document = `<!doctype html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8" />
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
72
|
+
<title>${title}</title>
|
|
73
|
+
${tags}
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div id="root"></div>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|
|
79
|
+
`
|
|
80
|
+
return taps(document)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The slice of the chat driver's connection the page needs: the process-token exchange that sets the browser's session cookie. */
|
|
84
|
+
interface ConnectionPort {
|
|
85
|
+
/** True when the caller may serve the index; false when it already answered (a token redirect or 401). */
|
|
86
|
+
authorizeIndex(req: IncomingMessage, res: ServerResponse): boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Answer with a file under `root`, or 404: a path that escapes `root` and a missing file both 404. */
|
|
90
|
+
async function serveFile(root: string, target: string, response: ServerResponse, method: string): Promise<void> {
|
|
91
|
+
const path = normalize(join(root, target))
|
|
92
|
+
if (path !== root && !path.startsWith(root + sep)) {
|
|
93
|
+
response.writeHead(404).end('not found')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const bytes = await readFile(path)
|
|
98
|
+
response.writeHead(200, {
|
|
99
|
+
'content-type': CONTENT_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream',
|
|
100
|
+
'content-length': String(bytes.byteLength),
|
|
101
|
+
'cache-control': 'no-cache',
|
|
102
|
+
})
|
|
103
|
+
response.end(method === 'HEAD' ? undefined : bytes)
|
|
104
|
+
} catch {
|
|
105
|
+
response.writeHead(404).end('not found')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Serve `/` (our document), `/index.html`, the built assets and the root files the index names. */
|
|
110
|
+
export function apply(ctx: Context, config: Partial<Config> = {}): void {
|
|
111
|
+
const title = config.title ?? 'ACRYL'
|
|
112
|
+
const dist = frontendDist()
|
|
113
|
+
const assets = join(dist, 'assets')
|
|
114
|
+
ctx.webServer.register({
|
|
115
|
+
kind: 'exact',
|
|
116
|
+
path: '/',
|
|
117
|
+
handler: async (req, res) => {
|
|
118
|
+
// The chat's remote RPC is authenticated by the connection's process token: the page's root request is where the token is exchanged
|
|
119
|
+
// for the session cookie. A composition without the driver has no connection and serves the page directly.
|
|
120
|
+
const connection = (ctx as unknown as { get(name: string): unknown }).get('connection') as ConnectionPort | undefined
|
|
121
|
+
if (connection !== undefined && !connection.authorizeIndex(req, res)) return
|
|
122
|
+
const index = await readFile(join(dist, 'index.html'), 'utf8')
|
|
123
|
+
const document = pageDocument(clientAssetTags(index), title, (html) => ctx.webServer.index(html))
|
|
124
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
125
|
+
res.end(req.method === 'HEAD' ? undefined : document)
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
ctx.webServer.register({
|
|
129
|
+
kind: 'exact',
|
|
130
|
+
path: '/index.html',
|
|
131
|
+
handler: async (req, res) => {
|
|
132
|
+
const index = await readFile(join(dist, 'index.html'), 'utf8')
|
|
133
|
+
const document = pageDocument(clientAssetTags(index), title, (html) => ctx.webServer.index(html))
|
|
134
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
135
|
+
res.end(req.method === 'HEAD' ? undefined : document)
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
ctx.webServer.register({
|
|
139
|
+
kind: 'prefix',
|
|
140
|
+
path: '/assets',
|
|
141
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
142
|
+
const pathname = new URL(req.url ?? '/', 'http://page.invalid').pathname
|
|
143
|
+
await serveFile(assets, pathname.slice('/assets'.length), res, req.method ?? 'GET')
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
// Browsers ask for /favicon.ico unconditionally; the built light icon answers it.
|
|
147
|
+
ctx.webServer.register({
|
|
148
|
+
kind: 'exact',
|
|
149
|
+
path: '/favicon.ico',
|
|
150
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
151
|
+
await serveFile(dist, 'favicon.svg', res, req.method ?? 'GET')
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
for (const file of ['favicon.svg', 'favicon-dark.svg', 'manifest.webmanifest']) {
|
|
155
|
+
ctx.webServer.register({
|
|
156
|
+
kind: 'exact',
|
|
157
|
+
path: `/${file}`,
|
|
158
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
159
|
+
await serveFile(dist, file, res, req.method ?? 'GET')
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* response body still streaming and waits for all of it to settle.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { appendFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
10
12
|
import { Context, Service } from '@deepseek-ai/cordis'
|
|
11
13
|
import z from '@deepseek-ai/schemastery'
|
|
12
14
|
import type { ConnectionInfo, RouterBindings } from './connection.ts'
|
|
@@ -21,6 +23,27 @@ import type { ErrorHandler, H, NotFoundHandler } from './core/types.ts'
|
|
|
21
23
|
|
|
22
24
|
const DEFAULT_DISPOSE_GRACE_MS = 2000
|
|
23
25
|
|
|
26
|
+
/** How long disposal waits for the flights it had to abort after the drain, before it continues without them. */
|
|
27
|
+
const ABORT_SETTLE_MS = 300
|
|
28
|
+
|
|
29
|
+
/** Set by ACRYL_ROUTER_TRACE=1: append request-signal lifecycle lines to ACRYL_ROUTER_TRACE_FILE (the CI artifact) - diagnostics for the abort-reaches-handler defect. */
|
|
30
|
+
const traceFile = (): string => process.env.ACRYL_ROUTER_TRACE_FILE ?? `${tmpdir()}/acryl-router-trace.log`
|
|
31
|
+
const trace = (line: string): void => {
|
|
32
|
+
if (process.env.ACRYL_ROUTER_TRACE !== '1') return
|
|
33
|
+
try { appendFileSync(traceFile(), `${String(Date.now())} [${String(process.pid)}] ${line}\n`) } catch { /* tracing never affects a request */ }
|
|
34
|
+
}
|
|
35
|
+
let flightSeq = 0
|
|
36
|
+
const SIGNAL_TAG = Symbol.for('acryl.router.signalTag')
|
|
37
|
+
const tagOf = (signal: AbortSignal | undefined): string => {
|
|
38
|
+
if (signal === undefined) return 'none'
|
|
39
|
+
const tag = (signal as unknown as Record<symbol, unknown>)[SIGNAL_TAG]
|
|
40
|
+
return typeof tag === 'string' ? tag : 'untagged'
|
|
41
|
+
}
|
|
42
|
+
const tagSignal = (signal: AbortSignal, tag: string): void => {
|
|
43
|
+
try { (signal as unknown as Record<symbol, unknown>)[SIGNAL_TAG] = tag } catch { /* a frozen signal keeps no tag */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
24
47
|
/** How a trailing slash is read: `strict` keeps `/a` and `/a/` distinct, `ignore` treats them as one path. */
|
|
25
48
|
export type TrailingSlash = 'strict' | 'ignore'
|
|
26
49
|
|
|
@@ -105,6 +128,10 @@ interface RouterState {
|
|
|
105
128
|
|
|
106
129
|
interface Flight {
|
|
107
130
|
readonly controller: AbortController
|
|
131
|
+
/** The request the handlers run on (the caller's request with the flight's signal): held strongly for the flight's lifetime, so its signal cannot be collected under a listener. */
|
|
132
|
+
readonly inner: Request
|
|
133
|
+
/** Names the flight in the ACRYL_ROUTER_TRACE diagnostics. */
|
|
134
|
+
readonly tag: string
|
|
108
135
|
/** How many of each mount's handlers are running for this request right now (a middleware counts until its `next()` returns). */
|
|
109
136
|
readonly running: Map<Mount, number>
|
|
110
137
|
/** The mount whose handler produced the response; its body is that mount's code still running. */
|
|
@@ -150,8 +177,8 @@ export class Router extends Service {
|
|
|
150
177
|
}
|
|
151
178
|
ctx.effect(() => async () => {
|
|
152
179
|
this.state.closing = true
|
|
153
|
-
await this.
|
|
154
|
-
}, 'acryl-router:
|
|
180
|
+
await this.drain()
|
|
181
|
+
}, 'acryl-router: drain in-flight requests, then abort what is left')
|
|
155
182
|
}
|
|
156
183
|
|
|
157
184
|
/** Registrations currently held (mounts, and the router-wide error and not-found handlers); a leak check for reload tests. */
|
|
@@ -168,12 +195,16 @@ export class Router extends Service {
|
|
|
168
195
|
const mount = this.snapshot(path, app)
|
|
169
196
|
this.state.mounts.add(mount)
|
|
170
197
|
this.state.composed = undefined
|
|
171
|
-
// Removing a mount ends the requests its code is still serving:
|
|
172
|
-
// plugin
|
|
198
|
+
// Removing a mount ends the requests its code is still serving: they get the grace to finish first (a save being written when its plugin unloads,
|
|
199
|
+
// or when the whole app stops and every plugin unloads together, must not be cut), and only then are their signals aborted, their bodies cancelled
|
|
200
|
+
// and their settle awaited, so nothing a plugin started outlives it by more than that bounded grace.
|
|
173
201
|
const remove = (): Promise<void> => {
|
|
174
202
|
if (!this.state.mounts.delete(mount)) return Promise.resolve()
|
|
175
203
|
this.state.composed = undefined
|
|
176
|
-
return this.
|
|
204
|
+
return this.drainFlights(
|
|
205
|
+
[...this.flights].filter(flight => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount),
|
|
206
|
+
`removing ${path}`,
|
|
207
|
+
)
|
|
177
208
|
}
|
|
178
209
|
// Each route the mount serves is a child effect of it, labeled on its own, so the inspector can report routes without asking the
|
|
179
210
|
// router and no label grows with the size of the app (`ALL` is middleware or `all`). The children hold nothing: the mount does.
|
|
@@ -215,6 +246,7 @@ export class Router extends Service {
|
|
|
215
246
|
const handler: H = async (c, next) => {
|
|
216
247
|
const flight = this.flightOf.get(c.req.raw)
|
|
217
248
|
if (flight === undefined) return inner(c, next)
|
|
249
|
+
trace(`handler ${flight.tag} requestTag=${tagOf(c.req.raw.signal)} innerTag=${tagOf(flight.inner.signal)} sameSignal=${String(c.req.raw.signal === flight.inner.signal)} aborted=${String(c.req.raw.signal.aborted)}`)
|
|
218
250
|
let calledNext = false
|
|
219
251
|
flight.running.set(mount, (flight.running.get(mount) ?? 0) + 1)
|
|
220
252
|
try {
|
|
@@ -234,18 +266,46 @@ export class Router extends Service {
|
|
|
234
266
|
return mount
|
|
235
267
|
}
|
|
236
268
|
|
|
237
|
-
/**
|
|
238
|
-
|
|
269
|
+
/**
|
|
270
|
+
* The router's own disposal: let the flights already in flight finish within `disposeGraceMs` - a save being written when the app stops must not be cut
|
|
271
|
+
* by the router beneath it - then abort what is left and settle it for a moment, so disposal does not wait a second full grace on code that ignores
|
|
272
|
+
* its abort signal.
|
|
273
|
+
*/
|
|
274
|
+
private async drain(): Promise<void> {
|
|
275
|
+
await this.drainFlights([...this.flights], 'disposal')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The one draining rule: let `flights` finish inside the grace, then abort and settle what is left. */
|
|
279
|
+
private async drainFlights(flights: readonly Flight[], why: string): Promise<void> {
|
|
280
|
+
trace(`drain ${why} chosen=[${flights.map(flight => flight.tag).join(', ')}]`)
|
|
281
|
+
if (flights.length === 0) return
|
|
282
|
+
if (await this.settledWithin(flights, this.graceMs)) { trace(`drain ${why} settled inside the grace`); return }
|
|
283
|
+
const left = flights.filter(flight => this.flights.has(flight))
|
|
284
|
+
trace(`drain ${why} grace over left=[${left.map(flight => flight.tag).join(', ')}]`)
|
|
285
|
+
if (left.length > 0) await this.end(left, why, ABORT_SETTLE_MS)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Resolve true when every flight settled inside `ms`, false at the deadline. */
|
|
289
|
+
private async settledWithin(flights: readonly Flight[], ms: number): Promise<boolean> {
|
|
290
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
291
|
+
const gaveUp = new Promise<false>(resolve => { timer = setTimeout(() => { resolve(false) }, ms) })
|
|
292
|
+
const outcome = await Promise.race([Promise.allSettled(flights.map(flight => flight.settled)).then(() => true as const), gaveUp])
|
|
293
|
+
clearTimeout(timer)
|
|
294
|
+
return outcome
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Abort `flights`, cut their bodies short and wait for them to settle, at most `settleMs`. */
|
|
298
|
+
private async end(flights: readonly Flight[], why: string, settleMs: number = this.graceMs): Promise<void> {
|
|
239
299
|
if (flights.length === 0) return
|
|
240
300
|
for (const flight of flights) {
|
|
301
|
+
trace(`abort ${flight.tag} before=${String(flight.controller.signal.aborted)}`)
|
|
241
302
|
flight.controller.abort()
|
|
303
|
+
trace(`abort ${flight.tag} after=${String(flight.controller.signal.aborted)} innerAborted=${String(flight.inner.signal.aborted)}`)
|
|
304
|
+
queueMicrotask(() => { trace(`post-abort ${flight.tag} innerAborted=${String(flight.inner.signal.aborted)}`) })
|
|
242
305
|
flight.endBody()
|
|
243
306
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const outcome = await Promise.race([Promise.allSettled(flights.map(flight => flight.settled)).then(() => 'settled' as const), gaveUp])
|
|
247
|
-
clearTimeout(timer)
|
|
248
|
-
if (outcome === 'gave-up') this.ctx.logger.warn(`acryl-router: ${String(flights.length)} request(s) did not finish ${String(this.graceMs)} ms after abort (${why}); continued without them`)
|
|
307
|
+
if (await this.settledWithin(flights, settleMs)) return
|
|
308
|
+
this.ctx.logger.warn(`acryl-router: ${String(flights.length)} request(s) did not finish ${String(settleMs)} ms after abort (${why}); continued without them`)
|
|
249
309
|
}
|
|
250
310
|
|
|
251
311
|
/** Turn errors no mounted app handled into responses. One handler per router; the calling plugin owns it. */
|
|
@@ -292,7 +352,7 @@ export class Router extends Service {
|
|
|
292
352
|
* `bindings` is an object an adapter hands to the handler as `c.env` (an upgrade request's socket hooks, for `upgradeWebSocket`); `connection` is added to it.
|
|
293
353
|
*/
|
|
294
354
|
fetch(request: Request, connection: ConnectionInfo = {}, bindings?: object): Promise<Response> {
|
|
295
|
-
if (this.state.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503 }))
|
|
355
|
+
if (this.state.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503, headers: { connection: 'close' } }))
|
|
296
356
|
const controller = new AbortController()
|
|
297
357
|
const forward = (): void => controller.abort()
|
|
298
358
|
if (request.signal.aborted) controller.abort()
|
|
@@ -308,8 +368,11 @@ export class Router extends Service {
|
|
|
308
368
|
let bodyDone: Promise<void> = Promise.resolve()
|
|
309
369
|
let endBody = (): void => {}
|
|
310
370
|
let resolveSettled!: (value: Promise<unknown>) => void
|
|
311
|
-
const
|
|
371
|
+
const tag = `f${String(++flightSeq)}`
|
|
312
372
|
const inner = new Request(request, { signal: controller.signal })
|
|
373
|
+
tagSignal(inner.signal, tag)
|
|
374
|
+
const flight: Flight = { controller, inner, tag, running: new Map(), producer: undefined, endBody: () => { endBody() }, settled: new Promise(resolve => { resolveSettled = resolve }) }
|
|
375
|
+
trace(`create ${tag} ${request.method} ${new URL(request.url).pathname}`)
|
|
313
376
|
this.flightOf.set(inner, flight)
|
|
314
377
|
// The caller gets the Response the moment the handler returns it; the flight lasts until its body ends and every waitUntil task settles.
|
|
315
378
|
const response = this.dispatch(inner, env, executionCtx).then(raw => {
|
|
@@ -41,6 +41,10 @@ export class RouterHttp extends Service implements RouterHttpService {
|
|
|
41
41
|
private readonly router: Context['router']
|
|
42
42
|
private readonly web: WebServerPort
|
|
43
43
|
private hub: UpgradeHub | undefined
|
|
44
|
+
/** One upgrade helper per calling context: a fresh wrapper per call made every service event look like an owner change. */
|
|
45
|
+
private readonly upgradeHelpers = new WeakMap<object, UpgradeWebSocket>()
|
|
46
|
+
/** A stable identity for this service instance's hub: a caller that must recognize the same owner across calls keys on this, not on a helper. */
|
|
47
|
+
private readonly hubIdentity: object = {}
|
|
44
48
|
/** The adapter's own context: the hub lives as long as the adapter, whichever plugin asked for it first (`this.ctx` is the caller's). */
|
|
45
49
|
private readonly home: Context
|
|
46
50
|
private readonly upgradesIn = new UpgradeEmitter()
|
|
@@ -99,8 +103,18 @@ export class RouterHttp extends Service implements RouterHttpService {
|
|
|
99
103
|
* `upgradeWebSocket` for the calling plugin: the sockets its routes accept are closed with 1001 when it unloads. Needs the `ws` package.
|
|
100
104
|
* The route must be on a path the plugin mounted with `upgrade: [path]`.
|
|
101
105
|
*/
|
|
106
|
+
/** Stable for this service's lifetime; a re-provided adapter has a new one, which is how a caller sees a new owner. */
|
|
107
|
+
hubToken(): object {
|
|
108
|
+
return this.hubIdentity
|
|
109
|
+
}
|
|
110
|
+
|
|
102
111
|
upgrades(): UpgradeWebSocket {
|
|
103
|
-
|
|
112
|
+
const owner = this.ctx
|
|
113
|
+
const cached = this.upgradeHelpers.get(owner)
|
|
114
|
+
if (cached !== undefined) return cached
|
|
115
|
+
const created = this.ensureHub().upgradeWebSocketFor(owner) as unknown as UpgradeWebSocket
|
|
116
|
+
this.upgradeHelpers.set(owner, created)
|
|
117
|
+
return created
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
/** How many WebSocket connections are open now. */
|
|
@@ -6,6 +6,35 @@ export interface FetchRouter {
|
|
|
6
6
|
fetch(request: Request, connection?: { readonly remoteAddress?: string | undefined, readonly localPort?: number | undefined }): Promise<Response>
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/** How long a refusal waits for a request body the handler never read. */
|
|
10
|
+
export const REQUEST_BODY_SETTLE_MS = 1_000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read and discard a request body the handler left unread (a guard's 403, a 415, a body over its limit), so the client can finish sending and
|
|
14
|
+
* read the answer. Ending a response while request data is still arriving makes Node close the socket with that data pending, and the peer sees a
|
|
15
|
+
* reset instead of the answer: reliably on Windows (the workspace git worktree test's read ECONNRESET) and on other systems under load. Bounded:
|
|
16
|
+
* a client that stops sending waits at most `ms`, then loses the socket as before.
|
|
17
|
+
*/
|
|
18
|
+
export function settleRequestBody(req: IncomingMessage, ms: number = REQUEST_BODY_SETTLE_MS): Promise<void> {
|
|
19
|
+
if (req.readableEnded || req.destroyed) return Promise.resolve()
|
|
20
|
+
return new Promise<void>((resolve) => {
|
|
21
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
22
|
+
const done = (): void => {
|
|
23
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
24
|
+
req.off('end', done)
|
|
25
|
+
req.off('close', done)
|
|
26
|
+
req.off('error', done)
|
|
27
|
+
resolve()
|
|
28
|
+
}
|
|
29
|
+
timer = setTimeout(done, ms)
|
|
30
|
+
timer.unref?.()
|
|
31
|
+
req.once('end', done)
|
|
32
|
+
req.once('close', done)
|
|
33
|
+
req.once('error', done)
|
|
34
|
+
req.resume()
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
9
38
|
/**
|
|
10
39
|
* Serve one node request through the router and write the result. The request's abort signal fires when the client
|
|
11
40
|
* disconnects before the response finished; the socket's peer address and local port travel as connection info, never in the request.
|
|
@@ -20,5 +49,7 @@ export async function serve(router: FetchRouter, req: IncomingMessage, res: Serv
|
|
|
20
49
|
} catch {
|
|
21
50
|
response = new Response('Internal Server Error', { status: 500 })
|
|
22
51
|
}
|
|
52
|
+
// The handler may have answered without reading the body; let the client finish sending before the answer ends the connection.
|
|
53
|
+
if (method !== 'GET' && method !== 'HEAD') await settleRequestBody(req)
|
|
23
54
|
await sendResponse(res, response, method)
|
|
24
55
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acryl-web-server",
|
|
3
|
+
"description": "The webServer port of a framework-hosted app: the node request/upgrade interface DSH's driver rows register on, backed by acryl-node-server's raw routes. Ours, so the chat driver's connection and gateway run on our host without DSH's web server.",
|
|
4
|
+
"requires": ["acryl-node-server"],
|
|
5
|
+
"inject": ["nodeServer"],
|
|
6
|
+
"provides": ["webServer"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4"
|
|
9
|
+
},
|
|
10
|
+
"mount": "mount it before the DSH driver rows that inject webServer (the chat connection and its gateway); the framework's page serving applies its index taps",
|
|
11
|
+
"entries": {
|
|
12
|
+
"": "src/index.ts"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `webServer` port for a framework-hosted app. DSH's driver rows (the chat's connection and its remote gateway) register node handlers with
|
|
3
|
+
* `webServer.register` and `webServer.registerUpgrade`; this plugin serves them on the framework's own node server, so those rows run unchanged
|
|
4
|
+
* while the HTTP host, the page and the client-module serving stay ours.
|
|
5
|
+
*
|
|
6
|
+
* It is the inverse of `acryl-router-http`: there the Host's web server serves a router prefix; here our node server presents the web-server
|
|
7
|
+
* interface a DSH row expects. The surface is deliberately small: `host` and `port` (what `acryl-web` reads), `register` (exact or prefix),
|
|
8
|
+
* `registerUpgrade` (one exact path), and the index taps the page serving applies. A row that needs more of DSH's interface gets the extra
|
|
9
|
+
* method here, named with the row that needs it - so this seam can shrink when a row goes away.
|
|
10
|
+
*/
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
12
|
+
import type { Duplex } from 'node:stream'
|
|
13
|
+
import { Context, Service } from '@deepseek-ai/cordis'
|
|
14
|
+
import type { NodeServer, RawRoute, RawUpgradeRoute } from 'acryl-node-server'
|
|
15
|
+
|
|
16
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
17
|
+
export const name = 'acryl-web-server'
|
|
18
|
+
|
|
19
|
+
/** Hard requirement: the node server whose raw routes carry these handlers. */
|
|
20
|
+
export const inject = ['nodeServer']
|
|
21
|
+
|
|
22
|
+
/** A node handler at a path `exact` (the pathname itself) or a `prefix` (the path and everything below it). */
|
|
23
|
+
export interface WebServerRoute {
|
|
24
|
+
readonly kind: 'prefix' | 'exact'
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One exact-path upgrade handler; the socket belongs to the handler once its path matches. */
|
|
30
|
+
export interface WebServerUpgradeRoute {
|
|
31
|
+
readonly path: string
|
|
32
|
+
readonly handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Transforms the served index document, in registration order. */
|
|
36
|
+
export type IndexTap = (html: string) => string
|
|
37
|
+
|
|
38
|
+
/** What a driver row (or `acryl-web`) reads from `webServer`. */
|
|
39
|
+
export interface WebServerService {
|
|
40
|
+
/** The address the node server bound. */
|
|
41
|
+
readonly host: string
|
|
42
|
+
readonly port: number
|
|
43
|
+
/** Serve `route` until the returned remover runs or the calling plugin unloads. A path already served throws. */
|
|
44
|
+
register(route: WebServerRoute): () => void
|
|
45
|
+
/** Claim `route.path` for an upgrade until the returned remover runs or the calling plugin unloads. */
|
|
46
|
+
registerUpgrade(route: WebServerUpgradeRoute): () => void
|
|
47
|
+
/** Transform the served index document while the caller lives; returns its remover. */
|
|
48
|
+
tapIndex(transform: IndexTap): () => void
|
|
49
|
+
/** The document with the structured injection rows (the client modules' boot wire) and then every live tap applied. */
|
|
50
|
+
index(html: string): string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
declare module '@deepseek-ai/cordis' {
|
|
54
|
+
interface Context {
|
|
55
|
+
webServer: WebServerService
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One structured index-injection row (DSH's contract): the kinds the client modules and a door script use. */
|
|
60
|
+
interface IndexRow {
|
|
61
|
+
readonly kind?: string
|
|
62
|
+
readonly placement?: 'head' | 'body'
|
|
63
|
+
readonly text?: string
|
|
64
|
+
readonly src?: string
|
|
65
|
+
readonly name?: string
|
|
66
|
+
readonly value?: unknown
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The boot-readiness tail the client shell waits for, as DSH renders it. */
|
|
70
|
+
const READY_MARKUP = '<script>(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()</script>'
|
|
71
|
+
|
|
72
|
+
/** A JSON literal safe inside a `<script>`: a sequence like `</script>` must not end it. */
|
|
73
|
+
const scriptJson = (value: unknown): string => JSON.stringify(value).replaceAll('</', '<\\/')
|
|
74
|
+
|
|
75
|
+
/** Render one row; an unknown kind is skipped rather than emitted as broken markup. */
|
|
76
|
+
function rowMarkup(row: IndexRow): string {
|
|
77
|
+
if (row.kind === 'script') return `<script>${row.text ?? ''}</script>`
|
|
78
|
+
if (row.kind === 'script-src') return `<script type="module" crossorigin src="${row.src ?? ''}"></script>`
|
|
79
|
+
if (row.kind === 'script-preload') return `<link rel="modulepreload" crossorigin href="${row.src ?? ''}" />`
|
|
80
|
+
if (row.kind === 'global') return `<script>globalThis[${scriptJson(row.name ?? '')}] = ${scriptJson(row.value)}</script>`
|
|
81
|
+
return ''
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Insert each group after its opening tag, as DSH's served index does; a document without the tag gets the group first. */
|
|
85
|
+
export function renderIndexRows(html: string, rows: readonly IndexRow[]): string {
|
|
86
|
+
let head = ''
|
|
87
|
+
let body = ''
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
const markup = rowMarkup(row)
|
|
90
|
+
if (markup === '') continue
|
|
91
|
+
if (row.placement === 'body') body += markup
|
|
92
|
+
else head += markup
|
|
93
|
+
}
|
|
94
|
+
body += READY_MARKUP
|
|
95
|
+
let document = html
|
|
96
|
+
const headOpen = /<head(?:\s[^>]*)?>/iu.exec(document)
|
|
97
|
+
if (head !== '') {
|
|
98
|
+
const at = headOpen === null ? 0 : headOpen.index + headOpen[0].length
|
|
99
|
+
document = `${document.slice(0, at)}${head}${document.slice(at)}`
|
|
100
|
+
}
|
|
101
|
+
const bodyOpen = /<body(?:\s[^>]*)?>/iu.exec(document)
|
|
102
|
+
if (body !== '') {
|
|
103
|
+
const at = bodyOpen === null ? document.length : bodyOpen.index + bodyOpen[0].length
|
|
104
|
+
document = `${document.slice(0, at)}${body}${document.slice(at)}`
|
|
105
|
+
}
|
|
106
|
+
return document
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The port, backed by `nodeServer`'s raw routes; every registration belongs to the plugin that made it. */
|
|
110
|
+
export class WebServer extends Service implements WebServerService {
|
|
111
|
+
private readonly taps: IndexTap[] = []
|
|
112
|
+
|
|
113
|
+
constructor(ctx: Context) {
|
|
114
|
+
super(ctx, 'webServer')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get host(): string {
|
|
118
|
+
return this.ctx.nodeServer.hostname
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get port(): number {
|
|
122
|
+
return this.ctx.nodeServer.port
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
register(route: WebServerRoute): () => void {
|
|
126
|
+
const remove = this.ctx.nodeServer.registerRaw(route as RawRoute)
|
|
127
|
+
this.ctx.effect(() => () => { remove() }, `acryl-web-server: ${route.kind} ${route.path}`)
|
|
128
|
+
return remove
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
registerUpgrade(route: WebServerUpgradeRoute): () => void {
|
|
132
|
+
const remove = this.ctx.nodeServer.registerRawUpgrade(route as RawUpgradeRoute)
|
|
133
|
+
this.ctx.effect(() => () => { remove() }, `acryl-web-server: upgrade ${route.path}`)
|
|
134
|
+
return remove
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
tapIndex(transform: IndexTap): () => void {
|
|
138
|
+
this.taps.push(transform)
|
|
139
|
+
const remove = (): void => {
|
|
140
|
+
const at = this.taps.indexOf(transform)
|
|
141
|
+
if (at >= 0) this.taps.splice(at, 1)
|
|
142
|
+
}
|
|
143
|
+
this.ctx.effect(() => () => { remove() }, 'acryl-web-server: index tap')
|
|
144
|
+
return remove
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
index(html: string): string {
|
|
148
|
+
// The rows are DSH's index-injection contract (`webserver/index-inject`): the client-module row pushes its boot wire there. The rows render
|
|
149
|
+
// first, the string taps after, the same order DSH's own web server uses.
|
|
150
|
+
const rows: IndexRow[] = []
|
|
151
|
+
;(this.ctx as unknown as { emit(name: string, table: IndexRow[]): void }).emit('webserver/index-inject', rows)
|
|
152
|
+
let document = renderIndexRows(html, rows)
|
|
153
|
+
for (const tap of [...this.taps]) document = tap(document)
|
|
154
|
+
return document
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Provide `webServer` on this context; the node server must already be up (the plugin injects it). */
|
|
159
|
+
export function apply(ctx: Context): void {
|
|
160
|
+
new WebServer(ctx)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Re-exported for callers that shape their own routes: the raw types the port is built on. */
|
|
164
|
+
export type { NodeServer, RawRoute, RawUpgradeRoute }
|