@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/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server.ts
4
+ import { createServer } from "node:http";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join, normalize, extname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ // src/ws.ts
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
12
+ var OPCODES = { continuation: 0, text: 1, close: 8, ping: 9, pong: 10 };
13
+ var acceptKey = (key) => createHash("sha1").update(key + WS_GUID).digest("base64");
14
+ function encodeTextFrame(text) {
15
+ const payload = Buffer.from(text, "utf8");
16
+ const len = payload.length;
17
+ let header;
18
+ if (len < 126) {
19
+ header = Buffer.from([128 | OPCODES.text, len]);
20
+ } else if (len < 65536) {
21
+ header = Buffer.alloc(4);
22
+ header[0] = 128 | OPCODES.text;
23
+ header[1] = 126;
24
+ header.writeUInt16BE(len, 2);
25
+ } else {
26
+ header = Buffer.alloc(10);
27
+ header[0] = 128 | OPCODES.text;
28
+ header[1] = 127;
29
+ header.writeBigUInt64BE(BigInt(len), 2);
30
+ }
31
+ return Buffer.concat([header, payload]);
32
+ }
33
+ var encodeControl = (opcode, payload = Buffer.alloc(0)) => Buffer.concat([Buffer.from([128 | opcode, payload.length]), payload]);
34
+ function decodeFrames(buf) {
35
+ const frames = [];
36
+ for (; ; ) {
37
+ if (buf.length < 2) break;
38
+ const fin = (buf[0] & 128) !== 0;
39
+ const opcode = buf[0] & 15;
40
+ const masked = (buf[1] & 128) !== 0;
41
+ let len = buf[1] & 127;
42
+ let off = 2;
43
+ if (len === 126) {
44
+ if (buf.length < off + 2) break;
45
+ len = buf.readUInt16BE(off);
46
+ off += 2;
47
+ } else if (len === 127) {
48
+ if (buf.length < off + 8) break;
49
+ len = Number(buf.readBigUInt64BE(off));
50
+ off += 8;
51
+ }
52
+ const maskLen = masked ? 4 : 0;
53
+ if (buf.length < off + maskLen + len) break;
54
+ let payload = buf.subarray(off + maskLen, off + maskLen + len);
55
+ if (masked) {
56
+ const mask = buf.subarray(off, off + 4);
57
+ const un = Buffer.alloc(len);
58
+ for (let i = 0; i < len; i++) un[i] = payload[i] ^ mask[i & 3];
59
+ payload = un;
60
+ }
61
+ buf = buf.subarray(off + maskLen + len);
62
+ frames.push({ fin, opcode, payload });
63
+ }
64
+ return { frames, rest: buf };
65
+ }
66
+ function acceptWebSocket(req, socket) {
67
+ const key = req.headers["sec-websocket-key"];
68
+ socket.write(
69
+ `HTTP/1.1 101 Switching Protocols\r
70
+ Upgrade: websocket\r
71
+ Connection: Upgrade\r
72
+ Sec-WebSocket-Accept: ${acceptKey(String(key ?? randomBytes(16).toString("base64")))}\r
73
+ \r
74
+ `
75
+ );
76
+ let buf = Buffer.alloc(0);
77
+ let fragments = [];
78
+ let closed = false;
79
+ const conn = {
80
+ send(text) {
81
+ if (!closed) socket.write(encodeTextFrame(text));
82
+ },
83
+ close() {
84
+ if (closed) return;
85
+ closed = true;
86
+ try {
87
+ socket.write(encodeControl(OPCODES.close));
88
+ socket.end();
89
+ } catch {
90
+ }
91
+ conn.onClose?.();
92
+ },
93
+ onMessage: null,
94
+ onClose: null,
95
+ get closed() {
96
+ return closed;
97
+ }
98
+ };
99
+ const die = () => {
100
+ if (!closed) {
101
+ closed = true;
102
+ conn.onClose?.();
103
+ }
104
+ };
105
+ const drain = () => {
106
+ const { frames, rest } = decodeFrames(buf);
107
+ buf = rest;
108
+ for (const { fin, opcode, payload } of frames) {
109
+ if (opcode === OPCODES.close) {
110
+ conn.close();
111
+ return;
112
+ }
113
+ if (opcode === OPCODES.ping) {
114
+ socket.write(encodeControl(OPCODES.pong, payload));
115
+ continue;
116
+ }
117
+ if (opcode === OPCODES.pong) continue;
118
+ if (opcode === OPCODES.text || opcode === OPCODES.continuation) {
119
+ fragments.push(payload);
120
+ if (fin) {
121
+ const text = Buffer.concat(fragments).toString("utf8");
122
+ fragments = [];
123
+ conn.onMessage?.(text);
124
+ }
125
+ }
126
+ }
127
+ };
128
+ socket.on("data", (chunk) => {
129
+ buf = Buffer.concat([buf, chunk]);
130
+ drain();
131
+ });
132
+ socket.on("error", die);
133
+ socket.on("close", die);
134
+ return conn;
135
+ }
136
+
137
+ // src/server.ts
138
+ var MIME = {
139
+ ".html": "text/html; charset=utf-8",
140
+ ".js": "text/javascript; charset=utf-8",
141
+ ".mjs": "text/javascript; charset=utf-8",
142
+ ".css": "text/css; charset=utf-8",
143
+ ".json": "application/json",
144
+ ".svg": "image/svg+xml",
145
+ ".png": "image/png",
146
+ ".ico": "image/x-icon",
147
+ ".map": "application/json"
148
+ };
149
+ var defaultAppDir = () => fileURLToPath(new URL("../app/", import.meta.url));
150
+ var serveStatic = (appDir, req, res) => {
151
+ const url = new URL(req.url ?? "/", "http://localhost");
152
+ let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");
153
+ if (path === "" || path === ".") path = "index.html";
154
+ const file = join(appDir, path);
155
+ if (!file.startsWith(normalize(appDir)) || !existsSync(file)) {
156
+ res.writeHead(404).end("not found");
157
+ return;
158
+ }
159
+ res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
160
+ res.end(readFileSync(file));
161
+ };
162
+ function createDevtoolsServer(opts = {}) {
163
+ const host = opts.host ?? "127.0.0.1";
164
+ const appDir = opts.appDir ?? defaultAppDir();
165
+ const replayCap = opts.replayBatches ?? 500;
166
+ const viewers = /* @__PURE__ */ new Set();
167
+ const sessions = /* @__PURE__ */ new Map();
168
+ const closedSessions = [];
169
+ const closedCap = 20;
170
+ const closedTtlMs = opts.closedTtlMs ?? 30 * 60 * 1e3;
171
+ const tail = [];
172
+ const sessionList = () => [...sessions.values(), ...closedSessions];
173
+ const evictClosed = (sessionId) => {
174
+ const i = closedSessions.findIndex((s) => s.id === sessionId);
175
+ if (i < 0) return false;
176
+ closedSessions.splice(i, 1);
177
+ for (let j = tail.length - 1; j >= 0; j--) {
178
+ const m = tail[j];
179
+ if (m.type === "batch" && m.session.id === sessionId) tail.splice(j, 1);
180
+ }
181
+ return true;
182
+ };
183
+ const broadcast = (msg) => {
184
+ if (msg.type === "batch") {
185
+ tail.push(msg);
186
+ if (tail.length > replayCap) tail.shift();
187
+ }
188
+ const text = JSON.stringify(msg);
189
+ for (const v of viewers) v.send(text);
190
+ };
191
+ const server2 = createServer((req, res) => {
192
+ if (req.url === "/health") {
193
+ res.writeHead(200, { "content-type": "application/json" });
194
+ res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
195
+ return;
196
+ }
197
+ serveStatic(appDir, req, res);
198
+ });
199
+ server2.on("upgrade", (req, socket, head) => {
200
+ const url = new URL(req.url ?? "/", "http://localhost");
201
+ if (url.pathname !== "/events" && url.pathname !== "/view") {
202
+ socket.destroy();
203
+ return;
204
+ }
205
+ const conn = acceptWebSocket(req, socket);
206
+ if (head?.length) socket.unshift(head);
207
+ if (url.pathname === "/view") {
208
+ viewers.add(conn);
209
+ conn.onClose = () => viewers.delete(conn);
210
+ conn.onMessage = (text) => {
211
+ let msg;
212
+ try {
213
+ msg = JSON.parse(text);
214
+ } catch {
215
+ return;
216
+ }
217
+ if (msg.type === "dismiss") {
218
+ if (evictClosed(msg.sessionId)) {
219
+ broadcast({ type: "sessions", sessions: sessionList() });
220
+ }
221
+ } else if (msg.type === "pin") {
222
+ const s = closedSessions.find((s2) => s2.id === msg.sessionId);
223
+ if (!s || s.pinned === msg.pinned) return;
224
+ s.pinned = msg.pinned;
225
+ broadcast({ type: "sessions", sessions: sessionList() });
226
+ }
227
+ };
228
+ conn.send(JSON.stringify({ type: "sessions", sessions: sessionList() }));
229
+ for (const msg of tail) conn.send(JSON.stringify(msg));
230
+ return;
231
+ }
232
+ let greeted = false;
233
+ conn.onMessage = (text) => {
234
+ let msg;
235
+ try {
236
+ msg = JSON.parse(text);
237
+ } catch {
238
+ return;
239
+ }
240
+ if (msg.type === "hello") {
241
+ greeted = true;
242
+ sessions.set(conn, msg.session);
243
+ broadcast({ type: "sessions", sessions: sessionList() });
244
+ return;
245
+ }
246
+ if (msg.type === "batch" && greeted) {
247
+ broadcast({ type: "batch", session: sessions.get(conn), events: msg.events });
248
+ }
249
+ };
250
+ conn.onClose = () => {
251
+ const info = sessions.get(conn);
252
+ if (sessions.delete(conn) && info) {
253
+ info.closed = true;
254
+ info.closedAt = Date.now();
255
+ closedSessions.push(info);
256
+ if (closedSessions.length > closedCap) {
257
+ const i = closedSessions.findIndex((s) => !s.pinned);
258
+ if (i >= 0) evictClosed(closedSessions[i].id);
259
+ }
260
+ broadcast({ type: "sessions", sessions: sessionList() });
261
+ }
262
+ };
263
+ });
264
+ const sweep = setInterval(() => {
265
+ const cutoff = Date.now() - closedTtlMs;
266
+ let changed = false;
267
+ for (const s of [...closedSessions]) {
268
+ if (!s.pinned && s.closedAt !== void 0 && s.closedAt < cutoff) {
269
+ changed = evictClosed(s.id) || changed;
270
+ }
271
+ }
272
+ if (changed) broadcast({ type: "sessions", sessions: sessionList() });
273
+ }, opts.sweepMs ?? 6e4);
274
+ sweep.unref();
275
+ return new Promise((resolve, reject) => {
276
+ server2.once("error", reject);
277
+ server2.listen(opts.port ?? 4780, host, () => {
278
+ const address = server2.address();
279
+ const port2 = typeof address === "object" && address ? address.port : opts.port ?? 4780;
280
+ const url = `http://${host}:${port2}`;
281
+ opts.onListen?.(url);
282
+ resolve({
283
+ port: port2,
284
+ url,
285
+ sessions: () => [...sessions.values()],
286
+ close: () => new Promise((done) => {
287
+ clearInterval(sweep);
288
+ for (const v of viewers) v.close();
289
+ for (const c of sessions.keys()) c.close();
290
+ server2.close(() => done());
291
+ })
292
+ });
293
+ });
294
+ });
295
+ }
296
+
297
+ // src/bin.ts
298
+ var arg = (name) => {
299
+ const i = process.argv.indexOf(`--${name}`);
300
+ return i >= 0 ? process.argv[i + 1] : void 0;
301
+ };
302
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
303
+ console.log("usage: atoll-devtools [--port N] \u2014 serve the devtools dashboard + event ingest (default port 4780)");
304
+ process.exit(0);
305
+ }
306
+ var port = Number(arg("port") ?? 4780);
307
+ var server = await createDevtoolsServer({
308
+ port: Number.isFinite(port) ? port : 4780
309
+ });
310
+ console.log(`atoll devtools \u2192 ${server.url}`);
311
+ console.log(" dashboard: " + server.url);
312
+ console.log(" ingest: " + server.url.replace("http://", "ws://") + "/events");
313
+ console.log("\nIn the app: import { connectDevtools } from '@atolljs/devtools'; connectDevtools()");
@@ -0,0 +1,3 @@
1
+ import { type SessionInfo } from './protocol.js';
2
+ import type { ConnectDevtoolsOptions, DevtoolsConnection } from './client.js';
3
+ export declare function connectBroadcast(opts: ConnectDevtoolsOptions, session: SessionInfo): DevtoolsConnection;