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.
- package/README.md +17 -12
- package/dist/assets/{angular-html-CoQle5-T.js → angular-html-vBvPnd6a.js} +1 -1
- package/dist/assets/{angular-ts-BB_A1Lpy.js → angular-ts-CDzF1Z6a.js} +1 -1
- package/dist/assets/{apl-B7_4_FNq.js → apl-Bm9bxvP7.js} +1 -1
- package/dist/assets/{astro-CXVo4VE4.js → astro-RNrNWCYO.js} +1 -1
- package/dist/assets/{blade-BVX41dq7.js → blade-On-NBYUv.js} +1 -1
- package/dist/assets/{c-Dhyi8Nd2.js → c-hRmhFgD0.js} +1 -1
- package/dist/assets/{cobol-CU5oLeZj.js → cobol-CIqc3k2t.js} +1 -1
- package/dist/assets/{coffee-D_FU6gC_.js → coffee-Cd5EyvvS.js} +1 -1
- package/dist/assets/{cpp-CxoYLSi3.js → cpp-zXFAAp_S.js} +1 -1
- package/dist/assets/{crystal-BdgqBN3z.js → crystal-DRYY4cSw.js} +1 -1
- package/dist/assets/{css-wLk1vVwJ.js → css-C4op_MsD.js} +1 -1
- package/dist/assets/{edge-DMylWoGl.js → edge-d3rJkErf.js} +1 -1
- package/dist/assets/{elixir-Djmgto_Z.js → elixir-DPqlJhYM.js} +1 -1
- package/dist/assets/{elm-D7B-eBLA.js → elm-DgU37I8a.js} +1 -1
- package/dist/assets/{erb-g1sso1GW.js → erb-CZVB_ntC.js} +1 -1
- package/dist/assets/{git-rebase-DEAhxutZ.js → git-rebase-hUkltHZG.js} +1 -1
- package/dist/assets/{glimmer-js-CHvGFGMp.js → glimmer-js-5XOT9GQ8.js} +1 -1
- package/dist/assets/{glimmer-ts-DvhL705p.js → glimmer-ts-DT7D0Kfq.js} +1 -1
- package/dist/assets/{glsl-C8mS_t8I.js → glsl-Br4_Eh5O.js} +1 -1
- package/dist/assets/{graphql-DW2pnvqi.js → graphql--BmZ_AYP.js} +1 -1
- package/dist/assets/{hack-DYc1d3he.js → hack-CMdqGDoA.js} +1 -1
- package/dist/assets/{haml-_5OmzH31.js → haml-DLEOHGmg.js} +1 -1
- package/dist/assets/{handlebars-rN-CaXUf.js → handlebars-BfUftoV0.js} +1 -1
- package/dist/assets/{html-derivative-BovOubry.js → html-derivative-D5cZE6C4.js} +1 -1
- package/dist/assets/{html-DQnaJUjl.js → html-uOgx2iYC.js} +1 -1
- package/dist/assets/{http-BKTgwOYc.js → http-cW9c66J_.js} +1 -1
- package/dist/assets/{hurl-ChS3_IgD.js → hurl-BztURO2n.js} +1 -1
- package/dist/assets/{index-BLtRBc2n.css → index-C2p_1iAk.css} +1 -1
- package/dist/assets/{index-CEglkSs-.js → index-DZFM4-3L.js} +11 -11
- package/dist/assets/{java-Dgnyk--z.js → java-Cy4mnKaj.js} +1 -1
- package/dist/assets/{javascript-BfnrwkRN.js → javascript-_Z02t_9Q.js} +1 -1
- package/dist/assets/{jinja-CD2AIvOi.js → jinja-BppBm5gt.js} +1 -1
- package/dist/assets/{jison-DmKNw3Pl.js → jison-Dzns436M.js} +1 -1
- package/dist/assets/{json-CKB5a8aj.js → json-BYkfLOry.js} +1 -1
- package/dist/assets/{jsx-jJXrDRBM.js → jsx-DtAm8NUb.js} +1 -1
- package/dist/assets/{julia-G8vVBW-t.js → julia-CLIMRuh0.js} +1 -1
- package/dist/assets/{just-DMXLrLZL.js → just-DMg47an6.js} +1 -1
- package/dist/assets/{latex-DS3xuxld.js → latex-D05ztCOx.js} +1 -1
- package/dist/assets/{liquid-COQnD_C4.js → liquid-DpImQR4t.js} +1 -1
- package/dist/assets/{lua-BwMKwosd.js → lua--ZHUYQUU.js} +1 -1
- package/dist/assets/{marko-cQCK_WIk.js → marko-Dh3Xi08i.js} +1 -1
- package/dist/assets/{mdc-DQUjY13N.js → mdc-Br6Pt78u.js} +1 -1
- package/dist/assets/{nginx-C4Kv3DJi.js → nginx-CwSU6U6d.js} +1 -1
- package/dist/assets/{nim-ChzLzuYD.js → nim-CBbTwyeq.js} +1 -1
- package/dist/assets/{perl-z5xUiWAY.js → perl-D3jQu3LN.js} +1 -1
- package/dist/assets/{php-BHqm-Xeh.js → php-BMeXBzlU.js} +1 -1
- package/dist/assets/{pug-C9C_Llft.js → pug-BGdY1g5E.js} +1 -1
- package/dist/assets/{qml-dtpuwXJZ.js → qml-DsT5biza.js} +1 -1
- package/dist/assets/{r-BicQMs_c.js → r-CbS9PfN5.js} +1 -1
- package/dist/assets/{razor-D5h00YTZ.js → razor-Bwm8roHi.js} +1 -1
- package/dist/assets/{regexp-CTa8S86v.js → regexp-Bc_9DyAu.js} +1 -1
- package/dist/assets/{rst-Bg-b-7HK.js → rst-Cj1Aii91.js} +1 -1
- package/dist/assets/{ruby-BiWYm056.js → ruby-BPMtFZnc.js} +1 -1
- package/dist/assets/{sas-bJTdfgUz.js → sas-CoOau1t2.js} +1 -1
- package/dist/assets/{scss-0qkNyHr1.js → scss-CvfaI808.js} +1 -1
- package/dist/assets/{shellscript-3sDkDE9z.js → shellscript-B0C-TdLE.js} +1 -1
- package/dist/assets/{shellsession-D4EBhEmz.js → shellsession-UhmLiqcA.js} +1 -1
- package/dist/assets/{soy-DG9hUwLL.js → soy-D419u0RL.js} +1 -1
- package/dist/assets/{sql-B0UoKHkx.js → sql-Do9k0rRn.js} +1 -1
- package/dist/assets/{stata-CsQbuXQp.js → stata-x47J6BC4.js} +1 -1
- package/dist/assets/{surrealql-D63VLxos.js → surrealql-BERQ6pHu.js} +1 -1
- package/dist/assets/{svelte-BApdnAma.js → svelte-dRWkyqKW.js} +1 -1
- package/dist/assets/{templ-B64pN6Lz.js → templ-CTq-KoWN.js} +1 -1
- package/dist/assets/{tex-DR2UVIPF.js → tex-CdRGjyYI.js} +1 -1
- package/dist/assets/{ts-tags-IwhomvQv.js → ts-tags-CgaMqyVb.js} +1 -1
- package/dist/assets/{tsx-DROdxqzS.js → tsx-cpBQKcPG.js} +1 -1
- package/dist/assets/{twig-BtbUqpR4.js → twig-vbs_PM4-.js} +1 -1
- package/dist/assets/{typescript-CHpSjYZL.js → typescript-Cc--nwIo.js} +1 -1
- package/dist/assets/{vue-DCvEu_ug.js → vue-CgHwdBmB.js} +1 -1
- package/dist/assets/{vue-html-BgL4Rf-L.js → vue-html-BHfoMw47.js} +1 -1
- package/dist/assets/{vue-vine-CRNdhPTn.js → vue-vine-_lVRp_NK.js} +1 -1
- package/dist/assets/{xml-CkfPxZuz.js → xml-G4NVmTde.js} +1 -1
- package/dist/assets/{xsl-CteaoFSV.js → xsl-s0so2M1i.js} +1 -1
- package/dist/assets/{yaml-CUr3TDRN.js → yaml-CpsmW05t.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +49 -23
- package/scripts/access-token.mjs +21 -0
- package/scripts/build-diff-data.mjs +260 -39
- package/scripts/cache.mjs +301 -0
- package/scripts/cli-args.mjs +206 -57
- package/scripts/coding-agents.mjs +279 -162
- package/scripts/dev.mjs +218 -0
- package/scripts/doctor.mjs +265 -46
- package/scripts/generate-summaries.mjs +760 -260
- package/scripts/mock-agent.mjs +165 -0
- package/scripts/present.mjs +394 -85
- package/scripts/presenter-runtime.mjs +148 -0
- package/scripts/serve-built.mjs +343 -71
- package/scripts/summary-path.mjs +24 -7
- 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
|
+
}
|
package/scripts/serve-built.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
119
|
+
function decodeRequestPath(rawPath) {
|
|
120
|
+
try {
|
|
121
|
+
return decodeURIComponent(rawPath);
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
71
124
|
}
|
|
125
|
+
}
|
|
72
126
|
|
|
73
|
-
|
|
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
|
-
|
|
140
|
+
return new URL(raw, 'http://localhost');
|
|
76
141
|
} catch {
|
|
77
|
-
return
|
|
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
|
|
258
|
+
return clientError();
|
|
85
259
|
}
|
|
86
260
|
return fileResponse(file);
|
|
87
261
|
}
|
|
88
262
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
103
|
-
nodeResponse.writeHead(
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
161
|
-
|
|
162
|
-
:
|
|
163
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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());
|