@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
package/src/client.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The instrumented-app side: `connectDevtools()` installs the core event
|
|
3
|
+
* sink and batches events to dashboards. Two transports:
|
|
4
|
+
*
|
|
5
|
+
* connectDevtools() // auto — BroadcastChannel in a
|
|
6
|
+
* // browser (pure client), else ws
|
|
7
|
+
* connectDevtools({ url }) // explicit aggregate server
|
|
8
|
+
* connectDevtools({ transport: 'websocket' }) // server, default url
|
|
9
|
+
*
|
|
10
|
+
* BroadcastChannel mode needs no backend: the dashboard is served on the
|
|
11
|
+
* app's own origin (vite plugin mounts it at /__atoll/) and listens on
|
|
12
|
+
* the same channel — origin scoping is the isolation. WebSocket mode
|
|
13
|
+
* targets the standalone aggregate server (`atoll devtools`, default
|
|
14
|
+
* ws://127.0.0.1:4780/events) — opt-in for cross-app views and Node.
|
|
15
|
+
*
|
|
16
|
+
* Events emitted while a socket is still connecting are buffered; the
|
|
17
|
+
* buffer is bounded so an app that runs without a listener doesn't grow
|
|
18
|
+
* memory unboundedly.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
installFetchProbe,
|
|
22
|
+
installMemoryProbe,
|
|
23
|
+
setDevtoolsSink,
|
|
24
|
+
type EmittedDevtoolsEvent,
|
|
25
|
+
} from '@atolljs/core';
|
|
26
|
+
import { connectBroadcast } from './broadcast';
|
|
27
|
+
import type { ClientMessage, SessionInfo } from './protocol';
|
|
28
|
+
|
|
29
|
+
export interface ConnectDevtoolsOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Transport. Default `'auto'`: BroadcastChannel in a browser window
|
|
32
|
+
* (pure client — dashboards open on the app's own origin via /__atoll/),
|
|
33
|
+
* WebSocket everywhere else (Node, workers, non-BC browsers).
|
|
34
|
+
* `'websocket'` or setting `url` opts into the aggregate server.
|
|
35
|
+
*/
|
|
36
|
+
transport?: 'auto' | 'broadcast' | 'websocket';
|
|
37
|
+
/** Server ingest endpoint — implies websocket transport. */
|
|
38
|
+
url?: string;
|
|
39
|
+
/** Session identity shown in the dashboard. */
|
|
40
|
+
session?: { name?: string; hint?: string };
|
|
41
|
+
/** Batch flush interval ms (default 100). */
|
|
42
|
+
flushMs?: number;
|
|
43
|
+
/** Events buffered while disconnected (default 50_000 — oldest dropped). */
|
|
44
|
+
bufferCap?: number;
|
|
45
|
+
/** Batches kept for replay to late-joining dashboards (broadcast, default 500). */
|
|
46
|
+
replayBatches?: number;
|
|
47
|
+
/** Patch global fetch to log requests (default true). Worker fetch is probed via the pool's devtools INIT flag. */
|
|
48
|
+
network?: boolean;
|
|
49
|
+
/** Sample JS heap usage periodically — Chrome/Blink only (default true). */
|
|
50
|
+
memory?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DevtoolsConnection {
|
|
54
|
+
readonly open: boolean;
|
|
55
|
+
readonly session: SessionInfo;
|
|
56
|
+
/** Uninstall the sink and close the socket. */
|
|
57
|
+
close(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let sessionSeq = 0;
|
|
61
|
+
|
|
62
|
+
const makeSession = (opts: ConnectDevtoolsOptions): SessionInfo => {
|
|
63
|
+
const runtime: SessionInfo['runtime'] =
|
|
64
|
+
typeof window !== 'undefined' && typeof window.document !== 'undefined' ? 'browser' : 'node';
|
|
65
|
+
const hint =
|
|
66
|
+
opts.session?.hint ??
|
|
67
|
+
(runtime === 'browser'
|
|
68
|
+
? (globalThis as { location?: { href?: string } }).location?.href
|
|
69
|
+
: (globalThis as { process?: { title?: string } }).process?.title);
|
|
70
|
+
return {
|
|
71
|
+
id: `s-${Date.now().toString(36)}-${++sessionSeq}`,
|
|
72
|
+
name: opts.session?.name,
|
|
73
|
+
runtime,
|
|
74
|
+
hint,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** 'auto' picks broadcast only in a real browser window — workers and
|
|
79
|
+
* Node contexts keep the aggregate-server path. An explicit
|
|
80
|
+
* `transport: 'broadcast'` is honored wherever BroadcastChannel exists
|
|
81
|
+
* (SharedWorkers have no window but can still be pure-client hubs). */
|
|
82
|
+
const autoBroadcast = () =>
|
|
83
|
+
typeof BroadcastChannel !== 'undefined' &&
|
|
84
|
+
typeof window !== 'undefined' &&
|
|
85
|
+
typeof window.document !== 'undefined';
|
|
86
|
+
|
|
87
|
+
export function connectDevtools(opts: ConnectDevtoolsOptions = {}): DevtoolsConnection {
|
|
88
|
+
const session = makeSession(opts);
|
|
89
|
+
const t = opts.url !== undefined ? 'websocket' : (opts.transport ?? 'auto');
|
|
90
|
+
const useBc =
|
|
91
|
+
t === 'broadcast'
|
|
92
|
+
? typeof BroadcastChannel !== 'undefined'
|
|
93
|
+
: t === 'auto' && autoBroadcast();
|
|
94
|
+
return useBc ? connectBroadcast(opts, session) : connectWebSocket(opts, session);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function connectWebSocket(opts: ConnectDevtoolsOptions, session: SessionInfo): DevtoolsConnection {
|
|
98
|
+
const url = opts.url ?? 'ws://127.0.0.1:4780/events';
|
|
99
|
+
const cap = opts.bufferCap ?? 50_000;
|
|
100
|
+
const pending: EmittedDevtoolsEvent[] = [];
|
|
101
|
+
let closed = false;
|
|
102
|
+
|
|
103
|
+
/** Uniform socket surface over the global WebSocket and the Node fallback. */
|
|
104
|
+
let sock: { open: boolean; send(text: string): void; close(): void } | null = null;
|
|
105
|
+
|
|
106
|
+
const send = (msg: ClientMessage) => {
|
|
107
|
+
if (sock?.open) sock.send(JSON.stringify(msg));
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const flush = () => {
|
|
111
|
+
if (!sock?.open || pending.length === 0) return;
|
|
112
|
+
const events = pending.splice(0, pending.length);
|
|
113
|
+
send({ type: 'batch', events });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const onOpen = () => {
|
|
117
|
+
send({ type: 'hello', session });
|
|
118
|
+
flush();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const isNode = typeof window === 'undefined' || typeof window.document === 'undefined';
|
|
122
|
+
if (typeof WebSocket !== 'undefined') {
|
|
123
|
+
const ws = new WebSocket(url);
|
|
124
|
+
sock = {
|
|
125
|
+
get open() {
|
|
126
|
+
return ws.readyState === ws.OPEN;
|
|
127
|
+
},
|
|
128
|
+
send: (t) => ws.send(t),
|
|
129
|
+
close: () => ws.close(),
|
|
130
|
+
};
|
|
131
|
+
ws.onopen = onOpen;
|
|
132
|
+
ws.onclose = () => clearInterval(timer);
|
|
133
|
+
ws.onerror = () => ws.close();
|
|
134
|
+
} else if (isNode) {
|
|
135
|
+
// No global WebSocket (Node < 22) — dependency-free client, loaded lazily
|
|
136
|
+
// so node:net/crypto never enter a browser bundle. The specifier is
|
|
137
|
+
// computed: a literal would pull nodeWs.ts (and its node:* imports)
|
|
138
|
+
// into browser-app tsconfig programs and vite's static analysis.
|
|
139
|
+
const mod = './nodeWs.ts';
|
|
140
|
+
void import(/* @vite-ignore */ mod).then((m) => {
|
|
141
|
+
if (closed) return;
|
|
142
|
+
const ws = m.connectNodeWebSocket(url);
|
|
143
|
+
sock = ws;
|
|
144
|
+
ws.onOpen = onOpen;
|
|
145
|
+
ws.onClose = () => clearInterval(timer);
|
|
146
|
+
}).catch(() => {
|
|
147
|
+
/* no net available — sink stays installed but dead */
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const timer = setInterval(flush, opts.flushMs ?? 100);
|
|
152
|
+
// Node: don't hold the process open for the flush interval.
|
|
153
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
154
|
+
|
|
155
|
+
if (opts.network !== false) installFetchProbe(globalThis);
|
|
156
|
+
if (opts.memory !== false) installMemoryProbe(globalThis);
|
|
157
|
+
|
|
158
|
+
setDevtoolsSink((event) => {
|
|
159
|
+
if (closed) return;
|
|
160
|
+
pending.push(event);
|
|
161
|
+
if (pending.length > cap) pending.shift();
|
|
162
|
+
if (pending.length >= 1000) flush(); // large bursts flush early
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
session,
|
|
167
|
+
get open() {
|
|
168
|
+
return sock?.open ?? false;
|
|
169
|
+
},
|
|
170
|
+
close() {
|
|
171
|
+
closed = true;
|
|
172
|
+
setDevtoolsSink(null);
|
|
173
|
+
flush();
|
|
174
|
+
sock?.close();
|
|
175
|
+
clearInterval(timer);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @atolljs/devtools — instrumented-app client for browsers (broadcast
|
|
2
|
+
// transport, /__atoll/ dashboard, overlay flyout). The aggregate server
|
|
3
|
+
// lives at '@atolljs/devtools/server'; Node apps use '@atolljs/devtools/node'
|
|
4
|
+
// (env gate, WebSocket-only — no overlay, no BroadcastChannel).
|
|
5
|
+
export { connectDevtools } from './client';
|
|
6
|
+
export type { ConnectDevtoolsOptions, DevtoolsConnection } from './client';
|
|
7
|
+
export { mountDevtoolsOverlay } from './overlay';
|
|
8
|
+
export type { DevtoolsOverlay, DevtoolsOverlayOptions } from './overlay';
|
|
9
|
+
export { DEVTOOLS_PARAM, devtoolsEnabled, initDevtools } from './init';
|
|
10
|
+
export type { InitDevtools, InitDevtoolsOptions } from './init';
|
|
11
|
+
export type { SessionInfo } from './protocol';
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-gated devtools: `initDevtools()` is a no-op unless the app was
|
|
3
|
+
* opened with `?__atoll_devtools` in the URL (Node: `ATOLL_DEVTOOLS`
|
|
4
|
+
* env). When enabled it connects the broadcast transport and mounts the
|
|
5
|
+
* overlay flyout — one line in the app, nothing when uninvited:
|
|
6
|
+
*
|
|
7
|
+
* import { initDevtools } from '@atolljs/devtools';
|
|
8
|
+
* initDevtools({ session: { name: 'my-app' } });
|
|
9
|
+
*
|
|
10
|
+
* my-app/?__atoll_devtools → devtools on
|
|
11
|
+
* my-app/ → zero cost, no sink installed
|
|
12
|
+
*/
|
|
13
|
+
import { connectDevtools, type ConnectDevtoolsOptions, type DevtoolsConnection } from './client';
|
|
14
|
+
import { mountDevtoolsOverlay, type DevtoolsOverlay, type DevtoolsOverlayOptions } from './overlay';
|
|
15
|
+
|
|
16
|
+
export const DEVTOOLS_PARAM = '__atoll_devtools';
|
|
17
|
+
|
|
18
|
+
/** Whether devtools should activate in this context. */
|
|
19
|
+
export const devtoolsEnabled = (enabled?: boolean): boolean => {
|
|
20
|
+
if (enabled !== undefined) return enabled;
|
|
21
|
+
if (typeof location !== 'undefined' && typeof location.search === 'string') {
|
|
22
|
+
return new URLSearchParams(location.search).has(DEVTOOLS_PARAM);
|
|
23
|
+
}
|
|
24
|
+
// Node — no URL to gate on; honor the env var instead.
|
|
25
|
+
return !!(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
|
|
26
|
+
?.ATOLL_DEVTOOLS;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface InitDevtoolsOptions extends ConnectDevtoolsOptions {
|
|
30
|
+
/** Force on/off — defaults to the ?__atoll_devtools URL param. */
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
/** Overlay flyout — default true in a browser window; pass options or false. */
|
|
33
|
+
overlay?: boolean | DevtoolsOverlayOptions;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface InitDevtools {
|
|
37
|
+
conn: DevtoolsConnection;
|
|
38
|
+
overlay: DevtoolsOverlay | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function initDevtools(opts: InitDevtoolsOptions = {}): InitDevtools | null {
|
|
42
|
+
if (!devtoolsEnabled(opts.enabled)) return null;
|
|
43
|
+
const conn = connectDevtools(opts);
|
|
44
|
+
const inWindow = typeof window !== 'undefined' && typeof window.document !== 'undefined';
|
|
45
|
+
const overlay =
|
|
46
|
+
inWindow && opts.overlay !== false
|
|
47
|
+
? mountDevtoolsOverlay(typeof opts.overlay === 'object' ? opts.overlay : {})
|
|
48
|
+
: null;
|
|
49
|
+
return { conn, overlay };
|
|
50
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@atolljs/devtools/node` — the Node-facing entry.
|
|
3
|
+
*
|
|
4
|
+
* Same API as the browser entry, minus the browser-only pieces: there is no
|
|
5
|
+
* BroadcastChannel transport (WebSocket is the only path — the global
|
|
6
|
+
* WebSocket on Node ≥ 22, the bundled dependency-free client below it) and
|
|
7
|
+
* no overlay flyout (there's no page to mount it in). `initDevtools` gates
|
|
8
|
+
* on the ATOLL_DEVTOOLS env var instead of the ?__atoll_devtools URL param;
|
|
9
|
+
* the dashboard is the standalone aggregate server (`atoll devtools`).
|
|
10
|
+
*
|
|
11
|
+
* import { initDevtools } from '@atolljs/devtools/node';
|
|
12
|
+
* initDevtools({ session: { name: 'my-api' } });
|
|
13
|
+
*/
|
|
14
|
+
export { connectDevtools } from './client';
|
|
15
|
+
export type { ConnectDevtoolsOptions, DevtoolsConnection } from './client';
|
|
16
|
+
export { initDevtools, devtoolsEnabled, DEVTOOLS_PARAM } from './init';
|
|
17
|
+
export type { InitDevtools, InitDevtoolsOptions } from './init';
|
|
18
|
+
export type { SessionInfo } from './protocol';
|
package/src/nodeWs.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal RFC6455 *client* for Node — the `ws`-free counterpart to the
|
|
3
|
+
* server in ws.ts. Used only when the global `WebSocket` doesn't exist
|
|
4
|
+
* (Node < 22); imported lazily from client.ts so it never enters browser
|
|
5
|
+
* bundles. Supports ws:// and wss://, text frames, ping/pong, and close.
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { connect as netConnect } from 'node:net';
|
|
9
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
10
|
+
import type { Socket } from 'node:net';
|
|
11
|
+
import {
|
|
12
|
+
decodeFrames,
|
|
13
|
+
encodeMaskedControl,
|
|
14
|
+
encodeMaskedTextFrame,
|
|
15
|
+
OPCODES,
|
|
16
|
+
} from './ws';
|
|
17
|
+
|
|
18
|
+
export interface NodeWebSocket {
|
|
19
|
+
readonly open: boolean;
|
|
20
|
+
send(text: string): void;
|
|
21
|
+
close(): void;
|
|
22
|
+
onOpen: (() => void) | null;
|
|
23
|
+
onClose: (() => void) | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Connect a WebSocket to `url` — handshake, then framed text IO. */
|
|
27
|
+
export function connectNodeWebSocket(url: string): NodeWebSocket {
|
|
28
|
+
const u = new URL(url);
|
|
29
|
+
const secure = u.protocol === 'wss:';
|
|
30
|
+
const port = Number(u.port || (secure ? 443 : 80));
|
|
31
|
+
const key = randomBytes(16).toString('base64');
|
|
32
|
+
const path = `${u.pathname || '/'}${u.search}`;
|
|
33
|
+
|
|
34
|
+
const socket: Socket = secure
|
|
35
|
+
? tlsConnect({ host: u.hostname, port, servername: u.hostname })
|
|
36
|
+
: netConnect(port, u.hostname);
|
|
37
|
+
|
|
38
|
+
let buf: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
39
|
+
let fragments: Buffer<ArrayBufferLike>[] = [];
|
|
40
|
+
let upgraded = false;
|
|
41
|
+
let open = false;
|
|
42
|
+
let closed = false;
|
|
43
|
+
|
|
44
|
+
const conn: NodeWebSocket = {
|
|
45
|
+
get open() {
|
|
46
|
+
return open;
|
|
47
|
+
},
|
|
48
|
+
send(text) {
|
|
49
|
+
if (open && !closed) socket.write(encodeMaskedTextFrame(text));
|
|
50
|
+
},
|
|
51
|
+
close() {
|
|
52
|
+
if (closed) return;
|
|
53
|
+
closed = true;
|
|
54
|
+
try {
|
|
55
|
+
if (open) socket.write(encodeMaskedControl(OPCODES.close));
|
|
56
|
+
socket.end();
|
|
57
|
+
} catch {
|
|
58
|
+
/* already gone */
|
|
59
|
+
}
|
|
60
|
+
open = false;
|
|
61
|
+
conn.onClose?.();
|
|
62
|
+
},
|
|
63
|
+
onOpen: null,
|
|
64
|
+
onClose: null,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const die = () => {
|
|
68
|
+
if (!closed) {
|
|
69
|
+
closed = true;
|
|
70
|
+
open = false;
|
|
71
|
+
conn.onClose?.();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
socket.on('connect', () => {
|
|
76
|
+
socket.write(
|
|
77
|
+
`GET ${path} HTTP/1.1\r\n` +
|
|
78
|
+
`Host: ${u.host}\r\n` +
|
|
79
|
+
'Upgrade: websocket\r\n' +
|
|
80
|
+
'Connection: Upgrade\r\n' +
|
|
81
|
+
`Sec-WebSocket-Key: ${key}\r\n` +
|
|
82
|
+
'Sec-WebSocket-Version: 13\r\n' +
|
|
83
|
+
'\r\n',
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
socket.on('data', (chunk: Buffer) => {
|
|
88
|
+
buf = Buffer.concat([buf, chunk]);
|
|
89
|
+
if (!upgraded) {
|
|
90
|
+
const end = buf.indexOf('\r\n\r\n');
|
|
91
|
+
if (end === -1) return;
|
|
92
|
+
const head = buf.subarray(0, end).toString('latin1');
|
|
93
|
+
buf = buf.subarray(end + 4);
|
|
94
|
+
if (!/^HTTP\/1\.1 101\b/.test(head)) return die();
|
|
95
|
+
upgraded = true;
|
|
96
|
+
open = true;
|
|
97
|
+
conn.onOpen?.();
|
|
98
|
+
if (closed) return; // onOpen may have closed us
|
|
99
|
+
}
|
|
100
|
+
const { frames, rest } = decodeFrames(buf);
|
|
101
|
+
buf = rest;
|
|
102
|
+
for (const { fin, opcode, payload } of frames) {
|
|
103
|
+
if (opcode === OPCODES.close) return conn.close();
|
|
104
|
+
if (opcode === OPCODES.ping) {
|
|
105
|
+
socket.write(encodeMaskedControl(OPCODES.pong, payload));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (opcode === OPCODES.pong) continue;
|
|
109
|
+
if (opcode === OPCODES.text || opcode === OPCODES.continuation) {
|
|
110
|
+
fragments.push(payload);
|
|
111
|
+
if (fin) fragments = []; // client.ts never reads frames — drain only
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
socket.on('error', die);
|
|
116
|
+
socket.on('close', die);
|
|
117
|
+
return conn;
|
|
118
|
+
}
|
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-app devtools overlay: a floating flyout hosting the mini dashboard
|
|
3
|
+
* in a same-origin iframe (default /__atoll/?mini=1, served by the vite
|
|
4
|
+
* plugin). Same-origin means the iframe shares the app's BroadcastChannel
|
|
5
|
+
* — the pure-client transport needs no other wiring.
|
|
6
|
+
*
|
|
7
|
+
* import { connectDevtools, mountDevtoolsOverlay } from '@atolljs/devtools';
|
|
8
|
+
* if (import.meta.env.DEV) {
|
|
9
|
+
* connectDevtools();
|
|
10
|
+
* mountDevtoolsOverlay();
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type DevtoolsOverlayPosition =
|
|
15
|
+
| 'topleft'
|
|
16
|
+
| 'topcenter'
|
|
17
|
+
| 'topright'
|
|
18
|
+
| 'bottomleft'
|
|
19
|
+
| 'bottomcenter'
|
|
20
|
+
| 'bottomright';
|
|
21
|
+
|
|
22
|
+
export interface DevtoolsOverlayOptions {
|
|
23
|
+
/** Dashboard URL — default '/__atoll/?mini=1' (vite-plugin mount, mini layout). */
|
|
24
|
+
src?: string;
|
|
25
|
+
/** Start expanded (default false — a small corner toggle shows first). */
|
|
26
|
+
startOpen?: boolean;
|
|
27
|
+
/** Initial size (default 760×580). */
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
/** Where the flyout (and its collapsed toggle) anchors — default 'bottomright'. */
|
|
31
|
+
position?: DevtoolsOverlayPosition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DevtoolsOverlay {
|
|
35
|
+
open(): void;
|
|
36
|
+
close(): void;
|
|
37
|
+
toggle(): void;
|
|
38
|
+
unmount(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const BTN_STYLE =
|
|
42
|
+
'position:fixed;z-index:2147483646;' +
|
|
43
|
+
'font:11px ui-monospace,monospace;color:#c9d1d9;background:#161b22;' +
|
|
44
|
+
'border:1px solid #30363d;border-radius:6px;padding:5px 9px;cursor:pointer';
|
|
45
|
+
|
|
46
|
+
const PANEL_STYLE =
|
|
47
|
+
'position:fixed;z-index:2147483646;display:flex;flex-direction:column;' +
|
|
48
|
+
'background:#0d1117;border:1px solid #30363d;border-radius:8px;' +
|
|
49
|
+
'box-shadow:0 12px 40px #000c;overflow:hidden;min-width:340px;min-height:220px';
|
|
50
|
+
|
|
51
|
+
const ANCHOR: Record<DevtoolsOverlayPosition, (m: number) => string> = {
|
|
52
|
+
topleft: (m) => `left:${m}px;top:${m}px`,
|
|
53
|
+
topcenter: (m) => `left:50%;top:${m}px;transform:translateX(-50%)`,
|
|
54
|
+
topright: (m) => `right:${m}px;top:${m}px`,
|
|
55
|
+
bottomleft: (m) => `left:${m}px;bottom:${m}px`,
|
|
56
|
+
bottomcenter: (m) => `left:50%;bottom:${m}px;transform:translateX(-50%)`,
|
|
57
|
+
bottomright: (m) => `right:${m}px;bottom:${m}px`,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function mountDevtoolsOverlay(opts: DevtoolsOverlayOptions = {}): DevtoolsOverlay {
|
|
61
|
+
const src = opts.src ?? '/__atoll/?mini=1';
|
|
62
|
+
const W = opts.width ?? 760;
|
|
63
|
+
const H = opts.height ?? 580;
|
|
64
|
+
const anchor = ANCHOR[opts.position ?? 'bottomright'];
|
|
65
|
+
let open = false;
|
|
66
|
+
|
|
67
|
+
const btn = document.createElement('button');
|
|
68
|
+
btn.textContent = 'atoll devtools';
|
|
69
|
+
btn.style.cssText = `${BTN_STYLE};${anchor(12)}`;
|
|
70
|
+
|
|
71
|
+
const panel = document.createElement('div');
|
|
72
|
+
panel.style.cssText =
|
|
73
|
+
`${PANEL_STYLE};width:${W}px;height:${H}px;display:none;${anchor(16)}`;
|
|
74
|
+
|
|
75
|
+
const head = document.createElement('div');
|
|
76
|
+
head.style.cssText =
|
|
77
|
+
'display:flex;align-items:center;gap:8px;padding:5px 10px;flex:none;' +
|
|
78
|
+
'border-bottom:1px solid #21262d;font:11px ui-monospace,monospace;' +
|
|
79
|
+
'color:#8b949e;background:#161b22;cursor:move;user-select:none';
|
|
80
|
+
head.textContent = 'atoll devtools';
|
|
81
|
+
|
|
82
|
+
const pop = document.createElement('a');
|
|
83
|
+
pop.href = '/__atoll/';
|
|
84
|
+
pop.target = '_blank';
|
|
85
|
+
pop.rel = 'noopener';
|
|
86
|
+
pop.textContent = 'full page ↗';
|
|
87
|
+
pop.style.cssText = 'color:#58a6ff;text-decoration:none;margin-left:auto';
|
|
88
|
+
const x = document.createElement('button');
|
|
89
|
+
x.textContent = '—';
|
|
90
|
+
x.title = 'collapse';
|
|
91
|
+
x.style.cssText =
|
|
92
|
+
'color:#8b949e;background:none;border:none;cursor:pointer;font:inherit;padding:0 2px';
|
|
93
|
+
head.append(pop, x);
|
|
94
|
+
|
|
95
|
+
const frame = document.createElement('iframe');
|
|
96
|
+
frame.src = src;
|
|
97
|
+
frame.style.cssText = 'flex:1;border:0;background:#0d1117;min-height:0';
|
|
98
|
+
|
|
99
|
+
// manual resize grip — CSS `resize` doesn't work over an iframe
|
|
100
|
+
const grip = document.createElement('div');
|
|
101
|
+
grip.style.cssText =
|
|
102
|
+
'position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;' +
|
|
103
|
+
'background:linear-gradient(135deg,transparent 50%,#30363d 50%);border-radius:0 0 7px';
|
|
104
|
+
panel.append(head, frame, grip);
|
|
105
|
+
document.body.append(panel, btn);
|
|
106
|
+
|
|
107
|
+
/** Pointer-drag helper: run `move` on pointermove until pointerup. */
|
|
108
|
+
const drag = (e: PointerEvent, move: (dx: number, dy: number) => void) => {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
let lx = e.clientX, ly = e.clientY;
|
|
111
|
+
const mm = (ev: PointerEvent) => {
|
|
112
|
+
move(ev.clientX - lx, ev.clientY - ly);
|
|
113
|
+
lx = ev.clientX; ly = ev.clientY;
|
|
114
|
+
};
|
|
115
|
+
const up = () => {
|
|
116
|
+
removeEventListener('pointermove', mm);
|
|
117
|
+
removeEventListener('pointerup', up);
|
|
118
|
+
};
|
|
119
|
+
addEventListener('pointermove', mm);
|
|
120
|
+
addEventListener('pointerup', up);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
head.onpointerdown = (e) => {
|
|
124
|
+
if ((e.target as HTMLElement).closest('a,button')) return;
|
|
125
|
+
drag(e, (dx, dy) => {
|
|
126
|
+
const r = panel.getBoundingClientRect();
|
|
127
|
+
// keep at least 40px of the title bar on screen
|
|
128
|
+
const left = Math.min(Math.max(0, r.left + dx), innerWidth - 60);
|
|
129
|
+
const top = Math.min(Math.max(0, r.top + dy), innerHeight - 40);
|
|
130
|
+
panel.style.left = `${left}px`;
|
|
131
|
+
panel.style.top = `${top}px`;
|
|
132
|
+
panel.style.right = 'auto';
|
|
133
|
+
panel.style.bottom = 'auto';
|
|
134
|
+
panel.style.transform = 'none'; // drop the center anchor's translateX
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
grip.onpointerdown = (e) => {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
drag(e, (dx, dy) => {
|
|
141
|
+
const r = panel.getBoundingClientRect();
|
|
142
|
+
panel.style.width = `${Math.max(340, r.width + dx)}px`;
|
|
143
|
+
panel.style.height = `${Math.max(220, r.height + dy)}px`;
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const set = (v: boolean) => {
|
|
148
|
+
open = v;
|
|
149
|
+
panel.style.display = v ? 'flex' : 'none';
|
|
150
|
+
btn.style.display = v ? 'none' : 'block';
|
|
151
|
+
};
|
|
152
|
+
btn.onclick = () => set(true);
|
|
153
|
+
x.onclick = () => set(false);
|
|
154
|
+
set(opts.startOpen === true);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
open: () => set(true),
|
|
158
|
+
close: () => set(false),
|
|
159
|
+
toggle: () => set(!open),
|
|
160
|
+
unmount: () => {
|
|
161
|
+
panel.remove();
|
|
162
|
+
btn.remove();
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
|
|
10
|
+
/** Identifies one connected app (one page, one Node process). */
|
|
11
|
+
export interface SessionInfo {
|
|
12
|
+
/** Client-generated id — stable for the socket's lifetime. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Human label — config `name`, app title, process argv, etc. */
|
|
15
|
+
name?: string;
|
|
16
|
+
runtime: 'browser' | 'node';
|
|
17
|
+
/** Browser location.href / process title — whatever helps identify it. */
|
|
18
|
+
hint?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Set by the server once the app's socket closes. Closed sessions are
|
|
21
|
+
* retained (bounded) so dashboards can keep post-mortem history instead of
|
|
22
|
+
* orphaning their pools/tasks/memory state.
|
|
23
|
+
*/
|
|
24
|
+
closed?: boolean;
|
|
25
|
+
/** Server clock (ms) when the socket closed — drives the cleanup sweep. */
|
|
26
|
+
closedAt?: number;
|
|
27
|
+
/** Viewer-pinned closed sessions are exempt from the retention sweep. */
|
|
28
|
+
pinned?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HelloMessage {
|
|
32
|
+
type: 'hello';
|
|
33
|
+
session: SessionInfo;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BatchMessage {
|
|
37
|
+
type: 'batch';
|
|
38
|
+
events: EmittedDevtoolsEvent[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What an instrumented app sends on /events. */
|
|
42
|
+
export type ClientMessage = HelloMessage | BatchMessage;
|
|
43
|
+
|
|
44
|
+
/** What a dashboard viewer receives on /view. */
|
|
45
|
+
export type ViewerMessage =
|
|
46
|
+
| { type: 'sessions'; sessions: SessionInfo[] }
|
|
47
|
+
| { type: 'batch'; session: SessionInfo; events: EmittedDevtoolsEvent[] };
|
|
48
|
+
|
|
49
|
+
/** What a dashboard viewer sends on /view. */
|
|
50
|
+
export type ViewerRequest =
|
|
51
|
+
/** Drop a closed session and its replayed history. Live sessions are ignored. */
|
|
52
|
+
| { type: 'dismiss'; sessionId: string }
|
|
53
|
+
/** Exempt a closed session from the retention sweep (or release it). */
|
|
54
|
+
| { type: 'pin'; sessionId: string; pinned: boolean };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pure-client transport: frames on a same-origin BroadcastChannel named
|
|
58
|
+
* 'atoll-devtools'. Origin scoping replaces the server's session list —
|
|
59
|
+
* a dashboard on the app's own origin sees exactly the apps on that origin.
|
|
60
|
+
*/
|
|
61
|
+
export const DEVTOOLS_CHANNEL = 'atoll-devtools';
|
|
62
|
+
|
|
63
|
+
export type BroadcastMessage =
|
|
64
|
+
| { type: 'hello'; session: SessionInfo }
|
|
65
|
+
| { type: 'batch'; session: SessionInfo; events: EmittedDevtoolsEvent[] }
|
|
66
|
+
/** Posted on close/pagehide so dashboards can mark the session ended. */
|
|
67
|
+
| { type: 'bye'; sessionId: string }
|
|
68
|
+
/** Dashboard announcing itself — apps re-hello and replay their tail. */
|
|
69
|
+
| { type: 'view' };
|