@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.
- package/README.md +139 -195
- package/dist/{index-DjulYFJL.d.mts → index-BkwUzwVt.d.mts} +94 -20
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -512
- package/dist/messages-CevD8qkV.mjs +851 -0
- package/dist/{plugins-1KweEBct.mjs → plugins-Ddp2AgOe.mjs} +51 -20
- package/dist/plugins-discovery-events.d.mts +5 -8
- package/dist/plugins-discovery-events.mjs +2 -3
- package/dist/plugins-health-events.d.mts +4 -7
- package/dist/plugins-health-events.mjs +2 -3
- package/dist/plugins-logs-events.d.mts +34 -0
- package/dist/plugins-logs-events.mjs +25 -0
- package/dist/plugins-media-events.d.mts +6 -11
- package/dist/plugins-media-events.mjs +2 -3
- package/dist/plugins-permissions-events.d.mts +7 -10
- package/dist/plugins-permissions-events.mjs +2 -3
- package/dist/plugins.d.mts +9 -11
- package/dist/plugins.mjs +1 -2
- package/dist/runtime.d.mts +75 -46
- package/dist/runtime.mjs +1314 -324
- package/dist/static-file-server-COxd-PFR.d.mts +120 -0
- package/dist/store-DTg1jN5E.mjs +49 -0
- package/dist/transports.d.mts +15 -14
- package/dist/transports.mjs +169 -82
- package/package.json +12 -8
- package/dist/static-file-server-DY9EKEVf.d.mts +0 -88
|
@@ -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 };
|
package/dist/transports.d.mts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
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
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
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 };
|
package/dist/transports.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ErrorCode, MessageProtocol, MessageType } from "./
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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, (
|
|
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.
|
|
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
|
-
|
|
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`
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
145
|
-
return bytes
|
|
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
|
|
153
|
-
const
|
|
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(
|
|
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
|
-
|
|
193
|
-
socket
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
if (
|
|
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
|
-
|
|
239
|
+
current.onclose = () => {
|
|
240
|
+
if (socket !== current) return;
|
|
208
241
|
machine.sendClose();
|
|
209
242
|
};
|
|
210
|
-
|
|
243
|
+
current.onerror = () => {};
|
|
211
244
|
}
|
|
212
245
|
machine.onChange((state) => {
|
|
213
246
|
if (state === "opening") openSocket();
|
|
214
|
-
else if (state === "gaveUp")
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
}
|
|
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(
|
|
313
|
+
socket.send(toSendable(encoded));
|
|
238
314
|
return;
|
|
239
315
|
}
|
|
240
316
|
if (machine.isGaveUp()) {
|
|
241
|
-
emitTransportError(header,
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|