@ekrooh/bare 0.4.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.
- package/README.md +129 -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-BbpF3wNH.mjs +849 -0
- package/dist/{plugins-1KweEBct.mjs → plugins-DZu_NWVp.mjs} +46 -18
- 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 +42 -22
- package/dist/runtime.mjs +853 -138
- package/dist/{static-file-server-DY9EKEVf.d.mts → static-file-server-C8pIX1JD.d.mts} +44 -17
- package/dist/store-DTg1jN5E.mjs +49 -0
- package/dist/transports.d.mts +14 -11
- package/dist/transports.mjs +170 -82
- package/package.json +12 -7
|
@@ -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
|
|
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
|
-
*
|
|
60
|
-
|
|
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.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* non-browser clients.
|
|
84
|
-
* token
|
|
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 };
|
package/dist/transports.d.mts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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). `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
|
-
|
|
35
|
-
|
|
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 };
|
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-BbpF3wNH.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,67 @@ 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 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(
|
|
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
|
-
|
|
193
|
-
socket
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
if (
|
|
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
|
-
|
|
240
|
+
current.onclose = () => {
|
|
241
|
+
if (socket !== current) return;
|
|
208
242
|
machine.sendClose();
|
|
209
243
|
};
|
|
210
|
-
|
|
244
|
+
current.onerror = () => {};
|
|
211
245
|
}
|
|
212
246
|
machine.onChange((state) => {
|
|
213
247
|
if (state === "opening") openSocket();
|
|
214
|
-
else if (state === "gaveUp")
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
}
|
|
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(
|
|
314
|
+
socket.send(toSendable(encoded));
|
|
238
315
|
return;
|
|
239
316
|
}
|
|
240
317
|
if (machine.isGaveUp()) {
|
|
241
|
-
emitTransportError(header,
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|