@lightpath-dev/daemon 0.0.1 → 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/dist/actions.d.ts +19 -0
- package/dist/actions.js +84 -0
- package/dist/correlator.d.ts +1 -1
- package/dist/correlator.js +196 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +42 -4
- package/dist/store.js +8 -1
- package/package.json +3 -3
|
@@ -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/correlator.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ResolveContext } from '@lightpath-dev/core';
|
|
2
|
-
import type
|
|
2
|
+
import { type IngestMessage } from '@lightpath-dev/protocol';
|
|
3
3
|
import type { Store } from './store.js';
|
|
4
4
|
/**
|
|
5
5
|
* Turns ingest messages into mapped traces (docs/ARCHITECTURE.md sections 3, 4, and 6).
|
package/dist/correlator.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { decodeDataUrlSourceMap, mapScript, mergeLineCounts, newTraceId, PeriodicFunctions, resolveFile, } from '@lightpath-dev/core';
|
|
1
|
+
import { decodeDataUrlSourceMap, mapFunctions, mapScript, mergeLineCounts, newTraceId, originalLocation, PeriodicFunctions, resolveFile, } from '@lightpath-dev/core';
|
|
2
|
+
import { MAX_TRACE_PROBLEMS, } from '@lightpath-dev/protocol';
|
|
2
3
|
/**
|
|
3
4
|
* Turns ingest messages into mapped traces (docs/ARCHITECTURE.md sections 3, 4, and 6).
|
|
4
5
|
* Pure in-memory logic with no I/O, so it's tested without sockets.
|
|
@@ -68,7 +69,62 @@ export class Correlator {
|
|
|
68
69
|
case 'lines':
|
|
69
70
|
this.#onLines(agentId, message);
|
|
70
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
|
+
}
|
|
71
125
|
}
|
|
126
|
+
const r = resolveFile(source, this.#ctx);
|
|
127
|
+
return r.kind === 'workspace' ? { path: r.path, line } : undefined;
|
|
72
128
|
}
|
|
73
129
|
#onDelta(agentId, delta) {
|
|
74
130
|
const agent = this.#agent(agentId);
|
|
@@ -78,6 +134,20 @@ export class Correlator {
|
|
|
78
134
|
return;
|
|
79
135
|
}
|
|
80
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
|
+
}
|
|
81
151
|
for (const coverage of delta.coverage) {
|
|
82
152
|
const info = agent.scripts.get(coverage.scriptId);
|
|
83
153
|
const source = info ? this.#sources.get(info.hash) : undefined;
|
|
@@ -108,6 +178,9 @@ export class Correlator {
|
|
|
108
178
|
const foreground = fgLines !== undefined && [...fgLines.values()].some((c) => c > 0);
|
|
109
179
|
this.#addFile(pending, path, agent.kind, lines, delta.startedAt, foreground);
|
|
110
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);
|
|
111
184
|
}
|
|
112
185
|
if (delta.request) {
|
|
113
186
|
pending.trace.requests.push({
|
|
@@ -126,10 +199,56 @@ export class Correlator {
|
|
|
126
199
|
pending.trace.endedAt = Math.max(pending.trace.endedAt ?? 0, delta.endedAt);
|
|
127
200
|
this.#publish(pending);
|
|
128
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
|
+
}
|
|
129
245
|
#onLines(agentId, msg) {
|
|
130
246
|
const agent = this.#agent(agentId);
|
|
131
247
|
const label = msg.request ? `${msg.request.method} ${msg.request.path}` : 'action';
|
|
132
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 });
|
|
133
252
|
for (const file of msg.files) {
|
|
134
253
|
const r = resolveFile(file.path, this.#ctx);
|
|
135
254
|
if (r.kind === 'dropped') {
|
|
@@ -182,7 +301,37 @@ export class Correlator {
|
|
|
182
301
|
const trace = this.#store.get(traceId);
|
|
183
302
|
if (!trace)
|
|
184
303
|
return undefined;
|
|
185
|
-
const p = {
|
|
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
|
+
}
|
|
186
335
|
for (const f of trace.files) {
|
|
187
336
|
p.files.set(f.path, {
|
|
188
337
|
collector: f.collector,
|
|
@@ -201,8 +350,48 @@ export class Correlator {
|
|
|
201
350
|
trace: { id, label, origin, startedAt: at, files: [], requests: [], dropped: [] },
|
|
202
351
|
files: new Map(),
|
|
203
352
|
dropped: new Map(),
|
|
353
|
+
phases: [],
|
|
354
|
+
functions: new Map(),
|
|
204
355
|
};
|
|
205
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
|
+
}
|
|
206
395
|
#addFile(p, path, collector, lines, at, foreground) {
|
|
207
396
|
const existing = p.files.get(path);
|
|
208
397
|
if (existing) {
|
|
@@ -235,6 +424,11 @@ export class Correlator {
|
|
|
235
424
|
files.sort((a, b) => a.firstSeenAt - b.firstSeenAt || a.path.localeCompare(b.path));
|
|
236
425
|
p.trace.files = files;
|
|
237
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
|
+
}
|
|
238
432
|
this.#store.put(structuredClone(p.trace));
|
|
239
433
|
// Keep at most 50 traces open for late backend deltas; older ones are re-read from the store.
|
|
240
434
|
if (this.#pending.size > 50) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { BACKEND_WAIT_MS, missingBackend, runAction, waitForBackend } from './actions.js';
|
|
1
2
|
export { authorize, newToken, tokensEqual } from './auth.js';
|
|
2
3
|
export { Correlator } from './correlator.js';
|
|
3
4
|
export { findDiscovery, readDiscovery, removeDiscovery, writeDiscovery } from './discovery.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { BACKEND_WAIT_MS, missingBackend, runAction, waitForBackend } from './actions.js';
|
|
1
2
|
export { authorize, newToken, tokensEqual } from './auth.js';
|
|
2
3
|
export { Correlator } from './correlator.js';
|
|
3
4
|
export { findDiscovery, readDiscovery, removeDiscovery, writeDiscovery } from './discovery.js';
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolveContext } from '@lightpath-dev/core';
|
|
2
|
+
import { type ActionRunner } from '@lightpath-dev/protocol';
|
|
2
3
|
import { Correlator } from './correlator.js';
|
|
3
4
|
import { type Store } from './store.js';
|
|
4
5
|
export interface DaemonOptions {
|
|
@@ -13,6 +14,8 @@ export interface DaemonOptions {
|
|
|
13
14
|
store?: Store;
|
|
14
15
|
writeDiscoveryFile?: boolean;
|
|
15
16
|
log?: (msg: string) => void;
|
|
17
|
+
/** Longest wait after an action's `traceEnd` for its backend lines (default 1,500 ms). */
|
|
18
|
+
backendWaitMs?: number;
|
|
16
19
|
}
|
|
17
20
|
export interface RunningDaemon {
|
|
18
21
|
port: number;
|
|
@@ -21,6 +24,11 @@ export interface RunningDaemon {
|
|
|
21
24
|
startedAt: number;
|
|
22
25
|
store: Store;
|
|
23
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;
|
|
24
32
|
close(): Promise<void>;
|
|
25
33
|
}
|
|
26
34
|
/** Starts the daemon on 127.0.0.1 (docs/ARCHITECTURE.md section 7). */
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http, {} from 'node:http';
|
|
2
|
-
import { ClientMessage, CloseCode, IngestBatch, MAX_MESSAGE_BYTES, PROTOCOL_VERSION, } from '@lightpath-dev/protocol';
|
|
2
|
+
import { ActionRequest, ClientMessage, CloseCode, IngestBatch, MAX_ACTION_BYTES, MAX_MESSAGE_BYTES, PROTOCOL_VERSION, } from '@lightpath-dev/protocol';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { BACKEND_WAIT_MS, runAction } from './actions.js';
|
|
4
5
|
import { authorize, newToken } from './auth.js';
|
|
5
6
|
import { Correlator } from './correlator.js';
|
|
6
7
|
import { removeDiscovery, writeDiscovery } from './discovery.js';
|
|
@@ -11,6 +12,8 @@ export async function startDaemon(options) {
|
|
|
11
12
|
const store = options.store ?? new MemoryStore();
|
|
12
13
|
const log = options.log ?? (() => { });
|
|
13
14
|
const startedAt = Date.now();
|
|
15
|
+
const backendWaitMs = options.backendWaitMs ?? BACKEND_WAIT_MS;
|
|
16
|
+
let actionRunner;
|
|
14
17
|
const correlator = new Correlator(store, {
|
|
15
18
|
workspaceRoots: options.workspaceRoots,
|
|
16
19
|
...(options.devServers ? { devServers: options.devServers } : {}),
|
|
@@ -52,6 +55,32 @@ export async function startDaemon(options) {
|
|
|
52
55
|
res.writeHead(204, { 'x-lightpath-started-at': String(startedAt) }).end();
|
|
53
56
|
return;
|
|
54
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
|
+
}
|
|
55
84
|
json(res, 404, { error: 'not found' });
|
|
56
85
|
}
|
|
57
86
|
// ── Client feed ───────────────────────────────────────────────────────────
|
|
@@ -119,6 +148,9 @@ export async function startDaemon(options) {
|
|
|
119
148
|
startedAt,
|
|
120
149
|
store,
|
|
121
150
|
correlator,
|
|
151
|
+
setActionRunner(runner) {
|
|
152
|
+
actionRunner = runner;
|
|
153
|
+
},
|
|
122
154
|
async close() {
|
|
123
155
|
for (const client of wss.clients)
|
|
124
156
|
client.close();
|
|
@@ -132,21 +164,27 @@ export async function startDaemon(options) {
|
|
|
132
164
|
function json(res, status, body) {
|
|
133
165
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
|
|
134
166
|
}
|
|
135
|
-
/**
|
|
136
|
-
|
|
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) {
|
|
137
172
|
return new Promise((resolve, reject) => {
|
|
138
173
|
const chunks = [];
|
|
139
174
|
let size = 0;
|
|
140
175
|
req.on('data', (chunk) => {
|
|
141
176
|
size += chunk.length;
|
|
142
177
|
if (size > limit) {
|
|
178
|
+
chunks.length = 0;
|
|
179
|
+
if (drain)
|
|
180
|
+
return;
|
|
143
181
|
resolve(undefined);
|
|
144
182
|
req.destroy();
|
|
145
183
|
return;
|
|
146
184
|
}
|
|
147
185
|
chunks.push(chunk);
|
|
148
186
|
});
|
|
149
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
187
|
+
req.on('end', () => resolve(size > limit ? undefined : Buffer.concat(chunks).toString('utf8')));
|
|
150
188
|
req.on('error', reject);
|
|
151
189
|
});
|
|
152
190
|
}
|
package/dist/store.js
CHANGED
|
@@ -28,7 +28,14 @@ export class MemoryStore {
|
|
|
28
28
|
for (const t of [...this.#traces.values()].reverse()) {
|
|
29
29
|
if (needle && !t.label.toLowerCase().includes(needle))
|
|
30
30
|
continue;
|
|
31
|
-
|
|
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
|
+
});
|
|
32
39
|
if (query.limit && out.length >= query.limit)
|
|
33
40
|
break;
|
|
34
41
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightpath-dev/daemon",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Local Lightpath daemon: receives coverage from agents, maps it to source lines, and serves traces",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"coverage",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@lightpath-dev/core": "0.0
|
|
39
|
-
"@lightpath-dev/protocol": "0.0
|
|
38
|
+
"@lightpath-dev/core": "0.1.0",
|
|
39
|
+
"@lightpath-dev/protocol": "0.1.0",
|
|
40
40
|
"ws": "8.22.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|