diffsplain 0.5.0 → 0.6.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 (91) hide show
  1. package/README.md +17 -12
  2. package/dist/assets/{angular-html-CoQle5-T.js → angular-html-vBvPnd6a.js} +1 -1
  3. package/dist/assets/{angular-ts-BB_A1Lpy.js → angular-ts-CDzF1Z6a.js} +1 -1
  4. package/dist/assets/{apl-B7_4_FNq.js → apl-Bm9bxvP7.js} +1 -1
  5. package/dist/assets/{astro-CXVo4VE4.js → astro-RNrNWCYO.js} +1 -1
  6. package/dist/assets/{blade-BVX41dq7.js → blade-On-NBYUv.js} +1 -1
  7. package/dist/assets/{c-Dhyi8Nd2.js → c-hRmhFgD0.js} +1 -1
  8. package/dist/assets/{cobol-CU5oLeZj.js → cobol-CIqc3k2t.js} +1 -1
  9. package/dist/assets/{coffee-D_FU6gC_.js → coffee-Cd5EyvvS.js} +1 -1
  10. package/dist/assets/{cpp-CxoYLSi3.js → cpp-zXFAAp_S.js} +1 -1
  11. package/dist/assets/{crystal-BdgqBN3z.js → crystal-DRYY4cSw.js} +1 -1
  12. package/dist/assets/{css-wLk1vVwJ.js → css-C4op_MsD.js} +1 -1
  13. package/dist/assets/{edge-DMylWoGl.js → edge-d3rJkErf.js} +1 -1
  14. package/dist/assets/{elixir-Djmgto_Z.js → elixir-DPqlJhYM.js} +1 -1
  15. package/dist/assets/{elm-D7B-eBLA.js → elm-DgU37I8a.js} +1 -1
  16. package/dist/assets/{erb-g1sso1GW.js → erb-CZVB_ntC.js} +1 -1
  17. package/dist/assets/{git-rebase-DEAhxutZ.js → git-rebase-hUkltHZG.js} +1 -1
  18. package/dist/assets/{glimmer-js-CHvGFGMp.js → glimmer-js-5XOT9GQ8.js} +1 -1
  19. package/dist/assets/{glimmer-ts-DvhL705p.js → glimmer-ts-DT7D0Kfq.js} +1 -1
  20. package/dist/assets/{glsl-C8mS_t8I.js → glsl-Br4_Eh5O.js} +1 -1
  21. package/dist/assets/{graphql-DW2pnvqi.js → graphql--BmZ_AYP.js} +1 -1
  22. package/dist/assets/{hack-DYc1d3he.js → hack-CMdqGDoA.js} +1 -1
  23. package/dist/assets/{haml-_5OmzH31.js → haml-DLEOHGmg.js} +1 -1
  24. package/dist/assets/{handlebars-rN-CaXUf.js → handlebars-BfUftoV0.js} +1 -1
  25. package/dist/assets/{html-derivative-BovOubry.js → html-derivative-D5cZE6C4.js} +1 -1
  26. package/dist/assets/{html-DQnaJUjl.js → html-uOgx2iYC.js} +1 -1
  27. package/dist/assets/{http-BKTgwOYc.js → http-cW9c66J_.js} +1 -1
  28. package/dist/assets/{hurl-ChS3_IgD.js → hurl-BztURO2n.js} +1 -1
  29. package/dist/assets/{index-BLtRBc2n.css → index-C2p_1iAk.css} +1 -1
  30. package/dist/assets/{index-CEglkSs-.js → index-DZFM4-3L.js} +11 -11
  31. package/dist/assets/{java-Dgnyk--z.js → java-Cy4mnKaj.js} +1 -1
  32. package/dist/assets/{javascript-BfnrwkRN.js → javascript-_Z02t_9Q.js} +1 -1
  33. package/dist/assets/{jinja-CD2AIvOi.js → jinja-BppBm5gt.js} +1 -1
  34. package/dist/assets/{jison-DmKNw3Pl.js → jison-Dzns436M.js} +1 -1
  35. package/dist/assets/{json-CKB5a8aj.js → json-BYkfLOry.js} +1 -1
  36. package/dist/assets/{jsx-jJXrDRBM.js → jsx-DtAm8NUb.js} +1 -1
  37. package/dist/assets/{julia-G8vVBW-t.js → julia-CLIMRuh0.js} +1 -1
  38. package/dist/assets/{just-DMXLrLZL.js → just-DMg47an6.js} +1 -1
  39. package/dist/assets/{latex-DS3xuxld.js → latex-D05ztCOx.js} +1 -1
  40. package/dist/assets/{liquid-COQnD_C4.js → liquid-DpImQR4t.js} +1 -1
  41. package/dist/assets/{lua-BwMKwosd.js → lua--ZHUYQUU.js} +1 -1
  42. package/dist/assets/{marko-cQCK_WIk.js → marko-Dh3Xi08i.js} +1 -1
  43. package/dist/assets/{mdc-DQUjY13N.js → mdc-Br6Pt78u.js} +1 -1
  44. package/dist/assets/{nginx-C4Kv3DJi.js → nginx-CwSU6U6d.js} +1 -1
  45. package/dist/assets/{nim-ChzLzuYD.js → nim-CBbTwyeq.js} +1 -1
  46. package/dist/assets/{perl-z5xUiWAY.js → perl-D3jQu3LN.js} +1 -1
  47. package/dist/assets/{php-BHqm-Xeh.js → php-BMeXBzlU.js} +1 -1
  48. package/dist/assets/{pug-C9C_Llft.js → pug-BGdY1g5E.js} +1 -1
  49. package/dist/assets/{qml-dtpuwXJZ.js → qml-DsT5biza.js} +1 -1
  50. package/dist/assets/{r-BicQMs_c.js → r-CbS9PfN5.js} +1 -1
  51. package/dist/assets/{razor-D5h00YTZ.js → razor-Bwm8roHi.js} +1 -1
  52. package/dist/assets/{regexp-CTa8S86v.js → regexp-Bc_9DyAu.js} +1 -1
  53. package/dist/assets/{rst-Bg-b-7HK.js → rst-Cj1Aii91.js} +1 -1
  54. package/dist/assets/{ruby-BiWYm056.js → ruby-BPMtFZnc.js} +1 -1
  55. package/dist/assets/{sas-bJTdfgUz.js → sas-CoOau1t2.js} +1 -1
  56. package/dist/assets/{scss-0qkNyHr1.js → scss-CvfaI808.js} +1 -1
  57. package/dist/assets/{shellscript-3sDkDE9z.js → shellscript-B0C-TdLE.js} +1 -1
  58. package/dist/assets/{shellsession-D4EBhEmz.js → shellsession-UhmLiqcA.js} +1 -1
  59. package/dist/assets/{soy-DG9hUwLL.js → soy-D419u0RL.js} +1 -1
  60. package/dist/assets/{sql-B0UoKHkx.js → sql-Do9k0rRn.js} +1 -1
  61. package/dist/assets/{stata-CsQbuXQp.js → stata-x47J6BC4.js} +1 -1
  62. package/dist/assets/{surrealql-D63VLxos.js → surrealql-BERQ6pHu.js} +1 -1
  63. package/dist/assets/{svelte-BApdnAma.js → svelte-dRWkyqKW.js} +1 -1
  64. package/dist/assets/{templ-B64pN6Lz.js → templ-CTq-KoWN.js} +1 -1
  65. package/dist/assets/{tex-DR2UVIPF.js → tex-CdRGjyYI.js} +1 -1
  66. package/dist/assets/{ts-tags-IwhomvQv.js → ts-tags-CgaMqyVb.js} +1 -1
  67. package/dist/assets/{tsx-DROdxqzS.js → tsx-cpBQKcPG.js} +1 -1
  68. package/dist/assets/{twig-BtbUqpR4.js → twig-vbs_PM4-.js} +1 -1
  69. package/dist/assets/{typescript-CHpSjYZL.js → typescript-Cc--nwIo.js} +1 -1
  70. package/dist/assets/{vue-DCvEu_ug.js → vue-CgHwdBmB.js} +1 -1
  71. package/dist/assets/{vue-html-BgL4Rf-L.js → vue-html-BHfoMw47.js} +1 -1
  72. package/dist/assets/{vue-vine-CRNdhPTn.js → vue-vine-_lVRp_NK.js} +1 -1
  73. package/dist/assets/{xml-CkfPxZuz.js → xml-G4NVmTde.js} +1 -1
  74. package/dist/assets/{xsl-CteaoFSV.js → xsl-s0so2M1i.js} +1 -1
  75. package/dist/assets/{yaml-CUr3TDRN.js → yaml-CpsmW05t.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +49 -23
  78. package/scripts/access-token.mjs +21 -0
  79. package/scripts/build-diff-data.mjs +260 -39
  80. package/scripts/cache.mjs +301 -0
  81. package/scripts/cli-args.mjs +206 -57
  82. package/scripts/coding-agents.mjs +279 -162
  83. package/scripts/dev.mjs +218 -0
  84. package/scripts/doctor.mjs +265 -46
  85. package/scripts/generate-summaries.mjs +760 -260
  86. package/scripts/mock-agent.mjs +165 -0
  87. package/scripts/present.mjs +394 -85
  88. package/scripts/presenter-runtime.mjs +148 -0
  89. package/scripts/serve-built.mjs +343 -71
  90. package/scripts/summary-path.mjs +24 -7
  91. package/scripts/support-record.mjs +242 -0
@@ -0,0 +1,148 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export function agentRunNeeded(
6
+ fingerprint,
7
+ { activeFingerprint, completedFingerprint, failedFingerprint } = {},
8
+ ) {
9
+ return Boolean(
10
+ fingerprint &&
11
+ fingerprint !== activeFingerprint &&
12
+ fingerprint !== completedFingerprint &&
13
+ fingerprint !== failedFingerprint,
14
+ );
15
+ }
16
+
17
+ function agentRunStopped({ code, error, signal }) {
18
+ return Boolean(error || code || signal);
19
+ }
20
+
21
+ export function agentRunCompleted({ code, error, signal, superseded }) {
22
+ if (superseded) return false;
23
+ return !agentRunStopped({ code, error, signal });
24
+ }
25
+
26
+ export function agentRunSuperseded(
27
+ queuedFingerprint,
28
+ finishedFingerprint,
29
+ ) {
30
+ return Boolean(
31
+ queuedFingerprint && queuedFingerprint !== finishedFingerprint,
32
+ );
33
+ }
34
+
35
+ export function agentFallbackRecordNeeded({
36
+ closing,
37
+ queuedFingerprint,
38
+ error,
39
+ signal,
40
+ }) {
41
+ if (closing) return false;
42
+ if (queuedFingerprint) return false;
43
+ return Boolean(error || signal);
44
+ }
45
+
46
+ export function agentRunFailed({
47
+ closing,
48
+ code,
49
+ error,
50
+ signal,
51
+ superseded,
52
+ }) {
53
+ if (closing) return false;
54
+ if (superseded) return false;
55
+ return agentRunStopped({ code, error, signal });
56
+ }
57
+
58
+ export function failedAgentRunForFingerprint(
59
+ failedFingerprint,
60
+ observedFingerprint,
61
+ ) {
62
+ if (!failedFingerprint || !observedFingerprint) return failedFingerprint;
63
+ return failedFingerprint === observedFingerprint
64
+ ? failedFingerprint
65
+ : undefined;
66
+ }
67
+
68
+ export function nextAgentFingerprint({
69
+ queuedFingerprint,
70
+ observedFingerprint,
71
+ finishedFingerprint,
72
+ }) {
73
+ const latest = queuedFingerprint || observedFingerprint;
74
+ if (!latest || latest === finishedFingerprint) return undefined;
75
+ return latest;
76
+ }
77
+
78
+ export function browserCommand({ url, browser, platform = process.platform }) {
79
+ if (browser) return { command: browser, args: [url] };
80
+ if (platform === 'darwin') return { command: 'open', args: [url] };
81
+ if (platform === 'win32') {
82
+ return { command: 'cmd.exe', args: ['/d', '/s', '/c', 'start', '', url] };
83
+ }
84
+ return { command: 'xdg-open', args: [url] };
85
+ }
86
+
87
+ export function openBrowser(
88
+ url,
89
+ {
90
+ browser = process.env.BROWSER,
91
+ platform = process.platform,
92
+ spawnProcess = spawn,
93
+ onError = () => {},
94
+ } = {},
95
+ ) {
96
+ try {
97
+ const { command, args } = browserCommand({ url, browser, platform });
98
+ const opener = spawnProcess(command, args, { detached: true, stdio: 'ignore' });
99
+ opener.once('error', onError);
100
+ opener.unref();
101
+ return opener;
102
+ } catch (error) {
103
+ onError(error);
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ function builtAssetsReady({ root, exists = existsSync }) {
109
+ const dist = join(root, 'dist');
110
+ return exists(join(dist, 'index.html')) && exists(join(dist, 'assets'));
111
+ }
112
+
113
+ function npmCommand(platform) {
114
+ return platform === 'win32' ? 'npm.cmd' : 'npm';
115
+ }
116
+
117
+ function buildAssets(runtime) {
118
+ const result = runtime.run(npmCommand(runtime.platform), ['run', 'build'], {
119
+ cwd: runtime.root,
120
+ stdio: 'inherit',
121
+ });
122
+ if (result.error) {
123
+ throw new Error(`Could not build the local page: ${result.error.message}`);
124
+ }
125
+ if (result.status === 0) return;
126
+ const status = Number.isInteger(result.status) ? Number(result.status) : 1;
127
+ const error = new Error(
128
+ `Could not build the local page: npm run build exited with ${status}.`,
129
+ );
130
+ error.exitCode = status;
131
+ throw error;
132
+ }
133
+
134
+ export function ensureBuiltAssets(options) {
135
+ const runtime = {
136
+ exists: existsSync,
137
+ run: spawnSync,
138
+ platform: process.platform,
139
+ ...options,
140
+ };
141
+ if (builtAssetsReady({ root: runtime.root, exists: runtime.exists })) return false;
142
+
143
+ buildAssets(runtime);
144
+ if (!builtAssetsReady({ root: runtime.root, exists: runtime.exists })) {
145
+ throw new Error('Could not build the local page: built assets are still missing.');
146
+ }
147
+ return true;
148
+ }
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createServer } from 'node:http';
3
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
4
4
  import { watchFile, unwatchFile } from 'node:fs';
5
5
  import { readFile, stat } from 'node:fs/promises';
6
- import { Readable } from 'node:stream';
6
+ import { createServer } from 'node:http';
7
7
  import { dirname, extname, resolve, sep } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
@@ -24,9 +24,18 @@ function option(name, fallback) {
24
24
  const output = resolve(option('--output', resolve(root, '.cache/diff-data.json')));
25
25
  const project = option('--project', '');
26
26
  const portValue = option('--port', '2299');
27
+ const host = option('--host', 'localhost').replace(/^\[|\]$/g, '');
28
+ const access = option('--access', randomBytes(32).toString('base64url'));
29
+ const previousAccess = option('--previous-access', '');
27
30
  if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
28
31
  throw new Error('--port must be a number from 0 to 65535');
29
32
  }
33
+ if (!/^[A-Za-z0-9_-]{32,}$/.test(access)) {
34
+ throw new Error('--access must be an unguessable URL-safe value');
35
+ }
36
+ if (previousAccess && !/^[A-Za-z0-9_-]{32,}$/.test(previousAccess)) {
37
+ throw new Error('--previous-access must be an unguessable URL-safe value');
38
+ }
30
39
  const incrementPort =
31
40
  rawArgs.includes('--increment-port') || !rawArgs.includes('--port');
32
41
 
@@ -41,22 +50,65 @@ const contentTypes = {
41
50
  '.woff2': 'font/woff2',
42
51
  };
43
52
 
53
+ const restrictiveHeaders = {
54
+ 'content-security-policy':
55
+ "base-uri 'none'; connect-src 'self'; default-src 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; style-src 'self' 'unsafe-inline'",
56
+ 'cross-origin-opener-policy': 'same-origin',
57
+ 'cross-origin-resource-policy': 'same-origin',
58
+ 'permissions-policy': 'geolocation=(), microphone=(), camera=()',
59
+ 'referrer-policy': 'no-referrer',
60
+ 'x-content-type-options': 'nosniff',
61
+ 'x-frame-options': 'DENY',
62
+ };
63
+
64
+ function webResponse(body, { status = 200, headers = {} } = {}) {
65
+ return new Response(body, {
66
+ status,
67
+ headers: { ...restrictiveHeaders, ...headers },
68
+ });
69
+ }
70
+
71
+ function clientError() {
72
+ return webResponse('Bad request', { status: 400 });
73
+ }
74
+
44
75
  function notFound() {
45
- return new Response('Not found', { status: 404 });
76
+ return webResponse('Not found', { status: 404 });
77
+ }
78
+
79
+ function isLoopback(address) {
80
+ const normalized = address.replace(/^\[|\]$/g, '').toLowerCase();
81
+ return (
82
+ normalized === 'localhost' ||
83
+ normalized === '::1' ||
84
+ normalized === '::ffff:127.0.0.1' ||
85
+ /^127(?:\.\d{1,3}){3}$/.test(normalized)
86
+ );
87
+ }
88
+
89
+ function normalizedAddress(address) {
90
+ return address.replace(/^\[|\]$/g, '').toLowerCase();
91
+ }
92
+
93
+ function cacheControl(file) {
94
+ const extension = extname(file).toLowerCase();
95
+ if (extension === '.html') return 'no-cache';
96
+ if (/[-_][A-Za-z0-9_-]{8,}\.(?:css|js|woff2?)$/.test(file)) {
97
+ return 'public, max-age=31536000, immutable';
98
+ }
99
+ return 'no-cache';
46
100
  }
47
101
 
48
102
  async function fileResponse(file, { live = false } = {}) {
49
103
  try {
50
104
  const info = await stat(file);
51
105
  if (!info.isFile()) return notFound();
52
- return new Response(await readFile(file), {
106
+ return webResponse(await readFile(file), {
53
107
  headers: {
108
+ 'cache-control': live ? 'no-store' : cacheControl(file),
54
109
  'content-type':
55
110
  contentTypes[extname(file).toLowerCase()] ||
56
111
  'application/octet-stream',
57
- 'cache-control': live
58
- ? 'no-store'
59
- : 'public, max-age=31536000, immutable',
60
112
  },
61
113
  });
62
114
  } catch {
@@ -64,103 +116,321 @@ async function fileResponse(file, { live = false } = {}) {
64
116
  }
65
117
  }
66
118
 
67
- async function fetchAsset(request) {
68
- const url = new URL(request.url);
69
- if (url.pathname === '/diff-data.json') {
70
- return fileResponse(output, { live: true });
119
+ function decodeRequestPath(rawPath) {
120
+ try {
121
+ return decodeURIComponent(rawPath);
122
+ } catch {
123
+ return undefined;
71
124
  }
125
+ }
72
126
 
73
- let pathname;
127
+ function unsafeRequestPath(rawPath, decodedPath) {
128
+ if (!rawPath.startsWith('/')) return true;
129
+ if (authorityPath(rawPath, decodedPath)) return true;
130
+ if (decodedPath.includes('\\')) return true;
131
+ return decodedPath.split('/').includes('..');
132
+ }
133
+
134
+ function authorityPath(rawPath, decodedPath) {
135
+ return rawPath.startsWith('//') || decodedPath.startsWith('//');
136
+ }
137
+
138
+ function parseUrl(raw) {
74
139
  try {
75
- pathname = decodeURIComponent(url.pathname);
140
+ return new URL(raw, 'http://localhost');
76
141
  } catch {
77
- return notFound();
142
+ return undefined;
78
143
  }
144
+ }
145
+
146
+ function parseRequestUrl(request) {
147
+ const raw = request.url || '/';
148
+ const rawPath = raw.split(/[?#]/, 1)[0];
149
+ const decodedPath = decodeRequestPath(rawPath);
150
+ if (decodedPath === undefined) return undefined;
151
+ if (unsafeRequestPath(rawPath, decodedPath)) return undefined;
152
+ return parseUrl(raw);
153
+ }
154
+
155
+ function parseHttpHost(value) {
156
+ if (!value) return undefined;
157
+ try {
158
+ return new URL(`http://${value}`);
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ function serverPort(url) {
165
+ if (url.port) return url.port;
166
+ return readyState?.port === 80 ? '80' : '';
167
+ }
168
+
169
+ function plainHost(url) {
170
+ return ![
171
+ url.username,
172
+ url.password,
173
+ url.pathname !== '/',
174
+ url.search,
175
+ url.hash,
176
+ ].some(Boolean);
177
+ }
178
+
179
+ function matchesServerAddress(candidate, localAddress) {
180
+ const local = normalizedAddress(localAddress);
181
+ const accepted = new Set([normalizedAddress(host), local]);
182
+ if (accepted.has(candidate)) return true;
183
+ return isLoopback(candidate) && isLoopback(local);
184
+ }
185
+
186
+ function validHost(value, localAddress) {
187
+ const url = parseHttpHost(value);
188
+ if (!url) return false;
189
+ if (!plainHost(url)) return false;
190
+ if (serverPort(url) !== String(readyState.port)) return false;
191
+ return matchesServerAddress(normalizedAddress(url.hostname), localAddress);
192
+ }
193
+
194
+ function parseOriginPair(value, requestHost) {
195
+ if (value === 'null') return undefined;
196
+ try {
197
+ return {
198
+ origin: new URL(value),
199
+ requestUrl: new URL(`http://${requestHost}`),
200
+ };
201
+ } catch {
202
+ return undefined;
203
+ }
204
+ }
205
+
206
+ function matchingOrigin({ origin, requestUrl }) {
207
+ const plainOrigin = ![
208
+ origin.protocol !== 'http:',
209
+ origin.username,
210
+ origin.password,
211
+ origin.pathname !== '/',
212
+ origin.search,
213
+ origin.hash,
214
+ ].some(Boolean);
215
+ if (!plainOrigin) return false;
216
+ if (
217
+ normalizedAddress(origin.hostname) !==
218
+ normalizedAddress(requestUrl.hostname)
219
+ ) {
220
+ return false;
221
+ }
222
+ return serverPort(origin) === serverPort(requestUrl);
223
+ }
224
+
225
+ function validOrigin(value, requestHost, localAddress) {
226
+ if (!value) return true;
227
+ const pair = parseOriginPair(value, requestHost);
228
+ if (!pair) return false;
229
+ if (!matchingOrigin(pair)) return false;
230
+ return validHost(pair.origin.host, localAddress);
231
+ }
232
+
233
+ function validAccess(value, { allowPrevious = false } = {}) {
234
+ if (matchingAccess(value, access)) return true;
235
+ return (
236
+ allowPrevious &&
237
+ Boolean(previousAccess) &&
238
+ matchingAccess(value, previousAccess)
239
+ );
240
+ }
241
+
242
+ function matchingAccess(value, expectedValue) {
243
+ const received = Buffer.from(value || '');
244
+ const expected = Buffer.from(expectedValue);
245
+ return received.length === expected.length && timingSafeEqual(received, expected);
246
+ }
247
+
248
+ async function fetchAsset(url) {
249
+ if (url.pathname === '/diff-data.json') {
250
+ return fileResponse(output, { live: true });
251
+ }
252
+ const pathname = decodeURIComponent(url.pathname);
79
253
  const file = resolve(
80
254
  clientRoot,
81
255
  pathname === '/' ? 'index.html' : `.${pathname}`,
82
256
  );
83
257
  if (file !== clientRoot && !file.startsWith(`${clientRoot}${sep}`)) {
84
- return notFound();
258
+ return clientError();
85
259
  }
86
260
  return fileResponse(file);
87
261
  }
88
262
 
89
- function nodeRequest(request) {
90
- const host = request.headers.host || 'localhost';
91
- const init = {
92
- method: request.method,
93
- headers: request.headers,
94
- };
95
- if (request.method !== 'GET' && request.method !== 'HEAD') {
96
- init.body = Readable.toWeb(request);
97
- init.duplex = 'half';
98
- }
99
- return new Request(`http://${host}${request.url}`, init);
263
+ function jsonResponse(value, status = 200) {
264
+ return webResponse(JSON.stringify(value), {
265
+ status,
266
+ headers: {
267
+ 'cache-control': 'no-store',
268
+ 'content-type': 'application/json; charset=utf-8',
269
+ },
270
+ });
100
271
  }
101
272
 
102
- async function send(nodeResponse, response) {
103
- nodeResponse.writeHead(response.status, Object.fromEntries(response.headers));
104
- if (!response.body) {
273
+ async function send(nodeResponse, webResponse) {
274
+ nodeResponse.writeHead(
275
+ webResponse.status,
276
+ Object.fromEntries(webResponse.headers),
277
+ );
278
+ if (!webResponse.body) {
105
279
  nodeResponse.end();
106
280
  return;
107
281
  }
108
- Readable.fromWeb(response.body).pipe(nodeResponse);
282
+ nodeResponse.end(Buffer.from(await webResponse.arrayBuffer()));
283
+ }
284
+
285
+ const eventClients = new Set();
286
+ let selectedPort = Number(portValue);
287
+ let readyState;
288
+ let closing = false;
289
+
290
+ function requestAuthorityError(request) {
291
+ const localAddress = request.socket.localAddress || '';
292
+ if (!validHost(request.headers.host, localAddress)) {
293
+ return clientError();
294
+ }
295
+ if (!validOrigin(request.headers.origin, request.headers.host, localAddress)) {
296
+ return webResponse('Forbidden', { status: 403 });
297
+ }
298
+ return undefined;
299
+ }
300
+
301
+ function requestContext(request) {
302
+ const url = parseRequestUrl(request);
303
+ if (!url) return { error: clientError() };
304
+ const authorityError = requestAuthorityError(request);
305
+ if (authorityError) return { error: authorityError };
306
+ if (!['GET', 'HEAD'].includes(request.method)) {
307
+ return { error: webResponse('Method not allowed', { status: 405 }) };
308
+ }
309
+ return { url };
310
+ }
311
+
312
+ function forcedHandlerFailure(url) {
313
+ return (
314
+ process.env.DIFFSPLAIN_TEST_HANDLER_FAILURE &&
315
+ url.pathname === '/__test/fail'
316
+ );
317
+ }
318
+
319
+ async function sendEvents(response, url) {
320
+ if (
321
+ !validAccess(url.searchParams.get('access'), { allowPrevious: true })
322
+ ) {
323
+ await send(response, webResponse('Forbidden', { status: 403 }));
324
+ return;
325
+ }
326
+ response.writeHead(200, {
327
+ ...restrictiveHeaders,
328
+ 'content-type': 'text/event-stream',
329
+ 'cache-control': 'no-store',
330
+ connection: 'keep-alive',
331
+ });
332
+ response.write('retry: 250\nevent: ready\ndata: {}\n\n');
333
+ response.write(`event: access\ndata: ${access}\n\n`);
334
+ eventClients.add(response);
335
+ const requestProject = url.searchParams.get('project');
336
+ if (project && requestProject === project) {
337
+ console.log('Diffsplain tab: connected');
338
+ }
339
+ response.once('close', () => eventClients.delete(response));
340
+ }
341
+
342
+ async function routeResponse(url) {
343
+ if (url.pathname === '/health') return jsonResponse(readyState);
344
+ if (url.pathname === '/diff-data.json') {
345
+ if (!validAccess(url.searchParams.get('access'))) {
346
+ return webResponse('Forbidden', { status: 403 });
347
+ }
348
+ }
349
+ return fetchAsset(url);
109
350
  }
110
351
 
111
- const server = createServer(async (request, response) => {
352
+ async function handleRequest(request, response) {
353
+ const context = requestContext(request);
354
+ if (context.error) {
355
+ await send(response, context.error);
356
+ return;
357
+ }
358
+ if (forcedHandlerFailure(context.url)) {
359
+ throw new Error('forced request handler failure');
360
+ }
361
+ if (context.url.pathname === '/events') {
362
+ await sendEvents(response, context.url);
363
+ return;
364
+ }
365
+ await send(response, await routeResponse(context.url));
366
+ }
367
+
368
+ async function containHandlerFailure(response) {
112
369
  try {
113
- const webRequest = nodeRequest(request);
114
- if (new URL(webRequest.url).pathname === '/events') {
115
- response.writeHead(200, {
116
- 'content-type': 'text/event-stream',
117
- 'cache-control': 'no-store',
118
- connection: 'keep-alive',
119
- });
120
- response.write('retry: 250\nevent: ready\ndata: {}\n\n');
121
- eventClients.add(response);
122
- const requestProject = new URL(webRequest.url).searchParams.get('project');
123
- if (project && requestProject === project) {
124
- console.log('Diffsplain tab: connected');
125
- }
126
- request.once('close', () => eventClients.delete(response));
370
+ if (response.headersSent) {
371
+ response.end();
127
372
  return;
128
373
  }
129
- await send(response, await fetchAsset(webRequest));
130
- } catch (error) {
131
- console.error(error);
132
- response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
133
- response.end('Diffsplain could not load this page.');
374
+ await send(
375
+ response,
376
+ webResponse('Internal server error', { status: 500 }),
377
+ );
378
+ } catch {
379
+ response.destroy();
134
380
  }
381
+ }
382
+
383
+ const server = createServer((request, response) => {
384
+ void handleRequest(request, response).catch(() =>
385
+ containHandlerFailure(response),
386
+ );
135
387
  });
136
388
 
137
- const eventClients = new Set();
138
389
  watchFile(output, { interval: 100 }, (current, previous) => {
139
- if (
140
- current.mtimeMs === previous.mtimeMs &&
141
- current.size === previous.size
142
- ) {
390
+ if (current.mtimeMs === previous.mtimeMs && current.size === previous.size) {
143
391
  return;
144
392
  }
145
393
  for (const client of eventClients) {
146
- client.write('event: update\ndata: {}\n\n');
394
+ if (!client.destroyed) client.write('event: update\ndata: {}\n\n');
147
395
  }
148
396
  });
149
397
 
150
- let selectedPort = Number(portValue);
398
+ function urlFor(address, port) {
399
+ const formattedAddress = address.includes(':') ? `[${address}]` : address;
400
+ return `http://${formattedAddress}:${port}`;
401
+ }
151
402
 
152
403
  function listen() {
153
- server.listen(selectedPort, 'localhost');
404
+ server.listen(selectedPort, host);
154
405
  }
155
406
 
156
407
  server.on('listening', () => {
408
+ if (closing) {
409
+ server.close();
410
+ return;
411
+ }
157
412
  const address = server.address();
413
+ const readyAddress =
414
+ address && typeof address === 'object' ? address.address : host;
158
415
  const readyPort =
159
416
  address && typeof address === 'object' ? address.port : selectedPort;
160
- const projectHash = project
161
- ? `#project=${encodeURIComponent(project)}`
162
- : '';
163
- console.log(`Diffsplain: http://localhost:${readyPort}${projectHash}`);
417
+ const url = urlFor(host, readyPort);
418
+ const fragment = new URLSearchParams({
419
+ ...(project ? { project } : {}),
420
+ access,
421
+ });
422
+ readyState = {
423
+ status: 'ok',
424
+ address: readyAddress,
425
+ port: readyPort,
426
+ };
427
+ if (!isLoopback(readyAddress)) {
428
+ console.warn(
429
+ `Warning: Diffsplain is listening on ${readyAddress}. Review data still requires the per-run access value.`,
430
+ );
431
+ }
432
+ console.log(`Diffsplain: ${url}#${fragment}`);
433
+ console.log(JSON.stringify({ event: 'ready', ...readyState, url, access }));
164
434
  });
165
435
 
166
436
  server.on('error', (error) => {
@@ -174,23 +444,25 @@ server.on('error', (error) => {
174
444
  listen();
175
445
  return;
176
446
  }
177
- console.error(`Could not start Diffsplain: ${error.message}`);
178
- process.exitCode = 1;
447
+ const message =
448
+ error.code === 'EADDRINUSE'
449
+ ? `port ${selectedPort} is already in use`
450
+ : error.message;
451
+ console.error(`Could not start Diffsplain: ${message}`);
452
+ close(1);
179
453
  });
180
454
 
181
455
  listen();
182
456
 
183
- let closing = false;
184
- function close() {
457
+ function close(exitCode = 0) {
185
458
  if (closing) return;
186
459
  closing = true;
460
+ process.exitCode = exitCode;
187
461
  unwatchFile(output);
188
462
  for (const client of eventClients) client.end();
189
463
  eventClients.clear();
190
- server.close(() => {
191
- process.exitCode = 0;
192
- });
464
+ if (server.listening) server.close();
193
465
  }
194
466
 
195
- process.on('SIGINT', close);
196
- process.on('SIGTERM', close);
467
+ process.on('SIGINT', () => close());
468
+ process.on('SIGTERM', () => close());