@lightpath-dev/daemon 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/actions.d.ts +19 -0
- package/dist/actions.js +84 -0
- package/dist/auth.d.ts +11 -0
- package/dist/auth.js +26 -0
- package/dist/correlator.d.ts +16 -0
- package/dist/correlator.js +441 -0
- package/dist/discovery.d.ts +15 -0
- package/dist/discovery.js +42 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/server.d.ts +36 -0
- package/dist/server.js +191 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +49 -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.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ActionRequest, ActionResult, ActionRunner, Trace, TraceId } from '@lightpath-dev/protocol';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
/** Longest wait after `traceEnd` for the backend lines of the requests an action sent (D-114). */
|
|
4
|
+
export declare const BACKEND_WAIT_MS = 1500;
|
|
5
|
+
type Tagged = {
|
|
6
|
+
method: string;
|
|
7
|
+
path: string;
|
|
8
|
+
};
|
|
9
|
+
/** The tagged requests that have no backend delta in the trace yet, matched by method and path. */
|
|
10
|
+
export declare function missingBackend(trace: Trace | undefined, tagged: readonly Tagged[]): Tagged[];
|
|
11
|
+
/**
|
|
12
|
+
* One wait for all of an action's requests: ends as soon as each tagged request has its backend
|
|
13
|
+
* delta in the stored trace, or after `waitMs`. Returns the requests still missing.
|
|
14
|
+
*/
|
|
15
|
+
export declare function waitForBackend(store: Store, traceId: TraceId, tagged: readonly Tagged[], waitMs?: number): Promise<Tagged[]>;
|
|
16
|
+
/** Runs one `/v1/action` request: the runner's action, then the backend wait (section 4.3). */
|
|
17
|
+
export declare function runAction(runner: ActionRunner | undefined, store: Store, request: ActionRequest, waitMs?: number): Promise<ActionResult>;
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=actions.d.ts.map
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Longest wait after `traceEnd` for the backend lines of the requests an action sent (D-114). */
|
|
2
|
+
export const BACKEND_WAIT_MS = 1500;
|
|
3
|
+
const withoutQuery = (path) => path.split('?')[0] ?? path;
|
|
4
|
+
const key = (r) => `${r.method.toUpperCase()} ${withoutQuery(r.path)}`;
|
|
5
|
+
/** The tagged requests that have no backend delta in the trace yet, matched by method and path. */
|
|
6
|
+
export function missingBackend(trace, tagged) {
|
|
7
|
+
const have = new Map();
|
|
8
|
+
for (const r of trace?.requests ?? []) {
|
|
9
|
+
if (r.collector === 'browser')
|
|
10
|
+
continue;
|
|
11
|
+
have.set(key(r), (have.get(key(r)) ?? 0) + 1);
|
|
12
|
+
}
|
|
13
|
+
const missing = [];
|
|
14
|
+
for (const r of tagged) {
|
|
15
|
+
const n = have.get(key(r)) ?? 0;
|
|
16
|
+
if (n > 0)
|
|
17
|
+
have.set(key(r), n - 1);
|
|
18
|
+
else
|
|
19
|
+
missing.push(r);
|
|
20
|
+
}
|
|
21
|
+
return missing;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One wait for all of an action's requests: ends as soon as each tagged request has its backend
|
|
25
|
+
* delta in the stored trace, or after `waitMs`. Returns the requests still missing.
|
|
26
|
+
*/
|
|
27
|
+
export function waitForBackend(store, traceId, tagged, waitMs = BACKEND_WAIT_MS) {
|
|
28
|
+
const initial = missingBackend(store.get(traceId), tagged);
|
|
29
|
+
if (initial.length === 0)
|
|
30
|
+
return Promise.resolve([]);
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const finish = () => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
unsubscribe();
|
|
35
|
+
resolve(missingBackend(store.get(traceId), tagged));
|
|
36
|
+
};
|
|
37
|
+
const timer = setTimeout(finish, waitMs);
|
|
38
|
+
const unsubscribe = store.subscribe((trace) => {
|
|
39
|
+
if (trace.id === traceId && missingBackend(trace, tagged).length === 0)
|
|
40
|
+
finish();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Runs one `/v1/action` request: the runner's action, then the backend wait (section 4.3). */
|
|
45
|
+
export async function runAction(runner, store, request, waitMs = BACKEND_WAIT_MS) {
|
|
46
|
+
if (!runner) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: {
|
|
50
|
+
code: 'no_browser',
|
|
51
|
+
message: 'Lightpath has no browser open to act in. Start it with a browser (lightpath dev -- <your dev command>, ' +
|
|
52
|
+
'without --no-browser) and wait until it prints "watching".',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const outcome = await runner(request);
|
|
57
|
+
if (!outcome.ok)
|
|
58
|
+
return outcome;
|
|
59
|
+
const missing = await waitForBackend(store, outcome.traceId, outcome.tagged, waitMs);
|
|
60
|
+
const trace = store.get(outcome.traceId);
|
|
61
|
+
if (!trace) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
error: { code: 'no_trace', message: `Trace ${outcome.traceId} was evicted before it could be read.` },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const warnings = [...outcome.warnings];
|
|
68
|
+
if (missing.length > 0) {
|
|
69
|
+
warnings.push({
|
|
70
|
+
code: 'backend_partial',
|
|
71
|
+
message: `No backend lines arrived within ${waitMs} ms for ${missing.map(key).join(', ')}: a remote API, or a server not started by lightpath dev.`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
trace,
|
|
77
|
+
boundary: outcome.boundary,
|
|
78
|
+
settled: outcome.settled,
|
|
79
|
+
backend: missing.length > 0 ? 'partial' : 'complete',
|
|
80
|
+
pageUrl: outcome.pageUrl,
|
|
81
|
+
warnings,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=actions.js.map
|
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,441 @@
|
|
|
1
|
+
import { decodeDataUrlSourceMap, mapFunctions, mapScript, mergeLineCounts, newTraceId, originalLocation, PeriodicFunctions, resolveFile, } from '@lightpath-dev/core';
|
|
2
|
+
import { MAX_TRACE_PROBLEMS, } from '@lightpath-dev/protocol';
|
|
3
|
+
/**
|
|
4
|
+
* Turns ingest messages into mapped traces (docs/ARCHITECTURE.md sections 3, 4, and 6).
|
|
5
|
+
* Pure in-memory logic with no I/O, so it's tested without sockets.
|
|
6
|
+
*/
|
|
7
|
+
export class Correlator {
|
|
8
|
+
#agents = new Map();
|
|
9
|
+
/** Sources are cached by script hash, shared across agents (ADR 0006). */
|
|
10
|
+
#sources = new Map();
|
|
11
|
+
#pending = new Map();
|
|
12
|
+
#store;
|
|
13
|
+
#ctx;
|
|
14
|
+
constructor(store, ctx) {
|
|
15
|
+
this.#store = store;
|
|
16
|
+
this.#ctx = ctx;
|
|
17
|
+
}
|
|
18
|
+
/** Maps URLs served by a dev server (e.g. Vite at http://localhost:5173) onto a directory. */
|
|
19
|
+
addDevServer(origin, root) {
|
|
20
|
+
const others = (this.#ctx.devServers ?? []).filter((s) => s.origin !== origin);
|
|
21
|
+
this.#ctx = { ...this.#ctx, devServers: [...others, { origin, root }] };
|
|
22
|
+
}
|
|
23
|
+
get sourceCount() {
|
|
24
|
+
return this.#sources.size;
|
|
25
|
+
}
|
|
26
|
+
ingest(agentId, message) {
|
|
27
|
+
switch (message.type) {
|
|
28
|
+
case 'hello':
|
|
29
|
+
this.#agents.set(agentId, {
|
|
30
|
+
kind: message.kind,
|
|
31
|
+
pid: message.pid,
|
|
32
|
+
scripts: new Map(),
|
|
33
|
+
periodic: new PeriodicFunctions(),
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
case 'scripts': {
|
|
37
|
+
const agent = this.#agent(agentId);
|
|
38
|
+
for (const s of message.scripts)
|
|
39
|
+
agent.scripts.set(s.scriptId, s);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
case 'source':
|
|
43
|
+
this.#sources.set(message.hash, message.source);
|
|
44
|
+
return;
|
|
45
|
+
case 'traceStart': {
|
|
46
|
+
// Merge rather than replace, in case a backend delta with this ID arrived first.
|
|
47
|
+
const existing = this.#pending.get(message.traceId);
|
|
48
|
+
if (existing) {
|
|
49
|
+
existing.trace.label = message.label;
|
|
50
|
+
existing.trace.origin = 'browser';
|
|
51
|
+
existing.trace.startedAt = Math.min(existing.trace.startedAt, message.at);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
this.#pending.set(message.traceId, this.#newPending(message.traceId, message.label, 'browser', message.at));
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
case 'traceEnd': {
|
|
59
|
+
const p = this.#pending.get(message.traceId);
|
|
60
|
+
if (p) {
|
|
61
|
+
p.trace.endedAt = message.at;
|
|
62
|
+
this.#publish(p);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
case 'delta':
|
|
67
|
+
this.#onDelta(agentId, message);
|
|
68
|
+
return;
|
|
69
|
+
case 'lines':
|
|
70
|
+
this.#onLines(agentId, message);
|
|
71
|
+
return;
|
|
72
|
+
case 'problem':
|
|
73
|
+
this.#onProblem(agentId, message);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Attaches an error or failed request to its trace (D-121). Without an open trace, dropped. */
|
|
78
|
+
#onProblem(agentId, msg) {
|
|
79
|
+
if (!msg.traceId)
|
|
80
|
+
return;
|
|
81
|
+
const agent = this.#agent(agentId);
|
|
82
|
+
// A backend crash during run_request comes before any delta for its trace: it opens one.
|
|
83
|
+
const pending = this.#pending.get(msg.traceId) ??
|
|
84
|
+
this.#fromStore(msg.traceId) ??
|
|
85
|
+
(agent.kind === 'browser' ? undefined : this.#pendingFor(msg.traceId, 'uncaught error', msg.at));
|
|
86
|
+
if (!pending)
|
|
87
|
+
return;
|
|
88
|
+
pending.trace.problems ??= [];
|
|
89
|
+
const problems = pending.trace.problems;
|
|
90
|
+
if (problems.length >= MAX_TRACE_PROBLEMS) {
|
|
91
|
+
pending.trace.problemsDropped = (pending.trace.problemsDropped ?? 0) + 1;
|
|
92
|
+
this.#publish(pending);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const where = msg.location ? this.#locate(agent, msg.location) : undefined;
|
|
96
|
+
problems.push({
|
|
97
|
+
collector: agent.kind,
|
|
98
|
+
kind: msg.kind,
|
|
99
|
+
message: msg.message,
|
|
100
|
+
at: msg.at,
|
|
101
|
+
...(where ? { path: where.path, line: where.line } : {}),
|
|
102
|
+
...(msg.request ? { request: msg.request } : {}),
|
|
103
|
+
});
|
|
104
|
+
this.#publish(pending);
|
|
105
|
+
}
|
|
106
|
+
/** A problem's generated location as a workspace path and original line, if it maps there. */
|
|
107
|
+
#locate(agent, loc) {
|
|
108
|
+
if (loc.line < 1)
|
|
109
|
+
return undefined;
|
|
110
|
+
const info = [...agent.scripts.values()].reverse().find((s) => s.url === loc.url);
|
|
111
|
+
let source = loc.url;
|
|
112
|
+
let line = loc.line;
|
|
113
|
+
if (info?.sourceMapURL) {
|
|
114
|
+
try {
|
|
115
|
+
const map = decodeDataUrlSourceMap(info.sourceMapURL);
|
|
116
|
+
const orig = map ? originalLocation(map, info.url, loc.line, loc.column) : undefined;
|
|
117
|
+
if (!orig)
|
|
118
|
+
return undefined;
|
|
119
|
+
source = orig.source;
|
|
120
|
+
line = orig.line;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const r = resolveFile(source, this.#ctx);
|
|
127
|
+
return r.kind === 'workspace' ? { path: r.path, line } : undefined;
|
|
128
|
+
}
|
|
129
|
+
#onDelta(agentId, delta) {
|
|
130
|
+
const agent = this.#agent(agentId);
|
|
131
|
+
// Background deltas only feed the periodic-function rule (ARCHITECTURE 4.6); never traces.
|
|
132
|
+
if (delta.phase === 'background') {
|
|
133
|
+
agent.periodic.observeBackground(delta.coverage, (id) => agent.scripts.get(id)?.hash);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const pending = this.#pendingFor(delta.traceId, delta.request ? `${delta.request.method} ${delta.request.path}` : 'action', delta.startedAt);
|
|
137
|
+
// Which phase this delta's functions belong to (D-116). A backend phase is made below, once
|
|
138
|
+
// the request has its index.
|
|
139
|
+
let phase;
|
|
140
|
+
if (delta.beforeRequest) {
|
|
141
|
+
phase = { kind: 'before-request', at: delta.endedAt, leaving: delta.beforeRequest };
|
|
142
|
+
pending.phases.push(phase);
|
|
143
|
+
}
|
|
144
|
+
else if (!delta.request) {
|
|
145
|
+
phase = this.#closingPhase(pending, delta.endedAt);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
phase = { kind: 'backend', at: delta.startedAt, request: pending.trace.requests.length };
|
|
149
|
+
pending.phases.push(phase);
|
|
150
|
+
}
|
|
151
|
+
for (const coverage of delta.coverage) {
|
|
152
|
+
const info = agent.scripts.get(coverage.scriptId);
|
|
153
|
+
const source = info ? this.#sources.get(info.hash) : undefined;
|
|
154
|
+
if (source === undefined) {
|
|
155
|
+
// Without the exact source V8 ran, offsets can't be turned into lines.
|
|
156
|
+
const r = resolveFile(coverage.url, this.#ctx);
|
|
157
|
+
this.#drop(pending, r.kind === 'dropped' ? r.reason : 'no-source');
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
let sourceMap;
|
|
161
|
+
try {
|
|
162
|
+
sourceMap = info?.sourceMapURL ? decodeDataUrlSourceMap(info.sourceMapURL) : undefined;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
this.#drop(pending, 'no-source-map');
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const map = (c) => mapScript({ coverage: c, source, ...(sourceMap ? { sourceMap } : {}) }, this.#ctx);
|
|
169
|
+
const result = map(coverage);
|
|
170
|
+
for (const [reason, n] of result.dropped)
|
|
171
|
+
this.#drop(pending, reason, n);
|
|
172
|
+
// Lines are never removed. A file whose lit lines all come from periodic code (a timer that
|
|
173
|
+
// happened to fire during the action) is marked background so clients can tone it down.
|
|
174
|
+
const fg = info ? agent.periodic.foreground(coverage, info.hash) : coverage;
|
|
175
|
+
const fgFiles = fg === coverage ? result.files : fg ? map(fg).files : new Map();
|
|
176
|
+
for (const [path, lines] of result.files) {
|
|
177
|
+
const fgLines = fgFiles.get(path);
|
|
178
|
+
const foreground = fgLines !== undefined && [...fgLines.values()].some((c) => c > 0);
|
|
179
|
+
this.#addFile(pending, path, agent.kind, lines, delta.startedAt, foreground);
|
|
180
|
+
}
|
|
181
|
+
const fns = mapFunctions({ coverage, source, ...(sourceMap ? { sourceMap } : {}) }, result.files, this.#ctx);
|
|
182
|
+
for (const fn of fns)
|
|
183
|
+
this.#addFunction(pending, phase, agent.kind, fn);
|
|
184
|
+
}
|
|
185
|
+
if (delta.request) {
|
|
186
|
+
pending.trace.requests.push({
|
|
187
|
+
collector: agent.kind,
|
|
188
|
+
pid: agent.pid,
|
|
189
|
+
method: delta.request.method,
|
|
190
|
+
path: delta.request.path,
|
|
191
|
+
...(delta.request.status !== undefined ? { status: delta.request.status } : {}),
|
|
192
|
+
durationMs: Math.max(0, delta.endedAt - delta.startedAt),
|
|
193
|
+
overlapped: (delta.overlapped?.length ?? 0) > 0,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// A browser trace ends only with traceEnd, after the frontend's own delta. Backend deltas
|
|
197
|
+
// arrive mid-click and must not make the trace look finished to clients.
|
|
198
|
+
if (pending.trace.origin === 'backend')
|
|
199
|
+
pending.trace.endedAt = Math.max(pending.trace.endedAt ?? 0, delta.endedAt);
|
|
200
|
+
this.#publish(pending);
|
|
201
|
+
}
|
|
202
|
+
/** Adds one function to its phase, summing calls and keeping lines not run in every delta. */
|
|
203
|
+
#addFunction(p, phase, collector, fn) {
|
|
204
|
+
const key = `${p.phases.indexOf(phase)}|${phase.kind}|${fn.path}|${fn.name}|${fn.startLine}`;
|
|
205
|
+
const prev = p.functions.get(key);
|
|
206
|
+
if (!prev) {
|
|
207
|
+
p.functions.set(key, {
|
|
208
|
+
phase,
|
|
209
|
+
hit: {
|
|
210
|
+
path: fn.path,
|
|
211
|
+
collector,
|
|
212
|
+
name: fn.name,
|
|
213
|
+
startLine: fn.startLine,
|
|
214
|
+
endLine: fn.endLine,
|
|
215
|
+
count: fn.count,
|
|
216
|
+
...(fn.notRun.length ? { notRun: fn.notRun } : {}),
|
|
217
|
+
...(fn.notRunSpans.length ? { notRunSpans: fn.notRunSpans } : {}),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const hit = prev.hit;
|
|
223
|
+
hit.count += fn.count;
|
|
224
|
+
hit.endLine = Math.max(hit.endLine, fn.endLine);
|
|
225
|
+
const notRun = (hit.notRun ?? []).filter((l) => fn.notRun.includes(l));
|
|
226
|
+
const spans = (hit.notRunSpans ?? []).filter((s) => fn.notRunSpans.some((t) => t.line === s.line && t.startColumn === s.startColumn));
|
|
227
|
+
if (notRun.length)
|
|
228
|
+
hit.notRun = notRun;
|
|
229
|
+
else
|
|
230
|
+
delete hit.notRun;
|
|
231
|
+
if (spans.length)
|
|
232
|
+
hit.notRunSpans = spans;
|
|
233
|
+
else
|
|
234
|
+
delete hit.notRunSpans;
|
|
235
|
+
}
|
|
236
|
+
/** The one browser phase for reads not taken before a request (D-116), made on first use. */
|
|
237
|
+
#closingPhase(p, at) {
|
|
238
|
+
if (!p.closing) {
|
|
239
|
+
p.closing = { kind: 'browser', at };
|
|
240
|
+
p.phases.push(p.closing);
|
|
241
|
+
}
|
|
242
|
+
p.closing.at = Math.max(p.closing.at, at);
|
|
243
|
+
return p.closing;
|
|
244
|
+
}
|
|
245
|
+
#onLines(agentId, msg) {
|
|
246
|
+
const agent = this.#agent(agentId);
|
|
247
|
+
const label = msg.request ? `${msg.request.method} ${msg.request.path}` : 'action';
|
|
248
|
+
const pending = this.#pendingFor(msg.traceId, label, msg.startedAt);
|
|
249
|
+
// Python files have no function list; the summary lists them by line range in this phase.
|
|
250
|
+
if (msg.request)
|
|
251
|
+
pending.phases.push({ kind: 'backend', at: msg.startedAt, request: pending.trace.requests.length });
|
|
252
|
+
for (const file of msg.files) {
|
|
253
|
+
const r = resolveFile(file.path, this.#ctx);
|
|
254
|
+
if (r.kind === 'dropped') {
|
|
255
|
+
this.#drop(pending, r.reason);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
this.#addFile(pending, r.path, 'python', new Map(file.lines.map((l) => [l, 1])), msg.startedAt, true);
|
|
259
|
+
}
|
|
260
|
+
if (msg.request) {
|
|
261
|
+
pending.trace.requests.push({
|
|
262
|
+
collector: 'python',
|
|
263
|
+
pid: agent.pid || msg.pid,
|
|
264
|
+
method: msg.request.method,
|
|
265
|
+
path: msg.request.path,
|
|
266
|
+
...(msg.request.status !== undefined ? { status: msg.request.status } : {}),
|
|
267
|
+
durationMs: Math.max(0, msg.endedAt - msg.startedAt),
|
|
268
|
+
overlapped: false,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (pending.trace.origin === 'backend')
|
|
272
|
+
pending.trace.endedAt = Math.max(pending.trace.endedAt ?? 0, msg.endedAt);
|
|
273
|
+
this.#publish(pending);
|
|
274
|
+
}
|
|
275
|
+
#agent(agentId) {
|
|
276
|
+
let a = this.#agents.get(agentId);
|
|
277
|
+
if (!a) {
|
|
278
|
+
// A batch can arrive before (or without) hello, for example after a daemon restart.
|
|
279
|
+
a = {
|
|
280
|
+
kind: agentId.startsWith('py-') ? 'python' : 'node',
|
|
281
|
+
pid: 0,
|
|
282
|
+
scripts: new Map(),
|
|
283
|
+
periodic: new PeriodicFunctions(),
|
|
284
|
+
};
|
|
285
|
+
this.#agents.set(agentId, a);
|
|
286
|
+
}
|
|
287
|
+
return a;
|
|
288
|
+
}
|
|
289
|
+
/** Finds the browser-started trace for this ID, or starts a backend-only trace. */
|
|
290
|
+
#pendingFor(traceId, label, at) {
|
|
291
|
+
if (traceId) {
|
|
292
|
+
const existing = this.#pending.get(traceId) ?? this.#fromStore(traceId);
|
|
293
|
+
if (existing)
|
|
294
|
+
return existing;
|
|
295
|
+
}
|
|
296
|
+
const p = this.#newPending(traceId ?? newTraceId(), label, 'backend', at);
|
|
297
|
+
this.#pending.set(p.trace.id, p);
|
|
298
|
+
return p;
|
|
299
|
+
}
|
|
300
|
+
#fromStore(traceId) {
|
|
301
|
+
const trace = this.#store.get(traceId);
|
|
302
|
+
if (!trace)
|
|
303
|
+
return undefined;
|
|
304
|
+
const p = {
|
|
305
|
+
trace: structuredClone(trace),
|
|
306
|
+
files: new Map(),
|
|
307
|
+
dropped: new Map(),
|
|
308
|
+
phases: [],
|
|
309
|
+
functions: new Map(),
|
|
310
|
+
};
|
|
311
|
+
// Phases keep their published order: their old sort keys are their positions, which sort
|
|
312
|
+
// before any new read's time. The closing browser phase stays last.
|
|
313
|
+
for (const [i, ph] of (trace.phases ?? []).entries()) {
|
|
314
|
+
const open = {
|
|
315
|
+
kind: ph.kind,
|
|
316
|
+
at: i,
|
|
317
|
+
...(ph.request !== undefined ? { request: ph.request } : {}),
|
|
318
|
+
};
|
|
319
|
+
if (ph.kind === 'before-request' && ph.request !== undefined) {
|
|
320
|
+
const r = trace.requests[ph.request];
|
|
321
|
+
if (r)
|
|
322
|
+
open.leaving = { method: r.method, path: r.path };
|
|
323
|
+
}
|
|
324
|
+
if (ph.kind === 'browser' || ph.kind === 'after-response')
|
|
325
|
+
p.closing = open;
|
|
326
|
+
p.phases.push(open);
|
|
327
|
+
}
|
|
328
|
+
for (const f of trace.functions ?? []) {
|
|
329
|
+
const phase = p.phases[f.phase];
|
|
330
|
+
if (!phase)
|
|
331
|
+
continue;
|
|
332
|
+
const { phase: _, ...hit } = f;
|
|
333
|
+
p.functions.set(`${f.phase}|${phase.kind}|${f.path}|${f.name}|${f.startLine}`, { phase, hit });
|
|
334
|
+
}
|
|
335
|
+
for (const f of trace.files) {
|
|
336
|
+
p.files.set(f.path, {
|
|
337
|
+
collector: f.collector,
|
|
338
|
+
lines: new Map(f.lines.map((l) => [l.line, l.count])),
|
|
339
|
+
firstSeenAt: f.firstSeenAt,
|
|
340
|
+
foreground: !f.background,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
for (const d of trace.dropped)
|
|
344
|
+
p.dropped.set(d.reason, d.count);
|
|
345
|
+
this.#pending.set(traceId, p);
|
|
346
|
+
return p;
|
|
347
|
+
}
|
|
348
|
+
#newPending(id, label, origin, at) {
|
|
349
|
+
return {
|
|
350
|
+
trace: { id, label, origin, startedAt: at, files: [], requests: [], dropped: [] },
|
|
351
|
+
files: new Map(),
|
|
352
|
+
dropped: new Map(),
|
|
353
|
+
phases: [],
|
|
354
|
+
functions: new Map(),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Orders the phases and numbers them (design section 5.3): reads and requests in time order, the
|
|
359
|
+
* closing browser phase last. It is `after-response` when any request left the page before it,
|
|
360
|
+
* else `browser`. Each before-request phase is tied to the first unmatched backend request with
|
|
361
|
+
* the same method and path.
|
|
362
|
+
*/
|
|
363
|
+
#phasesOf(p, published) {
|
|
364
|
+
if (p.phases.length === 0)
|
|
365
|
+
return undefined;
|
|
366
|
+
const ordered = p.phases.filter((ph) => ph !== p.closing).sort((a, b) => a.at - b.at);
|
|
367
|
+
if (p.closing)
|
|
368
|
+
ordered.push(p.closing);
|
|
369
|
+
const hasBefore = ordered.some((ph) => ph.kind === 'before-request');
|
|
370
|
+
const matched = new Set();
|
|
371
|
+
const phases = ordered.map((ph) => {
|
|
372
|
+
if (ph === p.closing)
|
|
373
|
+
return { kind: hasBefore ? 'after-response' : 'browser' };
|
|
374
|
+
if (ph.kind === 'backend')
|
|
375
|
+
return { kind: 'backend', ...(ph.request !== undefined ? { request: ph.request } : {}) };
|
|
376
|
+
if (ph.kind === 'before-request' && ph.leaving) {
|
|
377
|
+
const { method, path } = ph.leaving;
|
|
378
|
+
const i = p.trace.requests.findIndex((r, j) => !matched.has(j) && r.collector !== 'browser' && r.method === method && r.path === path);
|
|
379
|
+
if (i >= 0) {
|
|
380
|
+
matched.add(i);
|
|
381
|
+
return { kind: 'before-request', request: i };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { kind: ph.kind };
|
|
385
|
+
});
|
|
386
|
+
const functions = [];
|
|
387
|
+
for (const { phase, hit } of p.functions.values()) {
|
|
388
|
+
if (!published.has(hit.path))
|
|
389
|
+
continue; // its file has no lit line, so it isn't in the trace
|
|
390
|
+
functions.push({ ...hit, phase: ordered.indexOf(phase) });
|
|
391
|
+
}
|
|
392
|
+
functions.sort((a, b) => a.phase - b.phase || a.path.localeCompare(b.path) || a.startLine - b.startLine);
|
|
393
|
+
return { phases, functions };
|
|
394
|
+
}
|
|
395
|
+
#addFile(p, path, collector, lines, at, foreground) {
|
|
396
|
+
const existing = p.files.get(path);
|
|
397
|
+
if (existing) {
|
|
398
|
+
existing.lines = mergeLineCounts(existing.lines, lines);
|
|
399
|
+
existing.foreground ||= foreground;
|
|
400
|
+
}
|
|
401
|
+
else
|
|
402
|
+
p.files.set(path, { collector, lines, firstSeenAt: at, foreground });
|
|
403
|
+
}
|
|
404
|
+
#drop(p, reason, n = 1) {
|
|
405
|
+
p.dropped.set(reason, (p.dropped.get(reason) ?? 0) + n);
|
|
406
|
+
}
|
|
407
|
+
#publish(p) {
|
|
408
|
+
const files = [];
|
|
409
|
+
for (const [path, f] of p.files) {
|
|
410
|
+
const lines = [...f.lines]
|
|
411
|
+
.filter(([, c]) => c > 0)
|
|
412
|
+
.sort((a, b) => a[0] - b[0])
|
|
413
|
+
.map(([line, count]) => ({ line, count }));
|
|
414
|
+
if (lines.length === 0)
|
|
415
|
+
continue;
|
|
416
|
+
files.push({
|
|
417
|
+
path,
|
|
418
|
+
collector: f.collector,
|
|
419
|
+
lines,
|
|
420
|
+
firstSeenAt: f.firstSeenAt,
|
|
421
|
+
...(f.foreground ? {} : { background: true }),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
files.sort((a, b) => a.firstSeenAt - b.firstSeenAt || a.path.localeCompare(b.path));
|
|
425
|
+
p.trace.files = files;
|
|
426
|
+
p.trace.dropped = [...p.dropped].map(([reason, count]) => ({ reason, count }));
|
|
427
|
+
const shaped = this.#phasesOf(p, new Set(files.map((f) => f.path)));
|
|
428
|
+
if (shaped) {
|
|
429
|
+
p.trace.phases = shaped.phases;
|
|
430
|
+
p.trace.functions = shaped.functions;
|
|
431
|
+
}
|
|
432
|
+
this.#store.put(structuredClone(p.trace));
|
|
433
|
+
// Keep at most 50 traces open for late backend deltas; older ones are re-read from the store.
|
|
434
|
+
if (this.#pending.size > 50) {
|
|
435
|
+
const oldest = this.#pending.keys().next().value;
|
|
436
|
+
if (oldest !== undefined)
|
|
437
|
+
this.#pending.delete(oldest);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
//# 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,7 @@
|
|
|
1
|
+
export { BACKEND_WAIT_MS, missingBackend, runAction, waitForBackend } from './actions.js';
|
|
2
|
+
export { authorize, newToken, tokensEqual } from './auth.js';
|
|
3
|
+
export { Correlator } from './correlator.js';
|
|
4
|
+
export { findDiscovery, readDiscovery, removeDiscovery, writeDiscovery } from './discovery.js';
|
|
5
|
+
export { type DaemonOptions, type RunningDaemon, startDaemon } from './server.js';
|
|
6
|
+
export { MemoryStore, type Store, type TraceQuery } from './store.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { BACKEND_WAIT_MS, missingBackend, runAction, waitForBackend } from './actions.js';
|
|
2
|
+
export { authorize, newToken, tokensEqual } from './auth.js';
|
|
3
|
+
export { Correlator } from './correlator.js';
|
|
4
|
+
export { findDiscovery, readDiscovery, removeDiscovery, writeDiscovery } from './discovery.js';
|
|
5
|
+
export { startDaemon } from './server.js';
|
|
6
|
+
export { MemoryStore } from './store.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ResolveContext } from '@lightpath-dev/core';
|
|
2
|
+
import { type ActionRunner } from '@lightpath-dev/protocol';
|
|
3
|
+
import { Correlator } from './correlator.js';
|
|
4
|
+
import { type Store } from './store.js';
|
|
5
|
+
export interface DaemonOptions {
|
|
6
|
+
/** Workspace roots; the first one holds `.lightpath/daemon.json`. */
|
|
7
|
+
workspaceRoots: string[];
|
|
8
|
+
devServers?: ResolveContext['devServers'];
|
|
9
|
+
/** Globs for workspace files to leave out of traces (`ignore` in lightpath.config.json). */
|
|
10
|
+
ignore?: string[];
|
|
11
|
+
/** 0 picks a free port. */
|
|
12
|
+
port?: number;
|
|
13
|
+
token?: string;
|
|
14
|
+
store?: Store;
|
|
15
|
+
writeDiscoveryFile?: boolean;
|
|
16
|
+
log?: (msg: string) => void;
|
|
17
|
+
/** Longest wait after an action's `traceEnd` for its backend lines (default 1,500 ms). */
|
|
18
|
+
backendWaitMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface RunningDaemon {
|
|
21
|
+
port: number;
|
|
22
|
+
url: string;
|
|
23
|
+
token: string;
|
|
24
|
+
startedAt: number;
|
|
25
|
+
store: Store;
|
|
26
|
+
correlator: Correlator;
|
|
27
|
+
/**
|
|
28
|
+
* Registers what runs `POST /v1/action` (the browser collector, once its page is open), or
|
|
29
|
+
* removes it. Without one, every action answers `no_browser` (D-115).
|
|
30
|
+
*/
|
|
31
|
+
setActionRunner(runner: ActionRunner | undefined): void;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** Starts the daemon on 127.0.0.1 (docs/ARCHITECTURE.md section 7). */
|
|
35
|
+
export declare function startDaemon(options: DaemonOptions): Promise<RunningDaemon>;
|
|
36
|
+
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import http, {} from 'node:http';
|
|
2
|
+
import { ActionRequest, ClientMessage, CloseCode, IngestBatch, MAX_ACTION_BYTES, MAX_MESSAGE_BYTES, PROTOCOL_VERSION, } from '@lightpath-dev/protocol';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { BACKEND_WAIT_MS, runAction } from './actions.js';
|
|
5
|
+
import { authorize, newToken } from './auth.js';
|
|
6
|
+
import { Correlator } from './correlator.js';
|
|
7
|
+
import { removeDiscovery, writeDiscovery } from './discovery.js';
|
|
8
|
+
import { MemoryStore } from './store.js';
|
|
9
|
+
/** Starts the daemon on 127.0.0.1 (docs/ARCHITECTURE.md section 7). */
|
|
10
|
+
export async function startDaemon(options) {
|
|
11
|
+
const token = options.token ?? newToken();
|
|
12
|
+
const store = options.store ?? new MemoryStore();
|
|
13
|
+
const log = options.log ?? (() => { });
|
|
14
|
+
const startedAt = Date.now();
|
|
15
|
+
const backendWaitMs = options.backendWaitMs ?? BACKEND_WAIT_MS;
|
|
16
|
+
let actionRunner;
|
|
17
|
+
const correlator = new Correlator(store, {
|
|
18
|
+
workspaceRoots: options.workspaceRoots,
|
|
19
|
+
...(options.devServers ? { devServers: options.devServers } : {}),
|
|
20
|
+
...(options.ignore?.length ? { ignore: options.ignore } : {}),
|
|
21
|
+
});
|
|
22
|
+
const server = http.createServer((req, res) => {
|
|
23
|
+
void route(req, res).catch((err) => {
|
|
24
|
+
log(`request error: ${err.stack ?? err}`);
|
|
25
|
+
if (!res.headersSent)
|
|
26
|
+
res.writeHead(500).end();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
async function route(req, res) {
|
|
30
|
+
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
31
|
+
if (req.method === 'GET' && path === '/v1/health') {
|
|
32
|
+
return json(res, 200, { ok: true, protocolVersion: PROTOCOL_VERSION });
|
|
33
|
+
}
|
|
34
|
+
if (req.method === 'POST' && path === '/v1/ingest') {
|
|
35
|
+
const auth = authorize(req, token);
|
|
36
|
+
if (auth !== 'ok')
|
|
37
|
+
return json(res, auth === 'forbidden' ? 403 : 401, { error: auth });
|
|
38
|
+
const body = await readBody(req, MAX_MESSAGE_BYTES);
|
|
39
|
+
if (body === undefined)
|
|
40
|
+
return json(res, 413, { error: 'body too large' });
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(body);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return json(res, 400, { error: 'invalid JSON' });
|
|
47
|
+
}
|
|
48
|
+
const batch = IngestBatch.safeParse(parsed);
|
|
49
|
+
if (!batch.success)
|
|
50
|
+
return json(res, 400, { error: batch.error.issues.slice(0, 5) });
|
|
51
|
+
if (batch.data.protocolVersion !== PROTOCOL_VERSION)
|
|
52
|
+
return json(res, 426, { error: 'protocol version mismatch' });
|
|
53
|
+
for (const message of batch.data.messages)
|
|
54
|
+
correlator.ingest(batch.data.agentId, message);
|
|
55
|
+
res.writeHead(204, { 'x-lightpath-started-at': String(startedAt) }).end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (req.method === 'POST' && path === '/v1/action') {
|
|
59
|
+
// Same rules as /v1/ingest (D-115). The body holds `fill` values, which are secrets: it is
|
|
60
|
+
// never logged, and a schema error reply names fields, never values.
|
|
61
|
+
const auth = authorize(req, token);
|
|
62
|
+
if (auth !== 'ok')
|
|
63
|
+
return json(res, auth === 'forbidden' ? 403 : 401, { error: auth });
|
|
64
|
+
// Drain an oversized body instead of dropping the connection, so the caller sees the 413.
|
|
65
|
+
const body = await readBody(req, MAX_ACTION_BYTES, true);
|
|
66
|
+
if (body === undefined)
|
|
67
|
+
return json(res, 413, { error: 'body too large' });
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(body);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return json(res, 400, { error: 'invalid JSON' });
|
|
74
|
+
}
|
|
75
|
+
const request = ActionRequest.safeParse(parsed);
|
|
76
|
+
if (!request.success) {
|
|
77
|
+
const issues = request.error.issues.slice(0, 5).map((i) => ({ path: i.path, message: i.message }));
|
|
78
|
+
return json(res, 400, { error: issues });
|
|
79
|
+
}
|
|
80
|
+
if (request.data.protocolVersion !== PROTOCOL_VERSION)
|
|
81
|
+
return json(res, 426, { error: 'protocol version mismatch' });
|
|
82
|
+
return json(res, 200, await runAction(actionRunner, store, request.data, backendWaitMs));
|
|
83
|
+
}
|
|
84
|
+
json(res, 404, { error: 'not found' });
|
|
85
|
+
}
|
|
86
|
+
// ── Client feed ───────────────────────────────────────────────────────────
|
|
87
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES });
|
|
88
|
+
server.on('upgrade', (req, socket, head) => {
|
|
89
|
+
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
90
|
+
const auth = authorize(req, token);
|
|
91
|
+
if (path !== '/v1/client' || auth !== 'ok') {
|
|
92
|
+
socket.end(`HTTP/1.1 ${auth === 'forbidden' ? '403 Forbidden' : path !== '/v1/client' ? '404 Not Found' : '401 Unauthorized'}\r\n\r\n`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
wss.handleUpgrade(req, socket, head, (ws) => onClient(ws));
|
|
96
|
+
});
|
|
97
|
+
function onClient(ws) {
|
|
98
|
+
const send = (m) => ws.send(JSON.stringify(m));
|
|
99
|
+
send({ type: 'welcome', protocolVersion: PROTOCOL_VERSION, workspaceRoots: options.workspaceRoots });
|
|
100
|
+
const unsubscribe = store.subscribe((trace) => send({ type: 'trace', trace }));
|
|
101
|
+
ws.on('close', unsubscribe);
|
|
102
|
+
ws.on('message', (data) => {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = ClientMessage.safeParse(JSON.parse(String(data)));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
ws.close(CloseCode.BadMessage, 'invalid JSON');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!parsed.success) {
|
|
112
|
+
ws.close(CloseCode.BadMessage, 'invalid message');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const msg = parsed.data;
|
|
116
|
+
if (msg.type === 'listTraces') {
|
|
117
|
+
send({
|
|
118
|
+
type: 'traces',
|
|
119
|
+
requestId: msg.requestId,
|
|
120
|
+
traces: store.list({ limit: msg.limit, label: msg.label }),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
send({ type: 'traceResult', requestId: msg.requestId, trace: store.get(msg.traceId) ?? null });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
await new Promise((resolve, reject) => {
|
|
129
|
+
server.once('error', reject);
|
|
130
|
+
server.listen(options.port ?? 0, '127.0.0.1', () => resolve());
|
|
131
|
+
});
|
|
132
|
+
const port = server.address().port;
|
|
133
|
+
const root = options.workspaceRoots[0];
|
|
134
|
+
if (options.writeDiscoveryFile !== false && root) {
|
|
135
|
+
await writeDiscovery(root, {
|
|
136
|
+
port,
|
|
137
|
+
token,
|
|
138
|
+
pid: process.pid,
|
|
139
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
140
|
+
startedAt,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
log(`daemon listening on 127.0.0.1:${port}`);
|
|
144
|
+
return {
|
|
145
|
+
port,
|
|
146
|
+
url: `http://127.0.0.1:${port}`,
|
|
147
|
+
token,
|
|
148
|
+
startedAt,
|
|
149
|
+
store,
|
|
150
|
+
correlator,
|
|
151
|
+
setActionRunner(runner) {
|
|
152
|
+
actionRunner = runner;
|
|
153
|
+
},
|
|
154
|
+
async close() {
|
|
155
|
+
for (const client of wss.clients)
|
|
156
|
+
client.close();
|
|
157
|
+
wss.close();
|
|
158
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
159
|
+
if (options.writeDiscoveryFile !== false && root)
|
|
160
|
+
await removeDiscovery(root);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function json(res, status, body) {
|
|
165
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Reads a request body as UTF-8, or returns undefined if it exceeds `limit` bytes. Past the limit
|
|
169
|
+
* it drops the connection, or with `drain` reads the rest without keeping it.
|
|
170
|
+
*/
|
|
171
|
+
function readBody(req, limit, drain = false) {
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const chunks = [];
|
|
174
|
+
let size = 0;
|
|
175
|
+
req.on('data', (chunk) => {
|
|
176
|
+
size += chunk.length;
|
|
177
|
+
if (size > limit) {
|
|
178
|
+
chunks.length = 0;
|
|
179
|
+
if (drain)
|
|
180
|
+
return;
|
|
181
|
+
resolve(undefined);
|
|
182
|
+
req.destroy();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
chunks.push(chunk);
|
|
186
|
+
});
|
|
187
|
+
req.on('end', () => resolve(size > limit ? undefined : Buffer.concat(chunks).toString('utf8')));
|
|
188
|
+
req.on('error', reject);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
//# 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,49 @@
|
|
|
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
|
+
const problems = (t.problems?.length ?? 0) + (t.problemsDropped ?? 0);
|
|
32
|
+
out.push({
|
|
33
|
+
id: t.id,
|
|
34
|
+
label: t.label,
|
|
35
|
+
startedAt: t.startedAt,
|
|
36
|
+
fileCount: t.files.length,
|
|
37
|
+
...(problems > 0 ? { problemCount: problems } : {}),
|
|
38
|
+
});
|
|
39
|
+
if (query.limit && out.length >= query.limit)
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
subscribe(listener) {
|
|
45
|
+
this.#listeners.add(listener);
|
|
46
|
+
return () => this.#listeners.delete(listener);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=store.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightpath-dev/daemon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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.1.0",
|
|
39
|
+
"@lightpath-dev/protocol": "0.1.0",
|
|
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
|
}
|