@atolljs/devtools 0.1.7
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 +156 -0
- package/app/index.html +388 -0
- package/app/main.js +1823 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +313 -0
- package/dist/broadcast.d.ts +3 -0
- package/dist/chunk-4N3NBFL3.js +474 -0
- package/dist/client.d.ts +34 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/init.d.ts +28 -0
- package/dist/node.d.ts +18 -0
- package/dist/node.js +12 -0
- package/dist/nodeWs.d.ts +9 -0
- package/dist/overlay.d.ts +31 -0
- package/dist/protocol.d.ts +83 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +296 -0
- package/dist/ws.d.ts +43 -0
- package/package.json +49 -0
- package/src/bin.ts +23 -0
- package/src/broadcast.ts +97 -0
- package/src/client.ts +178 -0
- package/src/index.ts +11 -0
- package/src/init.ts +50 -0
- package/src/node.ts +18 -0
- package/src/nodeWs.ts +118 -0
- package/src/overlay.ts +165 -0
- package/src/protocol.ts +69 -0
- package/src/server.ts +218 -0
- package/src/ws.ts +206 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire protocol between instrumented apps, the devtools server, and
|
|
3
|
+
* dashboard viewers — all JSON text frames over WebSocket.
|
|
4
|
+
*
|
|
5
|
+
* app → server /events { hello } then { batch } frames
|
|
6
|
+
* server → viewer /view { sessions } on connect, then { batch } per app frame
|
|
7
|
+
*/
|
|
8
|
+
import type { EmittedDevtoolsEvent } from '@atolljs/core';
|
|
9
|
+
/** Identifies one connected app (one page, one Node process). */
|
|
10
|
+
export interface SessionInfo {
|
|
11
|
+
/** Client-generated id — stable for the socket's lifetime. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Human label — config `name`, app title, process argv, etc. */
|
|
14
|
+
name?: string;
|
|
15
|
+
runtime: 'browser' | 'node';
|
|
16
|
+
/** Browser location.href / process title — whatever helps identify it. */
|
|
17
|
+
hint?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Set by the server once the app's socket closes. Closed sessions are
|
|
20
|
+
* retained (bounded) so dashboards can keep post-mortem history instead of
|
|
21
|
+
* orphaning their pools/tasks/memory state.
|
|
22
|
+
*/
|
|
23
|
+
closed?: boolean;
|
|
24
|
+
/** Server clock (ms) when the socket closed — drives the cleanup sweep. */
|
|
25
|
+
closedAt?: number;
|
|
26
|
+
/** Viewer-pinned closed sessions are exempt from the retention sweep. */
|
|
27
|
+
pinned?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface HelloMessage {
|
|
30
|
+
type: 'hello';
|
|
31
|
+
session: SessionInfo;
|
|
32
|
+
}
|
|
33
|
+
export interface BatchMessage {
|
|
34
|
+
type: 'batch';
|
|
35
|
+
events: EmittedDevtoolsEvent[];
|
|
36
|
+
}
|
|
37
|
+
/** What an instrumented app sends on /events. */
|
|
38
|
+
export type ClientMessage = HelloMessage | BatchMessage;
|
|
39
|
+
/** What a dashboard viewer receives on /view. */
|
|
40
|
+
export type ViewerMessage = {
|
|
41
|
+
type: 'sessions';
|
|
42
|
+
sessions: SessionInfo[];
|
|
43
|
+
} | {
|
|
44
|
+
type: 'batch';
|
|
45
|
+
session: SessionInfo;
|
|
46
|
+
events: EmittedDevtoolsEvent[];
|
|
47
|
+
};
|
|
48
|
+
/** What a dashboard viewer sends on /view. */
|
|
49
|
+
export type ViewerRequest =
|
|
50
|
+
/** Drop a closed session and its replayed history. Live sessions are ignored. */
|
|
51
|
+
{
|
|
52
|
+
type: 'dismiss';
|
|
53
|
+
sessionId: string;
|
|
54
|
+
}
|
|
55
|
+
/** Exempt a closed session from the retention sweep (or release it). */
|
|
56
|
+
| {
|
|
57
|
+
type: 'pin';
|
|
58
|
+
sessionId: string;
|
|
59
|
+
pinned: boolean;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Pure-client transport: frames on a same-origin BroadcastChannel named
|
|
63
|
+
* 'atoll-devtools'. Origin scoping replaces the server's session list —
|
|
64
|
+
* a dashboard on the app's own origin sees exactly the apps on that origin.
|
|
65
|
+
*/
|
|
66
|
+
export declare const DEVTOOLS_CHANNEL = "atoll-devtools";
|
|
67
|
+
export type BroadcastMessage = {
|
|
68
|
+
type: 'hello';
|
|
69
|
+
session: SessionInfo;
|
|
70
|
+
} | {
|
|
71
|
+
type: 'batch';
|
|
72
|
+
session: SessionInfo;
|
|
73
|
+
events: EmittedDevtoolsEvent[];
|
|
74
|
+
}
|
|
75
|
+
/** Posted on close/pagehide so dashboards can mark the session ended. */
|
|
76
|
+
| {
|
|
77
|
+
type: 'bye';
|
|
78
|
+
sessionId: string;
|
|
79
|
+
}
|
|
80
|
+
/** Dashboard announcing itself — apps re-hello and replay their tail. */
|
|
81
|
+
| {
|
|
82
|
+
type: 'view';
|
|
83
|
+
};
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SessionInfo } from './protocol.js';
|
|
2
|
+
export interface DevtoolsServerOptions {
|
|
3
|
+
/** Default 4780. 0 picks an ephemeral port (tests). */
|
|
4
|
+
port?: number;
|
|
5
|
+
/** Host — bound to loopback only; the dashboard has no auth. */
|
|
6
|
+
host?: string;
|
|
7
|
+
/** Static dashboard directory — defaults to the packaged app/. */
|
|
8
|
+
appDir?: string;
|
|
9
|
+
/** Batches kept for viewer replay (default 500). */
|
|
10
|
+
replayBatches?: number;
|
|
11
|
+
/** Closed-session retention before auto-cleanup (default 30min). */
|
|
12
|
+
closedTtlMs?: number;
|
|
13
|
+
/** How often the retention sweep runs (default 60s — tests lower it). */
|
|
14
|
+
sweepMs?: number;
|
|
15
|
+
onListen?: (url: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface DevtoolsServer {
|
|
18
|
+
readonly port: number;
|
|
19
|
+
readonly url: string;
|
|
20
|
+
sessions(): SessionInfo[];
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare function createDevtoolsServer(opts?: DevtoolsServerOptions): Promise<DevtoolsServer>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join, normalize, extname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// src/ws.ts
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
10
|
+
var OPCODES = { continuation: 0, text: 1, close: 8, ping: 9, pong: 10 };
|
|
11
|
+
var acceptKey = (key) => createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
12
|
+
function encodeTextFrame(text) {
|
|
13
|
+
const payload = Buffer.from(text, "utf8");
|
|
14
|
+
const len = payload.length;
|
|
15
|
+
let header;
|
|
16
|
+
if (len < 126) {
|
|
17
|
+
header = Buffer.from([128 | OPCODES.text, len]);
|
|
18
|
+
} else if (len < 65536) {
|
|
19
|
+
header = Buffer.alloc(4);
|
|
20
|
+
header[0] = 128 | OPCODES.text;
|
|
21
|
+
header[1] = 126;
|
|
22
|
+
header.writeUInt16BE(len, 2);
|
|
23
|
+
} else {
|
|
24
|
+
header = Buffer.alloc(10);
|
|
25
|
+
header[0] = 128 | OPCODES.text;
|
|
26
|
+
header[1] = 127;
|
|
27
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
28
|
+
}
|
|
29
|
+
return Buffer.concat([header, payload]);
|
|
30
|
+
}
|
|
31
|
+
var encodeControl = (opcode, payload = Buffer.alloc(0)) => Buffer.concat([Buffer.from([128 | opcode, payload.length]), payload]);
|
|
32
|
+
function decodeFrames(buf) {
|
|
33
|
+
const frames = [];
|
|
34
|
+
for (; ; ) {
|
|
35
|
+
if (buf.length < 2) break;
|
|
36
|
+
const fin = (buf[0] & 128) !== 0;
|
|
37
|
+
const opcode = buf[0] & 15;
|
|
38
|
+
const masked = (buf[1] & 128) !== 0;
|
|
39
|
+
let len = buf[1] & 127;
|
|
40
|
+
let off = 2;
|
|
41
|
+
if (len === 126) {
|
|
42
|
+
if (buf.length < off + 2) break;
|
|
43
|
+
len = buf.readUInt16BE(off);
|
|
44
|
+
off += 2;
|
|
45
|
+
} else if (len === 127) {
|
|
46
|
+
if (buf.length < off + 8) break;
|
|
47
|
+
len = Number(buf.readBigUInt64BE(off));
|
|
48
|
+
off += 8;
|
|
49
|
+
}
|
|
50
|
+
const maskLen = masked ? 4 : 0;
|
|
51
|
+
if (buf.length < off + maskLen + len) break;
|
|
52
|
+
let payload = buf.subarray(off + maskLen, off + maskLen + len);
|
|
53
|
+
if (masked) {
|
|
54
|
+
const mask = buf.subarray(off, off + 4);
|
|
55
|
+
const un = Buffer.alloc(len);
|
|
56
|
+
for (let i = 0; i < len; i++) un[i] = payload[i] ^ mask[i & 3];
|
|
57
|
+
payload = un;
|
|
58
|
+
}
|
|
59
|
+
buf = buf.subarray(off + maskLen + len);
|
|
60
|
+
frames.push({ fin, opcode, payload });
|
|
61
|
+
}
|
|
62
|
+
return { frames, rest: buf };
|
|
63
|
+
}
|
|
64
|
+
function acceptWebSocket(req, socket) {
|
|
65
|
+
const key = req.headers["sec-websocket-key"];
|
|
66
|
+
socket.write(
|
|
67
|
+
`HTTP/1.1 101 Switching Protocols\r
|
|
68
|
+
Upgrade: websocket\r
|
|
69
|
+
Connection: Upgrade\r
|
|
70
|
+
Sec-WebSocket-Accept: ${acceptKey(String(key ?? randomBytes(16).toString("base64")))}\r
|
|
71
|
+
\r
|
|
72
|
+
`
|
|
73
|
+
);
|
|
74
|
+
let buf = Buffer.alloc(0);
|
|
75
|
+
let fragments = [];
|
|
76
|
+
let closed = false;
|
|
77
|
+
const conn = {
|
|
78
|
+
send(text) {
|
|
79
|
+
if (!closed) socket.write(encodeTextFrame(text));
|
|
80
|
+
},
|
|
81
|
+
close() {
|
|
82
|
+
if (closed) return;
|
|
83
|
+
closed = true;
|
|
84
|
+
try {
|
|
85
|
+
socket.write(encodeControl(OPCODES.close));
|
|
86
|
+
socket.end();
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
conn.onClose?.();
|
|
90
|
+
},
|
|
91
|
+
onMessage: null,
|
|
92
|
+
onClose: null,
|
|
93
|
+
get closed() {
|
|
94
|
+
return closed;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const die = () => {
|
|
98
|
+
if (!closed) {
|
|
99
|
+
closed = true;
|
|
100
|
+
conn.onClose?.();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const drain = () => {
|
|
104
|
+
const { frames, rest } = decodeFrames(buf);
|
|
105
|
+
buf = rest;
|
|
106
|
+
for (const { fin, opcode, payload } of frames) {
|
|
107
|
+
if (opcode === OPCODES.close) {
|
|
108
|
+
conn.close();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (opcode === OPCODES.ping) {
|
|
112
|
+
socket.write(encodeControl(OPCODES.pong, payload));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (opcode === OPCODES.pong) continue;
|
|
116
|
+
if (opcode === OPCODES.text || opcode === OPCODES.continuation) {
|
|
117
|
+
fragments.push(payload);
|
|
118
|
+
if (fin) {
|
|
119
|
+
const text = Buffer.concat(fragments).toString("utf8");
|
|
120
|
+
fragments = [];
|
|
121
|
+
conn.onMessage?.(text);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
socket.on("data", (chunk) => {
|
|
127
|
+
buf = Buffer.concat([buf, chunk]);
|
|
128
|
+
drain();
|
|
129
|
+
});
|
|
130
|
+
socket.on("error", die);
|
|
131
|
+
socket.on("close", die);
|
|
132
|
+
return conn;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/server.ts
|
|
136
|
+
var MIME = {
|
|
137
|
+
".html": "text/html; charset=utf-8",
|
|
138
|
+
".js": "text/javascript; charset=utf-8",
|
|
139
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
140
|
+
".css": "text/css; charset=utf-8",
|
|
141
|
+
".json": "application/json",
|
|
142
|
+
".svg": "image/svg+xml",
|
|
143
|
+
".png": "image/png",
|
|
144
|
+
".ico": "image/x-icon",
|
|
145
|
+
".map": "application/json"
|
|
146
|
+
};
|
|
147
|
+
var defaultAppDir = () => fileURLToPath(new URL("../app/", import.meta.url));
|
|
148
|
+
var serveStatic = (appDir, req, res) => {
|
|
149
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
150
|
+
let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");
|
|
151
|
+
if (path === "" || path === ".") path = "index.html";
|
|
152
|
+
const file = join(appDir, path);
|
|
153
|
+
if (!file.startsWith(normalize(appDir)) || !existsSync(file)) {
|
|
154
|
+
res.writeHead(404).end("not found");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
|
|
158
|
+
res.end(readFileSync(file));
|
|
159
|
+
};
|
|
160
|
+
function createDevtoolsServer(opts = {}) {
|
|
161
|
+
const host = opts.host ?? "127.0.0.1";
|
|
162
|
+
const appDir = opts.appDir ?? defaultAppDir();
|
|
163
|
+
const replayCap = opts.replayBatches ?? 500;
|
|
164
|
+
const viewers = /* @__PURE__ */ new Set();
|
|
165
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
166
|
+
const closedSessions = [];
|
|
167
|
+
const closedCap = 20;
|
|
168
|
+
const closedTtlMs = opts.closedTtlMs ?? 30 * 60 * 1e3;
|
|
169
|
+
const tail = [];
|
|
170
|
+
const sessionList = () => [...sessions.values(), ...closedSessions];
|
|
171
|
+
const evictClosed = (sessionId) => {
|
|
172
|
+
const i = closedSessions.findIndex((s) => s.id === sessionId);
|
|
173
|
+
if (i < 0) return false;
|
|
174
|
+
closedSessions.splice(i, 1);
|
|
175
|
+
for (let j = tail.length - 1; j >= 0; j--) {
|
|
176
|
+
const m = tail[j];
|
|
177
|
+
if (m.type === "batch" && m.session.id === sessionId) tail.splice(j, 1);
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
};
|
|
181
|
+
const broadcast = (msg) => {
|
|
182
|
+
if (msg.type === "batch") {
|
|
183
|
+
tail.push(msg);
|
|
184
|
+
if (tail.length > replayCap) tail.shift();
|
|
185
|
+
}
|
|
186
|
+
const text = JSON.stringify(msg);
|
|
187
|
+
for (const v of viewers) v.send(text);
|
|
188
|
+
};
|
|
189
|
+
const server = createServer((req, res) => {
|
|
190
|
+
if (req.url === "/health") {
|
|
191
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
192
|
+
res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
serveStatic(appDir, req, res);
|
|
196
|
+
});
|
|
197
|
+
server.on("upgrade", (req, socket, head) => {
|
|
198
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
199
|
+
if (url.pathname !== "/events" && url.pathname !== "/view") {
|
|
200
|
+
socket.destroy();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const conn = acceptWebSocket(req, socket);
|
|
204
|
+
if (head?.length) socket.unshift(head);
|
|
205
|
+
if (url.pathname === "/view") {
|
|
206
|
+
viewers.add(conn);
|
|
207
|
+
conn.onClose = () => viewers.delete(conn);
|
|
208
|
+
conn.onMessage = (text) => {
|
|
209
|
+
let msg;
|
|
210
|
+
try {
|
|
211
|
+
msg = JSON.parse(text);
|
|
212
|
+
} catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (msg.type === "dismiss") {
|
|
216
|
+
if (evictClosed(msg.sessionId)) {
|
|
217
|
+
broadcast({ type: "sessions", sessions: sessionList() });
|
|
218
|
+
}
|
|
219
|
+
} else if (msg.type === "pin") {
|
|
220
|
+
const s = closedSessions.find((s2) => s2.id === msg.sessionId);
|
|
221
|
+
if (!s || s.pinned === msg.pinned) return;
|
|
222
|
+
s.pinned = msg.pinned;
|
|
223
|
+
broadcast({ type: "sessions", sessions: sessionList() });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
conn.send(JSON.stringify({ type: "sessions", sessions: sessionList() }));
|
|
227
|
+
for (const msg of tail) conn.send(JSON.stringify(msg));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
let greeted = false;
|
|
231
|
+
conn.onMessage = (text) => {
|
|
232
|
+
let msg;
|
|
233
|
+
try {
|
|
234
|
+
msg = JSON.parse(text);
|
|
235
|
+
} catch {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (msg.type === "hello") {
|
|
239
|
+
greeted = true;
|
|
240
|
+
sessions.set(conn, msg.session);
|
|
241
|
+
broadcast({ type: "sessions", sessions: sessionList() });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (msg.type === "batch" && greeted) {
|
|
245
|
+
broadcast({ type: "batch", session: sessions.get(conn), events: msg.events });
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
conn.onClose = () => {
|
|
249
|
+
const info = sessions.get(conn);
|
|
250
|
+
if (sessions.delete(conn) && info) {
|
|
251
|
+
info.closed = true;
|
|
252
|
+
info.closedAt = Date.now();
|
|
253
|
+
closedSessions.push(info);
|
|
254
|
+
if (closedSessions.length > closedCap) {
|
|
255
|
+
const i = closedSessions.findIndex((s) => !s.pinned);
|
|
256
|
+
if (i >= 0) evictClosed(closedSessions[i].id);
|
|
257
|
+
}
|
|
258
|
+
broadcast({ type: "sessions", sessions: sessionList() });
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
const sweep = setInterval(() => {
|
|
263
|
+
const cutoff = Date.now() - closedTtlMs;
|
|
264
|
+
let changed = false;
|
|
265
|
+
for (const s of [...closedSessions]) {
|
|
266
|
+
if (!s.pinned && s.closedAt !== void 0 && s.closedAt < cutoff) {
|
|
267
|
+
changed = evictClosed(s.id) || changed;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (changed) broadcast({ type: "sessions", sessions: sessionList() });
|
|
271
|
+
}, opts.sweepMs ?? 6e4);
|
|
272
|
+
sweep.unref();
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
server.once("error", reject);
|
|
275
|
+
server.listen(opts.port ?? 4780, host, () => {
|
|
276
|
+
const address = server.address();
|
|
277
|
+
const port = typeof address === "object" && address ? address.port : opts.port ?? 4780;
|
|
278
|
+
const url = `http://${host}:${port}`;
|
|
279
|
+
opts.onListen?.(url);
|
|
280
|
+
resolve({
|
|
281
|
+
port,
|
|
282
|
+
url,
|
|
283
|
+
sessions: () => [...sessions.values()],
|
|
284
|
+
close: () => new Promise((done) => {
|
|
285
|
+
clearInterval(sweep);
|
|
286
|
+
for (const v of viewers) v.close();
|
|
287
|
+
for (const c of sessions.keys()) c.close();
|
|
288
|
+
server.close(() => done());
|
|
289
|
+
})
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
export {
|
|
295
|
+
createDevtoolsServer
|
|
296
|
+
};
|
package/dist/ws.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import type { Duplex } from 'node:stream';
|
|
3
|
+
export declare const OPCODES: {
|
|
4
|
+
readonly continuation: 0;
|
|
5
|
+
readonly text: 1;
|
|
6
|
+
readonly close: 8;
|
|
7
|
+
readonly ping: 9;
|
|
8
|
+
readonly pong: 10;
|
|
9
|
+
};
|
|
10
|
+
export interface WsConnection {
|
|
11
|
+
/** Queue a text frame — safe before/without concern for socket state. */
|
|
12
|
+
send(text: string): void;
|
|
13
|
+
close(): void;
|
|
14
|
+
onMessage: ((text: string) => void) | null;
|
|
15
|
+
onClose: (() => void) | null;
|
|
16
|
+
readonly closed: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Sec-WebSocket-Accept for a client key. */
|
|
19
|
+
export declare const acceptKey: (key: string) => string;
|
|
20
|
+
/** One unmasked text frame (server→client frames MUST NOT be masked). */
|
|
21
|
+
export declare function encodeTextFrame(text: string): Buffer;
|
|
22
|
+
export interface WsFrame {
|
|
23
|
+
fin: boolean;
|
|
24
|
+
opcode: number;
|
|
25
|
+
payload: Buffer<ArrayBufferLike>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Decode as many complete frames as `buf` holds — shared by the server-side
|
|
29
|
+
* drain (masked client frames) and the Node client (unmasked server frames).
|
|
30
|
+
* Returns the frames plus the unconsumed remainder.
|
|
31
|
+
*/
|
|
32
|
+
export declare function decodeFrames(buf: Buffer<ArrayBufferLike>): {
|
|
33
|
+
frames: WsFrame[];
|
|
34
|
+
rest: Buffer<ArrayBufferLike>;
|
|
35
|
+
};
|
|
36
|
+
/** One masked text frame (client→server frames MUST be masked per RFC6455). */
|
|
37
|
+
export declare function encodeMaskedTextFrame(text: string): Buffer;
|
|
38
|
+
export declare const encodeMaskedControl: (opcode: number, payload?: Buffer<ArrayBufferLike>) => Buffer;
|
|
39
|
+
/**
|
|
40
|
+
* Complete the upgrade handshake on `socket` and return a framed connection.
|
|
41
|
+
* Caller routes on req.url first — this writes the 101 response.
|
|
42
|
+
*/
|
|
43
|
+
export declare function acceptWebSocket(req: IncomingMessage, socket: Duplex): WsConnection;
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atolljs/devtools",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Devtools transport + local dashboard server for AtollJS instrumentation events",
|
|
5
|
+
"homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./node": {
|
|
13
|
+
"types": "./dist/node.d.ts",
|
|
14
|
+
"default": "./dist/node.js"
|
|
15
|
+
},
|
|
16
|
+
"./server": {
|
|
17
|
+
"types": "./dist/server.d.ts",
|
|
18
|
+
"default": "./dist/server.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"atoll-devtools": "./dist/bin.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"dist",
|
|
27
|
+
"app"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node ../../scripts/build-lib.mjs && esbuild src/bin.ts --bundle --format=esm --platform=node --target=node20.12 --outfile=dist/bin.js",
|
|
31
|
+
"dev": "esbuild src/bin.ts --bundle --format=esm --platform=node --target=node20.12 --outfile=dist/bin.js && node dist/bin.js"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@atolljs/core": "0.1.7"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/jwhenry3/atolljs",
|
|
43
|
+
"directory": "packages/devtools"
|
|
44
|
+
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/jwhenry3/atolljs/issues"
|
|
47
|
+
},
|
|
48
|
+
"license": "MIT"
|
|
49
|
+
}
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** `atoll-devtools [--port N]` — boot the local devtools server + dashboard. */
|
|
3
|
+
import { createDevtoolsServer } from './server';
|
|
4
|
+
|
|
5
|
+
const arg = (name: string): string | undefined => {
|
|
6
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
7
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
11
|
+
console.log('usage: atoll-devtools [--port N] — serve the devtools dashboard + event ingest (default port 4780)');
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const port = Number(arg('port') ?? 4780);
|
|
16
|
+
const server = await createDevtoolsServer({
|
|
17
|
+
port: Number.isFinite(port) ? port : 4780,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
console.log(`atoll devtools → ${server.url}`);
|
|
21
|
+
console.log(' dashboard: ' + server.url);
|
|
22
|
+
console.log(' ingest: ' + server.url.replace('http://', 'ws://') + '/events');
|
|
23
|
+
console.log('\nIn the app: import { connectDevtools } from \'@atolljs/devtools\'; connectDevtools()');
|
package/src/broadcast.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-client transport: instead of a WebSocket to a devtools server, the
|
|
3
|
+
* app publishes session/hello/batch/bye frames on a same-origin
|
|
4
|
+
* BroadcastChannel. Dashboards open on the app's own origin (the vite
|
|
5
|
+
* plugin serves the dashboard at /__atoll/) and listen on the same
|
|
6
|
+
* channel — so a dashboard can only ever see apps on its own origin, and
|
|
7
|
+
* no backend process is involved at all.
|
|
8
|
+
*
|
|
9
|
+
* Late-joining dashboards post { type: 'view' }; the app re-hellos and
|
|
10
|
+
* replays a bounded tail of recent batches so the view isn't empty.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
installFetchProbe,
|
|
14
|
+
installMemoryProbe,
|
|
15
|
+
setDevtoolsSink,
|
|
16
|
+
type EmittedDevtoolsEvent,
|
|
17
|
+
} from '@atolljs/core';
|
|
18
|
+
import { DEVTOOLS_CHANNEL, type BroadcastMessage, type SessionInfo } from './protocol';
|
|
19
|
+
import type { ConnectDevtoolsOptions, DevtoolsConnection } from './client';
|
|
20
|
+
|
|
21
|
+
export function connectBroadcast(
|
|
22
|
+
opts: ConnectDevtoolsOptions,
|
|
23
|
+
session: SessionInfo,
|
|
24
|
+
): DevtoolsConnection {
|
|
25
|
+
const bc = new BroadcastChannel(DEVTOOLS_CHANNEL);
|
|
26
|
+
const cap = opts.bufferCap ?? 50_000;
|
|
27
|
+
const tailCap = opts.replayBatches ?? 500;
|
|
28
|
+
const pending: EmittedDevtoolsEvent[] = [];
|
|
29
|
+
/** Batches kept for replay to late-joining dashboards. */
|
|
30
|
+
const tail: EmittedDevtoolsEvent[][] = [];
|
|
31
|
+
let closed = false;
|
|
32
|
+
|
|
33
|
+
const post = (msg: BroadcastMessage) => {
|
|
34
|
+
try {
|
|
35
|
+
bc.postMessage(msg);
|
|
36
|
+
} catch {
|
|
37
|
+
// channel torn down — close() races with postMessage on some engines
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const hello = () => post({ type: 'hello', session });
|
|
42
|
+
|
|
43
|
+
const flush = () => {
|
|
44
|
+
if (closed || pending.length === 0) return;
|
|
45
|
+
const events = pending.splice(0, pending.length);
|
|
46
|
+
tail.push(events);
|
|
47
|
+
if (tail.length > tailCap) tail.shift();
|
|
48
|
+
post({ type: 'batch', session, events });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// A dashboard announcing itself — re-identify and replay so it doesn't
|
|
52
|
+
// start empty. The app listens on the same channel it publishes on.
|
|
53
|
+
bc.onmessage = (m: MessageEvent<BroadcastMessage>) => {
|
|
54
|
+
if (m.data?.type === 'view') {
|
|
55
|
+
hello();
|
|
56
|
+
for (const events of tail) post({ type: 'batch', session, events });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const timer = setInterval(flush, opts.flushMs ?? 100);
|
|
61
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
62
|
+
|
|
63
|
+
const bye = () => {
|
|
64
|
+
flush();
|
|
65
|
+
post({ type: 'bye', sessionId: session.id });
|
|
66
|
+
};
|
|
67
|
+
const onHide = () => bye();
|
|
68
|
+
if (typeof addEventListener === 'function') addEventListener('pagehide', onHide);
|
|
69
|
+
|
|
70
|
+
if (opts.network !== false) installFetchProbe(globalThis);
|
|
71
|
+
if (opts.memory !== false) installMemoryProbe(globalThis);
|
|
72
|
+
|
|
73
|
+
setDevtoolsSink((event) => {
|
|
74
|
+
if (closed) return;
|
|
75
|
+
pending.push(event);
|
|
76
|
+
if (pending.length > cap) pending.shift();
|
|
77
|
+
if (pending.length >= 1000) flush();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
hello();
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
session,
|
|
84
|
+
get open() {
|
|
85
|
+
return !closed;
|
|
86
|
+
},
|
|
87
|
+
close() {
|
|
88
|
+
if (closed) return;
|
|
89
|
+
closed = true;
|
|
90
|
+
bye();
|
|
91
|
+
setDevtoolsSink(null);
|
|
92
|
+
clearInterval(timer);
|
|
93
|
+
if (typeof removeEventListener === 'function') removeEventListener('pagehide', onHide);
|
|
94
|
+
bc.close();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|