@ekrooh/bare 0.4.0 → 0.6.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.
@@ -0,0 +1,120 @@
1
+ import { HTTPIncomingMessage, HTTPServerResponse } from "bare-http1";
2
+ import "bare-tcp";
3
+ /** The protocol socket as the routing layer sees it. */
4
+ type WebSocketLike = {
5
+ on(event: 'data', listener: (chunk: unknown) => void): unknown;
6
+ on(event: 'close', listener: () => void): unknown;
7
+ on(event: 'drain', listener: () => void): unknown;
8
+ on(event: 'error', listener: (err: Error) => void): unknown;
9
+ write(data: Uint8Array): boolean;
10
+ destroy(): void;
11
+ /** Stops/restarts `data` events (inbound backpressure). */
12
+ pause?(): void;
13
+ resume?(): void;
14
+ };
15
+ /** Minimal shape of the HTTP request object handed to connection handlers. */
16
+ type LoopbackRequest = {
17
+ url?: string;
18
+ method?: string;
19
+ headers: Record<string, string | string[] | number | undefined>;
20
+ };
21
+ type LoopbackConnectionHandler = (socket: WebSocketLike, request: LoopbackRequest) => void;
22
+ /** Accumulates an HTTP request body into a UTF-8 string. Node http emits
23
+ * Buffers, bare-http1 emits Uint8Arrays, and a setEncoding'd stream emits
24
+ * strings — `String(uint8array)` would join bytes with commas, so decode
25
+ * explicitly (the `/login` token body must match byte-for-byte). */
26
+ declare function collectRequestBody(req: HTTPIncomingMessage, maxBytes?: number): Promise<string>;
27
+ /** Handler for a registered HTTP route on the loopback server. */
28
+ type LoopbackRouteHandler = (req: HTTPIncomingMessage, res: HTTPServerResponse) => void;
29
+ interface LoopbackServerOptions {
30
+ /** Bind address. Defaults to `127.0.0.1`. A non-loopback address throws
31
+ * unless `auth` is on. */
32
+ host?: string;
33
+ /** Port to bind; `0` picks an ephemeral port (on-device). */
34
+ port?: number;
35
+ /** Require the per-session token/cookie on every gated request and WS
36
+ * upgrade. Defaults to on; only the dev backend turns it off. The origin
37
+ * check applies either way. */
38
+ auth?: boolean;
39
+ /** Fixed session token (tests); otherwise a random 32-byte token. */
40
+ token?: string;
41
+ /** Extra page origins allowed to call the server, e.g. the Vite dev server
42
+ * (`http://localhost:5173`). The bound origin and its loopback aliases
43
+ * (`127.0.0.1`, `localhost`, `[::1]` on the bound port) are always allowed. */
44
+ allowedOrigins?: readonly string[];
45
+ /** Idle timeout (ms) after which an authenticated WebSocket connection is
46
+ * dropped. Defaults to 60s. */
47
+ wsIdleTimeoutMs?: number;
48
+ /** Dead-peer timeout for the protocol socket, in ms. The socket sends a
49
+ * protocol-level ping at half this interval and is destroyed when nothing (a
50
+ * pong included) arrives within it, so a suspended or reloaded WebView frees
51
+ * the slot in seconds. Browsers answer pings themselves, so this needs no
52
+ * application message. Defaults to 10s. */
53
+ wsDeadPeerTimeoutMs?: number;
54
+ /** Minimum gap, in ms, between an accepted protocol socket and the next one
55
+ * that may replace it; an upgrade that arrives sooner is held until then.
56
+ * Bounds eviction churn if two live clients compete. Defaults to 1000. */
57
+ wsReplaceMinIntervalMs?: number;
58
+ /** Most bytes queued behind a protocol socket whose reader has stopped reading
59
+ * before server pushes are dropped, newest first. Responses are queued past
60
+ * this (to 8x) and then the socket is closed rather than lose one. Defaults to
61
+ * 2 MiB. */
62
+ wsMaxQueuedBytes?: number;
63
+ }
64
+ interface LoopbackServer {
65
+ /** Resolves to the server origin (`http://127.0.0.1:<port>`), binding on
66
+ * first use. */
67
+ origin(): Promise<string>;
68
+ /** Resolves to a URL for `path` (`<origin><path>`). The per-session cookie
69
+ * authorizes media loads; no token is embedded in the URL. */
70
+ url(path: string): Promise<string>;
71
+ /** The per-session token. The host reads it from `credentials()` for its own
72
+ * `X-Bare-Token` calls and never injects it into the page. */
73
+ token(): string;
74
+ /** Resolves to the bound origin, port, token and the one-time bootstrap
75
+ * nonce (empty once spent). The host injects `bootstrap` into the page (not
76
+ * the token); the page exchanges it once via `POST /login` for the HttpOnly
77
+ * session cookie. */
78
+ credentials(): Promise<{
79
+ origin: string;
80
+ port: number;
81
+ token: string;
82
+ bootstrap: string;
83
+ }>;
84
+ /** Serves `filePath` at `http://<origin><path>` (exact match). */
85
+ mount(path: string, filePath: string): void;
86
+ unmount(path: string): void;
87
+ /** Serves every file under `dirPath` below `prefix` (longest-prefix match).
88
+ * The SPA fallback to `dirPath/index.html` for unknown navigation GETs
89
+ * applies only to `public` mounts (the bootstrap web app). Pass
90
+ * `{ public: true }` to mark a mount as world-readable bootstrap content
91
+ * served before auth; a non-public directory mount gates every request
92
+ * behind the session. A request never resolves outside `dirPath`, symlinks
93
+ * included. */
94
+ mountDir(prefix: string, dirPath: string, opts?: {
95
+ public?: boolean;
96
+ }): void;
97
+ /** Registers a handler for authenticated, handshaken WebSocket connections
98
+ * (the protocol socket). */
99
+ onConnection(handler: LoopbackConnectionHandler): void;
100
+ /** Registers an HTTP route. `handler` runs before mount resolution, after
101
+ * the auth gate (device mode requires the session cookie/token). The route
102
+ * body is read with `collectRequestBody`. */
103
+ registerRoute(method: string, path: string, handler: LoopbackRouteHandler): void;
104
+ /** Writes a raw frame to the connected protocol socket (if any) — the
105
+ * server-initiated push seam (e.g. a `photos.changed` dispatch). */
106
+ push(frame: Uint8Array): boolean;
107
+ /** Stops accepting connections and closes the server. */
108
+ close(cb?: (err?: Error | null) => void): void;
109
+ }
110
+ /**
111
+ * The single loopback HTTP+WS server for the worklet. One origin serves the
112
+ * web app (`/`), the media files mounted by plugins, and the framed-protocol
113
+ * WebSocket socket the page connects to. On device it binds `127.0.0.1` on an
114
+ * ephemeral port. Admission (origin rule, session credential, bootstrap login)
115
+ * is decided by the loopback gate (`loopback-gate.ts`); the protocol socket's
116
+ * lifecycle by the protocol socket slot (`protocol-socket.ts`). All responses
117
+ * send `Referrer-Policy: no-referrer`.
118
+ */
119
+ declare function createLoopbackServer(options?: LoopbackServerOptions): LoopbackServer;
120
+ export { createLoopbackServer as i, LoopbackServer as n, collectRequestBody as r, LoopbackRouteHandler as t };
@@ -0,0 +1,49 @@
1
+ // core/logs/store.ts
2
+ /** Bounded FIFO log buffer. Newest entries are retained; the oldest are
3
+ * dropped once `capacity` is exceeded. `seq` is a monotonic counter assigned
4
+ * on append and shared across every source, so read-back stays ordered. */
5
+ function createLogRingBuffer(capacity) {
6
+ if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError(`log ring capacity must be a positive integer (got ${capacity})`);
7
+ const ring = Array.from({ length: capacity });
8
+ let start = 0;
9
+ let size = 0;
10
+ let seq = 0;
11
+ /** Entries oldest-first. */
12
+ function ordered() {
13
+ const out = [];
14
+ for (let i = 0; i < size; i++) out.push(ring[(start + i) % capacity]);
15
+ return out;
16
+ }
17
+ return {
18
+ get capacity() {
19
+ return capacity;
20
+ },
21
+ append(entry) {
22
+ const record = {
23
+ ...entry,
24
+ seq: seq++
25
+ };
26
+ if (size < capacity) {
27
+ ring[(start + size) % capacity] = record;
28
+ size++;
29
+ } else {
30
+ ring[start] = record;
31
+ start = (start + 1) % capacity;
32
+ }
33
+ },
34
+ view(options = {}) {
35
+ const { tail, level, source } = options;
36
+ const filtered = ordered().filter((e) => (level === void 0 || e.level === level) && (source === void 0 || e.source === source));
37
+ const count = tail === void 0 ? filtered.length : Math.min(tail, filtered.length);
38
+ return filtered.slice(filtered.length - count);
39
+ },
40
+ clear() {
41
+ const cleared = size;
42
+ ring.fill(void 0);
43
+ start = 0;
44
+ size = 0;
45
+ return cleared;
46
+ }
47
+ };
48
+ }
49
+ export { createLogRingBuffer as t };
@@ -1,13 +1,14 @@
1
- import { V as MessageHeader, X as WireMessage, tt as MessageTypeValue } from "./index-DjulYFJL.mjs";
2
- //#region web/websocket-client.d.ts
1
+ import { K as MessageHeader, ot as MessageTypeValue, tt as WireMessage } from "./index-BkwUzwVt.mjs";
3
2
  declare global {
4
3
  interface Window {
5
4
  /** Per-session bridge state injected by the embedding shell on-device.
6
- * `token` is exchanged for a session cookie via `POST /login`, which then
7
- * rides every same-origin request — including the WebSocket upgrade.
8
- * Browser dev has none (the dev backend runs without auth). */
5
+ * `bootstrap` is a one-time nonce the page exchanges for a session cookie
6
+ * via `POST /login` (single-use — the raw session token is never exposed
7
+ * to page JS). The cookie then rides every same-origin request —
8
+ * including the WebSocket upgrade. Browser dev has none (the dev backend
9
+ * runs without auth). */
9
10
  __ekrooh?: {
10
- token?: string;
11
+ bootstrap?: string;
11
12
  [key: string]: unknown;
12
13
  };
13
14
  /** Set by the embedding shell on-device. */
@@ -18,21 +19,21 @@ interface CreateWebSocketTransportOptions {
18
19
  /** WebSocket URL. Defaults to the page's same-origin (`ws://location.host`).
19
20
  * Browser dev passes `VITE_BARE_WS_URL` explicitly. */
20
21
  url?: string;
21
- /** Whether to bootstrap the session cookie via `POST /login` when a token
22
- * is present. Defaults to `true`. */
22
+ /** Whether to bootstrap the session cookie via `POST /login` when a
23
+ * bootstrap nonce is present. Defaults to `true`. */
23
24
  login?: boolean;
24
25
  /** Reconnect attempts after an unexpected close (boot-window). Default 5. */
25
26
  maxRetries?: number;
26
27
  /** Initial reconnect backoff in ms, doubling to a 2s cap. Default 250. */
27
28
  backoffMs?: number;
29
+ /** How long to wait for `/login` before treating it as failed. Default
30
+ * 2000ms. Exposed so tests can exercise the timeout without waiting. */
31
+ loginTimeoutMs?: number;
28
32
  }
29
33
  interface MessageTransport {
30
34
  send(type: MessageTypeValue, header: MessageHeader, payload?: Uint8Array | ArrayBuffer | string | null): void;
31
35
  subscribe(handler: (message: WireMessage) => void): () => void;
32
36
  }
33
- declare function createWebSocketTransport(urlOrOptions?: string | CreateWebSocketTransportOptions): MessageTransport;
34
- //#endregion
35
- //#region web/transports/mock.d.ts
36
- declare function createMockTransport(): MessageTransport;
37
- //#endregion
38
- export { type MessageTransport, createMockTransport, createWebSocketTransport };
37
+ export declare function createWebSocketTransport(urlOrOptions?: string | CreateWebSocketTransportOptions): MessageTransport;
38
+ export declare function createMockTransport(): MessageTransport;
39
+ export type { MessageTransport };
@@ -1,6 +1,7 @@
1
- import { ErrorCode, MessageProtocol, MessageType } from "./index.mjs";
1
+ import { g as ErrorCode, o as MessageProtocol, y as MessageType } from "./messages-CevD8qkV.mjs";
2
+ import { t as createLogRingBuffer } from "./store-DTg1jN5E.mjs";
2
3
  import { Actor, RealClock, event, state } from "@mantaq/core";
3
- //#region web/connection-machine.ts
4
+ // web/connection-machine.ts
4
5
  const idleState = state("idle")();
5
6
  const openingState = state("opening")();
6
7
  const connectedState = state("connected")();
@@ -10,31 +11,24 @@ const loginOk = event("LOGIN_OK")();
10
11
  const loginFail = event("LOGIN_FAIL")();
11
12
  const socketOpen = event("SOCKET_OPEN")();
12
13
  const socketClose = event("SOCKET_CLOSE")();
14
+ const wake = event("WAKE")();
13
15
  const retryTimer = event("RETRY_TIMER")();
14
- /** Append the session token to the URL as a `?token=...` query param, keeping
15
- * any query the URL already carries. */
16
- function withToken(url, token) {
17
- const parsed = new URL(url);
18
- parsed.searchParams.set("token", token);
19
- return parsed.toString();
20
- }
21
16
  function createConnectionMachine(options) {
22
17
  const context = {
23
18
  url: options.url,
24
- baseUrl: options.url,
25
- token: options.token,
26
19
  maxRetries: options.maxRetries,
27
20
  initialBackoffMs: options.initialBackoffMs,
28
21
  maxBackoffMs: options.maxBackoffMs,
29
22
  retries: 0,
30
- tokenTried: false
23
+ everConnected: false
31
24
  };
32
25
  const actor = new Actor({
33
26
  inputs: [
34
27
  loginOk,
35
28
  loginFail,
36
29
  socketOpen,
37
- socketClose
30
+ socketClose,
31
+ wake
38
32
  ],
39
33
  internal: [retryTimer],
40
34
  outputs: [],
@@ -51,42 +45,32 @@ function createConnectionMachine(options) {
51
45
  setup: (m) => {
52
46
  m.effect(backoffState, ({ signal, context, clock, emit }) => {
53
47
  const s = context.get();
54
- const delay = Math.min(s.initialBackoffMs * 2 ** (s.retries - 1), s.maxBackoffMs);
48
+ const capped = Math.min(s.initialBackoffMs * 2 ** (s.retries - 1), s.maxBackoffMs);
49
+ const delay = Math.floor(capped * Math.max(0, Math.min(1, (options.random ?? Math.random)())));
55
50
  clock.setTimeout(delay, () => {
56
- if (signal.aborted) return;
51
+ if (signal.aborted) {
52
+ try {
53
+ console.debug(`[ws-machine] backoff aborted by signal; dropping pending retry (retries=${s.retries})`);
54
+ } catch {}
55
+ return;
56
+ }
57
57
  emit({ type: "RETRY_TIMER" });
58
58
  });
59
59
  });
60
60
  m.on(idleState, loginOk, () => ({ state: openingState }));
61
- m.on(idleState, loginFail, (_event, opts) => {
62
- const s = opts.context.get();
63
- const tokenUrl = s.token && s.token.length > 0 ? withToken(s.baseUrl, s.token) : s.url;
64
- opts.context.set({
65
- ...s,
66
- url: tokenUrl,
67
- tokenTried: true
68
- });
69
- return { state: openingState };
70
- });
61
+ m.on(idleState, loginFail, () => ({ state: gaveUpState }));
71
62
  m.on(openingState, socketOpen, (_event, opts) => {
72
63
  const s = opts.context.get();
73
64
  opts.context.set({
74
65
  ...s,
75
- retries: 0
66
+ retries: 0,
67
+ everConnected: true
76
68
  });
77
69
  return { state: connectedState };
78
70
  });
79
71
  m.on(openingState, socketClose, (_event, opts) => {
80
72
  const s = opts.context.get();
81
- if (!s.tokenTried && typeof s.token === "string" && s.token.length > 0) {
82
- opts.context.set({
83
- ...s,
84
- url: withToken(s.baseUrl, s.token),
85
- tokenTried: true
86
- });
87
- return { state: openingState };
88
- }
89
- if (s.retries >= s.maxRetries) return { state: gaveUpState };
73
+ if (!s.everConnected && s.retries >= s.maxRetries) return { state: gaveUpState };
90
74
  opts.context.set({
91
75
  ...s,
92
76
  retries: s.retries + 1
@@ -95,7 +79,6 @@ function createConnectionMachine(options) {
95
79
  });
96
80
  m.on(connectedState, socketClose, (_event, opts) => {
97
81
  const s = opts.context.get();
98
- if (s.retries >= s.maxRetries) return { state: gaveUpState };
99
82
  opts.context.set({
100
83
  ...s,
101
84
  retries: s.retries + 1
@@ -103,6 +86,7 @@ function createConnectionMachine(options) {
103
86
  return { state: backoffState };
104
87
  });
105
88
  m.on(backoffState, retryTimer, () => ({ state: openingState }));
89
+ m.on(backoffState, wake, () => ({ state: openingState }));
106
90
  m.on(backoffState, socketClose, () => ({}));
107
91
  }
108
92
  });
@@ -112,23 +96,27 @@ function createConnectionMachine(options) {
112
96
  state: () => name(actor.snapshot()),
113
97
  isConnected: () => name(actor.snapshot()) === "connected",
114
98
  isGaveUp: () => name(actor.snapshot()) === "gaveUp",
99
+ isDegraded: () => actor.context.everConnected && actor.context.retries > actor.context.maxRetries,
115
100
  sendLoginOk: () => actor.send(loginOk.create()),
116
101
  sendLoginFail: () => actor.send(loginFail.create()),
117
102
  sendOpen: () => actor.send(socketOpen.create()),
118
103
  sendClose: () => actor.send(socketClose.create()),
104
+ sendWake: () => actor.send(wake.create()),
119
105
  onChange(fn) {
120
106
  return actor.on("change", (snapshot) => fn(name(snapshot)));
121
107
  }
122
108
  };
123
109
  }
124
- //#endregion
125
- //#region web/websocket-client.ts
110
+ // web/websocket-client.ts
126
111
  const protocol = new MessageProtocol();
127
112
  const WS_DISCONNECTED_MESSAGE = "WebSocket disconnected before request could be sent";
128
113
  const DEFAULT_RETRIES = 5;
129
114
  const DEFAULT_BACKOFF_MS = 250;
130
115
  const MAX_BACKOFF_MS = 2e3;
131
116
  const LOGIN_TIMEOUT_MS = 2e3;
117
+ /** Wait before the single retry after a 409 (`bootstrap` nonce already
118
+ * spent): long enough for the sibling document's `Set-Cookie` to land. */
119
+ const SPENT_NONCE_RETRY_MS = 250;
132
120
  /** Same-origin default: on-device the page is served by the loopback server,
133
121
  * so `ws://<location.host>` is the protocol socket with no mixed content and
134
122
  * no custom scheme. */
@@ -137,30 +125,66 @@ function defaultWsUrl() {
137
125
  return "ws://localhost:8080";
138
126
  }
139
127
  /**
140
- * `websocket.send` needs a `BufferSource` backed by a plain `ArrayBuffer`;
141
- * the codec may produce a view into a pooled Buffer, so slice a standalone
142
- * copy of just the frame.
128
+ * `websocket.send` sends exactly the bytes a typed-array view covers (its
129
+ * offset and length), so the frame goes out as is. This was a `slice()` copy of
130
+ * the whole frame, kept for a pooled-`Buffer` view that the browser never
131
+ * produces.
143
132
  */
144
- function toArrayBuffer(bytes) {
145
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
133
+ function toSendable(bytes) {
134
+ return bytes;
135
+ }
136
+ /** Per-line character cap for the folded log tail. */
137
+ const LOG_TAIL_LINE_CHARS = 200;
138
+ /** How long to wait for the log-tail fetch before giving up on enrichment. */
139
+ const LOG_TAIL_TIMEOUT_MS = 2e3;
140
+ /**
141
+ * Fetches the loopback server's recent `[loopback]` rejection lines over HTTP.
142
+ * This works precisely because the failure class is upgrade-only: the page
143
+ * itself loads from the same server, so the auth-gated `GET /logs` route still
144
+ * answers when every WebSocket handshake dies. Returns null when nothing can
145
+ * be learned (no fetch available, network error, timeout).
146
+ */
147
+ async function serverRejectionTail(wsUrl) {
148
+ if (typeof fetch !== "function") return null;
149
+ const httpBase = wsUrl.startsWith("wss://") ? `https://${wsUrl.slice(6)}` : wsUrl.startsWith("ws://") ? `http://${wsUrl.slice(5)}` : wsUrl;
150
+ try {
151
+ const response = await Promise.race([fetch(`${httpBase}/logs?tail=50&format=text`), new Promise((resolve) => setTimeout(() => resolve(null), LOG_TAIL_TIMEOUT_MS))]);
152
+ if (response === null) return null;
153
+ if (!response.ok) return `server log tail unavailable (HTTP ${response.status})`;
154
+ const lines = (await response.text()).split("\n").filter((line) => line.includes("[loopback]")).slice(-3).map((line) => line.slice(0, LOG_TAIL_LINE_CHARS));
155
+ return lines.length > 0 ? lines.join(" | ") : "no [loopback] rejection lines in server log tail";
156
+ } catch {
157
+ return null;
158
+ }
146
159
  }
147
160
  function createWebSocketTransport(urlOrOptions = {}) {
148
161
  const options = typeof urlOrOptions === "string" ? { url: urlOrOptions } : urlOrOptions;
149
162
  const url = options.url ?? defaultWsUrl();
150
163
  const maxRetries = options.maxRetries ?? DEFAULT_RETRIES;
151
164
  const initialBackoff = options.backoffMs ?? DEFAULT_BACKOFF_MS;
152
- const token = typeof window !== "undefined" ? window.__ekrooh?.token : void 0;
153
- const shouldLogin = options.login !== false && typeof token === "string" && token.length > 0;
165
+ const loginTimeoutMs = options.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
166
+ const credential = (typeof window !== "undefined" ? window.__ekrooh : void 0)?.bootstrap;
167
+ const shouldLogin = options.login !== false && typeof credential === "string" && credential.length > 0;
154
168
  const listeners = /* @__PURE__ */ new Set();
155
169
  const queued = [];
156
170
  let socket = null;
171
+ /** Human-readable reason when the give-up was caused by the `/login`
172
+ * exchange. The surfaced transport error is the only telemetry CI gets
173
+ * from an on-device WebView, so it must say which path fired instead of
174
+ * collapsing login rejection, login timeout and retry exhaustion into a
175
+ * single string. */
176
+ let loginFailureDetail = null;
177
+ /** Error text for requests lost to a terminal give-up. */
178
+ function gaveUpMessage() {
179
+ return loginFailureDetail === null ? `${WS_DISCONNECTED_MESSAGE} (socket never opened after ${maxRetries} retries)` : `${WS_DISCONNECTED_MESSAGE} (${loginFailureDetail})`;
180
+ }
157
181
  const machine = createConnectionMachine({
158
182
  url,
159
- token: typeof token === "string" ? token : void 0,
160
183
  maxRetries,
161
184
  initialBackoffMs: initialBackoff,
162
185
  maxBackoffMs: MAX_BACKOFF_MS
163
186
  });
187
+ const reconnectingMessage = `${WS_DISCONNECTED_MESSAGE} (reconnecting)`;
164
188
  const emitTransportError = (header, message) => {
165
189
  if (header.type !== "INVOKE_REQUEST") return;
166
190
  for (const listener of listeners) listener({
@@ -183,20 +207,28 @@ function createWebSocketTransport(urlOrOptions = {}) {
183
207
  queued.length = 0;
184
208
  };
185
209
  const flushQueued = () => {
186
- for (const queuedMessage of queued) socket?.send(toArrayBuffer(queuedMessage.bytes));
210
+ for (const queuedMessage of queued) socket?.send(toSendable(queuedMessage.bytes));
187
211
  queued.length = 0;
188
212
  };
189
213
  /** Opens a WebSocket for the machine's current URL and wires it to the
190
214
  * machine's lifecycle events. */
191
215
  function openSocket() {
192
- socket = new WebSocket(machine.url());
193
- socket.binaryType = "arraybuffer";
194
- socket.onopen = () => {
216
+ const current = new WebSocket(machine.url());
217
+ socket = current;
218
+ current.binaryType = "arraybuffer";
219
+ current.onopen = () => {
220
+ if (socket !== current) return;
195
221
  machine.sendOpen();
196
222
  flushQueued();
197
223
  };
198
- socket.onmessage = (event) => {
199
- if ((event.data?.byteLength ?? 0) === 0) return;
224
+ current.onmessage = (event) => {
225
+ if (socket !== current) return;
226
+ if ((event.data?.byteLength ?? 0) === 0) {
227
+ try {
228
+ console.debug("[ws] received empty frame; ignoring");
229
+ } catch {}
230
+ return;
231
+ }
200
232
  try {
201
233
  const message = protocol.decode(event.data);
202
234
  for (const listener of listeners) listener(message);
@@ -204,41 +236,89 @@ function createWebSocketTransport(urlOrOptions = {}) {
204
236
  console.error("Failed to parse WS message:", err);
205
237
  }
206
238
  };
207
- socket.onclose = () => {
239
+ current.onclose = () => {
240
+ if (socket !== current) return;
208
241
  machine.sendClose();
209
242
  };
210
- socket.onerror = () => {};
243
+ current.onerror = () => {};
211
244
  }
212
245
  machine.onChange((state) => {
213
246
  if (state === "opening") openSocket();
214
- else if (state === "gaveUp") failQueuedMessages(WS_DISCONNECTED_MESSAGE);
247
+ else if (state === "gaveUp") failWithServerDetail();
248
+ else if (state === "backoff" && machine.isDegraded()) failQueuedMessages(reconnectingMessage);
249
+ });
250
+ /** The app returned to the foreground. A suspended page may hold a socket
251
+ * the browser already closed without firing `onclose`; treat it as lost, and
252
+ * retry now instead of waiting out the backoff. */
253
+ function onForeground() {
254
+ if (machine.isConnected() && socket && socket.readyState !== WebSocket.OPEN) machine.sendClose();
255
+ machine.sendWake();
256
+ }
257
+ if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("visibilitychange", () => {
258
+ if (document.visibilityState === "visible") onForeground();
215
259
  });
260
+ /** Terminal give-up: when we have no better name for the failure, fold the
261
+ * server's own view (its `[loopback]` upgrade rejections) into the surfaced
262
+ * error. Async by one localhost round-trip — queued requests are already
263
+ * terminally lost, so a short delay changes nothing semantically. */
264
+ async function failWithServerDetail() {
265
+ let message = gaveUpMessage();
266
+ if (loginFailureDetail === null) {
267
+ const tail = await serverRejectionTail(url);
268
+ if (tail !== null) message = `${message}; server: ${tail}`;
269
+ }
270
+ failQueuedMessages(message);
271
+ }
272
+ /** One `/login` attempt, classified for the bootstrap race. `spent` means
273
+ * the server recognized our nonce as already consumed by a sibling
274
+ * document whose session cookie is on its way — worth exactly one retry. */
275
+ function postLogin() {
276
+ const loginTimeout = new Promise((resolve) => {
277
+ setTimeout(() => resolve("timeout"), loginTimeoutMs);
278
+ });
279
+ const attempt = fetch("/login", {
280
+ method: "POST",
281
+ body: credential
282
+ }).then((response) => {
283
+ if (response.ok) return "ok";
284
+ return response.status === 409 ? "spent" : "rejected";
285
+ });
286
+ return Promise.race([attempt, loginTimeout]);
287
+ }
216
288
  async function bootstrap() {
217
- if (shouldLogin) {
218
- const loginTimeout = new Promise((resolve) => {
219
- setTimeout(() => resolve("timeout"), LOGIN_TIMEOUT_MS);
220
- });
221
- try {
222
- if (await Promise.race([fetch("/login", {
223
- method: "POST",
224
- body: token
225
- }).then((response) => response.ok ? "ok" : "rejected"), loginTimeout]) === "ok") machine.sendLoginOk();
226
- else machine.sendLoginFail();
227
- } catch {
289
+ if (shouldLogin) try {
290
+ let outcome = await postLogin();
291
+ if (outcome === "spent") {
292
+ await new Promise((resolve) => {
293
+ setTimeout(resolve, SPENT_NONCE_RETRY_MS);
294
+ });
295
+ outcome = await postLogin();
296
+ }
297
+ if (outcome === "ok") machine.sendLoginOk();
298
+ else {
299
+ loginFailureDetail = outcome === "timeout" ? `session login timed out after ${loginTimeoutMs}ms` : "session login rejected";
228
300
  machine.sendLoginFail();
229
301
  }
230
- } else machine.sendLoginOk();
302
+ } catch {
303
+ loginFailureDetail = "session login request failed";
304
+ machine.sendLoginFail();
305
+ }
306
+ else machine.sendLoginOk();
231
307
  }
232
308
  bootstrap();
233
309
  return {
234
310
  send(type, header, payload) {
235
311
  const encoded = protocol.encode(type, header, payload);
236
312
  if (machine.isConnected() && socket && socket.readyState === WebSocket.OPEN) {
237
- socket.send(toArrayBuffer(encoded));
313
+ socket.send(toSendable(encoded));
238
314
  return;
239
315
  }
240
316
  if (machine.isGaveUp()) {
241
- emitTransportError(header, WS_DISCONNECTED_MESSAGE);
317
+ emitTransportError(header, gaveUpMessage());
318
+ return;
319
+ }
320
+ if (machine.state() === "backoff" && machine.isDegraded()) {
321
+ emitTransportError(header, reconnectingMessage);
242
322
  return;
243
323
  }
244
324
  queued.push({
@@ -254,8 +334,22 @@ function createWebSocketTransport(urlOrOptions = {}) {
254
334
  }
255
335
  };
256
336
  }
257
- //#endregion
258
- //#region web/transports/mock-handlers.ts
337
+ // web/transports/mock-handlers.ts
338
+ function createLogsInvokeHandlers(store = createLogRingBuffer(500)) {
339
+ return {
340
+ "logs.view": (args) => {
341
+ const tail = args?.tail === void 0 ? void 0 : Number(args.tail);
342
+ const level = args?.level === "debug" || args?.level === "info" || args?.level === "warn" || args?.level === "error" ? args.level : void 0;
343
+ const source = args?.source === "backend" || args?.source === "web" ? args.source : void 0;
344
+ return { entries: store.view({
345
+ tail: tail !== void 0 && Number.isFinite(tail) ? tail : void 0,
346
+ level,
347
+ source
348
+ }) };
349
+ },
350
+ "logs.clear": () => ({ cleared: store.clear() })
351
+ };
352
+ }
259
353
  function createHealthInvokeHandlers() {
260
354
  return {
261
355
  "health.ping": (args) => ({
@@ -275,12 +369,12 @@ function createHealthInvokeHandlers() {
275
369
  })
276
370
  };
277
371
  }
278
- //#endregion
279
- //#region web/transports/mock.ts
372
+ // web/transports/mock.ts
280
373
  const MOCK_MEDIA_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAbUlEQVR4nO3O7UkCABiAQcd5hjXSyCKLPrCiklRswHOM/rw3wS0s4yquYxXruInb2MRd3MdDbOMxnuI5XuI13mIX7/ERn/EV3/ET+/iNQxzjFOf4y2ICE5jABCYwgQlMYAITmMAEJjCBCfx34ALvwBam5OydHgAAAABJRU5ErkJggg==";
281
374
  const permissionOf = (args) => typeof args?.permission === "string" ? args.permission : "storage";
282
375
  const mockInvokeHandlers = {
283
376
  ...createHealthInvokeHandlers(),
377
+ ...createLogsInvokeHandlers(createLogRingBuffer(500)),
284
378
  "discovery.list": () => ({
285
379
  schemaVersion: 1,
286
380
  capabilities: [
@@ -322,14 +416,8 @@ const mockInvokeHandlers = {
322
416
  permission: permissionOf(args),
323
417
  status: "granted"
324
418
  }),
325
- "media.pick": () => ({
326
- url: MOCK_MEDIA_DATA_URI,
327
- path: ""
328
- }),
329
- "media.capture": () => ({
330
- url: MOCK_MEDIA_DATA_URI,
331
- path: ""
332
- })
419
+ "media.pick": () => ({ url: MOCK_MEDIA_DATA_URI }),
420
+ "media.capture": () => ({ url: MOCK_MEDIA_DATA_URI })
333
421
  };
334
422
  function createMockTransport() {
335
423
  const listeners = /* @__PURE__ */ new Set();
@@ -380,5 +468,4 @@ function createMockTransport() {
380
468
  }
381
469
  };
382
470
  }
383
- //#endregion
384
471
  export { createMockTransport, createWebSocketTransport };