acrylblends 1.0.7 → 1.0.8

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.
@@ -1,10 +1,12 @@
1
1
  import { t as Acryl } from "../app-CQcGWjrk.js";
2
2
  import { r as streamSSE } from "../streaming-BlDaph8U.js";
3
+ import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
3
4
  import "../helpers-BEjcdbgw.js";
4
5
  import { n as clientModules } from "../client-modules-drj4mjhj.js";
5
- import { a as MAX_WAIT_MS, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES } from "../protocol-CQ_if7Dg.js";
6
+ import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
6
7
  import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-m13Pk-AI.js";
7
8
  import "../src-BTBm4OPD.js";
9
+ import "../middleware-BfG_fp8P.js";
8
10
  import { isAbsolute, join, relative, resolve } from "node:path";
9
11
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
12
  import z from "@deepseek-ai/schemastery";
@@ -1153,6 +1155,66 @@ function operatorRoutes(prefix, secret, door, reportError) {
1153
1155
  }
1154
1156
  });
1155
1157
  }
1158
+ /** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
1159
+ const ownOrigin = async (c, next) => {
1160
+ const authority = loopbackAuthority(c);
1161
+ const origin = c.req.header("origin");
1162
+ const site = c.req.header("sec-fetch-site");
1163
+ if (authority === void 0 || origin === void 0 || origin !== authority.origin || site !== void 0 && site !== "same-origin") return c.json(error("only this app's own page may open the channel"), 403);
1164
+ const windowId = c.req.query("window");
1165
+ if (windowId === void 0 || !WINDOW_ID.test(windowId)) return c.json(error("a window id is required"), 400);
1166
+ await next();
1167
+ };
1168
+ function pageSocketRoutes(channel, options) {
1169
+ const heartbeatMs = options.heartbeatMs ?? 15e3;
1170
+ return new Acryl().get(`${options.prefix}/channel/ws`, ownOrigin, options.upgrade((c) => {
1171
+ const windowId = c.req.query("window");
1172
+ const focused = c.req.query("focused") === "1";
1173
+ const shellQuery = Number(c.req.query("shell"));
1174
+ const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : void 0;
1175
+ let detach;
1176
+ let beat;
1177
+ return {
1178
+ onOpen(_event, ws) {
1179
+ const write = (frame) => {
1180
+ ws.send(JSON.stringify(frame));
1181
+ };
1182
+ detach = channel.attach(windowId, focused, {
1183
+ send: (data) => {
1184
+ write({
1185
+ e: "call",
1186
+ d: data
1187
+ });
1188
+ },
1189
+ close: () => {
1190
+ ws.close(1e3, "replaced");
1191
+ }
1192
+ }, shellWindow);
1193
+ write({ e: "ready" });
1194
+ beat = setInterval(() => {
1195
+ try {
1196
+ write({ e: "ping" });
1197
+ } catch {}
1198
+ }, heartbeatMs);
1199
+ },
1200
+ onMessage(event) {
1201
+ if (typeof event.data !== "string") return;
1202
+ let message;
1203
+ try {
1204
+ message = JSON.parse(event.data);
1205
+ } catch {
1206
+ return;
1207
+ }
1208
+ const parsed = parseChannelToHost(message);
1209
+ if (parsed !== null) channel.receive(windowId, parsed);
1210
+ },
1211
+ onClose() {
1212
+ if (beat !== void 0) clearInterval(beat);
1213
+ detach?.();
1214
+ }
1215
+ };
1216
+ }, { textOnly: true }));
1217
+ }
1156
1218
  /** The oldest Node the client runs on (the framework's own floor). */
1157
1219
  const MIN_NODE = [22, 19];
1158
1220
  /**
@@ -1363,7 +1425,16 @@ function apply(ctx, rawConfig = {}) {
1363
1425
  const router = ctx.router;
1364
1426
  if (typeof router.wrap === "function") router.wrap(addScript);
1365
1427
  else ctx.router.route("/", new Acryl().use("*", addScript));
1366
- ctx.router.route("/", privateApi(DOOR_PREFIX, pageModule, pageChannelRoutes(channel, { prefix: DOOR_PREFIX }), shellRoutes(DOOR_PREFIX, secret, shell), operatorRoutes(DOOR_PREFIX, secret, door, reportError), mcpRoutes(DOOR_PREFIX, secret, door, reportError)));
1428
+ let upgrade;
1429
+ try {
1430
+ upgrade = ctx.nodeServer?.upgrades?.();
1431
+ } catch (cause) {
1432
+ reportError("open the page channel over a WebSocket (the pages use the event stream)", cause);
1433
+ }
1434
+ ctx.router.route("/", privateApi(DOOR_PREFIX, pageModule, pageChannelRoutes(channel, { prefix: DOOR_PREFIX }), ...upgrade === void 0 ? [] : [pageSocketRoutes(channel, {
1435
+ prefix: DOOR_PREFIX,
1436
+ upgrade
1437
+ })], shellRoutes(DOOR_PREFIX, secret, shell), operatorRoutes(DOOR_PREFIX, secret, door, reportError), mcpRoutes(DOOR_PREFIX, secret, door, reportError)));
1367
1438
  ctx.inject(["tools"], (inner) => {
1368
1439
  inner.effect(() => registerChatTools(inner.get("tools"), door), "acryl-door: the verbs in the tools registry");
1369
1440
  });
@@ -55,6 +55,12 @@ declare class UiDriver {
55
55
  private emit;
56
56
  }
57
57
  //#endregion
58
+ //#region ../../plugins/acryl-door/src/page/socket-source.d.ts
59
+ interface SocketSource extends ChannelSource {
60
+ /** Send one message up; false when the socket is not open (the caller posts it instead). */
61
+ send(text: string): boolean;
62
+ }
63
+ //#endregion
58
64
  //#region ../../plugins/acryl-door/src/page/channel.d.ts
59
65
  /** The slice of an `EventSource` the channel uses, so tests can supply their own. */
60
66
  interface ChannelSource {
@@ -73,6 +79,11 @@ interface PageChannelOptions {
73
79
  /** Where the door's routes are, e.g. `/api/acryl-door`. */
74
80
  readonly base: string;
75
81
  readonly createSource?: (url: string) => ChannelSource;
82
+ /** Opens the WebSocket form of the channel (`<base>/channel/ws`); the browser's own by default. Pass `transport: 'stream'` to keep to the event stream. */
83
+ readonly createSocket?: (url: string) => SocketSource;
84
+ readonly transport?: 'auto' | 'stream';
85
+ /** Where the page says it fell back to the event stream (the console by default). */
86
+ readonly warn?: (message: string) => void;
76
87
  readonly post?: (url: string, body: string) => Promise<unknown>;
77
88
  readonly silenceMs?: number;
78
89
  readonly now?: () => number;
@@ -82,6 +93,9 @@ declare class PageChannel {
82
93
  private reconnectTimer;
83
94
  private watchdog;
84
95
  private backoff;
96
+ /** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
97
+ private socketAllowed;
98
+ private socketReady;
85
99
  private lastHeard;
86
100
  private disposed;
87
101
  private readonly now;
@@ -89,6 +103,8 @@ declare class PageChannel {
89
103
  private readonly options;
90
104
  constructor(driver: UiDriver, options: PageChannelOptions);
91
105
  connect(): void;
106
+ /** The WebSocket did not open: use the event stream from now on, and say why once. */
107
+ private fallBack;
92
108
  /** Tell the Host whether this window is the one in front. */
93
109
  reportFocus(focused: boolean): void;
94
110
  /**
package/lib/door/page.js CHANGED
@@ -1,4 +1,54 @@
1
1
  import { a as MAX_WAIT_MS, f as parseChannelToPage, l as UiControlError, o as MUTATING_OPS, r as MAX_SNAPSHOT_NODES } from "../protocol-CQ_if7Dg.js";
2
+ //#region ../../plugins/acryl-door/src/page/socket-source.ts
3
+ function socketSource(socket) {
4
+ const listeners = /* @__PURE__ */ new Map();
5
+ let closed = false;
6
+ const source = {
7
+ onerror: null,
8
+ get readyState() {
9
+ return closed || socket.readyState >= 2 ? 2 : socket.readyState;
10
+ },
11
+ addEventListener(type, listener) {
12
+ listeners.set(type, [...listeners.get(type) ?? [], listener]);
13
+ },
14
+ send(text) {
15
+ if (socket.readyState !== 1) return false;
16
+ try {
17
+ socket.send(text);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ },
23
+ close() {
24
+ closed = true;
25
+ socket.onmessage = null;
26
+ socket.onerror = null;
27
+ socket.onclose = null;
28
+ try {
29
+ socket.close(1e3, "page left");
30
+ } catch {}
31
+ }
32
+ };
33
+ socket.onmessage = (event) => {
34
+ if (typeof event.data !== "string") return;
35
+ let frame;
36
+ try {
37
+ frame = JSON.parse(event.data);
38
+ } catch {
39
+ return;
40
+ }
41
+ if (typeof frame.e !== "string") return;
42
+ for (const listener of listeners.get(frame.e) ?? []) listener({ data: typeof frame.d === "string" ? frame.d : "" });
43
+ };
44
+ const lost = (event) => {
45
+ if (!closed) source.onerror?.(event);
46
+ };
47
+ socket.onerror = lost;
48
+ socket.onclose = lost;
49
+ return source;
50
+ }
51
+ //#endregion
2
52
  //#region ../../plugins/acryl-door/src/page/channel.ts
3
53
  /**
4
54
  * The page's end of the door's channel.
@@ -18,6 +68,9 @@ var PageChannel = class {
18
68
  reconnectTimer;
19
69
  watchdog;
20
70
  backoff = BACKOFF_START_MS;
71
+ /** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
72
+ socketAllowed;
73
+ socketReady = false;
21
74
  lastHeard = 0;
22
75
  disposed = false;
23
76
  now;
@@ -27,32 +80,49 @@ var PageChannel = class {
27
80
  this.driver = driver;
28
81
  this.options = options;
29
82
  this.now = options.now ?? (() => Date.now());
83
+ this.socketAllowed = options.transport !== "stream" && (options.createSocket !== void 0 || options.createSource === void 0 && typeof WebSocket !== "undefined");
30
84
  }
31
85
  connect() {
32
86
  if (this.disposed || this.source !== void 0) return;
33
- const create = this.options.createSource ?? ((url) => new EventSource(url));
34
87
  const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? "1" : "0"}${this.options.shellWindow === void 0 ? "" : `&shell=${String(this.options.shellWindow)}`}`;
35
- const source = create(`${this.options.base}/channel?${query}`);
88
+ const viaSocket = this.socketAllowed;
89
+ this.socketReady = false;
90
+ const source = viaSocket ? (this.options.createSocket ?? ((url) => socketSource(new WebSocket(url))))(`${socketBase(this.options.base)}/channel/ws?${query}`) : (this.options.createSource ?? ((url) => new EventSource(url)))(`${this.options.base}/channel?${query}`);
36
91
  this.source = source;
37
92
  this.lastHeard = this.now();
38
93
  const heard = () => {
39
94
  this.lastHeard = this.now();
40
95
  this.backoff = BACKOFF_START_MS;
41
96
  };
42
- source.addEventListener("ready", heard);
97
+ source.addEventListener("ready", () => {
98
+ this.socketReady = true;
99
+ heard();
100
+ });
43
101
  source.addEventListener("ping", heard);
44
102
  source.addEventListener("call", (event) => {
45
103
  heard();
46
104
  if (typeof event.data === "string") this.answer(event.data);
47
105
  });
48
106
  source.onerror = () => {
49
- if (source.readyState === CLOSED) this.restart(source);
107
+ if (source.readyState !== CLOSED) return;
108
+ if (viaSocket && !this.socketReady) this.fallBack(source);
109
+ else this.restart(source);
50
110
  };
51
111
  const silence = this.options.silenceMs ?? 45e3;
52
112
  this.watchdog = setInterval(() => {
53
113
  if (this.now() - this.lastHeard > silence) this.restart(source);
54
114
  }, Math.max(50, Math.floor(silence / 3)));
55
115
  }
116
+ /** The WebSocket did not open: use the event stream from now on, and say why once. */
117
+ fallBack(source) {
118
+ if (this.source !== source) return;
119
+ this.socketAllowed = false;
120
+ (this.options.warn ?? ((message) => {
121
+ console.warn(message);
122
+ }))("acryl-door: the page channel could not open a WebSocket (the server has no upgrade route, or a proxy refused it); using server-sent events, which count against the browser's six connections per host");
123
+ this.stop();
124
+ this.connect();
125
+ }
56
126
  /** Tell the Host whether this window is the one in front. */
57
127
  reportFocus(focused) {
58
128
  this.send({
@@ -130,6 +200,7 @@ var PageChannel = class {
130
200
  });
131
201
  }
132
202
  send(message) {
203
+ if (this.source?.send?.(JSON.stringify(message)) === true) return;
133
204
  (this.options.post ?? ((url, body) => fetch(url, {
134
205
  method: "POST",
135
206
  headers: { "content-type": "application/json" },
@@ -140,6 +211,12 @@ var PageChannel = class {
140
211
  })).catch(() => {});
141
212
  }
142
213
  };
214
+ /** The WebSocket address of the door's routes: this page's own origin, `ws:` or `wss:`, then the base path. */
215
+ function socketBase(base) {
216
+ const here = globalThis.location;
217
+ if (here === void 0) return base;
218
+ return `${here.protocol === "https:" ? "wss" : "ws"}://${here.host}${base}`;
219
+ }
143
220
  //#endregion
144
221
  //#region ../../plugins/acryl-door/src/page/driver/sensitivity.ts
145
222
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,6 +8,9 @@
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "@deepseek-ai/schemastery": "3.18.4"
10
10
  },
11
+ "devDependencies": {
12
+ "ws": "8.21.3"
13
+ },
11
14
  "mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
12
15
  "gitignore": [".acryl/"],
13
16
  "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The page channel over a WebSocket. An event stream (`page-routes.ts`) holds one of the browser's six connections per host for as long as the page lives; two windows of an app that
3
+ * also stream their own state, with the panel following a run, use them all, and then no request of either window is sent (the door's answers, Stop). A WebSocket is not one of
4
+ * those six: it opens at once however many streams are open, and the page's answers travel on it too, so nothing is queued behind a stream.
5
+ *
6
+ * `GET <prefix>/channel/ws?window=<id>&focused=0|1[&shell=<n>]` upgrades. The messages are the same JSON as the stream's: down, `{ "e": "call", "d": "<the call>" }` and
7
+ * `{ "e": "ready" }` / `{ "e": "ping" }`; up, one {@link ChannelToHost} message per frame. A WebSocket has no CORS, so the `Origin` header is the boundary: it must be present and be
8
+ * exactly this app's own origin, or the upgrade is refused (403) before any handshake. A page on another port, a web site, and a program that sends no `Origin` are all refused.
9
+ */
10
+
11
+ import { Acryl, type MiddlewareHandler } from 'acryl-router'
12
+ import type { UpgradeWebSocket } from 'acryl-router/helpers'
13
+ import { loopbackAuthority } from 'acryl-router/middleware'
14
+ import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
15
+ import type { UiChannel } from './channel.ts'
16
+ import { error } from './http.ts'
17
+
18
+ /** How often the Host writes a `ping`; the page gives up on a socket that is silent for three of these. */
19
+ export const SOCKET_HEARTBEAT_MS = 15_000
20
+
21
+ export interface PageSocketOptions {
22
+ readonly prefix: string
23
+ readonly upgrade: UpgradeWebSocket
24
+ readonly heartbeatMs?: number
25
+ }
26
+
27
+ /** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
28
+ export const ownOrigin: MiddlewareHandler = async (c, next) => {
29
+ const authority = loopbackAuthority(c)
30
+ const origin = c.req.header('origin')
31
+ const site = c.req.header('sec-fetch-site')
32
+ if (authority === undefined || origin === undefined || origin !== authority.origin || (site !== undefined && site !== 'same-origin')) {
33
+ return c.json(error('only this app\'s own page may open the channel'), 403)
34
+ }
35
+ const windowId = c.req.query('window')
36
+ if (windowId === undefined || !WINDOW_ID.test(windowId)) return c.json(error('a window id is required'), 400)
37
+ await next()
38
+ }
39
+
40
+ export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions): Acryl {
41
+ const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
42
+ return new Acryl().get(
43
+ `${options.prefix}/channel/ws`,
44
+ ownOrigin,
45
+ options.upgrade((c) => {
46
+ const windowId = c.req.query('window') as string
47
+ const focused = c.req.query('focused') === '1'
48
+ const shellQuery = Number(c.req.query('shell'))
49
+ const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
50
+ let detach: (() => void) | undefined
51
+ let beat: NodeJS.Timeout | undefined
52
+ return {
53
+ onOpen(_event, ws) {
54
+ const write = (frame: { e: string; d?: string }): void => {
55
+ ws.send(JSON.stringify(frame))
56
+ }
57
+ // A call that cannot be written fails at once (`disconnected`), and a socket that is gone is dropped by its close: the door never holds a call for it.
58
+ detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow)
59
+ write({ e: 'ready' })
60
+ beat = setInterval(() => {
61
+ try { write({ e: 'ping' }) } catch { /* the close event follows */ }
62
+ }, heartbeatMs)
63
+ },
64
+ onMessage(event) {
65
+ if (typeof event.data !== 'string') return
66
+ let message: unknown
67
+ try { message = JSON.parse(event.data) } catch { return }
68
+ const parsed = parseChannelToHost(message)
69
+ if (parsed !== null) channel.receive(windowId, parsed)
70
+ },
71
+ onClose() {
72
+ if (beat !== undefined) clearInterval(beat)
73
+ detach?.()
74
+ },
75
+ }
76
+ }, { textOnly: true }),
77
+ )
78
+ }
@@ -13,6 +13,7 @@ import { join } from 'node:path'
13
13
  import type { Context } from '@deepseek-ai/cordis'
14
14
  import z from '@deepseek-ai/schemastery'
15
15
  import { Acryl } from 'acryl-router'
16
+ import type { UpgradeWebSocket } from 'acryl-router/helpers'
16
17
  import { clientModules } from 'acryl-node-server/client-modules'
17
18
  import { auditPath, AuditLog } from './host/audit.ts'
18
19
  import { UiChannel } from './host/channel.ts'
@@ -23,6 +24,7 @@ import { DOOR_PREFIX, privateApi } from './host/http.ts'
23
24
  import { mcpRoutes } from './host/mcp-routes.ts'
24
25
  import { operatorRoutes } from './host/operator-routes.ts'
25
26
  import { pageChannelRoutes } from './host/page-routes.ts'
27
+ import { pageSocketRoutes } from './host/page-socket.ts'
26
28
  import { ShellChannel, shellRoutes } from './host/shell.ts'
27
29
  import { realNode, writeShims, type ShimTarget } from './host/shim.ts'
28
30
 
@@ -154,10 +156,18 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
154
156
  // An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
155
157
  if (typeof router.wrap === 'function') router.wrap(addScript)
156
158
  else ctx.router.route('/', new Acryl().use('*', addScript as never))
159
+ // The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without the `ws` package or an adapter that upgrades, the pages use the stream.
160
+ let upgrade: UpgradeWebSocket | undefined
161
+ try {
162
+ upgrade = (ctx as unknown as { nodeServer?: { upgrades?: () => UpgradeWebSocket } }).nodeServer?.upgrades?.()
163
+ } catch (cause) {
164
+ reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
165
+ }
157
166
  ctx.router.route('/', privateApi(
158
167
  DOOR_PREFIX,
159
168
  pageModule,
160
169
  pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
170
+ ...(upgrade === undefined ? [] : [pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade })]),
161
171
  shellRoutes(DOOR_PREFIX, secret, shell),
162
172
  operatorRoutes(DOOR_PREFIX, secret, door, reportError),
163
173
  mcpRoutes(DOOR_PREFIX, secret, door, reportError),
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { UiControlError, parseChannelToPage, type ChannelToHost } from './protocol.ts'
11
11
  import type { UiDriver } from './driver/driver.ts'
12
+ import { type SocketLike, type SocketSource, socketSource } from './socket-source.ts'
12
13
 
13
14
  /** The slice of an `EventSource` the channel uses, so tests can supply their own. */
14
15
  export interface ChannelSource {
@@ -32,6 +33,11 @@ export interface PageChannelOptions {
32
33
  /** Where the door's routes are, e.g. `/api/acryl-door`. */
33
34
  readonly base: string
34
35
  readonly createSource?: (url: string) => ChannelSource
36
+ /** Opens the WebSocket form of the channel (`<base>/channel/ws`); the browser's own by default. Pass `transport: 'stream'` to keep to the event stream. */
37
+ readonly createSocket?: (url: string) => SocketSource
38
+ readonly transport?: 'auto' | 'stream'
39
+ /** Where the page says it fell back to the event stream (the console by default). */
40
+ readonly warn?: (message: string) => void
35
41
  readonly post?: (url: string, body: string) => Promise<unknown>
36
42
  readonly silenceMs?: number
37
43
  readonly now?: () => number
@@ -42,6 +48,9 @@ export class PageChannel {
42
48
  private reconnectTimer: ReturnType<typeof setTimeout> | undefined
43
49
  private watchdog: ReturnType<typeof setInterval> | undefined
44
50
  private backoff = BACKOFF_START_MS
51
+ /** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
52
+ private socketAllowed: boolean
53
+ private socketReady = false
45
54
  private lastHeard = 0
46
55
  private disposed = false
47
56
  private readonly now: () => number
@@ -53,33 +62,56 @@ export class PageChannel {
53
62
  this.driver = driver
54
63
  this.options = options
55
64
  this.now = options.now ?? (() => Date.now())
65
+ // A test that supplies only a stream keeps to it; the browser's own WebSocket is tried when the page supplies neither.
66
+ this.socketAllowed = options.transport !== 'stream' && (options.createSocket !== undefined || (options.createSource === undefined && typeof WebSocket !== 'undefined'))
56
67
  }
57
68
 
58
69
  connect(): void {
59
70
  if (this.disposed || this.source !== undefined) return
60
- const create = this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url))
61
71
  const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}`
62
- const source = create(`${this.options.base}/channel?${query}`)
72
+ const viaSocket = this.socketAllowed
73
+ this.socketReady = false
74
+ const source: ChannelSource = viaSocket
75
+ ? (this.options.createSocket ?? ((url: string): SocketSource => socketSource(new WebSocket(url) as unknown as SocketLike)))(`${socketBase(this.options.base)}/channel/ws?${query}`)
76
+ : (this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url) as unknown as ChannelSource))(`${this.options.base}/channel?${query}`)
63
77
  this.source = source
64
78
  this.lastHeard = this.now()
65
79
  const heard = (): void => {
66
80
  this.lastHeard = this.now()
67
81
  this.backoff = BACKOFF_START_MS
68
82
  }
69
- source.addEventListener('ready', heard)
83
+ source.addEventListener('ready', () => {
84
+ this.socketReady = true
85
+ heard()
86
+ })
70
87
  source.addEventListener('ping', heard)
71
88
  source.addEventListener('call', (event) => {
72
89
  heard()
73
90
  if (typeof event.data === 'string') this.answer(event.data)
74
91
  })
75
- // An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over.
76
- source.onerror = () => { if (source.readyState === CLOSED) this.restart(source) }
92
+ // An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over. A WebSocket that never became ready is not retried: the page
93
+ // falls back to the event stream, and says so once.
94
+ source.onerror = () => {
95
+ if (source.readyState !== CLOSED) return
96
+ if (viaSocket && !this.socketReady) this.fallBack(source)
97
+ else this.restart(source)
98
+ }
77
99
  const silence = this.options.silenceMs ?? SILENCE_MS
78
100
  this.watchdog = setInterval(() => {
79
101
  if (this.now() - this.lastHeard > silence) this.restart(source)
80
102
  }, Math.max(50, Math.floor(silence / 3)))
81
103
  }
82
104
 
105
+ /** The WebSocket did not open: use the event stream from now on, and say why once. */
106
+ private fallBack(source: ChannelSource): void {
107
+ if (this.source !== source) return
108
+ this.socketAllowed = false
109
+ const warn = this.options.warn ?? ((message: string) => { console.warn(message) })
110
+ warn('acryl-door: the page channel could not open a WebSocket (the server has no upgrade route, or a proxy refused it); using server-sent events, which count against the browser\'s six connections per host')
111
+ this.stop()
112
+ this.connect()
113
+ }
114
+
83
115
  /** Tell the Host whether this window is the one in front. */
84
116
  reportFocus(focused: boolean): void {
85
117
  this.send({ t: 'focus', focused })
@@ -149,9 +181,19 @@ export class PageChannel {
149
181
  }
150
182
 
151
183
  private send(message: ChannelToHost): void {
184
+ // On a WebSocket the answer goes up the socket: a request would queue behind the browser's six connections per host.
185
+ const source = this.source as Partial<SocketSource> | undefined
186
+ if (source?.send?.(JSON.stringify(message)) === true) return
152
187
  const post = this.options.post ?? ((url: string, body: string) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
153
188
  post(`${this.options.base}/channel/answer`, JSON.stringify({ window: this.options.windowId, message })).catch(() => {
154
189
  // The Host is gone or restarting; the call it made settles on its side.
155
190
  })
156
191
  }
157
192
  }
193
+
194
+ /** The WebSocket address of the door's routes: this page's own origin, `ws:` or `wss:`, then the base path. */
195
+ function socketBase(base: string): string {
196
+ const here = (globalThis as { location?: { protocol: string; host: string } }).location
197
+ if (here === undefined) return base
198
+ return `${here.protocol === 'https:' ? 'wss' : 'ws'}://${here.host}${base}`
199
+ }
@@ -22,7 +22,15 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
22
22
  const detachInput = clock.attach(document)
23
23
  const removeIndicator = mountIndicator(driver, document)
24
24
  const shellWindow = Number(/\bAcrylWindow\/(\d{1,9})\b/u.exec(view.navigator?.userAgent ?? '')?.[1])
25
- const channel = new PageChannel(driver, { windowId, focused, base, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
25
+ // `localStorage.setItem('acryl-door-transport', 'stream')` keeps this browser to the event stream (to compare, or behind a proxy that cannot upgrade).
26
+ const transport = ((): 'auto' | 'stream' => {
27
+ try {
28
+ return view.localStorage.getItem('acryl-door-transport') === 'stream' ? 'stream' : 'auto'
29
+ } catch {
30
+ return 'auto'
31
+ }
32
+ })()
33
+ const channel = new PageChannel(driver, { windowId, focused, base, transport, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
26
34
  const reportFocus = (): void => { channel.reportFocus(focused()) }
27
35
  view.addEventListener('focus', reportFocus)
28
36
  view.addEventListener('blur', reportFocus)
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A WebSocket that looks like the event stream the channel already runs on: `addEventListener('call' | 'ready' | 'ping')`, `onerror`, `close()` and `readyState` (0 connecting, 1 open,
3
+ * 2 closed). Each frame from the Host is `{ "e": <event>, "d": <data> }`. It can also send, so the page's answers go up the same socket and need no request of their own: a request
4
+ * would wait behind the browser's six connections per host, which is what the socket is for.
5
+ */
6
+
7
+ import type { ChannelSource } from './channel.ts'
8
+
9
+ /** The slice of the browser's `WebSocket` used here, so a test can supply its own. */
10
+ export interface SocketLike {
11
+ readyState: number
12
+ onopen: ((event: Event) => void) | null
13
+ onmessage: ((event: MessageEvent) => void) | null
14
+ onerror: ((event: Event) => void) | null
15
+ onclose: ((event: CloseEvent) => void) | null
16
+ send(data: string): void
17
+ close(code?: number, reason?: string): void
18
+ }
19
+
20
+ export interface SocketSource extends ChannelSource {
21
+ /** Send one message up; false when the socket is not open (the caller posts it instead). */
22
+ send(text: string): boolean
23
+ }
24
+
25
+ export function socketSource(socket: SocketLike): SocketSource {
26
+ const listeners = new Map<string, Array<(event: MessageEvent) => void>>()
27
+ let closed = false
28
+ const source: SocketSource = {
29
+ onerror: null,
30
+ get readyState(): number {
31
+ return closed || socket.readyState >= 2 ? 2 : socket.readyState
32
+ },
33
+ addEventListener(type, listener) {
34
+ listeners.set(type, [...(listeners.get(type) ?? []), listener])
35
+ },
36
+ send(text) {
37
+ if (socket.readyState !== 1) return false
38
+ try {
39
+ socket.send(text)
40
+ return true
41
+ } catch {
42
+ return false
43
+ }
44
+ },
45
+ close() {
46
+ closed = true
47
+ socket.onmessage = null
48
+ socket.onerror = null
49
+ socket.onclose = null
50
+ try {
51
+ socket.close(1000, 'page left')
52
+ } catch {
53
+ // Already closed.
54
+ }
55
+ },
56
+ }
57
+ socket.onmessage = (event) => {
58
+ if (typeof event.data !== 'string') return
59
+ let frame: { e?: unknown; d?: unknown }
60
+ try {
61
+ frame = JSON.parse(event.data) as { e?: unknown; d?: unknown }
62
+ } catch {
63
+ return
64
+ }
65
+ if (typeof frame.e !== 'string') return
66
+ for (const listener of listeners.get(frame.e) ?? []) listener({ data: typeof frame.d === 'string' ? frame.d : '' } as MessageEvent)
67
+ }
68
+ // An error or a close that this side did not ask for is the channel's cue to start over, or to fall back to the event stream.
69
+ const lost = (event: Event): void => {
70
+ if (!closed) source.onerror?.(event)
71
+ }
72
+ socket.onerror = lost
73
+ socket.onclose = lost
74
+ return source
75
+ }