peekinside 0.0.0-stage → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +561 -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,561 @@
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
+ // If the interface doesn't arrive (blocked, offline, or still missing after 20 seconds), says so
61
+ // instead of "Loading" forever. The app replaces the loading message when it starts, so the timer
62
+ // finds nothing to replace then.
63
+ const BOOT_WATCH = '(function(){var shown=false;function show(){if(shown)return;var loading=document.querySelector(".boot-loading"),failed=document.querySelector(".boot-failed");if(!loading||!failed)return;shown=true;loading.hidden=true;failed.hidden=false}function broken(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",show);else show()}document.addEventListener("securitypolicyviolation",function(e){if(/^(script|style)-src/.test(e.effectiveDirective||e.violatedDirective||""))broken()});window.addEventListener("error",function(e){var t=e.target;if(t&&t.tagName==="SCRIPT"&&/\\/app\\.js$/.test(t.src||""))broken()},true);setTimeout(function(){if(document.querySelector(".boot-loading"))broken()},20000)})()';
64
+
65
+ // Applies the saved theme before first paint (same as the PeekInside site).
66
+ 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")}})()';
67
+
68
+ // The PeekInside mark (Span Core): the approved files, unchanged. "-dark" is drawn for dark
69
+ // backgrounds and "-light" for light ones; the page links each for the matching browser theme.
70
+ const BRAND_FILES = new Map(
71
+ ['peekinside-span-core-mark-dark.svg', 'peekinside-span-core-mark-light.svg'].map((name) => [
72
+ name,
73
+ readFileSync(new URL(`./brand/${name}`, import.meta.url))
74
+ ])
75
+ );
76
+ const BRAND_ROUTE = /^\/brand\/([a-z0-9-]+\.svg)$/;
77
+
78
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
79
+
80
+ /**
81
+ * The hub's HTTP API and dashboard page. Owns the http.Server; each route is a method.
82
+ */
83
+ export class HubServer {
84
+ /**
85
+ * @param {Object} options
86
+ * @param {import('./store.js').Store} options.store
87
+ * @param {number} options.port - Port the server will listen on (checked against the Host header)
88
+ * @param {string} options.siteUrl - Origin the dashboard interface is loaded from
89
+ * @param {string} options.version - Package version
90
+ * @param {string} options.dataDir - Data folder, shown in the dashboard
91
+ * @param {Function} [options.onActivity] - Called for every API request and ingest, to tell
92
+ * whether the hub is still in use
93
+ */
94
+ constructor({ store, port, siteUrl, version, dataDir, onActivity = () => {} }) {
95
+ this.store = store;
96
+ this.port = port;
97
+ this.siteUrl = siteUrl;
98
+ this.version = version;
99
+ this.dataDir = dataDir;
100
+ this.onActivity = onActivity;
101
+ // The names this server is reached by. Any other Host means DNS rebinding: a foreign site
102
+ // whose domain resolves to 127.0.0.1.
103
+ this._hosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]);
104
+ this._assets = `${siteUrl}/local/v${HUB_API_VERSION}`;
105
+ this.startedAt = Date.now();
106
+
107
+ /** @type {http.Server} */
108
+ this.httpServer = http.createServer((req, res) => {
109
+ this.handle(req, res).catch((error) => {
110
+ console.error(`Request failed: ${error && error.stack ? error.stack : error}`);
111
+ if (!res.headersSent) sendJson(res, 500, { error: 'Internal error' });
112
+ else res.destroy();
113
+ });
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Starts listening on 127.0.0.1, never on other interfaces: the data is only for this machine.
119
+ *
120
+ * @param {Function} [callback] - Called once listening
121
+ * @returns {HubServer} This server
122
+ */
123
+ listen(callback) {
124
+ this.httpServer.listen(this.port, '127.0.0.1', callback);
125
+ return this;
126
+ }
127
+
128
+ /**
129
+ * Stops listening and closes open connections, including dashboards polling it.
130
+ *
131
+ * @returns {void}
132
+ */
133
+ close() {
134
+ this.httpServer.close();
135
+ this.httpServer.closeAllConnections?.();
136
+ }
137
+
138
+ /**
139
+ * What GET /api/status returns. `app` lets clients tell the hub apart from other programs.
140
+ *
141
+ * @returns {Object}
142
+ */
143
+ status() {
144
+ return {
145
+ app: HUB_APP_ID,
146
+ apiVersion: HUB_API_VERSION,
147
+ version: this.version,
148
+ pid: process.pid,
149
+ startedAt: this.startedAt,
150
+ dataDir: this.dataDir,
151
+ siteUrl: this.siteUrl,
152
+ maxRequests: this.store.maxRequests,
153
+ maxExceptions: this.store.maxExceptions
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Answers one request.
159
+ *
160
+ * @param {http.IncomingMessage} req
161
+ * @param {http.ServerResponse} res
162
+ * @returns {Promise<void>}
163
+ */
164
+ async handle(req, res) {
165
+ const host = String(req.headers.host || '').toLowerCase();
166
+ if (!this._hosts.has(host)) {
167
+ return sendText(res, 403, 'Forbidden: open this dashboard at http://localhost:' + this.port);
168
+ }
169
+
170
+ let url;
171
+ try {
172
+ url = new URL(req.url, `http://${host}`);
173
+ } catch (error) {
174
+ return sendText(res, 400, 'Bad Request');
175
+ }
176
+ const { pathname, searchParams } = url;
177
+
178
+ if (pathname === '/api/ingest') {
179
+ this.onActivity();
180
+ return this._ingest(req, res);
181
+ }
182
+ if (pathname.startsWith('/api/')) {
183
+ if (!isSameOrigin(req, host)) {
184
+ return sendJson(res, 403, { error: 'Only the dashboard on this port can use this API.' });
185
+ }
186
+ this.onActivity();
187
+ return this._api(req, res, pathname, searchParams);
188
+ }
189
+
190
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
191
+ res.setHeader('Allow', 'GET, HEAD');
192
+ return sendText(res, 405, 'Method Not Allowed');
193
+ }
194
+ if (pathname === '/' || SHELL_ROUTE.test(pathname)) {
195
+ return this._shell(req, res);
196
+ }
197
+ const brandFile = pathname === '/favicon.svg' || pathname === '/favicon.ico'
198
+ ? 'peekinside-span-core-mark-dark.svg'
199
+ : BRAND_ROUTE.exec(pathname)?.[1];
200
+ if (brandFile && BRAND_FILES.has(brandFile)) {
201
+ return send(res, 200, BRAND_FILES.get(brandFile), { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' }, req.method === 'HEAD');
202
+ }
203
+ return sendText(res, 404, 'Not Found');
204
+ }
205
+
206
+ // POST /api/ingest: a batch from an app's SDK.
207
+ async _ingest(req, res) {
208
+ if (req.method !== 'POST') {
209
+ res.setHeader('Allow', 'POST');
210
+ return sendJson(res, 405, { error: 'Method not allowed' });
211
+ }
212
+ // Browsers always send Origin or Sec-Fetch-Site on cross-origin POSTs; the SDK sends neither.
213
+ if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined) {
214
+ return sendJson(res, 403, { error: 'Browsers cannot send requests to the hub.' });
215
+ }
216
+ if (!/^application\/json\b/i.test(String(req.headers['content-type'] || ''))) {
217
+ return sendJson(res, 415, { error: 'Expected application/json' });
218
+ }
219
+ if (!req.headers['x-peekinside-sdk']) {
220
+ return sendJson(res, 400, { error: 'Missing x-peekinside-sdk header' });
221
+ }
222
+
223
+ let body;
224
+ try {
225
+ body = JSON.parse(await readBody(req, MAX_INGEST_BYTES));
226
+ } catch (error) {
227
+ if (error && error.status === 413) {
228
+ res.setHeader('Connection', 'close');
229
+ return sendJson(res, 413, { error: 'Batch too large' });
230
+ }
231
+ return sendJson(res, 400, { error: 'Invalid JSON' });
232
+ }
233
+
234
+ const payload = IngestPayload.parse(body);
235
+ if (!payload) {
236
+ return sendJson(res, 400, { error: 'Expected { project, instance, requests }' });
237
+ }
238
+
239
+ this.store.touch(payload.project, payload.instance);
240
+ const added = this.store.append(payload.project.id, payload.records);
241
+ const addedExceptions = this.store.appendExceptions(payload.project.id, payload.exceptions);
242
+ this.store.noteBlocked(payload.project.id, payload.blocked);
243
+ this.store.addSamples(payload.project.id, payload.instance, payload.samples);
244
+ const addedJobs = this.store.appendJobs(payload.project.id, payload.jobs);
245
+ const addedLogs = this.store.appendLogs(payload.project.id, payload.logs);
246
+ // SDKs from before exceptions get the answer they know.
247
+ return sendJson(res, 200, payload.hasExceptions
248
+ ? {
249
+ accepted: added.length,
250
+ rejected: payload.rejected,
251
+ acceptedExceptions: addedExceptions.length,
252
+ rejectedExceptions: payload.rejectedExceptions,
253
+ acceptedJobs: addedJobs.length,
254
+ acceptedLogs: addedLogs.length
255
+ }
256
+ : { accepted: added.length, rejected: payload.rejected, acceptedJobs: addedJobs.length, acceptedLogs: addedLogs.length });
257
+ }
258
+
259
+ // Every /api/ route except ingest; the caller has checked the origin.
260
+ _api(req, res, pathname, query) {
261
+ const read = isRead(req);
262
+
263
+ if (pathname === '/api/status') {
264
+ return read ? sendJson(res, 200, this.status()) : methodNotAllowed(res, 'GET, HEAD');
265
+ }
266
+ if (pathname === '/api/projects') {
267
+ return read ? sendJson(res, 200, { projects: this.store.listProjects() }) : methodNotAllowed(res, 'GET, HEAD');
268
+ }
269
+
270
+ const match = PROJECT_ROUTE.exec(pathname);
271
+ const projectId = match && decode(match[1]);
272
+ if (!match || !PROJECT_ID.test(projectId)) {
273
+ return sendJson(res, 404, { error: 'Not found' });
274
+ }
275
+ if (!this.store.has(projectId)) {
276
+ return sendJson(res, 404, { error: 'This project is not on this machine.' });
277
+ }
278
+
279
+ // State changes must come from this page: browsers always send Origin with these.
280
+ if (!read && req.headers.origin === undefined) {
281
+ return sendJson(res, 403, { error: 'Missing Origin header' });
282
+ }
283
+
284
+ const [, , collection, item] = match;
285
+ if (!collection) {
286
+ return this._project(req, res, projectId);
287
+ }
288
+ if (collection === 'requests') {
289
+ return item === undefined ? this._requests(req, res, projectId, query) : this._requestDetail(req, res, projectId, item);
290
+ }
291
+ if (collection === 'exceptions') {
292
+ return item === undefined ? this._exceptions(req, res, projectId, query) : this._exceptionDetail(req, res, projectId, item);
293
+ }
294
+ if (collection === 'jobs') {
295
+ if (item !== undefined) return this._item(req, res, decode(item), (id) => this.store.jobDetail(projectId, id), 'This job run is no longer stored.');
296
+ return this._collection(req, res, query, () => this.store.listJobs(projectId, pageOf(query)), () => this.store.clearJobs(projectId));
297
+ }
298
+ if (collection === 'logs') {
299
+ if (item !== undefined) return this._item(req, res, decode(item), (id) => this.store.logDetail(projectId, id), 'This log line is no longer stored.');
300
+ return this._collection(req, res, query, () => this.store.listLogs(projectId, pageOf(query)), () => this.store.clearLogs(projectId));
301
+ }
302
+ if (collection === 'runtime') {
303
+ if (item !== undefined) return sendJson(res, 404, { error: 'Not found' });
304
+ return read ? sendJson(res, 200, this.store.runtime(projectId)) : methodNotAllowed(res, 'GET, HEAD');
305
+ }
306
+ return item === undefined ? this._exceptionState(req, res, projectId) : sendJson(res, 404, { error: 'Not found' });
307
+ }
308
+
309
+ // A paged list (GET), or all of it deleted (DELETE).
310
+ _collection(req, res, query, list, clear) {
311
+ if (isRead(req)) return sendJson(res, 200, list());
312
+ if (req.method === 'DELETE') {
313
+ clear();
314
+ return sendJson(res, 200, { cleared: true });
315
+ }
316
+ return methodNotAllowed(res, 'GET, HEAD, DELETE');
317
+ }
318
+
319
+ // One stored item's detail.
320
+ _item(req, res, id, detailOf, missing) {
321
+ if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
322
+ const detail = REQUEST_ID.test(id) ? detailOf(id) : null;
323
+ return detail ? sendJson(res, 200, { detail }) : sendJson(res, 404, { error: missing });
324
+ }
325
+
326
+ // /api/projects/:id
327
+ _project(req, res, projectId) {
328
+ if (req.method === 'DELETE') {
329
+ this.store.remove(projectId);
330
+ return sendJson(res, 200, { removed: true });
331
+ }
332
+ return methodNotAllowed(res, 'DELETE');
333
+ }
334
+
335
+ // /api/projects/:id/requests
336
+ _requests(req, res, projectId, query) {
337
+ if (isRead(req)) {
338
+ return sendJson(res, 200, this.store.list(projectId, pageOf(query)));
339
+ }
340
+ if (req.method === 'DELETE') {
341
+ this.store.clear(projectId);
342
+ return sendJson(res, 200, { cleared: true });
343
+ }
344
+ return methodNotAllowed(res, 'GET, HEAD, DELETE');
345
+ }
346
+
347
+ // /api/projects/:id/requests/:rid
348
+ _requestDetail(req, res, projectId, encodedId) {
349
+ if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
350
+ const requestId = decode(encodedId);
351
+ const detail = REQUEST_ID.test(requestId) ? this.store.detail(projectId, requestId) : null;
352
+ return detail
353
+ ? sendJson(res, 200, { detail })
354
+ : sendJson(res, 404, { error: 'This request is no longer stored.' });
355
+ }
356
+
357
+ // /api/projects/:id/exceptions
358
+ _exceptions(req, res, projectId, query) {
359
+ if (isRead(req)) {
360
+ return sendJson(res, 200, this.store.listExceptions(projectId, pageOf(query)));
361
+ }
362
+ if (req.method === 'DELETE') {
363
+ this.store.clearExceptions(projectId);
364
+ return sendJson(res, 200, { cleared: true });
365
+ }
366
+ return methodNotAllowed(res, 'GET, HEAD, DELETE');
367
+ }
368
+
369
+ // /api/projects/:id/exceptions/:eid
370
+ _exceptionDetail(req, res, projectId, encodedId) {
371
+ if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
372
+ const exceptionId = decode(encodedId);
373
+ const detail = REQUEST_ID.test(exceptionId) ? this.store.exceptionDetail(projectId, exceptionId) : null;
374
+ return detail
375
+ ? sendJson(res, 200, { detail })
376
+ : sendJson(res, 404, { error: 'This exception is no longer stored.' });
377
+ }
378
+
379
+ // /api/projects/:id/exception-state: resolved issues. A save names the version it was based
380
+ // on; when another dashboard saved in between, it gets the current state back to merge with.
381
+ async _exceptionState(req, res, projectId) {
382
+ if (isRead(req)) {
383
+ return sendJson(res, 200, this.store.exceptionState(projectId));
384
+ }
385
+ if (req.method !== 'PUT') {
386
+ return methodNotAllowed(res, 'GET, HEAD, PUT');
387
+ }
388
+ if (!/^application\/json\b/i.test(String(req.headers['content-type'] || ''))) {
389
+ return sendJson(res, 415, { error: 'Expected application/json' });
390
+ }
391
+
392
+ let body;
393
+ try {
394
+ body = JSON.parse(await readBody(req, MAX_STATE_BYTES));
395
+ } catch (error) {
396
+ if (error && error.status === 413) {
397
+ res.setHeader('Connection', 'close');
398
+ return sendJson(res, 413, { error: 'State too large' });
399
+ }
400
+ return sendJson(res, 400, { error: 'Invalid JSON' });
401
+ }
402
+
403
+ const version = body && body.version;
404
+ const state = body ? ExceptionState.validate(body.state) : null;
405
+ if (!Number.isInteger(version) || version < 0 || !state) {
406
+ return sendJson(res, 400, { error: 'Expected { state: { resolved }, version }, with at most 5000 fingerprints' });
407
+ }
408
+ const result = this.store.saveExceptionState(projectId, state, version);
409
+ if (!result.saved) {
410
+ return sendJson(res, 409, { error: 'The exception state changed since it was read.', state: result.state, version: result.version });
411
+ }
412
+ return sendJson(res, 200, { version: result.version });
413
+ }
414
+
415
+ // The dashboard page: a shell that loads the interface from the PeekInside site. The Content
416
+ // Security Policy allows scripts only from that site (and the inline scripts, by nonce), and
417
+ // lets the page fetch data only from this server.
418
+ _shell(req, res) {
419
+ const assets = this._assets;
420
+ const siteUrl = this.siteUrl;
421
+ const host = new URL(siteUrl).host;
422
+ const nonce = randomBytes(16).toString('base64');
423
+ const assetOrigins = siteOrigins(assets);
424
+ const csp = [
425
+ "default-src 'none'",
426
+ `script-src 'nonce-${nonce}' ${assetOrigins}`,
427
+ `style-src ${assetOrigins} 'unsafe-inline'`,
428
+ "img-src 'self' data:",
429
+ `font-src ${assetOrigins}`,
430
+ "connect-src 'self'",
431
+ "base-uri 'none'",
432
+ "form-action 'none'",
433
+ "frame-ancestors 'none'"
434
+ ].join('; ');
435
+
436
+ const html = `<!doctype html>
437
+ <html lang="en" data-theme="dark">
438
+ <head>
439
+ <meta charset="utf-8">
440
+ <meta name="viewport" content="width=device-width, initial-scale=1">
441
+ <meta name="robots" content="noindex">
442
+ <title>PeekInside Local</title>
443
+ <link rel="icon" href="/brand/peekinside-span-core-mark-light.svg" type="image/svg+xml" media="(prefers-color-scheme: light)">
444
+ <link rel="icon" href="/brand/peekinside-span-core-mark-dark.svg" type="image/svg+xml" media="(prefers-color-scheme: dark)">
445
+ <script nonce="${nonce}">${BOOT_WATCH}</script>
446
+ <link rel="stylesheet" href="${escapeHtml(assets)}/app.css">
447
+ <script nonce="${nonce}">${THEME_SCRIPT}</script>
448
+ <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}.boot[hidden]{display:none}.boot-failed{opacity:.9}</style>
449
+ </head>
450
+ <body>
451
+ <div id="root"><div class="boot boot-loading"><p><strong>Loading PeekInside…</strong></p><p>The dashboard interface loads from <a href="${escapeHtml(siteUrl)}">${escapeHtml(host)}</a>. Your requests stay on this machine.</p></div><div class="boot boot-failed" hidden><p><strong>The dashboard didn’t load</strong></p><p>Its interface comes from <a href="${escapeHtml(siteUrl)}">${escapeHtml(host)}</a>, and this browser couldn’t load it. Check that this computer can reach ${escapeHtml(host)}: a firewall, VPN, proxy or ad blocker can block it. Then <a href="">reload this page</a>.</p><p>Your app keeps recording meanwhile, so nothing is lost.</p></div></div>
452
+ <script src="${escapeHtml(assets)}/app.js" defer></script>
453
+ </body>
454
+ </html>
455
+ `;
456
+ send(res, 200, html, {
457
+ 'Content-Type': 'text/html; charset=utf-8',
458
+ 'Cache-Control': 'no-store',
459
+ 'Content-Security-Policy': csp
460
+ }, req.method === 'HEAD');
461
+ }
462
+ }
463
+
464
+ // The site's origin and its www twin: hosting often redirects one to the other (Vercel sends the
465
+ // bare domain to www by default), and a Content Security Policy also checks where a redirect
466
+ // lands. Local addresses (localhost, IPs) have no twin.
467
+ function siteOrigins(siteUrl) {
468
+ const url = new URL(siteUrl);
469
+ const host = url.hostname;
470
+ const origins = [url.origin];
471
+ const named = host.includes('.') && !host.startsWith('[') && !/^[\d.]+$/.test(host) && host !== 'localhost' && !host.endsWith('.localhost');
472
+ if (named) {
473
+ const twin = new URL(url.origin);
474
+ twin.hostname = host.startsWith('www.') ? host.slice(4) : `www.${host}`;
475
+ origins.push(twin.origin);
476
+ }
477
+ return origins.join(' ');
478
+ }
479
+
480
+ function isRead(req) {
481
+ return req.method === 'GET' || req.method === 'HEAD';
482
+ }
483
+
484
+ // Same-origin fetches send Sec-Fetch-Site "same-origin" (or "none" when typed in the address bar).
485
+ // "same-site" is refused too: other ports on localhost count as the same site.
486
+ function isSameOrigin(req, host) {
487
+ const site = req.headers['sec-fetch-site'];
488
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') return false;
489
+ const origin = req.headers.origin;
490
+ return origin === undefined || origin === `http://${host}`;
491
+ }
492
+
493
+ // limit, and before or after (sequence numbers), from a list's query string.
494
+ function pageOf(query) {
495
+ const limit = Math.min(MAX_PAGE, toInt(query.get('limit'), DEFAULT_PAGE, 1));
496
+ const after = query.has('after') ? toInt(query.get('after'), 0, 0) : undefined;
497
+ const before = query.has('before') ? toInt(query.get('before'), 0, 0) : undefined;
498
+ return { after, before, limit };
499
+ }
500
+
501
+ function toInt(value, fallback, min) {
502
+ const parsed = Number.parseInt(value, 10);
503
+ return Number.isFinite(parsed) ? Math.max(min, parsed) : fallback;
504
+ }
505
+
506
+ function decode(value) {
507
+ try {
508
+ return decodeURIComponent(value);
509
+ } catch (error) {
510
+ return '';
511
+ }
512
+ }
513
+
514
+ // Stops collecting at `limit` bytes; the 413 response then closes the connection.
515
+ function readBody(req, limit) {
516
+ return new Promise((resolve, reject) => {
517
+ const chunks = [];
518
+ let size = 0;
519
+ let failed = false;
520
+ const fail = (error) => {
521
+ if (failed) return;
522
+ failed = true;
523
+ reject(error);
524
+ };
525
+ req.on('data', (chunk) => {
526
+ if (failed) return;
527
+ size += chunk.length;
528
+ if (size > limit) {
529
+ fail(Object.assign(new Error('Payload too large'), { status: 413 }));
530
+ return;
531
+ }
532
+ chunks.push(chunk);
533
+ });
534
+ req.on('end', () => {
535
+ if (!failed) resolve(Buffer.concat(chunks).toString('utf8'));
536
+ });
537
+ req.on('error', fail);
538
+ });
539
+ }
540
+
541
+ function escapeHtml(value) {
542
+ return String(value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
543
+ }
544
+
545
+ function send(res, status, body, headers, headOnly = false) {
546
+ res.writeHead(status, { ...BASE_HEADERS, 'Content-Length': Buffer.byteLength(body), ...headers });
547
+ res.end(headOnly ? undefined : body);
548
+ }
549
+
550
+ function sendText(res, status, body) {
551
+ send(res, status, body, { 'Content-Type': 'text/plain; charset=utf-8' });
552
+ }
553
+
554
+ function sendJson(res, status, payload) {
555
+ send(res, status, JSON.stringify(payload), { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
556
+ }
557
+
558
+ function methodNotAllowed(res, allow) {
559
+ res.setHeader('Allow', allow);
560
+ sendJson(res, 405, { error: 'Method not allowed' });
561
+ }
@@ -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
+ }