@lightpath-dev/daemon 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/auth.d.ts +11 -0
- package/dist/auth.js +26 -0
- package/dist/correlator.d.ts +16 -0
- package/dist/correlator.js +247 -0
- package/dist/discovery.d.ts +15 -0
- package/dist/discovery.js +42 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +153 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +42 -0
- package/package.json +46 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lightpath contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lightpath-dev/daemon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The local daemon of [Lightpath](https://github.com/Unknown-333/Lightpath#readme). It receives coverage from the browser and backend agents, builds one trace per action, and serves traces to the editor and MCP clients. It listens on `127.0.0.1` only and requires a token.
|
|
4
|
+
|
|
5
|
+
You don't need this package directly; [`@lightpath-dev/cli`](https://www.npmjs.com/package/@lightpath-dev/cli) starts it.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
/** 32 random bytes, base64url-encoded (43 characters). */
|
|
3
|
+
export declare function newToken(): string;
|
|
4
|
+
export declare function tokensEqual(expected: string, actual: string | undefined | null): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Checks a request against the daemon's rules (ARCHITECTURE section 7.2):
|
|
7
|
+
* - no `Origin` header, so web pages in the user's browser can't talk to the daemon;
|
|
8
|
+
* - the token in `Authorization: Bearer <token>` or, for WebSocket upgrades, `?token=`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function authorize(req: IncomingMessage, token: string): 'ok' | 'forbidden' | 'unauthorized';
|
|
11
|
+
//# sourceMappingURL=auth.d.ts.map
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** 32 random bytes, base64url-encoded (43 characters). */
|
|
3
|
+
export function newToken() {
|
|
4
|
+
return randomBytes(32).toString('base64url');
|
|
5
|
+
}
|
|
6
|
+
export function tokensEqual(expected, actual) {
|
|
7
|
+
if (!actual)
|
|
8
|
+
return false;
|
|
9
|
+
const a = Buffer.from(expected);
|
|
10
|
+
const b = Buffer.from(actual);
|
|
11
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Checks a request against the daemon's rules (ARCHITECTURE section 7.2):
|
|
15
|
+
* - no `Origin` header, so web pages in the user's browser can't talk to the daemon;
|
|
16
|
+
* - the token in `Authorization: Bearer <token>` or, for WebSocket upgrades, `?token=`.
|
|
17
|
+
*/
|
|
18
|
+
export function authorize(req, token) {
|
|
19
|
+
if (req.headers.origin !== undefined)
|
|
20
|
+
return 'forbidden';
|
|
21
|
+
const auth = req.headers.authorization;
|
|
22
|
+
const bearer = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : undefined;
|
|
23
|
+
const query = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token');
|
|
24
|
+
return tokensEqual(token, bearer ?? query) ? 'ok' : 'unauthorized';
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ResolveContext } from '@lightpath-dev/core';
|
|
2
|
+
import type { IngestMessage } from '@lightpath-dev/protocol';
|
|
3
|
+
import type { Store } from './store.js';
|
|
4
|
+
/**
|
|
5
|
+
* Turns ingest messages into mapped traces (docs/ARCHITECTURE.md sections 3, 4, and 6).
|
|
6
|
+
* Pure in-memory logic with no I/O, so it's tested without sockets.
|
|
7
|
+
*/
|
|
8
|
+
export declare class Correlator {
|
|
9
|
+
#private;
|
|
10
|
+
constructor(store: Store, ctx: ResolveContext);
|
|
11
|
+
/** Maps URLs served by a dev server (e.g. Vite at http://localhost:5173) onto a directory. */
|
|
12
|
+
addDevServer(origin: string, root: string): void;
|
|
13
|
+
get sourceCount(): number;
|
|
14
|
+
ingest(agentId: string, message: IngestMessage): void;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=correlator.d.ts.map
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { decodeDataUrlSourceMap, mapScript, mergeLineCounts, newTraceId, PeriodicFunctions, resolveFile, } from '@lightpath-dev/core';
|
|
2
|
+
/**
|
|
3
|
+
* Turns ingest messages into mapped traces (docs/ARCHITECTURE.md sections 3, 4, and 6).
|
|
4
|
+
* Pure in-memory logic with no I/O, so it's tested without sockets.
|
|
5
|
+
*/
|
|
6
|
+
export class Correlator {
|
|
7
|
+
#agents = new Map();
|
|
8
|
+
/** Sources are cached by script hash, shared across agents (ADR 0006). */
|
|
9
|
+
#sources = new Map();
|
|
10
|
+
#pending = new Map();
|
|
11
|
+
#store;
|
|
12
|
+
#ctx;
|
|
13
|
+
constructor(store, ctx) {
|
|
14
|
+
this.#store = store;
|
|
15
|
+
this.#ctx = ctx;
|
|
16
|
+
}
|
|
17
|
+
/** Maps URLs served by a dev server (e.g. Vite at http://localhost:5173) onto a directory. */
|
|
18
|
+
addDevServer(origin, root) {
|
|
19
|
+
const others = (this.#ctx.devServers ?? []).filter((s) => s.origin !== origin);
|
|
20
|
+
this.#ctx = { ...this.#ctx, devServers: [...others, { origin, root }] };
|
|
21
|
+
}
|
|
22
|
+
get sourceCount() {
|
|
23
|
+
return this.#sources.size;
|
|
24
|
+
}
|
|
25
|
+
ingest(agentId, message) {
|
|
26
|
+
switch (message.type) {
|
|
27
|
+
case 'hello':
|
|
28
|
+
this.#agents.set(agentId, {
|
|
29
|
+
kind: message.kind,
|
|
30
|
+
pid: message.pid,
|
|
31
|
+
scripts: new Map(),
|
|
32
|
+
periodic: new PeriodicFunctions(),
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
case 'scripts': {
|
|
36
|
+
const agent = this.#agent(agentId);
|
|
37
|
+
for (const s of message.scripts)
|
|
38
|
+
agent.scripts.set(s.scriptId, s);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
case 'source':
|
|
42
|
+
this.#sources.set(message.hash, message.source);
|
|
43
|
+
return;
|
|
44
|
+
case 'traceStart': {
|
|
45
|
+
// Merge rather than replace, in case a backend delta with this ID arrived first.
|
|
46
|
+
const existing = this.#pending.get(message.traceId);
|
|
47
|
+
if (existing) {
|
|
48
|
+
existing.trace.label = message.label;
|
|
49
|
+
existing.trace.origin = 'browser';
|
|
50
|
+
existing.trace.startedAt = Math.min(existing.trace.startedAt, message.at);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
this.#pending.set(message.traceId, this.#newPending(message.traceId, message.label, 'browser', message.at));
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
case 'traceEnd': {
|
|
58
|
+
const p = this.#pending.get(message.traceId);
|
|
59
|
+
if (p) {
|
|
60
|
+
p.trace.endedAt = message.at;
|
|
61
|
+
this.#publish(p);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case 'delta':
|
|
66
|
+
this.#onDelta(agentId, message);
|
|
67
|
+
return;
|
|
68
|
+
case 'lines':
|
|
69
|
+
this.#onLines(agentId, message);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
#onDelta(agentId, delta) {
|
|
74
|
+
const agent = this.#agent(agentId);
|
|
75
|
+
// Background deltas only feed the periodic-function rule (ARCHITECTURE 4.6); never traces.
|
|
76
|
+
if (delta.phase === 'background') {
|
|
77
|
+
agent.periodic.observeBackground(delta.coverage, (id) => agent.scripts.get(id)?.hash);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const pending = this.#pendingFor(delta.traceId, delta.request ? `${delta.request.method} ${delta.request.path}` : 'action', delta.startedAt);
|
|
81
|
+
for (const coverage of delta.coverage) {
|
|
82
|
+
const info = agent.scripts.get(coverage.scriptId);
|
|
83
|
+
const source = info ? this.#sources.get(info.hash) : undefined;
|
|
84
|
+
if (source === undefined) {
|
|
85
|
+
// Without the exact source V8 ran, offsets can't be turned into lines.
|
|
86
|
+
const r = resolveFile(coverage.url, this.#ctx);
|
|
87
|
+
this.#drop(pending, r.kind === 'dropped' ? r.reason : 'no-source');
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
let sourceMap;
|
|
91
|
+
try {
|
|
92
|
+
sourceMap = info?.sourceMapURL ? decodeDataUrlSourceMap(info.sourceMapURL) : undefined;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
this.#drop(pending, 'no-source-map');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const map = (c) => mapScript({ coverage: c, source, ...(sourceMap ? { sourceMap } : {}) }, this.#ctx);
|
|
99
|
+
const result = map(coverage);
|
|
100
|
+
for (const [reason, n] of result.dropped)
|
|
101
|
+
this.#drop(pending, reason, n);
|
|
102
|
+
// Lines are never removed. A file whose lit lines all come from periodic code (a timer that
|
|
103
|
+
// happened to fire during the action) is marked background so clients can tone it down.
|
|
104
|
+
const fg = info ? agent.periodic.foreground(coverage, info.hash) : coverage;
|
|
105
|
+
const fgFiles = fg === coverage ? result.files : fg ? map(fg).files : new Map();
|
|
106
|
+
for (const [path, lines] of result.files) {
|
|
107
|
+
const fgLines = fgFiles.get(path);
|
|
108
|
+
const foreground = fgLines !== undefined && [...fgLines.values()].some((c) => c > 0);
|
|
109
|
+
this.#addFile(pending, path, agent.kind, lines, delta.startedAt, foreground);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (delta.request) {
|
|
113
|
+
pending.trace.requests.push({
|
|
114
|
+
collector: agent.kind,
|
|
115
|
+
pid: agent.pid,
|
|
116
|
+
method: delta.request.method,
|
|
117
|
+
path: delta.request.path,
|
|
118
|
+
...(delta.request.status !== undefined ? { status: delta.request.status } : {}),
|
|
119
|
+
durationMs: Math.max(0, delta.endedAt - delta.startedAt),
|
|
120
|
+
overlapped: (delta.overlapped?.length ?? 0) > 0,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// A browser trace ends only with traceEnd, after the frontend's own delta. Backend deltas
|
|
124
|
+
// arrive mid-click and must not make the trace look finished to clients.
|
|
125
|
+
if (pending.trace.origin === 'backend')
|
|
126
|
+
pending.trace.endedAt = Math.max(pending.trace.endedAt ?? 0, delta.endedAt);
|
|
127
|
+
this.#publish(pending);
|
|
128
|
+
}
|
|
129
|
+
#onLines(agentId, msg) {
|
|
130
|
+
const agent = this.#agent(agentId);
|
|
131
|
+
const label = msg.request ? `${msg.request.method} ${msg.request.path}` : 'action';
|
|
132
|
+
const pending = this.#pendingFor(msg.traceId, label, msg.startedAt);
|
|
133
|
+
for (const file of msg.files) {
|
|
134
|
+
const r = resolveFile(file.path, this.#ctx);
|
|
135
|
+
if (r.kind === 'dropped') {
|
|
136
|
+
this.#drop(pending, r.reason);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
this.#addFile(pending, r.path, 'python', new Map(file.lines.map((l) => [l, 1])), msg.startedAt, true);
|
|
140
|
+
}
|
|
141
|
+
if (msg.request) {
|
|
142
|
+
pending.trace.requests.push({
|
|
143
|
+
collector: 'python',
|
|
144
|
+
pid: agent.pid || msg.pid,
|
|
145
|
+
method: msg.request.method,
|
|
146
|
+
path: msg.request.path,
|
|
147
|
+
...(msg.request.status !== undefined ? { status: msg.request.status } : {}),
|
|
148
|
+
durationMs: Math.max(0, msg.endedAt - msg.startedAt),
|
|
149
|
+
overlapped: false,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (pending.trace.origin === 'backend')
|
|
153
|
+
pending.trace.endedAt = Math.max(pending.trace.endedAt ?? 0, msg.endedAt);
|
|
154
|
+
this.#publish(pending);
|
|
155
|
+
}
|
|
156
|
+
#agent(agentId) {
|
|
157
|
+
let a = this.#agents.get(agentId);
|
|
158
|
+
if (!a) {
|
|
159
|
+
// A batch can arrive before (or without) hello, for example after a daemon restart.
|
|
160
|
+
a = {
|
|
161
|
+
kind: agentId.startsWith('py-') ? 'python' : 'node',
|
|
162
|
+
pid: 0,
|
|
163
|
+
scripts: new Map(),
|
|
164
|
+
periodic: new PeriodicFunctions(),
|
|
165
|
+
};
|
|
166
|
+
this.#agents.set(agentId, a);
|
|
167
|
+
}
|
|
168
|
+
return a;
|
|
169
|
+
}
|
|
170
|
+
/** Finds the browser-started trace for this ID, or starts a backend-only trace. */
|
|
171
|
+
#pendingFor(traceId, label, at) {
|
|
172
|
+
if (traceId) {
|
|
173
|
+
const existing = this.#pending.get(traceId) ?? this.#fromStore(traceId);
|
|
174
|
+
if (existing)
|
|
175
|
+
return existing;
|
|
176
|
+
}
|
|
177
|
+
const p = this.#newPending(traceId ?? newTraceId(), label, 'backend', at);
|
|
178
|
+
this.#pending.set(p.trace.id, p);
|
|
179
|
+
return p;
|
|
180
|
+
}
|
|
181
|
+
#fromStore(traceId) {
|
|
182
|
+
const trace = this.#store.get(traceId);
|
|
183
|
+
if (!trace)
|
|
184
|
+
return undefined;
|
|
185
|
+
const p = { trace: structuredClone(trace), files: new Map(), dropped: new Map() };
|
|
186
|
+
for (const f of trace.files) {
|
|
187
|
+
p.files.set(f.path, {
|
|
188
|
+
collector: f.collector,
|
|
189
|
+
lines: new Map(f.lines.map((l) => [l.line, l.count])),
|
|
190
|
+
firstSeenAt: f.firstSeenAt,
|
|
191
|
+
foreground: !f.background,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const d of trace.dropped)
|
|
195
|
+
p.dropped.set(d.reason, d.count);
|
|
196
|
+
this.#pending.set(traceId, p);
|
|
197
|
+
return p;
|
|
198
|
+
}
|
|
199
|
+
#newPending(id, label, origin, at) {
|
|
200
|
+
return {
|
|
201
|
+
trace: { id, label, origin, startedAt: at, files: [], requests: [], dropped: [] },
|
|
202
|
+
files: new Map(),
|
|
203
|
+
dropped: new Map(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
#addFile(p, path, collector, lines, at, foreground) {
|
|
207
|
+
const existing = p.files.get(path);
|
|
208
|
+
if (existing) {
|
|
209
|
+
existing.lines = mergeLineCounts(existing.lines, lines);
|
|
210
|
+
existing.foreground ||= foreground;
|
|
211
|
+
}
|
|
212
|
+
else
|
|
213
|
+
p.files.set(path, { collector, lines, firstSeenAt: at, foreground });
|
|
214
|
+
}
|
|
215
|
+
#drop(p, reason, n = 1) {
|
|
216
|
+
p.dropped.set(reason, (p.dropped.get(reason) ?? 0) + n);
|
|
217
|
+
}
|
|
218
|
+
#publish(p) {
|
|
219
|
+
const files = [];
|
|
220
|
+
for (const [path, f] of p.files) {
|
|
221
|
+
const lines = [...f.lines]
|
|
222
|
+
.filter(([, c]) => c > 0)
|
|
223
|
+
.sort((a, b) => a[0] - b[0])
|
|
224
|
+
.map(([line, count]) => ({ line, count }));
|
|
225
|
+
if (lines.length === 0)
|
|
226
|
+
continue;
|
|
227
|
+
files.push({
|
|
228
|
+
path,
|
|
229
|
+
collector: f.collector,
|
|
230
|
+
lines,
|
|
231
|
+
firstSeenAt: f.firstSeenAt,
|
|
232
|
+
...(f.foreground ? {} : { background: true }),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
files.sort((a, b) => a.firstSeenAt - b.firstSeenAt || a.path.localeCompare(b.path));
|
|
236
|
+
p.trace.files = files;
|
|
237
|
+
p.trace.dropped = [...p.dropped].map(([reason, count]) => ({ reason, count }));
|
|
238
|
+
this.#store.put(structuredClone(p.trace));
|
|
239
|
+
// Keep at most 50 traces open for late backend deltas; older ones are re-read from the store.
|
|
240
|
+
if (this.#pending.size > 50) {
|
|
241
|
+
const oldest = this.#pending.keys().next().value;
|
|
242
|
+
if (oldest !== undefined)
|
|
243
|
+
this.#pending.delete(oldest);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
//# sourceMappingURL=correlator.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DaemonDiscovery } from '@lightpath-dev/protocol';
|
|
2
|
+
/** Writes `.lightpath/daemon.json` with user-only permissions, plus a .gitignore for the folder. */
|
|
3
|
+
export declare function writeDiscovery(workspaceRoot: string, info: DaemonDiscovery): Promise<string>;
|
|
4
|
+
/** Reads and validates the discovery file, or returns undefined if it's missing or invalid. */
|
|
5
|
+
export declare function readDiscovery(workspaceRoot: string): Promise<DaemonDiscovery | undefined>;
|
|
6
|
+
export declare function removeDiscovery(workspaceRoot: string): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* The nearest folder at or above `dir` with a valid discovery file, and what it says. This is where
|
|
9
|
+
* `lightpath mcp` looks too, so `lightpath doctor` in a subfolder reports the daemon MCP would use.
|
|
10
|
+
*/
|
|
11
|
+
export declare function findDiscovery(dir: string): Promise<{
|
|
12
|
+
root: string;
|
|
13
|
+
info: DaemonDiscovery;
|
|
14
|
+
} | undefined>;
|
|
15
|
+
//# sourceMappingURL=discovery.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { DaemonDiscovery, DISCOVERY_FILE } from '@lightpath-dev/protocol';
|
|
4
|
+
/** Writes `.lightpath/daemon.json` with user-only permissions, plus a .gitignore for the folder. */
|
|
5
|
+
export async function writeDiscovery(workspaceRoot, info) {
|
|
6
|
+
const file = join(workspaceRoot, DISCOVERY_FILE);
|
|
7
|
+
await mkdir(dirname(file), { recursive: true });
|
|
8
|
+
await writeFile(join(dirname(file), '.gitignore'), '*\n');
|
|
9
|
+
// mode applies on POSIX; on Windows the file inherits the user's ACL from the workspace.
|
|
10
|
+
await writeFile(file, `${JSON.stringify(info, null, 2)}\n`, { mode: 0o600 });
|
|
11
|
+
return file;
|
|
12
|
+
}
|
|
13
|
+
/** Reads and validates the discovery file, or returns undefined if it's missing or invalid. */
|
|
14
|
+
export async function readDiscovery(workspaceRoot) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await readFile(join(workspaceRoot, DISCOVERY_FILE), 'utf8');
|
|
17
|
+
const parsed = DaemonDiscovery.safeParse(JSON.parse(raw));
|
|
18
|
+
return parsed.success ? parsed.data : undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function removeDiscovery(workspaceRoot) {
|
|
25
|
+
await rm(join(workspaceRoot, DISCOVERY_FILE), { force: true });
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The nearest folder at or above `dir` with a valid discovery file, and what it says. This is where
|
|
29
|
+
* `lightpath mcp` looks too, so `lightpath doctor` in a subfolder reports the daemon MCP would use.
|
|
30
|
+
*/
|
|
31
|
+
export async function findDiscovery(dir) {
|
|
32
|
+
for (let root = resolve(dir);;) {
|
|
33
|
+
const info = await readDiscovery(root);
|
|
34
|
+
if (info)
|
|
35
|
+
return { root, info };
|
|
36
|
+
const parent = dirname(root);
|
|
37
|
+
if (parent === root)
|
|
38
|
+
return undefined;
|
|
39
|
+
root = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=discovery.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { authorize, newToken, tokensEqual } from './auth.js';
|
|
2
|
+
export { Correlator } from './correlator.js';
|
|
3
|
+
export { findDiscovery, readDiscovery, removeDiscovery, writeDiscovery } from './discovery.js';
|
|
4
|
+
export { type DaemonOptions, type RunningDaemon, startDaemon } from './server.js';
|
|
5
|
+
export { MemoryStore, type Store, type TraceQuery } from './store.js';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { authorize, newToken, tokensEqual } from './auth.js';
|
|
2
|
+
export { Correlator } from './correlator.js';
|
|
3
|
+
export { findDiscovery, readDiscovery, removeDiscovery, writeDiscovery } from './discovery.js';
|
|
4
|
+
export { startDaemon } from './server.js';
|
|
5
|
+
export { MemoryStore } from './store.js';
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ResolveContext } from '@lightpath-dev/core';
|
|
2
|
+
import { Correlator } from './correlator.js';
|
|
3
|
+
import { type Store } from './store.js';
|
|
4
|
+
export interface DaemonOptions {
|
|
5
|
+
/** Workspace roots; the first one holds `.lightpath/daemon.json`. */
|
|
6
|
+
workspaceRoots: string[];
|
|
7
|
+
devServers?: ResolveContext['devServers'];
|
|
8
|
+
/** Globs for workspace files to leave out of traces (`ignore` in lightpath.config.json). */
|
|
9
|
+
ignore?: string[];
|
|
10
|
+
/** 0 picks a free port. */
|
|
11
|
+
port?: number;
|
|
12
|
+
token?: string;
|
|
13
|
+
store?: Store;
|
|
14
|
+
writeDiscoveryFile?: boolean;
|
|
15
|
+
log?: (msg: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface RunningDaemon {
|
|
18
|
+
port: number;
|
|
19
|
+
url: string;
|
|
20
|
+
token: string;
|
|
21
|
+
startedAt: number;
|
|
22
|
+
store: Store;
|
|
23
|
+
correlator: Correlator;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** Starts the daemon on 127.0.0.1 (docs/ARCHITECTURE.md section 7). */
|
|
27
|
+
export declare function startDaemon(options: DaemonOptions): Promise<RunningDaemon>;
|
|
28
|
+
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import http, {} from 'node:http';
|
|
2
|
+
import { ClientMessage, CloseCode, IngestBatch, MAX_MESSAGE_BYTES, PROTOCOL_VERSION, } from '@lightpath-dev/protocol';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { authorize, newToken } from './auth.js';
|
|
5
|
+
import { Correlator } from './correlator.js';
|
|
6
|
+
import { removeDiscovery, writeDiscovery } from './discovery.js';
|
|
7
|
+
import { MemoryStore } from './store.js';
|
|
8
|
+
/** Starts the daemon on 127.0.0.1 (docs/ARCHITECTURE.md section 7). */
|
|
9
|
+
export async function startDaemon(options) {
|
|
10
|
+
const token = options.token ?? newToken();
|
|
11
|
+
const store = options.store ?? new MemoryStore();
|
|
12
|
+
const log = options.log ?? (() => { });
|
|
13
|
+
const startedAt = Date.now();
|
|
14
|
+
const correlator = new Correlator(store, {
|
|
15
|
+
workspaceRoots: options.workspaceRoots,
|
|
16
|
+
...(options.devServers ? { devServers: options.devServers } : {}),
|
|
17
|
+
...(options.ignore?.length ? { ignore: options.ignore } : {}),
|
|
18
|
+
});
|
|
19
|
+
const server = http.createServer((req, res) => {
|
|
20
|
+
void route(req, res).catch((err) => {
|
|
21
|
+
log(`request error: ${err.stack ?? err}`);
|
|
22
|
+
if (!res.headersSent)
|
|
23
|
+
res.writeHead(500).end();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
async function route(req, res) {
|
|
27
|
+
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
28
|
+
if (req.method === 'GET' && path === '/v1/health') {
|
|
29
|
+
return json(res, 200, { ok: true, protocolVersion: PROTOCOL_VERSION });
|
|
30
|
+
}
|
|
31
|
+
if (req.method === 'POST' && path === '/v1/ingest') {
|
|
32
|
+
const auth = authorize(req, token);
|
|
33
|
+
if (auth !== 'ok')
|
|
34
|
+
return json(res, auth === 'forbidden' ? 403 : 401, { error: auth });
|
|
35
|
+
const body = await readBody(req, MAX_MESSAGE_BYTES);
|
|
36
|
+
if (body === undefined)
|
|
37
|
+
return json(res, 413, { error: 'body too large' });
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(body);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return json(res, 400, { error: 'invalid JSON' });
|
|
44
|
+
}
|
|
45
|
+
const batch = IngestBatch.safeParse(parsed);
|
|
46
|
+
if (!batch.success)
|
|
47
|
+
return json(res, 400, { error: batch.error.issues.slice(0, 5) });
|
|
48
|
+
if (batch.data.protocolVersion !== PROTOCOL_VERSION)
|
|
49
|
+
return json(res, 426, { error: 'protocol version mismatch' });
|
|
50
|
+
for (const message of batch.data.messages)
|
|
51
|
+
correlator.ingest(batch.data.agentId, message);
|
|
52
|
+
res.writeHead(204, { 'x-lightpath-started-at': String(startedAt) }).end();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
json(res, 404, { error: 'not found' });
|
|
56
|
+
}
|
|
57
|
+
// ── Client feed ───────────────────────────────────────────────────────────
|
|
58
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES });
|
|
59
|
+
server.on('upgrade', (req, socket, head) => {
|
|
60
|
+
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
61
|
+
const auth = authorize(req, token);
|
|
62
|
+
if (path !== '/v1/client' || auth !== 'ok') {
|
|
63
|
+
socket.end(`HTTP/1.1 ${auth === 'forbidden' ? '403 Forbidden' : path !== '/v1/client' ? '404 Not Found' : '401 Unauthorized'}\r\n\r\n`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
wss.handleUpgrade(req, socket, head, (ws) => onClient(ws));
|
|
67
|
+
});
|
|
68
|
+
function onClient(ws) {
|
|
69
|
+
const send = (m) => ws.send(JSON.stringify(m));
|
|
70
|
+
send({ type: 'welcome', protocolVersion: PROTOCOL_VERSION, workspaceRoots: options.workspaceRoots });
|
|
71
|
+
const unsubscribe = store.subscribe((trace) => send({ type: 'trace', trace }));
|
|
72
|
+
ws.on('close', unsubscribe);
|
|
73
|
+
ws.on('message', (data) => {
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = ClientMessage.safeParse(JSON.parse(String(data)));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
ws.close(CloseCode.BadMessage, 'invalid JSON');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!parsed.success) {
|
|
83
|
+
ws.close(CloseCode.BadMessage, 'invalid message');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const msg = parsed.data;
|
|
87
|
+
if (msg.type === 'listTraces') {
|
|
88
|
+
send({
|
|
89
|
+
type: 'traces',
|
|
90
|
+
requestId: msg.requestId,
|
|
91
|
+
traces: store.list({ limit: msg.limit, label: msg.label }),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
send({ type: 'traceResult', requestId: msg.requestId, trace: store.get(msg.traceId) ?? null });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
await new Promise((resolve, reject) => {
|
|
100
|
+
server.once('error', reject);
|
|
101
|
+
server.listen(options.port ?? 0, '127.0.0.1', () => resolve());
|
|
102
|
+
});
|
|
103
|
+
const port = server.address().port;
|
|
104
|
+
const root = options.workspaceRoots[0];
|
|
105
|
+
if (options.writeDiscoveryFile !== false && root) {
|
|
106
|
+
await writeDiscovery(root, {
|
|
107
|
+
port,
|
|
108
|
+
token,
|
|
109
|
+
pid: process.pid,
|
|
110
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
111
|
+
startedAt,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
log(`daemon listening on 127.0.0.1:${port}`);
|
|
115
|
+
return {
|
|
116
|
+
port,
|
|
117
|
+
url: `http://127.0.0.1:${port}`,
|
|
118
|
+
token,
|
|
119
|
+
startedAt,
|
|
120
|
+
store,
|
|
121
|
+
correlator,
|
|
122
|
+
async close() {
|
|
123
|
+
for (const client of wss.clients)
|
|
124
|
+
client.close();
|
|
125
|
+
wss.close();
|
|
126
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
127
|
+
if (options.writeDiscoveryFile !== false && root)
|
|
128
|
+
await removeDiscovery(root);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function json(res, status, body) {
|
|
133
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
|
134
|
+
}
|
|
135
|
+
/** Reads a request body as UTF-8, or returns undefined if it exceeds `limit` bytes. */
|
|
136
|
+
function readBody(req, limit) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const chunks = [];
|
|
139
|
+
let size = 0;
|
|
140
|
+
req.on('data', (chunk) => {
|
|
141
|
+
size += chunk.length;
|
|
142
|
+
if (size > limit) {
|
|
143
|
+
resolve(undefined);
|
|
144
|
+
req.destroy();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
chunks.push(chunk);
|
|
148
|
+
});
|
|
149
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
150
|
+
req.on('error', reject);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=server.js.map
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Trace, TraceId, TraceSummary } from '@lightpath-dev/protocol';
|
|
2
|
+
export interface TraceQuery {
|
|
3
|
+
limit?: number | undefined;
|
|
4
|
+
/** Case-insensitive substring match on the trace label. */
|
|
5
|
+
label?: string | undefined;
|
|
6
|
+
}
|
|
7
|
+
export interface Store {
|
|
8
|
+
put(trace: Trace): void;
|
|
9
|
+
get(id: TraceId): Trace | undefined;
|
|
10
|
+
/** Newest first. */
|
|
11
|
+
list(query?: TraceQuery): TraceSummary[];
|
|
12
|
+
subscribe(listener: (trace: Trace) => void): () => void;
|
|
13
|
+
}
|
|
14
|
+
/** Keeps the most recent traces in memory (ARCHITECTURE section 7.3). */
|
|
15
|
+
export declare class MemoryStore implements Store {
|
|
16
|
+
#private;
|
|
17
|
+
constructor(capacity?: number);
|
|
18
|
+
put(trace: Trace): void;
|
|
19
|
+
get(id: TraceId): Trace | undefined;
|
|
20
|
+
list(query?: TraceQuery): TraceSummary[];
|
|
21
|
+
subscribe(listener: (trace: Trace) => void): () => void;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=store.d.ts.map
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Keeps the most recent traces in memory (ARCHITECTURE section 7.3). */
|
|
2
|
+
export class MemoryStore {
|
|
3
|
+
#traces = new Map();
|
|
4
|
+
#listeners = new Set();
|
|
5
|
+
#capacity;
|
|
6
|
+
constructor(capacity = 200) {
|
|
7
|
+
this.#capacity = capacity;
|
|
8
|
+
}
|
|
9
|
+
put(trace) {
|
|
10
|
+
// Re-inserting moves the trace to the newest position.
|
|
11
|
+
this.#traces.delete(trace.id);
|
|
12
|
+
this.#traces.set(trace.id, trace);
|
|
13
|
+
while (this.#traces.size > this.#capacity) {
|
|
14
|
+
const oldest = this.#traces.keys().next().value;
|
|
15
|
+
if (oldest === undefined)
|
|
16
|
+
break;
|
|
17
|
+
this.#traces.delete(oldest);
|
|
18
|
+
}
|
|
19
|
+
for (const listener of this.#listeners)
|
|
20
|
+
listener(trace);
|
|
21
|
+
}
|
|
22
|
+
get(id) {
|
|
23
|
+
return this.#traces.get(id);
|
|
24
|
+
}
|
|
25
|
+
list(query = {}) {
|
|
26
|
+
const needle = query.label?.toLowerCase();
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const t of [...this.#traces.values()].reverse()) {
|
|
29
|
+
if (needle && !t.label.toLowerCase().includes(needle))
|
|
30
|
+
continue;
|
|
31
|
+
out.push({ id: t.id, label: t.label, startedAt: t.startedAt, fileCount: t.files.length });
|
|
32
|
+
if (query.limit && out.length >= query.limit)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
subscribe(listener) {
|
|
38
|
+
this.#listeners.add(listener);
|
|
39
|
+
return () => this.#listeners.delete(listener);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=store.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightpath-dev/daemon",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Local Lightpath daemon: receives coverage from agents, maps it to source lines, and serves traces",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"coverage",
|
|
7
|
+
"trace",
|
|
8
|
+
"daemon",
|
|
9
|
+
"websocket"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"homepage": "https://github.com/Unknown-333/Lightpath#readme",
|
|
13
|
+
"bugs": "https://github.com/Unknown-333/Lightpath/issues",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Unknown-333/Lightpath.git",
|
|
17
|
+
"directory": "packages/daemon"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"!dist/.tsbuildinfo",
|
|
29
|
+
"!dist/**/*.map"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.12"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@lightpath-dev/core": "0.0.1",
|
|
39
|
+
"@lightpath-dev/protocol": "0.0.1",
|
|
40
|
+
"ws": "8.22.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/ws": "8.18.2"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"typecheck": "tsc -p test"
|
|
48
|
+
}
|
|
6
49
|
}
|