@ekrooh/bare 0.3.0 → 0.5.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.
@@ -1,16 +1,19 @@
1
1
  import { HTTPIncomingMessage, HTTPServerResponse } from "bare-http1";
2
- //#region core/server/static-file-server.d.ts
3
2
  /** Accumulates an HTTP request body into a UTF-8 string. Node http emits
4
3
  * Buffers, bare-http1 emits Uint8Arrays, and a setEncoding'd stream emits
5
4
  * strings — `String(uint8array)` would join bytes with commas, so decode
6
5
  * explicitly (the `/login` token body must match byte-for-byte). */
7
- declare function collectRequestBody(req: HTTPIncomingMessage): Promise<string>;
6
+ declare function collectRequestBody(req: HTTPIncomingMessage, maxBytes?: number): Promise<string>;
8
7
  type WebSocketLike = {
9
8
  on(event: 'data', listener: (chunk: unknown) => void): unknown;
10
9
  on(event: 'close', listener: () => void): unknown;
10
+ on(event: 'drain', listener: () => void): unknown;
11
11
  on(event: 'error', listener: (err: Error) => void): unknown;
12
12
  write(data: unknown): boolean;
13
13
  destroy(): void;
14
+ /** Stops/restarts `data` events (inbound backpressure). */
15
+ pause?(): void;
16
+ resume?(): void;
14
17
  };
15
18
  /** Handler for a registered HTTP route on the loopback server. */
16
19
  type LoopbackRouteHandler = (req: HTTPIncomingMessage, res: HTTPServerResponse) => void;
@@ -18,7 +21,7 @@ type LoopbackRouteHandler = (req: HTTPIncomingMessage, res: HTTPServerResponse)
18
21
  type LoopbackRequest = {
19
22
  url?: string;
20
23
  method?: string;
21
- headers: Record<string, string | number>;
24
+ headers: Record<string, string | string[] | number>;
22
25
  };
23
26
  type LoopbackConnectionHandler = (socket: WebSocketLike, request: LoopbackRequest) => void;
24
27
  interface LoopbackServerOptions {
@@ -35,6 +38,21 @@ interface LoopbackServerOptions {
35
38
  /** Idle timeout (ms) after which an authenticated WebSocket connection is
36
39
  * dropped. Defaults to 60s. */
37
40
  wsIdleTimeoutMs?: number;
41
+ /** Dead-peer timeout for the protocol socket, in ms. The socket sends a
42
+ * protocol-level ping at half this interval and is destroyed when nothing (a
43
+ * pong included) arrives within it, so a suspended or reloaded WebView frees
44
+ * the slot in seconds. Browsers answer pings themselves, so this needs no
45
+ * application message. Defaults to 10s. */
46
+ wsDeadPeerTimeoutMs?: number;
47
+ /** Minimum gap, in ms, between an accepted protocol socket and the next one
48
+ * that may replace it. Bounds eviction churn if two live clients compete.
49
+ * Defaults to 1000. */
50
+ wsReplaceMinIntervalMs?: number;
51
+ /** Most bytes queued behind a protocol socket whose reader has stopped reading
52
+ * before server pushes are dropped, newest first. Responses are queued past
53
+ * this (to 8x) and then the socket is closed rather than lose one. Defaults to
54
+ * 2 MiB. */
55
+ wsMaxQueuedBytes?: number;
38
56
  }
39
57
  interface LoopbackServer {
40
58
  /** Resolves to the server origin (`http://127.0.0.1:<port>`), binding on
@@ -43,21 +61,30 @@ interface LoopbackServer {
43
61
  /** Resolves to a URL for `path` (`<origin><path>`). The per-session cookie
44
62
  * authorizes media loads; no token is embedded in the URL anymore. */
45
63
  url(path: string): Promise<string>;
46
- /** The per-session token (generated on first bind). */
64
+ /** The per-session token (generated on first bind). The host reads it from
65
+ * `credentials()` for its own IPC needs — never injects it into the page. */
47
66
  token(): string;
48
- /** Resolves to the bound origin, port and token — written to the handoff
49
- * file for the host to read before loading the page. */
67
+ /** Resolves to the bound origin, port, token and a one-time bootstrap nonce —
68
+ * written to the handoff file for the host to read before loading the page.
69
+ * The host injects `bootstrap` into the page (not the token); the page
70
+ * exchanges it once via `POST /login` for the HttpOnly session cookie. */
50
71
  credentials(): Promise<{
51
72
  origin: string;
52
73
  port: number;
53
74
  token: string;
75
+ bootstrap: string;
54
76
  }>;
55
77
  /** Serves `filePath` at `http://<origin><path>` (exact match). */
56
78
  mount(path: string, filePath: string): void;
57
79
  unmount(path: string): void;
58
- /** Serves every file under `dirPath` below `prefix` (longest-prefix match),
59
- * with an SPA fallback to `dirPath/index.html` for unknown GET paths. */
60
- mountDir(prefix: string, dirPath: string): void;
80
+ /** Serves every file under `dirPath` below `prefix` (longest-prefix match).
81
+ * The SPA fallback to `dirPath/index.html` for unknown nave GETs applies
82
+ * only to `public` mounts (the bootstrap web app). Pass `{ public: true }`
83
+ * to mark a mount as world-readable bootstrap content served before auth; a
84
+ * non-public directory mount gates every request behind the session. */
85
+ mountDir(prefix: string, dirPath: string, opts?: {
86
+ public?: boolean;
87
+ }): void;
61
88
  /** Registers a handler for authenticated, handshaken WebSocket connections
62
89
  * (the protocol socket). */
63
90
  onConnection(handler: LoopbackConnectionHandler): void;
@@ -75,14 +102,14 @@ interface LoopbackServer {
75
102
  * The single loopback HTTP+WS server for the worklet. One origin serves the
76
103
  * web app (`/`), the media files mounted by plugins, and the framed-protocol
77
104
  * WebSocket socket the page connects to. On device it binds `127.0.0.1` on an
78
- * ephemeral port. The bundled web app (directory mounts) is public content — a
79
- * fresh WebView must load the page before the page can `POST /login`. Mounted
80
- * session files (media) and the WS upgrade are gated by a per-session token,
81
- * accepted as a `bare_session` cookie (set via `POST /login`), the
82
- * `X-Bare-Token` header, or a `?token=` query param — defense in depth for
83
- * non-browser clients. All responses send `Referrer-Policy: no-referrer` so
84
- * token-bearing URLs never leak through a Referer header.
105
+ * ephemeral port. Only the explicit **bootstrap** directory mount (`/`, marked
106
+ * `{ public: true }`) is world-readable before auth — a fresh WebView must load
107
+ * the page before it can `POST /login`. Mounted session files (media) and the
108
+ * WS upgrade are gated by a per-session token, accepted as a `bare_session`
109
+ * cookie (set via `POST /login` with the one-time bootstrap nonce or the token)
110
+ * or the `X-Bare-Token` header (non-browser clients). The URL `?token=` query
111
+ * param is **not** accepted — a token must never appear in a URL an observer
112
+ * could read. All responses send `Referrer-Policy: no-referrer`.
85
113
  */
86
114
  declare function createLoopbackServer(options?: LoopbackServerOptions): LoopbackServer;
87
- //#endregion
88
115
  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,12 +1,15 @@
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). `token` remains as a legacy login fallback for consumers
8
+ * that still inject it. The cookie then rides every same-origin request —
9
+ * including the WebSocket upgrade. Browser dev has none (the dev backend
10
+ * runs without auth). */
9
11
  __ekrooh?: {
12
+ bootstrap?: string;
10
13
  token?: string;
11
14
  [key: string]: unknown;
12
15
  };
@@ -25,14 +28,14 @@ interface CreateWebSocketTransportOptions {
25
28
  maxRetries?: number;
26
29
  /** Initial reconnect backoff in ms, doubling to a 2s cap. Default 250. */
27
30
  backoffMs?: number;
31
+ /** How long to wait for `/login` before treating it as failed. Default
32
+ * 2000ms. Exposed so tests can exercise the timeout without waiting. */
33
+ loginTimeoutMs?: number;
28
34
  }
29
35
  interface MessageTransport {
30
36
  send(type: MessageTypeValue, header: MessageHeader, payload?: Uint8Array | ArrayBuffer | string | null): void;
31
37
  subscribe(handler: (message: WireMessage) => void): () => void;
32
38
  }
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 };
39
+ export declare function createWebSocketTransport(urlOrOptions?: string | CreateWebSocketTransportOptions): MessageTransport;
40
+ export declare function createMockTransport(): MessageTransport;
41
+ 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-BbpF3wNH.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,67 @@ 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 bridge = typeof window !== "undefined" ? window.__ekrooh : void 0;
167
+ const credential = bridge?.bootstrap ?? bridge?.token;
168
+ const shouldLogin = options.login !== false && typeof credential === "string" && credential.length > 0;
154
169
  const listeners = /* @__PURE__ */ new Set();
155
170
  const queued = [];
156
171
  let socket = null;
172
+ /** Human-readable reason when the give-up was caused by the `/login`
173
+ * exchange. The surfaced transport error is the only telemetry CI gets
174
+ * from an on-device WebView, so it must say which path fired instead of
175
+ * collapsing login rejection, login timeout and retry exhaustion into a
176
+ * single string. */
177
+ let loginFailureDetail = null;
178
+ /** Error text for requests lost to a terminal give-up. */
179
+ function gaveUpMessage() {
180
+ return loginFailureDetail === null ? `${WS_DISCONNECTED_MESSAGE} (socket never opened after ${maxRetries} retries)` : `${WS_DISCONNECTED_MESSAGE} (${loginFailureDetail})`;
181
+ }
157
182
  const machine = createConnectionMachine({
158
183
  url,
159
- token: typeof token === "string" ? token : void 0,
160
184
  maxRetries,
161
185
  initialBackoffMs: initialBackoff,
162
186
  maxBackoffMs: MAX_BACKOFF_MS
163
187
  });
188
+ const reconnectingMessage = `${WS_DISCONNECTED_MESSAGE} (reconnecting)`;
164
189
  const emitTransportError = (header, message) => {
165
190
  if (header.type !== "INVOKE_REQUEST") return;
166
191
  for (const listener of listeners) listener({
@@ -183,20 +208,28 @@ function createWebSocketTransport(urlOrOptions = {}) {
183
208
  queued.length = 0;
184
209
  };
185
210
  const flushQueued = () => {
186
- for (const queuedMessage of queued) socket?.send(toArrayBuffer(queuedMessage.bytes));
211
+ for (const queuedMessage of queued) socket?.send(toSendable(queuedMessage.bytes));
187
212
  queued.length = 0;
188
213
  };
189
214
  /** Opens a WebSocket for the machine's current URL and wires it to the
190
215
  * machine's lifecycle events. */
191
216
  function openSocket() {
192
- socket = new WebSocket(machine.url());
193
- socket.binaryType = "arraybuffer";
194
- socket.onopen = () => {
217
+ const current = new WebSocket(machine.url());
218
+ socket = current;
219
+ current.binaryType = "arraybuffer";
220
+ current.onopen = () => {
221
+ if (socket !== current) return;
195
222
  machine.sendOpen();
196
223
  flushQueued();
197
224
  };
198
- socket.onmessage = (event) => {
199
- if ((event.data?.byteLength ?? 0) === 0) return;
225
+ current.onmessage = (event) => {
226
+ if (socket !== current) return;
227
+ if ((event.data?.byteLength ?? 0) === 0) {
228
+ try {
229
+ console.debug("[ws] received empty frame; ignoring");
230
+ } catch {}
231
+ return;
232
+ }
200
233
  try {
201
234
  const message = protocol.decode(event.data);
202
235
  for (const listener of listeners) listener(message);
@@ -204,41 +237,89 @@ function createWebSocketTransport(urlOrOptions = {}) {
204
237
  console.error("Failed to parse WS message:", err);
205
238
  }
206
239
  };
207
- socket.onclose = () => {
240
+ current.onclose = () => {
241
+ if (socket !== current) return;
208
242
  machine.sendClose();
209
243
  };
210
- socket.onerror = () => {};
244
+ current.onerror = () => {};
211
245
  }
212
246
  machine.onChange((state) => {
213
247
  if (state === "opening") openSocket();
214
- else if (state === "gaveUp") failQueuedMessages(WS_DISCONNECTED_MESSAGE);
248
+ else if (state === "gaveUp") failWithServerDetail();
249
+ else if (state === "backoff" && machine.isDegraded()) failQueuedMessages(reconnectingMessage);
250
+ });
251
+ /** The app returned to the foreground. A suspended page may hold a socket
252
+ * the browser already closed without firing `onclose`; treat it as lost, and
253
+ * retry now instead of waiting out the backoff. */
254
+ function onForeground() {
255
+ if (machine.isConnected() && socket && socket.readyState !== WebSocket.OPEN) machine.sendClose();
256
+ machine.sendWake();
257
+ }
258
+ if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("visibilitychange", () => {
259
+ if (document.visibilityState === "visible") onForeground();
215
260
  });
261
+ /** Terminal give-up: when we have no better name for the failure, fold the
262
+ * server's own view (its `[loopback]` upgrade rejections) into the surfaced
263
+ * error. Async by one localhost round-trip — queued requests are already
264
+ * terminally lost, so a short delay changes nothing semantically. */
265
+ async function failWithServerDetail() {
266
+ let message = gaveUpMessage();
267
+ if (loginFailureDetail === null) {
268
+ const tail = await serverRejectionTail(url);
269
+ if (tail !== null) message = `${message}; server: ${tail}`;
270
+ }
271
+ failQueuedMessages(message);
272
+ }
273
+ /** One `/login` attempt, classified for the bootstrap race. `spent` means
274
+ * the server recognized our nonce as already consumed by a sibling
275
+ * document whose session cookie is on its way — worth exactly one retry. */
276
+ function postLogin() {
277
+ const loginTimeout = new Promise((resolve) => {
278
+ setTimeout(() => resolve("timeout"), loginTimeoutMs);
279
+ });
280
+ const attempt = fetch("/login", {
281
+ method: "POST",
282
+ body: credential
283
+ }).then((response) => {
284
+ if (response.ok) return "ok";
285
+ return response.status === 409 ? "spent" : "rejected";
286
+ });
287
+ return Promise.race([attempt, loginTimeout]);
288
+ }
216
289
  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 {
290
+ if (shouldLogin) try {
291
+ let outcome = await postLogin();
292
+ if (outcome === "spent") {
293
+ await new Promise((resolve) => {
294
+ setTimeout(resolve, SPENT_NONCE_RETRY_MS);
295
+ });
296
+ outcome = await postLogin();
297
+ }
298
+ if (outcome === "ok") machine.sendLoginOk();
299
+ else {
300
+ loginFailureDetail = outcome === "timeout" ? `session login timed out after ${loginTimeoutMs}ms` : "session login rejected";
228
301
  machine.sendLoginFail();
229
302
  }
230
- } else machine.sendLoginOk();
303
+ } catch {
304
+ loginFailureDetail = "session login request failed";
305
+ machine.sendLoginFail();
306
+ }
307
+ else machine.sendLoginOk();
231
308
  }
232
309
  bootstrap();
233
310
  return {
234
311
  send(type, header, payload) {
235
312
  const encoded = protocol.encode(type, header, payload);
236
313
  if (machine.isConnected() && socket && socket.readyState === WebSocket.OPEN) {
237
- socket.send(toArrayBuffer(encoded));
314
+ socket.send(toSendable(encoded));
238
315
  return;
239
316
  }
240
317
  if (machine.isGaveUp()) {
241
- emitTransportError(header, WS_DISCONNECTED_MESSAGE);
318
+ emitTransportError(header, gaveUpMessage());
319
+ return;
320
+ }
321
+ if (machine.state() === "backoff" && machine.isDegraded()) {
322
+ emitTransportError(header, reconnectingMessage);
242
323
  return;
243
324
  }
244
325
  queued.push({
@@ -254,8 +335,22 @@ function createWebSocketTransport(urlOrOptions = {}) {
254
335
  }
255
336
  };
256
337
  }
257
- //#endregion
258
- //#region web/transports/mock-handlers.ts
338
+ // web/transports/mock-handlers.ts
339
+ function createLogsInvokeHandlers(store = createLogRingBuffer(500)) {
340
+ return {
341
+ "logs.view": (args) => {
342
+ const tail = args?.tail === void 0 ? void 0 : Number(args.tail);
343
+ const level = args?.level === "debug" || args?.level === "info" || args?.level === "warn" || args?.level === "error" ? args.level : void 0;
344
+ const source = args?.source === "backend" || args?.source === "web" ? args.source : void 0;
345
+ return { entries: store.view({
346
+ tail: tail !== void 0 && Number.isFinite(tail) ? tail : void 0,
347
+ level,
348
+ source
349
+ }) };
350
+ },
351
+ "logs.clear": () => ({ cleared: store.clear() })
352
+ };
353
+ }
259
354
  function createHealthInvokeHandlers() {
260
355
  return {
261
356
  "health.ping": (args) => ({
@@ -275,12 +370,12 @@ function createHealthInvokeHandlers() {
275
370
  })
276
371
  };
277
372
  }
278
- //#endregion
279
- //#region web/transports/mock.ts
373
+ // web/transports/mock.ts
280
374
  const MOCK_MEDIA_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAbUlEQVR4nO3O7UkCABiAQcd5hjXSyCKLPrCiklRswHOM/rw3wS0s4yquYxXruInb2MRd3MdDbOMxnuI5XuI13mIX7/ERn/EV3/ET+/iNQxzjFOf4y2ICE5jABCYwgQlMYAITmMAEJjCBCfx34ALvwBam5OydHgAAAABJRU5ErkJggg==";
281
375
  const permissionOf = (args) => typeof args?.permission === "string" ? args.permission : "storage";
282
376
  const mockInvokeHandlers = {
283
377
  ...createHealthInvokeHandlers(),
378
+ ...createLogsInvokeHandlers(createLogRingBuffer(500)),
284
379
  "discovery.list": () => ({
285
380
  schemaVersion: 1,
286
381
  capabilities: [
@@ -322,14 +417,8 @@ const mockInvokeHandlers = {
322
417
  permission: permissionOf(args),
323
418
  status: "granted"
324
419
  }),
325
- "media.pick": () => ({
326
- url: MOCK_MEDIA_DATA_URI,
327
- path: ""
328
- }),
329
- "media.capture": () => ({
330
- url: MOCK_MEDIA_DATA_URI,
331
- path: ""
332
- })
420
+ "media.pick": () => ({ url: MOCK_MEDIA_DATA_URI }),
421
+ "media.capture": () => ({ url: MOCK_MEDIA_DATA_URI })
333
422
  };
334
423
  function createMockTransport() {
335
424
  const listeners = /* @__PURE__ */ new Set();
@@ -380,5 +469,4 @@ function createMockTransport() {
380
469
  }
381
470
  };
382
471
  }
383
- //#endregion
384
472
  export { createMockTransport, createWebSocketTransport };