acrylblends 1.0.10 → 1.2.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 +60 -2
- package/dist/index.js +1992 -33
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +174 -285
- package/docs/framework/workshop/facilitator.md +81 -90
- 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-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +44 -7
- package/lib/cordis/door.js +214 -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.d.ts +3 -2
- package/lib/cordis/state.js +371 -7
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +20 -1
- package/lib/door/page.js +75 -14
- 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-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
- 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/src/host/channel.ts +124 -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 +58 -23
- 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 +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +11 -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 +49 -7
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +46 -2
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- 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-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -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 +343 -81
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +187 -61
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- 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 +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- 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 +12 -3
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -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/AGENTS.md +4 -2
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +73 -21
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +83 -5
- 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/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/main.ts +1 -6
- 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/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/main.ts +1 -6
- 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/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/main.ts +1 -6
- 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/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
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
* One adapter per runtime: a plugin keeps asking `ctx.inject(['routerHttp'], ...)` for a mount, which simply never fires here, because this
|
|
7
7
|
* adapter serves the whole router.
|
|
8
8
|
*
|
|
9
|
-
* A configured port that is taken is followed by the next free one, at most `scan` further ports; port 0 lets the system choose.
|
|
10
|
-
* open streams, closes every connection and returns
|
|
9
|
+
* A configured port that is taken is followed by the next free one, at most `scan` further ports; port 0 lets the system choose. A port a
|
|
10
|
+
* browser refuses to open (`RESTRICTED_PORTS`) is skipped like a taken one. Disposal ends open streams, closes every connection and returns
|
|
11
|
+
* once the port is free.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import type { Server } from 'node:http'
|
|
14
|
+
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
|
14
15
|
import type { AddressInfo } from 'node:net'
|
|
16
|
+
import type { Duplex } from 'node:stream'
|
|
15
17
|
import { Context, Service } from '@deepseek-ai/cordis'
|
|
16
18
|
import z from '@deepseek-ai/schemastery'
|
|
17
|
-
import type {} from 'acryl-router'
|
|
19
|
+
import type { ConnectionInfo, Router } from 'acryl-router'
|
|
18
20
|
import { Request as AdapterRequest } from './adapter/request.ts'
|
|
21
|
+
import { RAW_RESPONSE_HANDLED, RAW_UPGRADE_HANDLED, type RawUpgradeRequest } from './adapter/raw.ts'
|
|
19
22
|
import { createAdaptorServer } from './adapter/server.ts'
|
|
20
23
|
import { UpgradeHub, type UpgradeHubOptions } from './upgrade-hub.ts'
|
|
21
24
|
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
@@ -30,6 +33,37 @@ export { UpgradeEmitter, UpgradeHub, type UpgradeHubOptions, type UpgradeRouter
|
|
|
30
33
|
export const name = 'acryl-node-server'
|
|
31
34
|
export const inject = ['router']
|
|
32
35
|
|
|
36
|
+
/** A raw node handler: the request and response are the platform's, and the adapter writes nothing for them. */
|
|
37
|
+
export interface RawRoute {
|
|
38
|
+
readonly kind: 'prefix' | 'exact'
|
|
39
|
+
readonly path: string
|
|
40
|
+
readonly handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A raw upgrade handler: the socket is the handler's once the path matches; the router never sees the upgrade. */
|
|
44
|
+
export interface RawUpgradeRoute {
|
|
45
|
+
readonly path: string
|
|
46
|
+
readonly handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Answer a request before raw routes and the router, or pass it on with `undefined`. The callback runs synchronously, so a request no gate answers still
|
|
51
|
+
* reaches a raw route's sentinel synchronously; a gate that needs the router (a host keeping an app's URLs in scope under a prefix, the ACRYL frame's canvas)
|
|
52
|
+
* returns its promise, and the adapter handles that like the router's own answer. `connection` carries the socket facts, which a router mount needs to check
|
|
53
|
+
* the peer and the real port.
|
|
54
|
+
*/
|
|
55
|
+
export type RequestGate = (request: Request, connection: ConnectionInfo) => Response | Promise<Response> | undefined
|
|
56
|
+
|
|
57
|
+
/** The pathname of a request target, for raw matching: a malformed one matches nothing (`'/'` matches only itself). */
|
|
58
|
+
function pathnameOf(target: string | undefined): string {
|
|
59
|
+
if (target === undefined) return '/'
|
|
60
|
+
try {
|
|
61
|
+
return new URL(target, 'http://node-server.invalid').pathname
|
|
62
|
+
} catch {
|
|
63
|
+
return '/'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
export interface Config {
|
|
34
68
|
hostname: string
|
|
35
69
|
port: number
|
|
@@ -41,8 +75,8 @@ export interface Config {
|
|
|
41
75
|
/** Every field has a default, so a caller passes only what it changes: `ctx.plugin(plugin, { ... })` takes the partial shape. */
|
|
42
76
|
export const Config: z<Partial<Config>, Config> = z.object({
|
|
43
77
|
hostname: z.string().default('127.0.0.1').description('Address to bind. Loopback by default; binding elsewhere exposes the app to the network.'),
|
|
44
|
-
port: z.number().min(0).max(65535).default(0).description('Port to listen on; 0 lets the system choose. A taken port is followed by the next
|
|
45
|
-
scan: z.number().min(0).default(20).description('How many further ports to try when the configured one is taken.'),
|
|
78
|
+
port: z.number().min(0).max(65535).default(0).description('Port to listen on; 0 lets the system choose. A taken port, or one a browser refuses, is followed by the next usable one.'),
|
|
79
|
+
scan: z.number().min(0).default(20).description('How many further ports to try when the configured one is taken or a browser refuses it.'),
|
|
46
80
|
websocket: z
|
|
47
81
|
.object({
|
|
48
82
|
maxPayload: z.number().min(1).default(1024 * 1024).description('The largest WebSocket frame accepted, in bytes; a bigger one closes the socket with 1009.'),
|
|
@@ -58,31 +92,249 @@ declare module '@deepseek-ai/cordis' {
|
|
|
58
92
|
}
|
|
59
93
|
}
|
|
60
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Milliseconds a request already in flight, or an upgraded socket's own owner, may finish once the server stops taking new work. A save during a
|
|
97
|
+
* restart must not be cut; a stream that never ends must not hold the stop open.
|
|
98
|
+
*/
|
|
99
|
+
export const STOP_GRACE_MS = 2_000
|
|
100
|
+
|
|
61
101
|
export class NodeServer extends Service {
|
|
62
102
|
readonly hostname: string
|
|
63
103
|
readonly config: Config
|
|
64
104
|
private readonly server: Server
|
|
105
|
+
private readonly router: Router
|
|
65
106
|
private hub: UpgradeHub | undefined
|
|
66
107
|
private bound = 0
|
|
108
|
+
/** The one stop this server runs: a second call (the disposer after an explicit stop) waits on the same promise. */
|
|
109
|
+
private stopping: Promise<void> | undefined
|
|
110
|
+
/** Requests whose response has not finished yet: the stop waits for them before their sockets are cut. */
|
|
111
|
+
private active = 0
|
|
112
|
+
/** Upgraded sockets this server handed out (WebSockets): neither close() nor closeAllConnections() reaches them, so the stop tracks them itself. */
|
|
113
|
+
private readonly upgraded = new Set<Duplex>()
|
|
114
|
+
/** Raw routes registered by the host's compatibility layer (see {@link registerRaw}). */
|
|
115
|
+
private readonly rawExact = new Map<string, RawRoute['handler']>()
|
|
116
|
+
private readonly rawPrefix = new Map<string, RawRoute['handler']>()
|
|
117
|
+
private readonly rawUpgrades = new Map<string, RawUpgradeRoute['handler']>()
|
|
118
|
+
/** Gates registered by a host that maps an app's URLs (see {@link NodeServer.gate}); they run in the order they were added. */
|
|
119
|
+
private readonly gates: RequestGate[] = []
|
|
120
|
+
/** One raw dispatch per upgrade request: a second listener (or a re-added one) must never emit the same request again. */
|
|
121
|
+
private readonly rawUpgradeSeen = new WeakSet<IncomingMessage>()
|
|
67
122
|
|
|
68
123
|
constructor(ctx: Context, config: Config) {
|
|
69
124
|
super(ctx, 'nodeServer')
|
|
70
125
|
this.config = config
|
|
71
126
|
this.hostname = config.hostname
|
|
72
127
|
const router = ctx.router
|
|
128
|
+
this.router = router
|
|
73
129
|
// The handler sees the router's bindings (`c.env.connection`), not the raw `incoming`/`outgoing` pair. The global `Request` and
|
|
74
130
|
// `Response` stay the platform's, because this adapter shares its process with the rest of the app; so the adapter's lightweight
|
|
75
131
|
// request is made a real one (its signal still aborts when the client goes away) before the router re-creates it with its own signal.
|
|
76
132
|
this.server = createAdaptorServer({
|
|
77
|
-
fetch: (request,
|
|
133
|
+
fetch: (request, bindings) => {
|
|
134
|
+
const { incoming, outgoing } = bindings as { incoming: IncomingMessage; outgoing: ServerResponse }
|
|
135
|
+
// One count of a request whose response has not finished: the stop (see the disposer below) lets these finish before their sockets are cut,
|
|
136
|
+
// which is what keeps a save made while the app restarts from being lost.
|
|
137
|
+
this.active += 1
|
|
138
|
+
let finished = false
|
|
139
|
+
const settle = (): void => {
|
|
140
|
+
if (finished) return
|
|
141
|
+
finished = true
|
|
142
|
+
this.active -= 1
|
|
143
|
+
}
|
|
144
|
+
outgoing.once('finish', settle)
|
|
145
|
+
outgoing.once('close', settle)
|
|
146
|
+
// A raw route owns the node response: start it and return the sentinel synchronously, so the adapter (which checks the
|
|
147
|
+
// sentinel before treating the result as a promise) writes nothing. The handler's own failure becomes a 500 here.
|
|
148
|
+
// An upgrade that reaches the HTTP fetch path has no WebSocket listener to claim it (a provider transition, or no hub at all): refuse it
|
|
149
|
+
// here, before any HTTP route (a raw prefix would otherwise answer it as a normal request), so it is never answered twice.
|
|
150
|
+
if (incoming.headers.upgrade !== undefined) {
|
|
151
|
+
const websocket = incoming.headers.upgrade.toLowerCase() === 'websocket'
|
|
152
|
+
return new Response('', {
|
|
153
|
+
status: websocket ? 503 : 400,
|
|
154
|
+
headers: { connection: 'close' },
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
// A gate decides before raw routes and the router (a host that keeps an app's URLs under a prefix answers a navigation the frame's root would
|
|
158
|
+
// otherwise claim, and sends the app's own sub-resources to the router so a raw prefix cannot answer for the app): its answer is the request's.
|
|
159
|
+
if (this.gates.length > 0) {
|
|
160
|
+
const requestLike = request as unknown as Request
|
|
161
|
+
const connection: ConnectionInfo = { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort }
|
|
162
|
+
for (const gate of this.gates) {
|
|
163
|
+
const answer = gate(requestLike, connection)
|
|
164
|
+
if (answer !== undefined) return answer
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const raw = this.rawFor(incoming)
|
|
168
|
+
if (raw !== undefined) {
|
|
169
|
+
void (async () => {
|
|
170
|
+
try {
|
|
171
|
+
await raw(incoming, outgoing)
|
|
172
|
+
} catch {
|
|
173
|
+
if (!outgoing.headersSent) {
|
|
174
|
+
outgoing.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
|
|
175
|
+
outgoing.end('internal error')
|
|
176
|
+
} else outgoing.destroy()
|
|
177
|
+
}
|
|
178
|
+
})()
|
|
179
|
+
return RAW_RESPONSE_HANDLED
|
|
180
|
+
}
|
|
181
|
+
return router.fetch(new AdapterRequest(request), { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort })
|
|
182
|
+
},
|
|
78
183
|
hostname: config.hostname,
|
|
79
184
|
overrideGlobalObjects: false,
|
|
80
185
|
}) as Server
|
|
81
|
-
|
|
82
|
-
|
|
186
|
+
// ONE raw upgrade listener for the server's lifetime, ahead of the router's WebSocket setups. It consults the registry, so a claim added or
|
|
187
|
+
// removed never adds or removes a listener; a request it dispatched once is never dispatched again (a guard beside the single ownership).
|
|
188
|
+
this.server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
189
|
+
if (this.rawUpgradeSeen.has(request)) return
|
|
190
|
+
// Upgraded sockets leave the HTTP connection lifecycle: close() and closeAllConnections() never reach them (measured, not assumed), so the stop
|
|
191
|
+
// tracks them and waits for their owner's close frame before cutting what is left.
|
|
192
|
+
this.upgraded.add(socket)
|
|
193
|
+
socket.once('close', () => { this.upgraded.delete(socket) })
|
|
194
|
+
// Node drops the HTTP server's own error listener when it hands a socket to 'upgrade', and the route may still be deciding (a check that
|
|
195
|
+
// reads a store) when the client resets: without a listener of its own, that ECONNRESET would throw and stop the whole server. The socket
|
|
196
|
+
// is simply gone; its owner, if it has one yet, sees the close.
|
|
197
|
+
socket.on('error', () => { socket.destroy() })
|
|
198
|
+
const websocket = request.headers.upgrade?.toLowerCase() === 'websocket'
|
|
199
|
+
const handler = this.rawUpgrades.get(pathnameOf(request.url))
|
|
200
|
+
// A raw claim owns its path. A WebSocket upgrade nobody claimed is left to the router's hub when one is attached; with no hub, or for a
|
|
201
|
+
// protocol this server does not speak, the refusal is written here, cleanly, and never by a second handler.
|
|
202
|
+
if (handler === undefined) {
|
|
203
|
+
if (!websocket || this.hub !== undefined) return
|
|
204
|
+
if (socket.destroyed || socket.writableEnded) return
|
|
205
|
+
;(request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] = true
|
|
206
|
+
socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (!websocket) {
|
|
210
|
+
if (socket.destroyed || socket.writableEnded) return
|
|
211
|
+
;(request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] = true
|
|
212
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
this.rawUpgradeSeen.add(request)
|
|
216
|
+
;(request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] = true
|
|
217
|
+
Promise.resolve(handler(request, socket, head)).catch(() => socket.destroy())
|
|
218
|
+
})
|
|
219
|
+
ctx.effect(() => () => this.stop(), 'acryl-node-server: close listener and open connections')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Stop serving, gracefully, and resolve once the port is free: no new connections; requests already in flight may finish (a stream that never ends is
|
|
224
|
+
* cut) within {@link STOP_GRACE_MS}; each upgraded socket's owner closes it with a close frame first, and what is left is destroyed. Idempotent - the
|
|
225
|
+
* disposer calls it.
|
|
226
|
+
*
|
|
227
|
+
* Calling this BEFORE the rest of the app is disposed is what makes the grace count: the router has its own disposal, and it aborts in-flight requests
|
|
228
|
+
* and ends their bodies by design, concurrently with every other disposer (Cordis runs them together). A launcher that disposes the context directly
|
|
229
|
+
* therefore cuts the request this drains; a launcher that stops the server first lets it answer, then disposes.
|
|
230
|
+
*/
|
|
231
|
+
stop(): Promise<void> {
|
|
232
|
+
this.stopping ??= this.closeGracefully()
|
|
233
|
+
return this.stopping
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async closeGracefully(): Promise<void> {
|
|
237
|
+
// The hub's close runs beside the request grace rather than before it: the stop takes one grace, not two.
|
|
238
|
+
const upgrades = this.hub?.close()
|
|
239
|
+
if (!this.server.listening) {
|
|
240
|
+
await upgrades
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const stopped = new Promise<void>(resolve => { this.server.close(() => { resolve() }) })
|
|
244
|
+
this.server.closeIdleConnections()
|
|
245
|
+
const settled = await this.settleStop(STOP_GRACE_MS)
|
|
246
|
+
// A response that finished exactly at the grace's end is written but not yet delivered: end its socket gracefully (a FIN behind the buffered body)
|
|
247
|
+
// instead of destroying it, which would answer the client with a reset and an empty body. A little flush window, then force what is left.
|
|
248
|
+
this.server.closeIdleConnections()
|
|
249
|
+
const drained = settled
|
|
250
|
+
? await Promise.race([
|
|
251
|
+
stopped.then(() => true),
|
|
252
|
+
new Promise<boolean>(resolve => { setTimeout(() => { resolve(false) }, 300) }),
|
|
253
|
+
])
|
|
254
|
+
: false
|
|
255
|
+
if (!drained) {
|
|
83
256
|
this.server.closeAllConnections()
|
|
84
|
-
|
|
85
|
-
|
|
257
|
+
// Upgraded sockets that outlived their owner's close: destroy them, or the server's own close never finishes.
|
|
258
|
+
for (const socket of this.upgraded) socket.destroy()
|
|
259
|
+
this.upgraded.clear()
|
|
260
|
+
}
|
|
261
|
+
await Promise.all([upgrades, stopped])
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Wait, bounded, until no request is in flight and no upgraded socket is left; true when both reached zero inside the bound. Every pass sweeps idle
|
|
266
|
+
* keep-alive sockets again: the single sweep `close()` does misses one that becomes idle after its request finished, and such a socket would otherwise
|
|
267
|
+
* hold the stop to its deadline.
|
|
268
|
+
*/
|
|
269
|
+
private async settleStop(ms: number): Promise<boolean> {
|
|
270
|
+
const deadline = Date.now() + ms
|
|
271
|
+
while (Date.now() < deadline) {
|
|
272
|
+
if (this.active === 0 && this.upgraded.size === 0) return true
|
|
273
|
+
this.server.closeIdleConnections()
|
|
274
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
275
|
+
}
|
|
276
|
+
return this.active === 0 && this.upgraded.size === 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Serve a raw node handler at `path`, beside the router: `exact` matches the pathname only, `prefix` the path and everything below it. The
|
|
281
|
+
* handler gets the platform's `IncomingMessage`/`ServerResponse` and owns the response; the router is not run for it. A path already served
|
|
282
|
+
* throws. The returned remover restores the path (the server itself goes on serving the rest).
|
|
283
|
+
*/
|
|
284
|
+
registerRaw(route: RawRoute): () => void {
|
|
285
|
+
const table = route.kind === 'exact' ? this.rawExact : this.rawPrefix
|
|
286
|
+
if (table.has(route.path)) throw new Error(`acryl-node-server: ${route.path} is already served`)
|
|
287
|
+
table.set(route.path, route.handler)
|
|
288
|
+
return () => {
|
|
289
|
+
if (table.get(route.path) === route.handler) table.delete(route.path)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Answer matching requests before raw routes and the router, so a host can keep an app's URLs in scope under a prefix (the frame's canvas). A gate runs
|
|
295
|
+
* synchronously and can answer with a promise (see {@link RequestGate}); gates run in the order they were added, and the returned remover takes this one back.
|
|
296
|
+
*/
|
|
297
|
+
gate(handler: RequestGate): () => void {
|
|
298
|
+
this.gates.push(handler)
|
|
299
|
+
return () => {
|
|
300
|
+
const at = this.gates.indexOf(handler)
|
|
301
|
+
if (at !== -1) this.gates.splice(at, 1)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A gate's asynchronous answer: run the request it was given through the router, in the adapter's request form (a gate never makes one itself), with the
|
|
307
|
+
* socket facts. The raw routes do not run for that request.
|
|
308
|
+
*/
|
|
309
|
+
throughRouter(request: Request, connection: ConnectionInfo = {}): Promise<Response> {
|
|
310
|
+
return this.router.fetch(new AdapterRequest(request), connection)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Claim an upgrade path for a raw node handler: the handler receives `(req, socket, head)` and from then on owns the socket. The router's
|
|
315
|
+
* WebSocket handling never sees a claimed upgrade. A path already claimed throws; the remover releases the claim. The transport's one raw
|
|
316
|
+
* listener exists for the server's whole lifetime and consults this registry, so claims coming and going never add or remove a listener.
|
|
317
|
+
*/
|
|
318
|
+
registerRawUpgrade(route: RawUpgradeRoute): () => void {
|
|
319
|
+
if (this.rawUpgrades.has(route.path))
|
|
320
|
+
throw new Error(`acryl-node-server: the upgrade path ${route.path} is already claimed`)
|
|
321
|
+
this.rawUpgrades.set(route.path, route.handler)
|
|
322
|
+
return () => {
|
|
323
|
+
if (this.rawUpgrades.get(route.path) !== route.handler) return
|
|
324
|
+
this.rawUpgrades.delete(route.path)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The raw handler for a request: an exact path first, then the longest matching prefix. */
|
|
329
|
+
private rawFor(incoming: IncomingMessage): RawRoute['handler'] | undefined {
|
|
330
|
+
const path = pathnameOf(incoming.url)
|
|
331
|
+
const exact = this.rawExact.get(path)
|
|
332
|
+
if (exact !== undefined) return exact
|
|
333
|
+
let best: { length: number; handler: RawRoute['handler'] } | undefined
|
|
334
|
+
for (const [prefix, handler] of this.rawPrefix)
|
|
335
|
+
if ((path === prefix || path.startsWith(`${prefix}/`)) && (best === undefined || prefix.length > best.length))
|
|
336
|
+
best = { length: prefix.length, handler }
|
|
337
|
+
return best?.handler
|
|
86
338
|
}
|
|
87
339
|
|
|
88
340
|
/**
|
|
@@ -93,11 +345,16 @@ export class NodeServer extends Service {
|
|
|
93
345
|
upgrades(): UpgradeWebSocket<WebSocketLike> {
|
|
94
346
|
if (!this.hub) {
|
|
95
347
|
this.hub = new UpgradeHub(this.config.websocket)
|
|
96
|
-
this.hub.attach(this.server, this.ctx.router)
|
|
348
|
+
this.hub.attach(this.server, this.ctx.router, { skipWhenRawUpgrade: true })
|
|
97
349
|
}
|
|
98
350
|
return this.hub.upgradeWebSocketFor(this.ctx)
|
|
99
351
|
}
|
|
100
352
|
|
|
353
|
+
/** How many `upgrade` listeners the transport holds (raw plus router setups); a test asserts it does not grow across claim changes. */
|
|
354
|
+
get upgradeListenerCount(): number {
|
|
355
|
+
return this.server.listenerCount('upgrade')
|
|
356
|
+
}
|
|
357
|
+
|
|
101
358
|
/** How many WebSocket connections are open now. */
|
|
102
359
|
get sockets(): number {
|
|
103
360
|
return this.hub?.size ?? 0
|
|
@@ -113,11 +370,11 @@ export class NodeServer extends Service {
|
|
|
113
370
|
return `http://${this.hostname.includes(':') ? `[${this.hostname}]` : this.hostname}:${String(this.bound)}`
|
|
114
371
|
}
|
|
115
372
|
|
|
116
|
-
/** Bind, scanning past taken ports. */
|
|
373
|
+
/** Bind, scanning past taken ports and past the ports a browser refuses. */
|
|
117
374
|
async listen(): Promise<void> {
|
|
118
375
|
const { port, scan, hostname } = this.config
|
|
119
|
-
const last = port === 0 ? 0 : Math.min(
|
|
120
|
-
for (
|
|
376
|
+
const last = port === 0 ? 0 : Math.min(65_535, port + scan)
|
|
377
|
+
for (const candidate of portCandidates(port, scan)) {
|
|
121
378
|
try {
|
|
122
379
|
await new Promise<void>((resolve, reject) => {
|
|
123
380
|
const onError = (error: Error): void => { reject(error) }
|
|
@@ -130,8 +387,39 @@ export class NodeServer extends Service {
|
|
|
130
387
|
if (!isPortUnavailable(error)) throw error
|
|
131
388
|
}
|
|
132
389
|
}
|
|
133
|
-
throw new Error(`acryl-node-server: no
|
|
390
|
+
throw new Error(`acryl-node-server: no usable port from ${String(port)} to ${String(last)} on ${hostname}: each is taken, or one a browser refuses`)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The ports every Fetch client refuses to connect to: the Fetch standard's bad ports (https://fetch.spec.whatwg.org/#port-blocking). Chromium
|
|
396
|
+
* refuses a page on one (ERR_UNSAFE_PORT) and Node's fetch answers "bad port"; the list below is the one Node 24 reports, probed port by port
|
|
397
|
+
* on 2026-11-03 (82 ports, 1 to 10080). An app's stable port is hashed into 3100..3999, and 3659 is on the list: without this rule one app in
|
|
398
|
+
* 900 would come up on an address no browser may open, which the frame gate caught that day (verify-frame-token was refused at 3659).
|
|
399
|
+
*/
|
|
400
|
+
export const RESTRICTED_PORTS: ReadonlySet<number> = new Set([
|
|
401
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104,
|
|
402
|
+
109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515,
|
|
403
|
+
526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049,
|
|
404
|
+
3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
|
405
|
+
])
|
|
406
|
+
|
|
407
|
+
/** Whether a browser refuses to open this port. */
|
|
408
|
+
export function isRestrictedPort(port: number): boolean {
|
|
409
|
+
return RESTRICTED_PORTS.has(port)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The ports `listen` may try, in order: `port` with at most `scan` further ones, without the ports a browser refuses. Port 0 stays alone: the
|
|
414
|
+
* system chooses it, and the ports it hands out are above the restricted range.
|
|
415
|
+
*/
|
|
416
|
+
export function portCandidates(port: number, scan: number): number[] {
|
|
417
|
+
const last = port === 0 ? 0 : Math.min(65_535, port + scan)
|
|
418
|
+
const candidates: number[] = []
|
|
419
|
+
for (let candidate = port; candidate <= last; candidate += 1) {
|
|
420
|
+
if (!isRestrictedPort(candidate)) candidates.push(candidate)
|
|
134
421
|
}
|
|
422
|
+
return candidates
|
|
135
423
|
}
|
|
136
424
|
|
|
137
425
|
/**
|
|
@@ -83,12 +83,12 @@ export class UpgradeHub {
|
|
|
83
83
|
* Accept upgrades from `source` and answer them with `router`: the request runs through the router's middleware with the socket's peer address and local port as connection
|
|
84
84
|
* facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
|
|
85
85
|
*/
|
|
86
|
-
attach(source: UpgradeSource, router: UpgradeRouter): void {
|
|
86
|
+
attach(source: UpgradeSource, router: UpgradeRouter, options: { skipWhenRawUpgrade?: boolean } = {}): void {
|
|
87
87
|
const fetchCallback: FetchCallback = (request, env) => {
|
|
88
88
|
const { incoming } = env as { incoming: { socket: { remoteAddress?: string; localPort?: number } } }
|
|
89
89
|
return router.fetch(request, { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort }, env)
|
|
90
90
|
}
|
|
91
|
-
setupWebSocket({ server: source, fetchCallback, wss: this.server })
|
|
91
|
+
setupWebSocket({ server: source, fetchCallback, wss: this.server, ...options })
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/** How many sockets are open. */
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acryl-page",
|
|
3
|
+
"description": "The page of a framework-hosted app: our own minimal index (the ACRYL frame's document, not DSH's), its index taps applied per request, and the built client assets the client runtime boots from. Ours, served by acryl-web-server on the framework's node server.",
|
|
4
|
+
"requires": ["acryl-web-server"],
|
|
5
|
+
"inject": ["webServer"],
|
|
6
|
+
"provides": [],
|
|
7
|
+
"dependencies": {},
|
|
8
|
+
"mount": "mount it after acryl-web-server and the client rows: it serves '/' and '/assets/*' and applies the webServer index taps (the client modules' __DSH_BOOT__ and registration scripts) to its own document",
|
|
9
|
+
"entries": {
|
|
10
|
+
"": "src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -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
|
+
}
|