stillhunter 0.0.1 → 0.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.
package/dist/relay.mjs ADDED
@@ -0,0 +1,235 @@
1
+ // The browser relay. A probe's child process can reach Playwright's raw
2
+ // protocol (`browser._channel`, `_connection`) and so ask the browser server
3
+ // for anything the protocol offers: a CDP session, a context with its own
4
+ // proxy, a file: page, a browser launched without the closed proxy, a fetch
5
+ // made from the host's own process. None of that can be stopped inside the
6
+ // child, so the child never sees the browser server's endpoint: it connects
7
+ // here, and every message it sends is checked before it reaches the server.
8
+ // The protocol is JSON, one {id, guid, method, params} message per frame; the
9
+ // type of each guid comes from the server's own __create__ messages. Methods
10
+ // are allowed by name per type, a few only with checked parameters, and
11
+ // anything else is answered with an error and never forwarded.
12
+ import { randomBytes } from 'node:crypto';
13
+ import { createServer } from 'node:http';
14
+ import { createRequire } from 'node:module';
15
+ export const REFUSED = 'refused by the browser relay';
16
+ const MAX_PAYLOAD = 256 * 1024 * 1024;
17
+ const MAX_KEPT = 20;
18
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
19
+ // An http or https URL on a loopback host. A relative URL resolves against the
20
+ // target, as the server resolves it against the context's baseURL, which is
21
+ // itself held to a loopback http or https URL below.
22
+ export function allowedUrl(url, target) {
23
+ if (typeof url !== 'string')
24
+ return false;
25
+ try {
26
+ const u = new URL(url, target);
27
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
28
+ return false;
29
+ return LOOPBACK.has(u.hostname) || u.hostname.endsWith('.localhost');
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ // Context options that change only what happens inside the browser. Left out
36
+ // on purpose: proxy (its own way out), recordVideo (the server writes files on
37
+ // the host) and clientCertificates.
38
+ const CONTEXT_OPTIONS = new Set([
39
+ 'noDefaultViewport', 'viewport', 'screen', 'ignoreHTTPSErrors', 'javaScriptEnabled', 'bypassCSP', 'userAgent', 'locale', 'timezoneId',
40
+ 'geolocation', 'permissions', 'extraHTTPHeaders', 'offline', 'httpCredentials', 'deviceScaleFactor', 'isMobile', 'hasTouch', 'colorScheme',
41
+ 'reducedMotion', 'forcedColors', 'acceptDownloads', 'contrast', 'baseURL', 'strictSelectors', 'serviceWorkers', 'selectorEngines',
42
+ 'testIdAttributeName', 'storageState',
43
+ ]);
44
+ // Permissions that reach nothing on the host: no clipboard, camera or microphone.
45
+ const PERMISSIONS = new Set([
46
+ 'geolocation', 'notifications', 'background-sync', 'background-fetch', 'payment-handler', 'accelerometer', 'gyroscope', 'magnetometer',
47
+ 'ambient-light-sensor', 'accessibility-events', 'storage-access', 'screen-wake-lock',
48
+ ]);
49
+ // Files for an input or a drop come from the probe as bytes, never as paths
50
+ // or streams the server would read from the host's disk.
51
+ const FILE_KEYS = new Set(['selector', 'strict', 'payloads', 'position', 'data', 'timeout']);
52
+ const keysOutside = (params, allowed) => Object.keys(params).filter((k) => params[k] !== undefined && !allowed.has(k));
53
+ // The server opens a page at each origin a storage state lists to restore
54
+ // its storage, so every origin is held to the same rule as a navigation.
55
+ const storageState = (state, target) => {
56
+ if (state === undefined || state === null)
57
+ return null;
58
+ const origins = typeof state === 'object' ? state.origins : undefined;
59
+ if (origins === undefined)
60
+ return null;
61
+ if (!Array.isArray(origins))
62
+ return 'a storage state\'s origins must be a list';
63
+ const bad = origins.map((o) => (o && typeof o === 'object' ? o.origin : o)).filter((o) => !allowedUrl(o, target));
64
+ return bad.length ? `a storage state may hold only loopback http or https origins, not ${bad.map((o) => String(o).slice(0, 120)).join(', ')}` : null;
65
+ };
66
+ const contextOptions = (p, target) => {
67
+ const extra = keysOutside(p, CONTEXT_OPTIONS);
68
+ if (extra.length)
69
+ return `a context may not set ${extra.join(', ')}; pages come from rr.newPage()`;
70
+ if (!allowedUrl(p.baseURL, target))
71
+ return 'a context needs a loopback http or https baseURL';
72
+ return storageState(p.storageState, target) ?? permissions(p, target);
73
+ };
74
+ const permissions = (p) => {
75
+ const list = Array.isArray(p.permissions) ? p.permissions : [];
76
+ const bad = list.filter((x) => !PERMISSIONS.has(String(x)));
77
+ return bad.length ? `the permission ${bad.join(', ')} reaches the host` : null;
78
+ };
79
+ const files = (p) => {
80
+ const extra = keysOutside(p, FILE_KEYS);
81
+ return extra.length ? `files must be given as bytes, not as ${extra.join(', ')} on the host` : null;
82
+ };
83
+ const url = (key, optional) => (p, target) => {
84
+ if (optional && p[key] === undefined)
85
+ return null;
86
+ return allowedUrl(p[key], target) ? null : `${String(p[key]).slice(0, 120)} is not an http or https URL on a loopback host`;
87
+ };
88
+ const fetchCheck = (p, target) => url('url', false)(p, target) ?? (p.maxRedirects === 0 ? null : 'a request from the host follows no redirects: use rr.request');
89
+ const ALL = null;
90
+ const names = (list, rules = {}) => ({ ...Object.fromEntries(list.trim().split(/\s+/).map((m) => [m, ALL])), ...rules });
91
+ const JS_HANDLE = 'dispose evaluateExpression evaluateExpressionHandle getProperty getPropertyList jsonValue';
92
+ // Everything the child may ask of each type, from playwright-core 1.64.1's
93
+ // protocol. A type or method missing here is refused: the browser type and
94
+ // its launch and connect calls, CDP sessions, tracing, local utils, Android,
95
+ // Electron, the debug controller, the recorder, temp files on the host and
96
+ // saving an artifact to a host path.
97
+ export const ALLOWED = {
98
+ Root: names('initialize'),
99
+ Browser: names('close', { newContext: contextOptions }),
100
+ BrowserContext: names(`addCookies addInitScript clearCookies clearPermissions clockFastForward clockInstall clockPauseAt clockResume
101
+ clockRunFor clockSetFixedTime clockSetSystemTime close cookies exposeBinding newPage registerSelectorEngine setExtraHTTPHeaders
102
+ setGeolocation setHTTPCredentials setNetworkInterceptionPatterns setOffline setTestIdAttributeName
103
+ setWebSocketInterceptionPatterns storageState updateSubscription`, { grantPermissions: permissions, setStorageState: (p, target) => storageState(p.storageState, target) }),
104
+ Page: names(`addInitScript bringToFront clearConsoleMessages clearPageErrors close consoleMessages emulateMedia expectScreenshot
105
+ exposeBinding goBack goForward keyboardDown keyboardInsertText keyboardPress keyboardType keyboardUp mouseClick mouseDown mouseMove
106
+ mouseUp mouseWheel pageErrors pdf registerLocatorHandler reload requestGC requests resolveLocatorHandlerNoReply runBeforeUnload
107
+ screenshot setExtraHTTPHeaders setNetworkInterceptionPatterns setViewportSize setWebSocketInterceptionPatterns startCSSCoverage
108
+ startJSCoverage stopCSSCoverage stopJSCoverage touchscreenTap unregisterLocatorHandler updateSubscription webStorageClear
109
+ webStorageGetItem webStorageItems webStorageRemoveItem webStorageSetItem`),
110
+ Frame: names(`ariaSnapshot ariaSnapshotJSON blur check click content dblclick dispatchEvent dragAndDrop
111
+ evalOnSelector evalOnSelectorAll evaluateExpression evaluateExpressionHandle expect fill focus frameElement getAttribute hover innerHTML
112
+ innerText inputValue isChecked isDisabled isEditable isEnabled isHidden isVisible press queryCount querySelector querySelectorAll
113
+ resolveSelector selectOption setContent tap textContent title type uncheck waitForFunction waitForSelector waitForTimeout`, { goto: url('url', false), addScriptTag: url('url', true), addStyleTag: url('url', true), setInputFiles: files, drop: files }),
114
+ ElementHandle: names(`${JS_HANDLE} boundingBox check click contentFrame dblclick dispatchEvent evalOnSelector evalOnSelectorAll fill
115
+ focus getAttribute hover innerHTML innerText inputValue isChecked isDisabled isEditable isEnabled isHidden isVisible ownerFrame press
116
+ querySelector querySelectorAll screenshot scrollIntoViewIfNeeded selectOption selectText tap textContent type uncheck
117
+ waitForElementState waitForSelector`, { setInputFiles: files }),
118
+ JSHandle: names(JS_HANDLE),
119
+ Worker: names('evaluateExpression evaluateExpressionHandle updateSubscription'),
120
+ Request: names('rawRequestHeaders response'),
121
+ Response: names('body httpVersion rawResponseHeaders securityDetails serverAddr sizes'),
122
+ Route: names('abort fulfill', { continue: url('url', true), redirectNavigationRequest: url('url', false) }),
123
+ WebSocketRoute: names('closePage closeServer connect ensureOpened sendToPage sendToServer'),
124
+ APIRequestContext: names('addCookies clearCookies cookies dispose disposeAPIResponse fetchLog fetchResponseBody storageState', { fetch: fetchCheck }),
125
+ Dialog: names('accept dismiss'),
126
+ BindingCall: names('reject resolve'),
127
+ Artifact: names('cancel delete failure saveAsStream stream'),
128
+ Stream: names('close read'),
129
+ Disposable: names('dispose'),
130
+ };
131
+ const CONNECTION_METHODS = new Set(['__waitInfo__', '__abort__']);
132
+ const unanswered = (method) => method.endsWith('NoReply') || CONNECTION_METHODS.has(method);
133
+ // Why a message from the child is refused, or null when it may go on.
134
+ export function refusal(type, message, target) {
135
+ const method = String(message.method);
136
+ if (type === undefined)
137
+ return `${method} on an object the server never created`;
138
+ // The connection's own bookkeeping, sent for any object: what a pending call
139
+ // waits for, and cancelling one. Neither is answered.
140
+ if (CONNECTION_METHODS.has(method))
141
+ return null;
142
+ const rules = Object.hasOwn(ALLOWED, type) ? ALLOWED[type] : null;
143
+ if (!rules || !Object.hasOwn(rules, method))
144
+ return `${type}.${method} is not available to a probe`;
145
+ const check = rules[method];
146
+ const params = message.params && typeof message.params === 'object' ? message.params : {};
147
+ const why = check ? check(params, target) : null;
148
+ return why ? `${type}.${method}: ${why}` : null;
149
+ }
150
+ const require = createRequire(import.meta.url);
151
+ const { ws: WebSocket, wsServer: WebSocketServer } = require('playwright-core/lib/utilsBundle');
152
+ // Serves a WebSocket on loopback, at a path only the child is told, and relays
153
+ // each connection to the browser server's endpoint, checking every message.
154
+ export async function startRelay(upstream, target) {
155
+ const path = `/${randomBytes(16).toString('hex')}`;
156
+ const refused = [];
157
+ const sockets = new Set();
158
+ const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_PAYLOAD });
159
+ const http = createServer((_, res) => { res.statusCode = 404; res.end(); });
160
+ const relay = (client, req) => {
161
+ // Only the user agent goes on: the server checks the client's version in
162
+ // it. Proxy and launch-option headers never do.
163
+ const ua = req.headers['user-agent'];
164
+ const server = new WebSocket(upstream, { headers: typeof ua === 'string' ? { 'user-agent': ua } : {}, perMessageDeflate: false, maxPayload: MAX_PAYLOAD });
165
+ sockets.add(client).add(server);
166
+ const types = new Map([['', 'Root']]);
167
+ const queue = [];
168
+ const end = () => { client.terminate(); server.terminate(); };
169
+ server.on('open', () => { for (const m of queue.splice(0))
170
+ server.send(m); });
171
+ server.on('message', (data) => {
172
+ const text = data.toString('utf8');
173
+ if (text.includes('__create__') || text.includes('__dispose__')) {
174
+ try {
175
+ const m = JSON.parse(text);
176
+ if (m.method === '__create__' && typeof m.params?.guid === 'string' && typeof m.params.type === 'string')
177
+ types.set(m.params.guid, m.params.type);
178
+ if (m.method === '__dispose__' && typeof m.guid === 'string')
179
+ types.delete(m.guid);
180
+ }
181
+ catch { }
182
+ }
183
+ client.send(text);
184
+ });
185
+ client.on('message', (data) => {
186
+ const text = data.toString('utf8');
187
+ let m;
188
+ try {
189
+ m = JSON.parse(text);
190
+ }
191
+ catch {
192
+ return end();
193
+ }
194
+ if (!m || typeof m !== 'object' || typeof m.guid !== 'string' || typeof m.method !== 'string')
195
+ return end();
196
+ const why = refusal(types.get(m.guid), m, target);
197
+ if (!why) {
198
+ if (server.readyState === WebSocket.OPEN)
199
+ server.send(text);
200
+ else
201
+ queue.push(text);
202
+ return;
203
+ }
204
+ if (refused.length < MAX_KEPT)
205
+ refused.push(why);
206
+ if ((typeof m.id === 'number' || typeof m.id === 'string') && !unanswered(m.method)) {
207
+ client.send(JSON.stringify({ id: m.id, error: { error: { name: 'Error', message: `${why} (${REFUSED})`, stack: '' } } }));
208
+ }
209
+ });
210
+ server.on('close', end);
211
+ client.on('close', end);
212
+ server.on('error', end);
213
+ client.on('error', end);
214
+ };
215
+ http.on('upgrade', (req, socket, head) => {
216
+ if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname !== path)
217
+ return socket.destroy();
218
+ wss.handleUpgrade(req, socket, head, (ws) => relay(ws, req));
219
+ });
220
+ await new Promise((resolve, reject) => {
221
+ http.once('error', reject);
222
+ http.listen(0, '127.0.0.1', () => resolve());
223
+ });
224
+ const { port } = http.address();
225
+ return {
226
+ endpoint: `ws://127.0.0.1:${port}${path}`,
227
+ refused,
228
+ close: async () => {
229
+ for (const s of sockets)
230
+ s.terminate();
231
+ wss.close();
232
+ await new Promise((resolve) => http.close(() => resolve()));
233
+ },
234
+ };
235
+ }
package/dist/reset.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // The state each probe starts from when the config has a reset, in the hunt
2
+ // and in the proof alike: app data goes back to what the reset makes, which
3
+ // may sign everyone out, and a sign-in probe then signs in again, unless the
4
+ // probe about to run is that sign-in probe. Sharing it is what lets finish
5
+ // prove a finding from the state the hunt found it in.
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { rmSync } from 'node:fs';
8
+ import { PROBE_TIMEOUT_MS } from './probe-host.mjs';
9
+ export const RESET_TIMEOUT_MS = 120_000;
10
+ // How long a reset stopped at its time limit may take to close its output.
11
+ const STOP_GRACE_MS = 5_000;
12
+ export const seconds = (ms) => Math.round(ms / 100) / 10;
13
+ // Stops a process and everything it started. On POSIX the reset leads its own
14
+ // process group; on Windows, taskkill follows the tree.
15
+ function stopAll(pid) {
16
+ if (pid === undefined)
17
+ return;
18
+ try {
19
+ if (process.platform === 'win32')
20
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
21
+ else
22
+ process.kill(-pid, 'SIGKILL');
23
+ }
24
+ catch { }
25
+ }
26
+ // Runs the configured reset in the repository root: null when it worked,
27
+ // otherwise the command, how it ended and the end of what it printed. A reset
28
+ // that runs past its time is stopped with every process it started, so none
29
+ // of them goes on changing app data once the next probe has begun.
30
+ export function resetApp(root, config, timeoutMs = RESET_TIMEOUT_MS) {
31
+ const command = config.reset;
32
+ if (!command)
33
+ return Promise.resolve(null);
34
+ return new Promise((resolve) => {
35
+ const child = spawn(command, { cwd: root, shell: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
36
+ let stdout = '';
37
+ let stderr = '';
38
+ child.stdout.on('data', (d) => { stdout = (stdout + d.toString('utf8')).slice(-4000); });
39
+ child.stderr.on('data', (d) => { stderr = (stderr + d.toString('utf8')).slice(-4000); });
40
+ let timedOut = false;
41
+ let grace;
42
+ const done = (why) => {
43
+ clearTimeout(timer);
44
+ clearTimeout(grace);
45
+ if (why === null)
46
+ return resolve(null);
47
+ const printed = stderr.trim() || stdout.trim();
48
+ resolve(`reset ("${command}") failed: ${why}${printed ? `: ${printed.slice(-300)}` : ''}`);
49
+ };
50
+ const timer = setTimeout(() => {
51
+ timedOut = true;
52
+ stopAll(child.pid);
53
+ // A process that left the group may still hold the output open.
54
+ grace = setTimeout(() => {
55
+ child.stdout.destroy();
56
+ child.stderr.destroy();
57
+ done(`timed out after ${seconds(timeoutMs)} s`);
58
+ }, STOP_GRACE_MS);
59
+ }, timeoutMs);
60
+ child.on('error', (e) => done(e.message));
61
+ child.on('close', (code, signal) => done(timedOut ? `timed out after ${seconds(timeoutMs)} s` : code === 0 ? null : `exit ${code ?? signal}`));
62
+ });
63
+ }
64
+ // Resets app data, then signs in again. A failed reset or sign-in is
65
+ // reported, not fatal: the probe still runs, and its result says so. null
66
+ // when a time limit left no room for the sign-in or cut it short, so the probe
67
+ // would start signed out for no reason of the app's.
68
+ export async function resetAndSignIn(job) {
69
+ const t0 = Date.now();
70
+ const resetError = await resetApp(job.root, job.config, job.resetTimeoutMs);
71
+ const resetSeconds = seconds(Date.now() - t0);
72
+ rmSync(job.sessionFile, { force: true });
73
+ if (!job.signIn || job.signIn === job.probe)
74
+ return { resetSeconds, resetError, signIn: null };
75
+ const timeoutMs = job.signInTimeoutMs ? job.signInTimeoutMs() : PROBE_TIMEOUT_MS;
76
+ if (timeoutMs === null)
77
+ return null;
78
+ const t1 = Date.now();
79
+ const s = await job.runSignIn(job.signIn, timeoutMs);
80
+ if (s.hostError === 'timeout' && timeoutMs < PROBE_TIMEOUT_MS)
81
+ return null;
82
+ const error = s.error ?? (s.savedSession ? null : 'it saved no session');
83
+ return { resetSeconds, resetError, signIn: { probe: job.signIn, seconds: seconds(Date.now() - t1), error, savedSession: s.savedSession } };
84
+ }
85
+ // A probe ran signed out when the sign-in before it failed without saving a
86
+ // session. One that saved a session and then failed left that session in place.
87
+ export const signedOut = (b) => Boolean(b?.signIn?.error && !b.signIn.savedSession);
@@ -0,0 +1,87 @@
1
+ // The result a probe run leaves behind, and the questions the runner asks of
2
+ // it: did a named check fail, which checks failed, which run is the latest.
3
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ export function emptyResult(probe, error, hostError) {
6
+ return { probe, checks: [], notes: [], error, hostError, blocked: [], savedSession: false, shots: [], requests: [], faults: [], pages: [], final: null, output: '' };
7
+ }
8
+ // A check fails in a run when any expectation of that name failed.
9
+ export function outcome(result, check) {
10
+ const named = (result.checks ?? []).filter((c) => c.name === check);
11
+ if (named.length === 0)
12
+ return { state: 'missing' };
13
+ const failed = named.find((c) => !c.pass);
14
+ return failed ? { state: 'failed', detail: failed.detail } : { state: 'held' };
15
+ }
16
+ export function failedChecks(result) {
17
+ return [...new Set((result.checks ?? []).filter((c) => !c.pass).map((c) => c.name))];
18
+ }
19
+ export const probesDir = (runDir) => path.join(runDir, 'probes');
20
+ export const latestFile = (runDir, name) => path.join(probesDir(runDir), `${name}.result.json`);
21
+ export const numberedFile = (runDir, name, n) => path.join(probesDir(runDir), `${name}.${n}.result.json`);
22
+ export function readResult(file) {
23
+ try {
24
+ return JSON.parse(readFileSync(file, 'utf8'));
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ export function probeNames(runDir) {
31
+ const dir = probesDir(runDir);
32
+ if (!existsSync(dir))
33
+ return [];
34
+ return readdirSync(dir).filter((f) => /^[a-z0-9][a-z0-9-]*\.mjs$/.test(f)).map((f) => f.slice(0, -4)).sort();
35
+ }
36
+ export function latestResults(runDir) {
37
+ const out = new Map();
38
+ for (const name of probeNames(runDir)) {
39
+ const r = readResult(latestFile(runDir, name));
40
+ if (r)
41
+ out.set(name, r);
42
+ }
43
+ return out;
44
+ }
45
+ export function nextRunNumber(runDir, name) {
46
+ const dir = probesDir(runDir);
47
+ if (!existsSync(dir))
48
+ return 1;
49
+ const pattern = new RegExp(`^${name}\\.(\\d+)\\.result\\.json$`);
50
+ let max = 0;
51
+ for (const f of readdirSync(dir)) {
52
+ const m = pattern.exec(f);
53
+ if (m)
54
+ max = Math.max(max, Number(m[1]));
55
+ }
56
+ return max + 1;
57
+ }
58
+ // Every run of every probe in the hunt, for coverage: a fault applied in an
59
+ // earlier run of a probe was still applied.
60
+ export function allResults(runDir) {
61
+ const dir = probesDir(runDir);
62
+ if (!existsSync(dir))
63
+ return [];
64
+ return readdirSync(dir).filter((f) => /^[a-z0-9][a-z0-9-]*\.\d+\.result\.json$/.test(f)).sort()
65
+ .map((f) => readResult(path.join(dir, f))).filter((r) => r !== null);
66
+ }
67
+ // The probe that signed in: the latest one whose run saved the session.
68
+ export function signInProbe(runDir) {
69
+ // The latest probe whose run saved a session and ended without an error;
70
+ // failing that, the latest that saved a session and failed after it. Its
71
+ // session is still good, and every run of it says that it failed, so a
72
+ // sign-in probe with a broken last step never leaves the hunt signed out.
73
+ let clean = null;
74
+ let saved = null;
75
+ for (const [name, r] of latestResults(runDir)) {
76
+ if (!r.savedSession)
77
+ continue;
78
+ const at = r.finishedAt ?? 0;
79
+ if (!r.error) {
80
+ if (!clean || at > clean.at)
81
+ clean = { name, at };
82
+ }
83
+ else if (!saved || at > saved.at)
84
+ saved = { name, at };
85
+ }
86
+ return (clean ?? saved)?.name ?? null;
87
+ }
@@ -0,0 +1,121 @@
1
+ // Finding the app to hunt: target.url from .stillhunter/config.json, or a Nuxt
2
+ // dev server proven to be this repository's own. The proof: the server that
3
+ // answers for localhost:<port> serves the bytes of this checkout's
4
+ // .nuxt/nuxt.lock, so another app on the same port can't pass for it.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { lookup } from 'node:dns/promises';
7
+ import { readFileSync, realpathSync } from 'node:fs';
8
+ import { request } from 'node:http';
9
+ import path from 'node:path';
10
+ const DROPPED = new Set([
11
+ 'node_modules', '.venv', 'venv', '__pycache__', '.nuxt', '.output', '.next', '.svelte-kit', 'dist', 'build',
12
+ 'coverage', '.turbo', '.vercel', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.stillhunter',
13
+ ]);
14
+ // nuxt.config.* files git can see, whose folder's package.json depends on nuxt.
15
+ export function nuxtCandidates(root) {
16
+ const listed = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ':(glob)**/nuxt.config.*'], { cwd: root, encoding: 'utf8' });
17
+ const found = [];
18
+ for (const file of (listed.stdout ?? '').split('\0').filter(Boolean)) {
19
+ const segments = file.split('/');
20
+ if (!/^nuxt\.config\.[a-z]+$/.test(segments.at(-1) ?? ''))
21
+ continue;
22
+ if (segments.slice(0, -1).some((s) => DROPPED.has(s)))
23
+ continue;
24
+ const dir = path.join(root, ...segments.slice(0, -1));
25
+ if (dependsOnNuxt(dir) && !found.some((c) => c.dir === dir))
26
+ found.push({ dir });
27
+ }
28
+ return found.sort((a, b) => a.dir.localeCompare(b.dir));
29
+ }
30
+ function dependsOnNuxt(dir) {
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
33
+ return ['dependencies', 'devDependencies'].some((key) => pkg[key]?.nuxt !== undefined);
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ function processAlive(pid) {
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ }
44
+ catch (error) {
45
+ return error.code === 'EPERM';
46
+ }
47
+ }
48
+ // A lock left by a dev server that was killed hard counts as no lock.
49
+ function readLock(candidate) {
50
+ try {
51
+ const raw = readFileSync(path.join(candidate.dir, '.nuxt', 'nuxt.lock'));
52
+ const parsed = JSON.parse(raw.toString('utf8'));
53
+ if (typeof parsed.cwd !== 'string')
54
+ return null;
55
+ if (typeof parsed.pid === 'number' && parsed.pid > 0 && !processAlive(parsed.pid))
56
+ return null;
57
+ return { raw, cwd: parsed.cwd, port: typeof parsed.port === 'number' ? parsed.port : null };
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ export function samePath(a, b) {
64
+ try {
65
+ return realpathSync.native(a) === realpathSync.native(b);
66
+ }
67
+ catch {
68
+ return path.resolve(a) === path.resolve(b);
69
+ }
70
+ }
71
+ // `/_nuxt/@fs<cwd>/.nuxt/nuxt.lock`; a Windows drive path gains the leading slash Vite expects.
72
+ export function lockUrlPath(cwd) {
73
+ const forward = cwd.replace(/\\/g, '/');
74
+ return `/_nuxt/@fs${forward.startsWith('/') ? '' : '/'}${encodeURI(forward)}/.nuxt/nuxt.lock`;
75
+ }
76
+ function fetchBytes(address, port, urlPath) {
77
+ return new Promise((resolve) => {
78
+ const req = request({ host: address, port, path: urlPath, headers: { host: `localhost:${port}` }, timeout: 3000 }, (res) => {
79
+ const chunks = [];
80
+ res.on('data', (c) => chunks.push(c));
81
+ res.on('end', () => resolve(res.statusCode === 200 ? Buffer.concat(chunks) : null));
82
+ res.on('error', () => resolve(null));
83
+ });
84
+ req.on('timeout', () => req.destroy());
85
+ req.on('error', () => resolve(null));
86
+ req.end();
87
+ });
88
+ }
89
+ // Nuxt dev may bind ::1 only while 127.0.0.1 is another process, so every
90
+ // address localhost resolves to is tried.
91
+ export async function proveLock(candidate) {
92
+ const lock = readLock(candidate);
93
+ if (lock === null || lock.port === null || !samePath(lock.cwd, candidate.dir))
94
+ return null;
95
+ const addresses = await lookup('localhost', { all: true }).catch(() => [{ address: '::1' }, { address: '127.0.0.1' }]);
96
+ for (const { address } of addresses) {
97
+ const served = await fetchBytes(address, lock.port, lockUrlPath(lock.cwd));
98
+ if (served !== null && served.equals(lock.raw))
99
+ return `http://localhost:${lock.port}`;
100
+ }
101
+ return null;
102
+ }
103
+ // The status of any HTTP response at all, or null when nothing answers.
104
+ export function answers(url) {
105
+ return new Promise((resolve) => {
106
+ let req;
107
+ try {
108
+ req = request(url, { method: 'GET', timeout: 3000 }, (res) => {
109
+ res.resume();
110
+ resolve(res.statusCode ?? null);
111
+ });
112
+ }
113
+ catch {
114
+ resolve(null);
115
+ return;
116
+ }
117
+ req.on('timeout', () => req.destroy());
118
+ req.on('error', () => resolve(null));
119
+ req.end();
120
+ });
121
+ }