peekinside 0.0.0-stage → 1.2.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +538 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. package/src/user.js +83 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * The local hub's HTTP server, on 127.0.0.1 only.
3
+ *
4
+ * GET / Dashboard page (also /p/<project id>,
5
+ * /p/<project id>/exceptions and /p/<project id>/queries)
6
+ * POST /api/ingest Requests and exceptions from the SDK in each app, and
7
+ * counts of server requests it didn't record (free plan)
8
+ * GET /api/status Hub version and settings
9
+ * GET /api/projects Projects, with their running processes
10
+ * GET /api/projects/:id/requests Summaries. Query: limit, before or after (sequence numbers)
11
+ * GET /api/projects/:id/requests/:rid One request's full detail
12
+ * DELETE /api/projects/:id/requests Delete a project's requests
13
+ * GET /api/projects/:id/exceptions Exception summaries, paged like requests
14
+ * GET /api/projects/:id/exceptions/:eid One exception's full detail
15
+ * DELETE /api/projects/:id/exceptions Delete a project's exceptions
16
+ * GET /api/projects/:id/runtime Recent processes with their memory, CPU and event-loop samples
17
+ * GET /api/projects/:id/jobs Job run summaries, paged like requests
18
+ * GET /api/projects/:id/jobs/:jid One job run's detail
19
+ * DELETE /api/projects/:id/jobs Delete a project's job runs
20
+ * GET /api/projects/:id/logs Log lines (their start), paged like requests
21
+ * GET /api/projects/:id/logs/:lid One log line in full
22
+ * DELETE /api/projects/:id/logs Delete a project's log lines
23
+ * GET /api/projects/:id/exception-state Resolved issues, with a version
24
+ * PUT /api/projects/:id/exception-state Save them, if the version is still the current one
25
+ * DELETE /api/projects/:id Forget a project
26
+ * GET /brand/<file> The PeekInside mark, for the page's icon (also
27
+ * /favicon.svg and /favicon.ico)
28
+ *
29
+ * The page is a small shell that loads the dashboard interface from the PeekInside site; all
30
+ * request data comes from this server. The API answers only this origin: requests with a
31
+ * foreign Host header (DNS rebinding) or from other sites are refused, and no CORS headers
32
+ * are ever sent. Ingest only accepts requests that don't come from a browser.
33
+ */
34
+
35
+ import http from 'http';
36
+ import { randomBytes } from 'crypto';
37
+ import { readFileSync } from 'fs';
38
+ import { HUB_API_VERSION, HUB_APP_ID } from '../config.js';
39
+ import { ExceptionState } from './exception-state.js';
40
+ import { IngestPayload, REQUEST_ID } from './ingest-payload.js';
41
+ import { PROJECT_ID } from './store.js';
42
+
43
+ const MAX_INGEST_BYTES = 32 * 1024 * 1024;
44
+ // 5000 fingerprints with their timestamps take about 200 KB.
45
+ const MAX_STATE_BYTES = 1024 * 1024;
46
+ const DEFAULT_PAGE = 500;
47
+ const MAX_PAGE = 1000;
48
+ // /api/projects/:id, then optionally a collection and an item.
49
+ const PROJECT_ROUTE = /^\/api\/projects\/([^/]+)(?:\/(requests|exceptions|exception-state|runtime|jobs|logs)(?:\/([^/]+))?)?$/;
50
+ // The dashboard page: the project list, a project's requests, its exceptions and its queries.
51
+ const SHELL_ROUTE = /^\/p\/[a-z0-9-]{1,64}(?:\/(?:exceptions|queries|runtime|logs|jobs))?\/?$/;
52
+
53
+ const BASE_HEADERS = {
54
+ 'X-Content-Type-Options': 'nosniff',
55
+ 'Referrer-Policy': 'no-referrer',
56
+ 'X-Frame-Options': 'DENY',
57
+ 'Cross-Origin-Resource-Policy': 'same-origin'
58
+ };
59
+
60
+ // Applies the saved theme before first paint (same as the PeekInside site).
61
+ const THEME_SCRIPT = '(function(){try{var t=localStorage.getItem("peekinside:theme");if(t!=="light"&&t!=="dark"){t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"}document.documentElement.setAttribute("data-theme",t)}catch(e){document.documentElement.setAttribute("data-theme","dark")}})()';
62
+
63
+ // The PeekInside mark (Span Core): the approved files, unchanged. "-dark" is drawn for dark
64
+ // backgrounds and "-light" for light ones; the page links each for the matching browser theme.
65
+ const BRAND_FILES = new Map(
66
+ ['peekinside-span-core-mark-dark.svg', 'peekinside-span-core-mark-light.svg'].map((name) => [
67
+ name,
68
+ readFileSync(new URL(`./brand/${name}`, import.meta.url))
69
+ ])
70
+ );
71
+ const BRAND_ROUTE = /^\/brand\/([a-z0-9-]+\.svg)$/;
72
+
73
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
74
+
75
+ /**
76
+ * The hub's HTTP API and dashboard page. Owns the http.Server; each route is a method.
77
+ */
78
+ export class HubServer {
79
+ /**
80
+ * @param {Object} options
81
+ * @param {import('./store.js').Store} options.store
82
+ * @param {number} options.port - Port the server will listen on (checked against the Host header)
83
+ * @param {string} options.siteUrl - Origin the dashboard interface is loaded from
84
+ * @param {string} options.version - Package version
85
+ * @param {string} options.dataDir - Data folder, shown in the dashboard
86
+ * @param {Function} [options.onActivity] - Called for every API request and ingest, to tell
87
+ * whether the hub is still in use
88
+ */
89
+ constructor({ store, port, siteUrl, version, dataDir, onActivity = () => {} }) {
90
+ this.store = store;
91
+ this.port = port;
92
+ this.siteUrl = siteUrl;
93
+ this.version = version;
94
+ this.dataDir = dataDir;
95
+ this.onActivity = onActivity;
96
+ // The names this server is reached by. Any other Host means DNS rebinding: a foreign site
97
+ // whose domain resolves to 127.0.0.1.
98
+ this._hosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]);
99
+ this._assets = `${siteUrl}/local/v${HUB_API_VERSION}`;
100
+ this.startedAt = Date.now();
101
+
102
+ /** @type {http.Server} */
103
+ this.httpServer = http.createServer((req, res) => {
104
+ this.handle(req, res).catch((error) => {
105
+ console.error(`Request failed: ${error && error.stack ? error.stack : error}`);
106
+ if (!res.headersSent) sendJson(res, 500, { error: 'Internal error' });
107
+ else res.destroy();
108
+ });
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Starts listening on 127.0.0.1, never on other interfaces: the data is only for this machine.
114
+ *
115
+ * @param {Function} [callback] - Called once listening
116
+ * @returns {HubServer} This server
117
+ */
118
+ listen(callback) {
119
+ this.httpServer.listen(this.port, '127.0.0.1', callback);
120
+ return this;
121
+ }
122
+
123
+ /**
124
+ * Stops listening and closes open connections, including dashboards polling it.
125
+ *
126
+ * @returns {void}
127
+ */
128
+ close() {
129
+ this.httpServer.close();
130
+ this.httpServer.closeAllConnections?.();
131
+ }
132
+
133
+ /**
134
+ * What GET /api/status returns. `app` lets clients tell the hub apart from other programs.
135
+ *
136
+ * @returns {Object}
137
+ */
138
+ status() {
139
+ return {
140
+ app: HUB_APP_ID,
141
+ apiVersion: HUB_API_VERSION,
142
+ version: this.version,
143
+ pid: process.pid,
144
+ startedAt: this.startedAt,
145
+ dataDir: this.dataDir,
146
+ siteUrl: this.siteUrl,
147
+ maxRequests: this.store.maxRequests,
148
+ maxExceptions: this.store.maxExceptions
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Answers one request.
154
+ *
155
+ * @param {http.IncomingMessage} req
156
+ * @param {http.ServerResponse} res
157
+ * @returns {Promise<void>}
158
+ */
159
+ async handle(req, res) {
160
+ const host = String(req.headers.host || '').toLowerCase();
161
+ if (!this._hosts.has(host)) {
162
+ return sendText(res, 403, 'Forbidden: open this dashboard at http://localhost:' + this.port);
163
+ }
164
+
165
+ let url;
166
+ try {
167
+ url = new URL(req.url, `http://${host}`);
168
+ } catch (error) {
169
+ return sendText(res, 400, 'Bad Request');
170
+ }
171
+ const { pathname, searchParams } = url;
172
+
173
+ if (pathname === '/api/ingest') {
174
+ this.onActivity();
175
+ return this._ingest(req, res);
176
+ }
177
+ if (pathname.startsWith('/api/')) {
178
+ if (!isSameOrigin(req, host)) {
179
+ return sendJson(res, 403, { error: 'Only the dashboard on this port can use this API.' });
180
+ }
181
+ this.onActivity();
182
+ return this._api(req, res, pathname, searchParams);
183
+ }
184
+
185
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
186
+ res.setHeader('Allow', 'GET, HEAD');
187
+ return sendText(res, 405, 'Method Not Allowed');
188
+ }
189
+ if (pathname === '/' || SHELL_ROUTE.test(pathname)) {
190
+ return this._shell(req, res);
191
+ }
192
+ const brandFile = pathname === '/favicon.svg' || pathname === '/favicon.ico'
193
+ ? 'peekinside-span-core-mark-dark.svg'
194
+ : BRAND_ROUTE.exec(pathname)?.[1];
195
+ if (brandFile && BRAND_FILES.has(brandFile)) {
196
+ return send(res, 200, BRAND_FILES.get(brandFile), { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' }, req.method === 'HEAD');
197
+ }
198
+ return sendText(res, 404, 'Not Found');
199
+ }
200
+
201
+ // POST /api/ingest: a batch from an app's SDK.
202
+ async _ingest(req, res) {
203
+ if (req.method !== 'POST') {
204
+ res.setHeader('Allow', 'POST');
205
+ return sendJson(res, 405, { error: 'Method not allowed' });
206
+ }
207
+ // Browsers always send Origin or Sec-Fetch-Site on cross-origin POSTs; the SDK sends neither.
208
+ if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined) {
209
+ return sendJson(res, 403, { error: 'Browsers cannot send requests to the hub.' });
210
+ }
211
+ if (!/^application\/json\b/i.test(String(req.headers['content-type'] || ''))) {
212
+ return sendJson(res, 415, { error: 'Expected application/json' });
213
+ }
214
+ if (!req.headers['x-peekinside-sdk']) {
215
+ return sendJson(res, 400, { error: 'Missing x-peekinside-sdk header' });
216
+ }
217
+
218
+ let body;
219
+ try {
220
+ body = JSON.parse(await readBody(req, MAX_INGEST_BYTES));
221
+ } catch (error) {
222
+ if (error && error.status === 413) {
223
+ res.setHeader('Connection', 'close');
224
+ return sendJson(res, 413, { error: 'Batch too large' });
225
+ }
226
+ return sendJson(res, 400, { error: 'Invalid JSON' });
227
+ }
228
+
229
+ const payload = IngestPayload.parse(body);
230
+ if (!payload) {
231
+ return sendJson(res, 400, { error: 'Expected { project, instance, requests }' });
232
+ }
233
+
234
+ this.store.touch(payload.project, payload.instance);
235
+ const added = this.store.append(payload.project.id, payload.records);
236
+ const addedExceptions = this.store.appendExceptions(payload.project.id, payload.exceptions);
237
+ this.store.noteBlocked(payload.project.id, payload.blocked);
238
+ this.store.addSamples(payload.project.id, payload.instance, payload.samples);
239
+ const addedJobs = this.store.appendJobs(payload.project.id, payload.jobs);
240
+ const addedLogs = this.store.appendLogs(payload.project.id, payload.logs);
241
+ // SDKs from before exceptions get the answer they know.
242
+ return sendJson(res, 200, payload.hasExceptions
243
+ ? {
244
+ accepted: added.length,
245
+ rejected: payload.rejected,
246
+ acceptedExceptions: addedExceptions.length,
247
+ rejectedExceptions: payload.rejectedExceptions,
248
+ acceptedJobs: addedJobs.length,
249
+ acceptedLogs: addedLogs.length
250
+ }
251
+ : { accepted: added.length, rejected: payload.rejected, acceptedJobs: addedJobs.length, acceptedLogs: addedLogs.length });
252
+ }
253
+
254
+ // Every /api/ route except ingest; the caller has checked the origin.
255
+ _api(req, res, pathname, query) {
256
+ const read = isRead(req);
257
+
258
+ if (pathname === '/api/status') {
259
+ return read ? sendJson(res, 200, this.status()) : methodNotAllowed(res, 'GET, HEAD');
260
+ }
261
+ if (pathname === '/api/projects') {
262
+ return read ? sendJson(res, 200, { projects: this.store.listProjects() }) : methodNotAllowed(res, 'GET, HEAD');
263
+ }
264
+
265
+ const match = PROJECT_ROUTE.exec(pathname);
266
+ const projectId = match && decode(match[1]);
267
+ if (!match || !PROJECT_ID.test(projectId)) {
268
+ return sendJson(res, 404, { error: 'Not found' });
269
+ }
270
+ if (!this.store.has(projectId)) {
271
+ return sendJson(res, 404, { error: 'This project is not on this machine.' });
272
+ }
273
+
274
+ // State changes must come from this page: browsers always send Origin with these.
275
+ if (!read && req.headers.origin === undefined) {
276
+ return sendJson(res, 403, { error: 'Missing Origin header' });
277
+ }
278
+
279
+ const [, , collection, item] = match;
280
+ if (!collection) {
281
+ return this._project(req, res, projectId);
282
+ }
283
+ if (collection === 'requests') {
284
+ return item === undefined ? this._requests(req, res, projectId, query) : this._requestDetail(req, res, projectId, item);
285
+ }
286
+ if (collection === 'exceptions') {
287
+ return item === undefined ? this._exceptions(req, res, projectId, query) : this._exceptionDetail(req, res, projectId, item);
288
+ }
289
+ if (collection === 'jobs') {
290
+ if (item !== undefined) return this._item(req, res, decode(item), (id) => this.store.jobDetail(projectId, id), 'This job run is no longer stored.');
291
+ return this._collection(req, res, query, () => this.store.listJobs(projectId, pageOf(query)), () => this.store.clearJobs(projectId));
292
+ }
293
+ if (collection === 'logs') {
294
+ if (item !== undefined) return this._item(req, res, decode(item), (id) => this.store.logDetail(projectId, id), 'This log line is no longer stored.');
295
+ return this._collection(req, res, query, () => this.store.listLogs(projectId, pageOf(query)), () => this.store.clearLogs(projectId));
296
+ }
297
+ if (collection === 'runtime') {
298
+ if (item !== undefined) return sendJson(res, 404, { error: 'Not found' });
299
+ return read ? sendJson(res, 200, this.store.runtime(projectId)) : methodNotAllowed(res, 'GET, HEAD');
300
+ }
301
+ return item === undefined ? this._exceptionState(req, res, projectId) : sendJson(res, 404, { error: 'Not found' });
302
+ }
303
+
304
+ // A paged list (GET), or all of it deleted (DELETE).
305
+ _collection(req, res, query, list, clear) {
306
+ if (isRead(req)) return sendJson(res, 200, list());
307
+ if (req.method === 'DELETE') {
308
+ clear();
309
+ return sendJson(res, 200, { cleared: true });
310
+ }
311
+ return methodNotAllowed(res, 'GET, HEAD, DELETE');
312
+ }
313
+
314
+ // One stored item's detail.
315
+ _item(req, res, id, detailOf, missing) {
316
+ if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
317
+ const detail = REQUEST_ID.test(id) ? detailOf(id) : null;
318
+ return detail ? sendJson(res, 200, { detail }) : sendJson(res, 404, { error: missing });
319
+ }
320
+
321
+ // /api/projects/:id
322
+ _project(req, res, projectId) {
323
+ if (req.method === 'DELETE') {
324
+ this.store.remove(projectId);
325
+ return sendJson(res, 200, { removed: true });
326
+ }
327
+ return methodNotAllowed(res, 'DELETE');
328
+ }
329
+
330
+ // /api/projects/:id/requests
331
+ _requests(req, res, projectId, query) {
332
+ if (isRead(req)) {
333
+ return sendJson(res, 200, this.store.list(projectId, pageOf(query)));
334
+ }
335
+ if (req.method === 'DELETE') {
336
+ this.store.clear(projectId);
337
+ return sendJson(res, 200, { cleared: true });
338
+ }
339
+ return methodNotAllowed(res, 'GET, HEAD, DELETE');
340
+ }
341
+
342
+ // /api/projects/:id/requests/:rid
343
+ _requestDetail(req, res, projectId, encodedId) {
344
+ if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
345
+ const requestId = decode(encodedId);
346
+ const detail = REQUEST_ID.test(requestId) ? this.store.detail(projectId, requestId) : null;
347
+ return detail
348
+ ? sendJson(res, 200, { detail })
349
+ : sendJson(res, 404, { error: 'This request is no longer stored.' });
350
+ }
351
+
352
+ // /api/projects/:id/exceptions
353
+ _exceptions(req, res, projectId, query) {
354
+ if (isRead(req)) {
355
+ return sendJson(res, 200, this.store.listExceptions(projectId, pageOf(query)));
356
+ }
357
+ if (req.method === 'DELETE') {
358
+ this.store.clearExceptions(projectId);
359
+ return sendJson(res, 200, { cleared: true });
360
+ }
361
+ return methodNotAllowed(res, 'GET, HEAD, DELETE');
362
+ }
363
+
364
+ // /api/projects/:id/exceptions/:eid
365
+ _exceptionDetail(req, res, projectId, encodedId) {
366
+ if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
367
+ const exceptionId = decode(encodedId);
368
+ const detail = REQUEST_ID.test(exceptionId) ? this.store.exceptionDetail(projectId, exceptionId) : null;
369
+ return detail
370
+ ? sendJson(res, 200, { detail })
371
+ : sendJson(res, 404, { error: 'This exception is no longer stored.' });
372
+ }
373
+
374
+ // /api/projects/:id/exception-state: resolved issues. A save names the version it was based
375
+ // on; when another dashboard saved in between, it gets the current state back to merge with.
376
+ async _exceptionState(req, res, projectId) {
377
+ if (isRead(req)) {
378
+ return sendJson(res, 200, this.store.exceptionState(projectId));
379
+ }
380
+ if (req.method !== 'PUT') {
381
+ return methodNotAllowed(res, 'GET, HEAD, PUT');
382
+ }
383
+ if (!/^application\/json\b/i.test(String(req.headers['content-type'] || ''))) {
384
+ return sendJson(res, 415, { error: 'Expected application/json' });
385
+ }
386
+
387
+ let body;
388
+ try {
389
+ body = JSON.parse(await readBody(req, MAX_STATE_BYTES));
390
+ } catch (error) {
391
+ if (error && error.status === 413) {
392
+ res.setHeader('Connection', 'close');
393
+ return sendJson(res, 413, { error: 'State too large' });
394
+ }
395
+ return sendJson(res, 400, { error: 'Invalid JSON' });
396
+ }
397
+
398
+ const version = body && body.version;
399
+ const state = body ? ExceptionState.validate(body.state) : null;
400
+ if (!Number.isInteger(version) || version < 0 || !state) {
401
+ return sendJson(res, 400, { error: 'Expected { state: { resolved }, version }, with at most 5000 fingerprints' });
402
+ }
403
+ const result = this.store.saveExceptionState(projectId, state, version);
404
+ if (!result.saved) {
405
+ return sendJson(res, 409, { error: 'The exception state changed since it was read.', state: result.state, version: result.version });
406
+ }
407
+ return sendJson(res, 200, { version: result.version });
408
+ }
409
+
410
+ // The dashboard page: a shell that loads the interface from the PeekInside site. The Content
411
+ // Security Policy allows scripts only from that site (and the inline theme script, by nonce),
412
+ // and lets the page fetch data only from this server.
413
+ _shell(req, res) {
414
+ const assets = this._assets;
415
+ const siteUrl = this.siteUrl;
416
+ const nonce = randomBytes(16).toString('base64');
417
+ const assetOrigin = new URL(assets).origin;
418
+ const csp = [
419
+ "default-src 'none'",
420
+ `script-src 'nonce-${nonce}' ${assetOrigin}`,
421
+ `style-src ${assetOrigin} 'unsafe-inline'`,
422
+ "img-src 'self' data:",
423
+ `font-src ${assetOrigin}`,
424
+ "connect-src 'self'",
425
+ "base-uri 'none'",
426
+ "form-action 'none'",
427
+ "frame-ancestors 'none'"
428
+ ].join('; ');
429
+
430
+ const html = `<!doctype html>
431
+ <html lang="en" data-theme="dark">
432
+ <head>
433
+ <meta charset="utf-8">
434
+ <meta name="viewport" content="width=device-width, initial-scale=1">
435
+ <meta name="robots" content="noindex">
436
+ <title>PeekInside Local</title>
437
+ <link rel="icon" href="/brand/peekinside-span-core-mark-light.svg" type="image/svg+xml" media="(prefers-color-scheme: light)">
438
+ <link rel="icon" href="/brand/peekinside-span-core-mark-dark.svg" type="image/svg+xml" media="(prefers-color-scheme: dark)">
439
+ <link rel="stylesheet" href="${escapeHtml(assets)}/app.css">
440
+ <script nonce="${nonce}">${THEME_SCRIPT}</script>
441
+ <style>.boot{font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:440px;margin:18vh auto 0;padding:0 16px;opacity:.75}.boot a{color:inherit}</style>
442
+ </head>
443
+ <body>
444
+ <div id="root"><div class="boot"><p><strong>Loading PeekInside…</strong></p><p>The dashboard interface loads from <a href="${escapeHtml(siteUrl)}">${escapeHtml(new URL(siteUrl).host)}</a>. Your requests stay on this machine.</p></div></div>
445
+ <script src="${escapeHtml(assets)}/app.js" defer></script>
446
+ </body>
447
+ </html>
448
+ `;
449
+ send(res, 200, html, {
450
+ 'Content-Type': 'text/html; charset=utf-8',
451
+ 'Cache-Control': 'no-store',
452
+ 'Content-Security-Policy': csp
453
+ }, req.method === 'HEAD');
454
+ }
455
+ }
456
+
457
+ function isRead(req) {
458
+ return req.method === 'GET' || req.method === 'HEAD';
459
+ }
460
+
461
+ // Same-origin fetches send Sec-Fetch-Site "same-origin" (or "none" when typed in the address bar).
462
+ // "same-site" is refused too: other ports on localhost count as the same site.
463
+ function isSameOrigin(req, host) {
464
+ const site = req.headers['sec-fetch-site'];
465
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') return false;
466
+ const origin = req.headers.origin;
467
+ return origin === undefined || origin === `http://${host}`;
468
+ }
469
+
470
+ // limit, and before or after (sequence numbers), from a list's query string.
471
+ function pageOf(query) {
472
+ const limit = Math.min(MAX_PAGE, toInt(query.get('limit'), DEFAULT_PAGE, 1));
473
+ const after = query.has('after') ? toInt(query.get('after'), 0, 0) : undefined;
474
+ const before = query.has('before') ? toInt(query.get('before'), 0, 0) : undefined;
475
+ return { after, before, limit };
476
+ }
477
+
478
+ function toInt(value, fallback, min) {
479
+ const parsed = Number.parseInt(value, 10);
480
+ return Number.isFinite(parsed) ? Math.max(min, parsed) : fallback;
481
+ }
482
+
483
+ function decode(value) {
484
+ try {
485
+ return decodeURIComponent(value);
486
+ } catch (error) {
487
+ return '';
488
+ }
489
+ }
490
+
491
+ // Stops collecting at `limit` bytes; the 413 response then closes the connection.
492
+ function readBody(req, limit) {
493
+ return new Promise((resolve, reject) => {
494
+ const chunks = [];
495
+ let size = 0;
496
+ let failed = false;
497
+ const fail = (error) => {
498
+ if (failed) return;
499
+ failed = true;
500
+ reject(error);
501
+ };
502
+ req.on('data', (chunk) => {
503
+ if (failed) return;
504
+ size += chunk.length;
505
+ if (size > limit) {
506
+ fail(Object.assign(new Error('Payload too large'), { status: 413 }));
507
+ return;
508
+ }
509
+ chunks.push(chunk);
510
+ });
511
+ req.on('end', () => {
512
+ if (!failed) resolve(Buffer.concat(chunks).toString('utf8'));
513
+ });
514
+ req.on('error', fail);
515
+ });
516
+ }
517
+
518
+ function escapeHtml(value) {
519
+ return String(value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
520
+ }
521
+
522
+ function send(res, status, body, headers, headOnly = false) {
523
+ res.writeHead(status, { ...BASE_HEADERS, 'Content-Length': Buffer.byteLength(body), ...headers });
524
+ res.end(headOnly ? undefined : body);
525
+ }
526
+
527
+ function sendText(res, status, body) {
528
+ send(res, status, body, { 'Content-Type': 'text/plain; charset=utf-8' });
529
+ }
530
+
531
+ function sendJson(res, status, payload) {
532
+ send(res, status, JSON.stringify(payload), { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
533
+ }
534
+
535
+ function methodNotAllowed(res, allow) {
536
+ res.setHeader('Allow', allow);
537
+ sendJson(res, 405, { error: 'Method not allowed' });
538
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Ingests the spool files apps write when they crash (see src/spool.js): at startup, then every
3
+ * second while the hub runs.
4
+ *
5
+ * A spool file holds an ingest batch ({ project, instance, requests, exceptions }) and is
6
+ * validated exactly like a batch posted to /api/ingest. It is deleted once ingested, and also
7
+ * when it can never be ingested (not JSON, malformed, or filed under another project's folder).
8
+ * The process that wrote it has exited, so it doesn't count as a check-in.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { CLOUD_FOLDER, Spool } from '../spool.js';
14
+ import { IngestPayload } from './ingest-payload.js';
15
+ import { PROJECT_ID } from './store.js';
16
+
17
+ const INTERVAL_MS = 1000;
18
+
19
+ /**
20
+ * Moves spooled records into the store.
21
+ */
22
+ export class SpoolIngester {
23
+ /**
24
+ * @param {Object} options
25
+ * @param {import('./store.js').Store} options.store
26
+ * @param {string} options.dataDir - PeekInside data folder
27
+ * @param {(message: string) => void} [options.log] - Receives a line per file it can't ingest
28
+ * @param {() => void} [options.onIngest] - Called when records were ingested
29
+ */
30
+ constructor({ store, dataDir, log = () => {}, onIngest = () => {} }) {
31
+ this.store = store;
32
+ this.root = Spool.root(dataDir);
33
+ this.log = log;
34
+ this.onIngest = onIngest;
35
+ this.timer = null;
36
+ }
37
+
38
+ /**
39
+ * Ingests what is waiting now, then checks every second.
40
+ *
41
+ * @returns {SpoolIngester} This ingester
42
+ */
43
+ start() {
44
+ this.ingest();
45
+ if (!this.timer) {
46
+ this.timer = setInterval(() => this.ingest(), INTERVAL_MS);
47
+ if (typeof this.timer.unref === 'function') this.timer.unref();
48
+ }
49
+ return this;
50
+ }
51
+
52
+ /**
53
+ * Stops checking.
54
+ *
55
+ * @returns {void}
56
+ */
57
+ stop() {
58
+ clearInterval(this.timer);
59
+ this.timer = null;
60
+ }
61
+
62
+ /**
63
+ * Ingests every spool file waiting.
64
+ *
65
+ * @returns {{ requests: number, exceptions: number }} Records added
66
+ */
67
+ ingest() {
68
+ const added = { requests: 0, exceptions: 0 };
69
+ let folders;
70
+ try {
71
+ folders = fs.readdirSync(this.root);
72
+ } catch (error) {
73
+ return added;
74
+ }
75
+
76
+ for (const folder of folders) {
77
+ // Cloud batches are sealed for PeekInside Cloud; the SDK resends them itself.
78
+ if (folder === CLOUD_FOLDER || !PROJECT_ID.test(folder)) continue;
79
+ const spool = new Spool(path.join(this.root, folder));
80
+ for (const name of spool.list()) {
81
+ try {
82
+ const counts = this._ingestFile(spool, folder, name);
83
+ added.requests += counts.requests;
84
+ added.exceptions += counts.exceptions;
85
+ } catch (error) {
86
+ // The store couldn't write: keep the file and try again next time.
87
+ this.log(`Could not ingest ${path.join(spool.dir, name)}: ${error.message}`);
88
+ }
89
+ }
90
+ }
91
+ if (added.requests || added.exceptions) this.onIngest();
92
+ return added;
93
+ }
94
+
95
+ _ingestFile(spool, folder, name) {
96
+ const payload = IngestPayload.parse(spool.read(name));
97
+ if (!payload || payload.project.id !== folder) {
98
+ this.log(`Deleting ${path.join(spool.dir, name)}: not a valid spool file.`);
99
+ spool.remove(name);
100
+ return { requests: 0, exceptions: 0 };
101
+ }
102
+ this.store.ensure(payload.project);
103
+ const requests = this.store.append(payload.project.id, payload.records).length;
104
+ const exceptions = this.store.appendExceptions(payload.project.id, payload.exceptions).length;
105
+ this.store.appendJobs(payload.project.id, payload.jobs);
106
+ this.store.appendLogs(payload.project.id, payload.logs);
107
+ spool.remove(name);
108
+ return { requests, exceptions };
109
+ }
110
+ }