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
|
@@ -60,6 +60,8 @@ export interface UiNode {
|
|
|
60
60
|
readonly value?: string
|
|
61
61
|
/** Heading level, when it is a heading. */
|
|
62
62
|
readonly level?: number
|
|
63
|
+
/** The choices of a select, as `act select` takes them, when it is not sensitive. */
|
|
64
|
+
readonly options?: readonly string[]
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
export interface UiSnapshot {
|
|
@@ -88,6 +90,8 @@ export interface UiActionResult {
|
|
|
88
90
|
readonly ok: true
|
|
89
91
|
readonly target?: { readonly role: string; readonly name: string }
|
|
90
92
|
readonly detail?: string
|
|
93
|
+
/** The act started a navigation (a link, a form that submits): this page is going away, and the Host waits for the next one. */
|
|
94
|
+
readonly leaving?: true
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
export type UiResult = UiSnapshot | UiActionResult
|
|
@@ -11,7 +11,8 @@ export function renderSnapshot(snapshot: UiSnapshot, more: (cursor: number) => s
|
|
|
11
11
|
const level = node.level === undefined ? '' : ` level=${String(node.level)}`
|
|
12
12
|
const states = node.states.length === 0 ? '' : ` [${node.states.join(', ')}]`
|
|
13
13
|
const value = node.value === undefined ? '' : ` value=${JSON.stringify(node.value)}`
|
|
14
|
-
|
|
14
|
+
const options = node.options === undefined ? '' : ` options=${JSON.stringify(node.options)}`
|
|
15
|
+
return `${' '.repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === '' ? '' : ` ${JSON.stringify(node.name)}`}${level}${states}${value}${options} [ref=${node.ref}]`
|
|
15
16
|
})
|
|
16
17
|
const rest = snapshot.nextCursor === undefined ? '' : `\n(${String(snapshot.total - snapshot.nextCursor)} more elements; ${more(snapshot.nextCursor)})`
|
|
17
18
|
return `Page: ${snapshot.title || '(untitled)'}\nSnapshot ${String(snapshot.generation)}, ${String(snapshot.nodes.length)} of ${String(snapshot.total)} elements:\n${lines.join('\n')}${rest}`
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from './request.ts'
|
|
15
15
|
import { defaultContentType, cacheKey, Response as LightweightResponse } from './response.ts'
|
|
16
16
|
import type { InternalCache } from './response.ts'
|
|
17
|
+
import { RAW_RESPONSE_HANDLED } from './raw.ts'
|
|
17
18
|
import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types.ts'
|
|
18
19
|
import {
|
|
19
20
|
readWithoutBlocking,
|
|
@@ -500,6 +501,9 @@ export const getRequestListener = (
|
|
|
500
501
|
res = fetchCallback(req, { incoming, outgoing } as HttpBindings) as
|
|
501
502
|
| Response
|
|
502
503
|
| Promise<Response>
|
|
504
|
+
| typeof RAW_RESPONSE_HANDLED
|
|
505
|
+
// A raw handler (not a `Response`) owns the node response: it has written (or is writing) it, so the adapter writes nothing.
|
|
506
|
+
if (res === RAW_RESPONSE_HANDLED) return
|
|
503
507
|
if (!isPromise(res) && isImmediateCacheableResponse(res)) {
|
|
504
508
|
// Synchronous cacheable response — no close listener needed.
|
|
505
509
|
// No I/O events can fire between fetchCallback returning and responseViaCache
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw HTTP handlers, outside the router: a host that must serve another server's node `(req, res)` handlers (a DSH compatibility port, the
|
|
3
|
+
* desktop shell's own endpoints) registers by path instead of building an `Acryl`. The adapter hands a matched request to the handler with the
|
|
4
|
+
* real `IncomingMessage`/`ServerResponse` and writes nothing itself; `RAW_RESPONSE_HANDLED` is how the fetch callback tells it so.
|
|
5
|
+
*
|
|
6
|
+
* Upgrades work the same way at the socket level: a registered path is claimed before the router's WebSocket handling, and the adapter's
|
|
7
|
+
* WebSocket setup leaves a request marked with `RAW_UPGRADE_HANDLED` alone.
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage } from 'node:http'
|
|
10
|
+
|
|
11
|
+
/** Returned by a raw-aware fetch callback: the node response is already owned, do not write. */
|
|
12
|
+
export const RAW_RESPONSE_HANDLED: unique symbol = Symbol.for('acryl-node-server.raw-response')
|
|
13
|
+
|
|
14
|
+
/** Set on an upgrade request a raw handler took over; the adapter's WebSocket setup skips it. */
|
|
15
|
+
export const RAW_UPGRADE_HANDLED: unique symbol = Symbol.for('acryl-node-server.raw-upgrade')
|
|
16
|
+
|
|
17
|
+
/** An upgrade request a raw handler took over. */
|
|
18
|
+
export type RawUpgradeRequest = IncomingMessage & { [RAW_UPGRADE_HANDLED]?: true }
|
|
@@ -3,6 +3,7 @@ import { defineWebSocketHelper } from 'acryl-router/helpers'
|
|
|
3
3
|
import type { IncomingMessage } from 'node:http'
|
|
4
4
|
import { STATUS_CODES } from 'node:http'
|
|
5
5
|
import type { Duplex } from 'node:stream'
|
|
6
|
+
import { RAW_UPGRADE_HANDLED, type RawUpgradeRequest } from './raw.ts'
|
|
6
7
|
import type { FetchCallback } from './types.ts'
|
|
7
8
|
import type { WebSocketData, WebSocketLike, WebSocketServerLike } from './websocket-types.ts'
|
|
8
9
|
|
|
@@ -144,6 +145,8 @@ const appendResponseHeaders = (headers: string[], responseHeaders?: Headers) =>
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
const rejectUpgradeRequest = (socket: Duplex, status: number, responseHeaders?: Headers) => {
|
|
148
|
+
// The socket already has its answer (a provider transition can route one upgrade to two handlers for a moment): never write a second one.
|
|
149
|
+
if (socket.destroyed || socket.writableEnded) return
|
|
147
150
|
const responseLines = ['Connection: close', 'Content-Length: 0']
|
|
148
151
|
appendResponseHeaders(responseLines, responseHeaders)
|
|
149
152
|
|
|
@@ -181,8 +184,13 @@ export const setupWebSocket = (options: {
|
|
|
181
184
|
server: UpgradeSource
|
|
182
185
|
fetchCallback: FetchCallback
|
|
183
186
|
wss: WebSocketServerLike
|
|
187
|
+
/**
|
|
188
|
+
* Leave a request alone when a raw upgrade handler claimed it. Set by a transport that shares its upgrade source with raw claims (the node
|
|
189
|
+
* server's HTTP server); a hub fed by its own emitter (the Web adapter) must still answer its own upgrades, which arrive through a raw claim.
|
|
190
|
+
*/
|
|
191
|
+
skipWhenRawUpgrade?: boolean
|
|
184
192
|
}): void => {
|
|
185
|
-
const { server, fetchCallback, wss } = options
|
|
193
|
+
const { server, fetchCallback, wss, skipWhenRawUpgrade = false } = options
|
|
186
194
|
|
|
187
195
|
const waiterMap = new Map<
|
|
188
196
|
IncomingMessage,
|
|
@@ -216,9 +224,11 @@ export const setupWebSocket = (options: {
|
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
server.on('upgrade', async (request, socket: Duplex, head) => {
|
|
227
|
+
// A raw upgrade handler claimed this path before the WebSocket setup ran: the socket is (or will be) its own.
|
|
228
|
+
if (skipWhenRawUpgrade && (request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] === true) return
|
|
219
229
|
if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
|
|
220
|
-
//
|
|
221
|
-
|
|
230
|
+
// An upgrade to another protocol is answered, not left hanging (the transport's raw listener flags the ones it already answered).
|
|
231
|
+
rejectUpgradeRequest(socket, 400)
|
|
222
232
|
return
|
|
223
233
|
}
|
|
224
234
|
|
|
@@ -241,9 +251,7 @@ export const setupWebSocket = (options: {
|
|
|
241
251
|
responseHeaders = response.headers
|
|
242
252
|
}
|
|
243
253
|
} catch {
|
|
244
|
-
|
|
245
|
-
rejectUpgradeRequest(socket, 500)
|
|
246
|
-
}
|
|
254
|
+
rejectUpgradeRequest(socket, 500)
|
|
247
255
|
return
|
|
248
256
|
}
|
|
249
257
|
|
|
@@ -251,9 +259,7 @@ export const setupWebSocket = (options: {
|
|
|
251
259
|
|
|
252
260
|
if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) {
|
|
253
261
|
rejectWaiter(request)
|
|
254
|
-
|
|
255
|
-
rejectUpgradeRequest(socket, status, responseHeaders)
|
|
256
|
-
}
|
|
262
|
+
rejectUpgradeRequest(socket, status, responseHeaders)
|
|
257
263
|
return
|
|
258
264
|
}
|
|
259
265
|
|
|
@@ -38,6 +38,12 @@ const DEFAULT_CACHE_BYTES = 16 * 1024 * 1024
|
|
|
38
38
|
export interface ClientModulesOptions {
|
|
39
39
|
/** The folder of client modules, absolute or relative to the working directory. */
|
|
40
40
|
readonly root: string
|
|
41
|
+
/**
|
|
42
|
+
* The URL prefix the app is mounted at, when the caller knows it. A host can compose app plugins under its own router, and under a
|
|
43
|
+
* Host whose router is a different copy of this package the request's match result is not visible here, so `basePath(c)` throws; the
|
|
44
|
+
* explicit prefix is then the only reliable way to know where the mount starts. Omitted, the router's `basePath(c)` is used.
|
|
45
|
+
*/
|
|
46
|
+
readonly mount?: string | undefined
|
|
41
47
|
/** The folder {@link buildClientModules} wrote; with `NODE_ENV=production` it is served instead of compiling, which needs no `typescript`. */
|
|
42
48
|
readonly compiled?: string | undefined
|
|
43
49
|
/** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
|
|
@@ -204,7 +210,7 @@ export function clientModules(options: ClientModulesOptions): Acryl {
|
|
|
204
210
|
|
|
205
211
|
const app = new Acryl()
|
|
206
212
|
app.get('/*', (c) => {
|
|
207
|
-
const requestPath = c.req.path.slice(basePath(c).length)
|
|
213
|
+
const requestPath = c.req.path.slice(options.mount === undefined ? basePath(c).length : options.mount.length)
|
|
208
214
|
const runtime = /^\/@acrylblends\/([a-z-]+\.js)$/u.exec(requestPath)?.[1]
|
|
209
215
|
const send = (code: string, etag: string): Response => {
|
|
210
216
|
if (c.req.header('if-none-match') === etag) return c.body(null, 304, { etag, 'cache-control': 'no-cache' })
|
|
@@ -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
|
+
}
|