@impeccable/reviews 0.1.3 → 0.1.5
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 +8 -4
- package/dist/agent.js +2 -2
- package/dist/bin.js +0 -0
- package/dist/fence.js +4 -0
- package/dist/host-questions.js +47 -1
- package/dist/hosted.js +6 -3
- package/dist/log.js +6 -1
- package/dist/login.js +49 -21
- package/dist/main.js +15 -1
- package/dist/netlify-password.js +65 -7
- package/dist/playbook/cases/netlify-previews.md +3 -2
- package/dist/playbook/cases/netlify-setup.md +3 -2
- package/dist/playbook/cases/preview-protection.md +7 -5
- package/dist/playbook/cases/pull-requests.md +2 -2
- package/dist/playbook/cases/server-previews.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +1 -0
- package/dist/playbook/cases/vercel-setup.md +6 -5
- package/dist/playbook/github-previews.js +101 -0
- package/dist/playbook/netlify-pattern.js +24 -0
- package/dist/playbook/netlify-previews.js +144 -0
- package/dist/playbook/probe-preview.js +67 -0
- package/dist/playbook/protection-secrets.js +15 -0
- package/dist/playbook/public-preview-request.js +96 -0
- package/dist/playbook/publication-wait.js +46 -0
- package/dist/playbook/vercel-protection.js +121 -0
- package/dist/playbook.js +5 -1
- package/dist/push-access.js +77 -0
- package/dist/scan.js +82 -14
- package/dist/tools.js +160 -57
- package/dist/transcript.js +23 -12
- package/dist/ui/bridge.js +1 -1
- package/dist/ui/look.js +12 -3
- package/dist/ui/screens.js +18 -23
- package/dist/vercel.js +27 -3
- package/package.json +3 -3
|
@@ -12,7 +12,7 @@ Every run, once the settings are saved. The person merges the setup pull request
|
|
|
12
12
|
- **Netlify** (netlify-previews, netlify-setup) builds a deploy preview only for a pull request: open the test drive now as a **draft**, step 5's `gh pr create` with `--draft`, which Impeccable leaves unreviewed. impeccable_netlify_previews with `pull` the number it printed waits up to five minutes for the deploy preview, under a status line with its timer. Prove the test drive's page on it, save the method as the case says, then `gh pr ready impeccable/test-drive --repo <owner>/<repo>` marks it ready, which starts the review. Then step 5's recording.
|
|
13
13
|
- **Impeccable's own preview** (static-site): the workflow in the branch builds it on the pull request, and Impeccable's review holds up to eight minutes for that job before it renders, so step 3's proof and the workflow in the branch (from the setup commit) are all this step takes. Then step 5.
|
|
14
14
|
|
|
15
|
-
Done when the test drive's page is proven on its preview, and a host's method saved; when nothing proved it, the reference below says how the run goes on.
|
|
15
|
+
A host's wait that ran out shows its own screen, which opens the host's page where its builds are turned on and waits again on the person's yes. Done when the test drive's page is proven on its preview, and a host's method saved; when nothing proved it, the reference below says how the run goes on.
|
|
16
16
|
5. **Open the test drive and record it**: `gh pr create --repo <owner>/<repo> --head impeccable/test-drive --base <defaultBranch>` with the title "Impeccable test drive (safe to close)" and a body that starts "This pull request only shows Impeccable reviewing a change. Close it when you are done; nothing here needs to be merged." and then names the two pages and lists the flaws planted for the review to find, one line each (a Netlify draft opened this way in step 4, and was marked ready there). Record it right away with impeccable_test_pr, so the web page follows its review; it refuses a draft. Done when impeccable_test_pr answered "Recorded".
|
|
17
17
|
6. **Check the test drive's preview once**, while it is the newest open pull request: impeccable_write to `latest-pull-check` with the app's `appKey`. Done when it answered; this early a 409 `no-preview-running` or a job still starting is expected, and the preview goes under "What is not verified".
|
|
18
18
|
7. **Open the setup pull request**, when there is a setup commit: `git push -u origin impeccable/setup`, then `gh pr create --repo <owner>/<repo> --head impeccable/setup --base <defaultBranch>` with the title "Set up Impeccable previews" and a body that starts "Merge this to keep Impeccable's previews working." and then says what it carries and why, one line each. Record it with impeccable_test_pr: the test drive again, and this one in `setup_pr`. Done when impeccable_test_pr answered "Recorded".
|
|
@@ -26,6 +26,6 @@ Every run, once the settings are saved. The person merges the setup pull request
|
|
|
26
26
|
- **Low-contrast text**: text in a solid light grey, well under 4.5:1 against its background.
|
|
27
27
|
- **A layout transition**: `transition: width 0.3s`, naming the layout property.
|
|
28
28
|
- **Body text at the viewport edge**: a paragraph of more than 40 characters in the main content, without a background, flush with the sides of the viewport.
|
|
29
|
-
- **No preview, no test drive**: when step 4 ends with the preview unproven (the host's build failed or none came within the five minutes, the host's login stayed in front, step 3 failed), open no test drive: mark the Test drive task failed, say under "What is not verified" what was missing and what the person can do, and put their step in `left`. The setup pull request still opens in step 7 when there is a setup commit. A Netlify draft stays open, unrecorded: name it in the report
|
|
29
|
+
- **No preview, no test drive**: when step 4 ends with the preview unproven (the host's build failed or none came within the five minutes, the host's login stayed in front, step 3 failed), open no test drive: mark the Test drive task failed, say under "What is not verified" what was missing and what the person can do, and put their step in `left`. The setup pull request still opens in step 7 when there is a setup commit. A Netlify draft stays open, unrecorded: name it in the report. Put "Mark the test drive #N ready once Netlify builds its preview" in `left` only where Netlify built no preview; where it built one and the lock stayed in front, preview-protection's step 3 says what the report says instead.
|
|
30
30
|
- **A single-page app**, whose routes live in the browser: the new page is a self-contained HTML file in the folder the build copies as it is (`public/` in Vite), so the output holds it for `entries` and the review counts it as a page added.
|
|
31
31
|
- **A branch name an earlier run took** (checkout or push says so): use `-2`, then `-3`, and name the earlier pull request in the report so the person can close it.
|
|
@@ -8,10 +8,10 @@ The pages need a server from this repository (SSR, API routes, or a backend the
|
|
|
8
8
|
- **Several apps**: ask once for each app this case applies to, the title naming the app (*"Order desk previews"*).
|
|
9
9
|
- **Asked already for this app in this run**, or a host's setup for it ended without a preview (the sign-in skipped, a step failed): ask nothing, and go on as after a skip.
|
|
10
10
|
2. **Set the host up**: on `vercel`, as the vercel-setup case says, and on `netlify`, as the netlify-setup case says. The pick stands in for that case's "When this applies": its steps run whatever the checkout holds for the host, and even where the host already deploys previews that showed another app or did not answer, since the test drive's own preview, proven on that host, decides. Done when that case's steps are done.
|
|
11
|
-
3. **After a skip**: no preview, no test drive. Save no method and write no workflow for these pages, and open no test drive, as pull-requests' "No preview, no test drive" says. Mark the Preview method and Test drive tasks failed, say under "What was skipped, and why" that the pages need a server and no host deploys previews of them, and
|
|
11
|
+
3. **After a skip**: no preview, no test drive. Save no method and write no workflow for these pages, and open no test drive, as pull-requests' "No preview, no test drive" says. Mark the Preview method and Test drive tasks failed, and say under "What was skipped, and why" that the pages need a server and no host deploys previews of them, naming Vercel, Netlify and Cloudflare as the hosts whose pull request previews Impeccable reviews. The checkup's entry for this preview then stays in `next`, which counts as done for the playbook's step 5. With several apps this holds for the skipped app alone: the other chosen apps' previews and the test drive go on as monorepo says, and the tasks fail only where no app has a preview. Done when the report says so.
|
|
12
12
|
|
|
13
13
|
### Reference
|
|
14
14
|
|
|
15
|
-
- **Already on Impeccable's tunnel**: a repository whose state shows `ci-tunnel` as the preview's method, with its workflow committed, keeps working as it is. Change nothing about that preview: a host the person picks here replaces it once its preview is saved, and after a skip the tunnel stays, which the report says
|
|
15
|
+
- **Already on Impeccable's tunnel**: a repository whose state shows `ci-tunnel` as the preview's method, with its workflow committed, keeps working as it is. Change nothing about that preview: a host the person picks here replaces it once its preview is saved, and after a skip the tunnel stays, which the report says.
|
|
16
16
|
- **Never save `ci-tunnel`**: impeccable_write refuses it.
|
|
17
17
|
- The sign-in case applies all the same: the accounts serve the host's previews once they come.
|
|
@@ -9,6 +9,7 @@ Vercel deploys previews of the pull requests: impeccable_hosted_previews lists a
|
|
|
9
9
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
12
|
+
- **The aim** holds here: the CLI does the work, and the person is asked only what the playbook's aim allows.
|
|
12
13
|
- **Behind Vercel's login** (`gate: "vercel-protection"`): Deployment Protection stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
|
|
13
14
|
- **Another app, or no answer from any of the three**: Vercel's previews do not show this app. Set the preview up as server-previews or static-site says instead, and say under "What is not verified" what the Vercel address answered.
|
|
14
15
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch, and Vercel builds its preview from the push, which pull-requests' step 4 waits for and proves the test drive's page on before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the install and build Vercel runs (`vercel.json`'s `installCommand` and `buildCommand`, else the package's), as plain commands (`pnpm install`, then `pnpm run build`) and not with impeccable_verify_preview, which is for a preview Impeccable starts itself, so a page that does not build is never pushed.
|
|
@@ -10,20 +10,21 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
|
|
|
10
10
|
- **Neither `team` nor `teams`**: the account has no team, and `vercel link --yes` without `--team` links in the person's own scope.
|
|
11
11
|
- **Skip**: set the preview up as server-previews or static-site says, and say under "What was skipped, and why" that Vercel was found and the sign-in was skipped. The steps below do not apply.
|
|
12
12
|
2. **Create the project** (run each Vercel command plain: its answer is short, and a pipe to `tail` is refused): `vercel link --yes --team <the team>` creates the project in that team, named after the folder (or links the one that has that name), and connects the GitHub `origin`; the CLI names no team on its own without a terminal, so the option is given whenever there is a team. Done when it prints the linked project.
|
|
13
|
-
3. **Connect the repository, and lock the project before its first deployment**, with impeccable_vercel_connect: it runs the connection on the linked project, asks the person for Vercel's GitHub app's access when the app cannot reach the repository, and then, on a project with no production deployment, puts Vercel's login in front of every address of the project, its own included. Vercel makes a new project's first deployment Production whatever branch it builds and serves it at the project's own address, and the test drive's push is that first deployment. Done when it answered `connected: true` with `production: "exists"` or `locked: true`; a project connected by the link already is, and it says so.
|
|
13
|
+
3. **Connect the repository, and lock the project before its first deployment**, with impeccable_vercel_connect: it runs the connection on the linked project, asks the person for Vercel's GitHub app's access when the app cannot reach the repository, and then, on a project with no production deployment, puts Vercel's login in front of every address of the project, its own included. Vercel makes a new project's first deployment Production whatever branch it builds and serves it at the project's own address, and the test drive's push is that first deployment. When Vercel refuses the lock, the tool asks the person on one screen to turn it on in the project's settings and waits for it. Done when it answered `connected: true` with `production: "exists"` or `locked: true`; a project connected by the link already is, and it says so.
|
|
14
14
|
4. **Build and push the test drive** (the Test drive task) as pull-requests' steps 2 to 4 say, with the changes vercel-previews' reference gives: from the default branch, no setup pull request, built once with plain commands. Stage its files by name: `vercel link` writes `.vercel/` and a `.env.local` that holds a Vercel token, and may add `.vercel` to `.gitignore`, and none of that goes into the pull request. Its push starts the project's first deployment. Done when `git push -u origin impeccable/test-drive` succeeded.
|
|
15
15
|
5. **Its preview, proven and saved, then its pull request** as pull-requests' step 4 (Vercel) and step 5 say: the wait for the pushed head's deployment, the test drive's page proven on the address it lists (past Vercel's login as preview-protection says), impeccable_write to `preview` as vercel-previews' step 2 says, then the pull request and its recording. The playbook's step 6 then has no pull request left to open: go on with the playbook's step 4. Done when impeccable_test_pr answered "Recorded".
|
|
16
16
|
|
|
17
17
|
### Reference
|
|
18
18
|
|
|
19
|
+
- **The aim** holds here: the CLI does the work, and the person is asked only what the playbook's aim allows.
|
|
19
20
|
- **A step fails before the test drive is pushed** (the sign-in skipped or not completed, the team skipped, the project not created, the repository not connected): the Vercel setup ends there. Set the preview up as server-previews or static-site says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Vercel's GitHub app access to the repository", with the `access` page in the report.
|
|
20
21
|
- **A push before step 3** is refused, since a connected project builds every push: run impeccable_vercel_connect first.
|
|
21
|
-
- **`locked: false`** from step 3, with `production` not "exists": push nothing, and set up no other preview either, since the connected project would build that push as its first deployment, public at its own address. Mark the Test drive task failed, say under "What was skipped, and why" what stopped the lock (the answer's `reason`)
|
|
22
|
+
- **`locked: false`** from step 3, with `production` not "exists" (the person skipped the tool's screen, or the lock was still off after its retries): push nothing, and set up no other preview either, since the connected project would build that push as its first deployment, public at its own address. Mark the Test drive task failed, and say under "What was skipped, and why" what stopped the lock (the answer's `reason`).
|
|
22
23
|
- **No preview for the pushed test drive**: save no method, set up no other preview and open no pull request, as pull-requests' "No preview, no test drive" says; the project and the connection stay, and the report names them.
|
|
23
|
-
- **The build failed** (`state` is `failure` or `error`): `vercel deploy --yes` builds this checkout on Vercel and prints why it failed,
|
|
24
|
-
- **No deployment within five minutes**: the
|
|
24
|
+
- **The build failed** (`state` is `failure` or `error`): `vercel deploy --yes` builds this checkout on Vercel and prints why it failed. When the cause is in the test drive's own change, correct it, commit, push once more and wait for that build; else the report names the cause.
|
|
25
|
+
- **No deployment within five minutes**: the wait's own screen opens the project's Git settings and waits again on the person's yes. When they skip it, or none came after its retries, say under "What is not verified" that Vercel built no push here, and that the project's Git connection is the thing to check.
|
|
25
26
|
- **Behind Vercel's login** (`gate: "vercel-protection"`) with nothing getting past it: preview-protection says what the report names.
|
|
26
|
-
- **The first deployment** of a new project is called Production, whatever branch it builds. Locked in step 3, and still so when its deployment is read, it is the test drive's preview: impeccable_hosted_previews lists it, and the proof opens it past Vercel's login as preview-protection says. Unlocked by then, or another project's, the evidence lists no address for it and the proof refuses it: no test drive, as "No preview for the pushed test drive" says, and the report says under "What is not verified" that Vercel serves the test drive's build at the project's own address (`vercel project ls` prints it) without a login until the default branch deploys,
|
|
27
|
+
- **The first deployment** of a new project is called Production, whatever branch it builds. Locked in step 3, and still so when its deployment is read, it is the test drive's preview: impeccable_hosted_previews lists it, and the proof opens it past Vercel's login as preview-protection says. Unlocked by then, or another project's, the evidence lists no address for it and the proof refuses it: no test drive, as "No preview for the pushed test drive" says, and the report says under "What is not verified" that Vercel serves the test drive's build at the project's own address (`vercel project ls` prints it) without a login until the default branch deploys, and that Vercel Authentication for All Deployments, under the project's Settings → Deployment Protection, puts its login back in front. A project that deployed production before the run (`production: "exists"`) builds the push as a Preview, and keeps its addresses as they were.
|
|
27
28
|
- **Behind Vercel's login** (`gate: "vercel-protection"`): a project the CLI creates starts with Vercel Authentication on, and step 3 locks it at every address, so expect it, and go on as the preview-protection case says.
|
|
28
29
|
- **The report** names the Vercel project and its team under "What was configured, and by whom", whichever way the run ended, since it stays in the person's Vercel account, and the CLI's install when there was one, and that the link wrote a `.env.local` with a Vercel token in the checkout. A project locked in step 3 gets this too: Vercel's login stands in front of every address of it, its production one included, until the person chooses Standard Protection under Settings → Deployment Protection, which opens production and keeps the previews behind the login. When the answer's `productionBranch` is not the repository's default branch, say under "Before you merge" that Vercel deploys production from that branch (Settings → Environments → Production → Branch Tracking changes it). The project's environment variables are the person's: when the app needs some (`.env.example`), list their names, never their values, under "Before you merge".
|
|
29
30
|
- **Status lines**: the sign-in, the link, the connection and the lock belong to the Preview method task; from the page on, the Test drive task, so name the test drive in each line about its build, its push, the wait for its preview, the proof and the save (*"Saving the test drive's preview as the method"*). Word Vercel's login as a login, since a line about signing in belongs to the Sign-in task.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const LIMIT = 20;
|
|
5
|
+
const COVERAGE = 'Only the 20 newest GitHub deployments, their latest statuses, and the 20 most recently updated pull requests are checked. A missing match does not prove previews are absent. Empty GitHub deployment history does not prove that a live app or host previews do not exist. A deployment URL does not prove the application rendered.';
|
|
6
|
+
const FAILURE = 'GitHub preview evidence could not be read completely.';
|
|
7
|
+
const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
|
|
8
|
+
function record(value) {
|
|
9
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
+
? value
|
|
11
|
+
: undefined;
|
|
12
|
+
}
|
|
13
|
+
function positiveInteger(value) {
|
|
14
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
15
|
+
}
|
|
16
|
+
function sha(value) {
|
|
17
|
+
return typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
|
|
18
|
+
}
|
|
19
|
+
function previewUrl(value) {
|
|
20
|
+
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#]+$/i.test(value))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
if (url.username || url.password || url.search || url.hash || /^https:\/\/[^/]*@/i.test(value))
|
|
25
|
+
return null;
|
|
26
|
+
return url.href;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Read a bounded projection of GitHub evidence; never return response bodies or command errors. */
|
|
33
|
+
export async function readHostedPreviews(owner, repo, dependencies = {}) {
|
|
34
|
+
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
35
|
+
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
36
|
+
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
37
|
+
}
|
|
38
|
+
const runGh = dependencies.runGh ?? (async (args) => {
|
|
39
|
+
const { stdout } = await execFileAsync('gh', args, {
|
|
40
|
+
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
41
|
+
});
|
|
42
|
+
return stdout;
|
|
43
|
+
});
|
|
44
|
+
const base = `/repos/${owner}/${repo}`;
|
|
45
|
+
async function get(route) {
|
|
46
|
+
if (dependencies.signal?.aborted)
|
|
47
|
+
throw new Error(FAILURE);
|
|
48
|
+
const stdout = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
49
|
+
if (dependencies.signal?.aborted)
|
|
50
|
+
throw new Error(FAILURE);
|
|
51
|
+
const data = JSON.parse(stdout);
|
|
52
|
+
if (!Array.isArray(data))
|
|
53
|
+
throw new Error(FAILURE);
|
|
54
|
+
return data.slice(0, LIMIT);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const [deployments, pulls] = await Promise.all([
|
|
58
|
+
get(`${base}/deployments?per_page=${LIMIT}`),
|
|
59
|
+
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${LIMIT}`),
|
|
60
|
+
]);
|
|
61
|
+
let incomplete = false;
|
|
62
|
+
const evidence = [];
|
|
63
|
+
for (const entry of deployments) {
|
|
64
|
+
if (dependencies.signal?.aborted) {
|
|
65
|
+
incomplete = true;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const deployment = record(entry);
|
|
69
|
+
if (!deployment || !positiveInteger(deployment.id) || !sha(deployment.sha))
|
|
70
|
+
continue;
|
|
71
|
+
let latest;
|
|
72
|
+
try {
|
|
73
|
+
latest = record((await get(`${base}/deployments/${deployment.id}/statuses?per_page=1`))[0]);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
incomplete = true;
|
|
77
|
+
if (dependencies.signal?.aborted)
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
const status = STATES.find((state) => state === latest?.state) ?? null;
|
|
81
|
+
const environment = typeof deployment.environment === 'string' && deployment.environment.length <= 100
|
|
82
|
+
&& !/[\u0000-\u001f\u007f]/.test(deployment.environment) ? deployment.environment : null;
|
|
83
|
+
const pullRequests = [];
|
|
84
|
+
for (const item of pulls) {
|
|
85
|
+
const pull = record(item);
|
|
86
|
+
if (pull && positiveInteger(pull.number) && (pull.state === 'open' || pull.state === 'closed')
|
|
87
|
+
&& record(pull.head)?.sha === deployment.sha) {
|
|
88
|
+
pullRequests.push({ number: pull.number, state: pull.state });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
evidence.push({ id: deployment.id, sha: deployment.sha, environment, status,
|
|
92
|
+
previewUrl: status ? previewUrl(latest?.environment_url) : null, pullRequests });
|
|
93
|
+
}
|
|
94
|
+
incomplete ||= dependencies.signal?.aborted ?? false;
|
|
95
|
+
return { coverage: COVERAGE, deployments: evidence,
|
|
96
|
+
...(incomplete ? { error: FAILURE } : { emptyDeploymentHistory: deployments.length === 0 }) };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Keep Netlify's repository-wide pattern tied to successful discovery evidence. */
|
|
2
|
+
export function netlifyPatternError(body, observed) {
|
|
3
|
+
const pattern = body.previewUrlPattern;
|
|
4
|
+
if (typeof pattern !== 'string')
|
|
5
|
+
return null;
|
|
6
|
+
let netlify = /netlify\.app|<site>/i.test(pattern);
|
|
7
|
+
try {
|
|
8
|
+
const hostname = new URL(pattern.replaceAll('{pr}', '1')).hostname;
|
|
9
|
+
netlify ||= hostname === 'netlify.app' || hostname.endsWith('.netlify.app');
|
|
10
|
+
}
|
|
11
|
+
catch { /* The canonical validation below rejects malformed Netlify patterns. */ }
|
|
12
|
+
if (!netlify)
|
|
13
|
+
return null;
|
|
14
|
+
const match = /^https:\/\/deploy-preview-\{pr\}--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.netlify\.app$/.exec(pattern);
|
|
15
|
+
if (!match || match[1].length > 63)
|
|
16
|
+
return 'Use a real Netlify site pattern from successful preview evidence; placeholders and malformed patterns are not saved.';
|
|
17
|
+
if (Object.hasOwn(body, 'appKey'))
|
|
18
|
+
return 'A Netlify preview URL pattern belongs to the repository; omit appKey.';
|
|
19
|
+
if (body.intendedMethod !== 'preview-deploy')
|
|
20
|
+
return 'Save a Netlify pattern with intendedMethod preview-deploy.';
|
|
21
|
+
if (!observed.has(pattern))
|
|
22
|
+
return 'Read this repository\'s Netlify previews first and choose a pattern backed by a successful current-head status.';
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const FAILURE = 'Netlify preview evidence could not be read completely.';
|
|
5
|
+
const COVERAGE = 'Only the 20 most recently updated pull requests, the newest 100 statuses on each exact head SHA, and the 100 most recently updated repository issue comments are checked. Context-derived URLs do not prove the application rendered. Bot comments are historical candidates, not current-head deployment proof. Missing evidence does not prove previews are absent.';
|
|
6
|
+
const SITE = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
7
|
+
const CONTEXT = new RegExp(`^netlify/(${SITE})/deploy-preview$`);
|
|
8
|
+
const PREVIEW_HOST = new RegExp(`^deploy-preview-([1-9][0-9]*)--(${SITE})\\.netlify\\.app$`);
|
|
9
|
+
const STATES = ['success', 'pending', 'failure', 'error'];
|
|
10
|
+
function record(value) {
|
|
11
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
+
? value : undefined;
|
|
13
|
+
}
|
|
14
|
+
function cleanUrl(value) {
|
|
15
|
+
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#@]+$/i.test(value))
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(value);
|
|
19
|
+
if (!url.username && !url.password && !url.port && !url.search && !url.hash)
|
|
20
|
+
return url;
|
|
21
|
+
}
|
|
22
|
+
catch { /* Discard unsafe evidence without returning it. */ }
|
|
23
|
+
}
|
|
24
|
+
function urls(pull, site) {
|
|
25
|
+
if (`deploy-preview-${pull.number}--${site}`.length > 63)
|
|
26
|
+
return;
|
|
27
|
+
return {
|
|
28
|
+
previewUrl: `https://deploy-preview-${pull.number}--${site}.netlify.app/`,
|
|
29
|
+
previewUrlPattern: `https://deploy-preview-{pr}--${site}.netlify.app`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function validTarget(value, pull, site) {
|
|
33
|
+
if (value === null || value === undefined || value === '')
|
|
34
|
+
return true;
|
|
35
|
+
const target = cleanUrl(value);
|
|
36
|
+
if (!target)
|
|
37
|
+
return false;
|
|
38
|
+
if (target.hostname === `deploy-preview-${pull.number}--${site}.netlify.app` && target.pathname === '/')
|
|
39
|
+
return true;
|
|
40
|
+
return target.hostname === 'app.netlify.com'
|
|
41
|
+
&& new RegExp(`^/(?:sites|projects)/${site}/deploys(?:/[a-zA-Z0-9_-]+)?/?$`).test(target.pathname);
|
|
42
|
+
}
|
|
43
|
+
/** Read a bounded, repository-scoped projection; never expose comments, command errors or credentials. */
|
|
44
|
+
export async function readNetlifyPreviews(owner, repo, dependencies = {}) {
|
|
45
|
+
const previews = [];
|
|
46
|
+
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
47
|
+
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
48
|
+
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
49
|
+
}
|
|
50
|
+
const runGh = dependencies.runGh ?? (async (args) => {
|
|
51
|
+
const { stdout } = await execFileAsync('gh', args, {
|
|
52
|
+
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
53
|
+
});
|
|
54
|
+
return stdout;
|
|
55
|
+
});
|
|
56
|
+
const base = `/repos/${owner}/${repo}`;
|
|
57
|
+
async function get(route, limit) {
|
|
58
|
+
if (dependencies.signal?.aborted)
|
|
59
|
+
throw new Error();
|
|
60
|
+
const raw = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
61
|
+
if (dependencies.signal?.aborted)
|
|
62
|
+
throw new Error();
|
|
63
|
+
const data = JSON.parse(raw);
|
|
64
|
+
if (!Array.isArray(data))
|
|
65
|
+
throw new Error();
|
|
66
|
+
return data.slice(0, limit);
|
|
67
|
+
}
|
|
68
|
+
const [pullRead, commentRead] = await Promise.allSettled([
|
|
69
|
+
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=20`, 20),
|
|
70
|
+
get(`${base}/issues/comments?sort=updated&direction=desc&per_page=100`, 100),
|
|
71
|
+
]);
|
|
72
|
+
if (pullRead.status === 'rejected')
|
|
73
|
+
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
74
|
+
let incomplete = false;
|
|
75
|
+
const warnings = commentRead.status === 'rejected'
|
|
76
|
+
? ['Historical Netlify bot comments could not be read. Current-head status evidence is still checked.'] : [];
|
|
77
|
+
const pulls = new Map();
|
|
78
|
+
for (const item of pullRead.value) {
|
|
79
|
+
const pull = record(item);
|
|
80
|
+
const headSha = record(pull?.head)?.sha;
|
|
81
|
+
if (!pull || typeof pull.number !== 'number' || !Number.isSafeInteger(pull.number) || pull.number <= 0
|
|
82
|
+
|| (pull.state !== 'open' && pull.state !== 'closed') || typeof headSha !== 'string' || !/^[a-f0-9]{40}$/.test(headSha))
|
|
83
|
+
continue;
|
|
84
|
+
const issueUrl = `https://api.github.com${base}/issues/${pull.number}`;
|
|
85
|
+
if (!pulls.has(issueUrl))
|
|
86
|
+
pulls.set(issueUrl, { number: pull.number, state: pull.state, headSha });
|
|
87
|
+
}
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const statusesBySha = new Map();
|
|
90
|
+
for (const pull of pulls.values()) {
|
|
91
|
+
if (dependencies.signal?.aborted) {
|
|
92
|
+
incomplete = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
if (!statusesBySha.has(pull.headSha)) {
|
|
97
|
+
statusesBySha.set(pull.headSha, get(`${base}/commits/${pull.headSha}/statuses?per_page=100`, 100));
|
|
98
|
+
}
|
|
99
|
+
for (const item of await statusesBySha.get(pull.headSha)) {
|
|
100
|
+
const status = record(item);
|
|
101
|
+
const site = typeof status?.context === 'string' ? CONTEXT.exec(status.context)?.[1] : undefined;
|
|
102
|
+
if (!site || seen.has(`${pull.number}:${site}`))
|
|
103
|
+
continue;
|
|
104
|
+
// GitHub returns newest first: even an invalid latest entry must hide older success.
|
|
105
|
+
seen.add(`${pull.number}:${site}`);
|
|
106
|
+
const state = STATES.find((state) => state === status?.state);
|
|
107
|
+
const canonical = urls(pull, site);
|
|
108
|
+
if (!state || !canonical || !validTarget(status?.target_url, pull, site))
|
|
109
|
+
continue;
|
|
110
|
+
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
111
|
+
status: state, ...canonical, source: 'commit-status', matchesHead: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
incomplete = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!dependencies.signal?.aborted && commentRead.status === 'fulfilled') {
|
|
119
|
+
for (const item of commentRead.value) {
|
|
120
|
+
const comment = record(item);
|
|
121
|
+
const author = record(comment?.user);
|
|
122
|
+
const pull = typeof comment?.issue_url === 'string' ? pulls.get(comment.issue_url) : undefined;
|
|
123
|
+
if (!pull || author?.login !== 'netlify[bot]' || author.type !== 'Bot'
|
|
124
|
+
|| typeof comment?.body !== 'string' || comment.body.length > 65_536)
|
|
125
|
+
continue;
|
|
126
|
+
for (const match of comment.body.matchAll(/https:\/\/[^\s<>"'()[\]`]+/gi)) {
|
|
127
|
+
const url = cleanUrl(match[0].replace(/[.,;:]+$/, ''));
|
|
128
|
+
const parts = url ? PREVIEW_HOST.exec(url.hostname) : null;
|
|
129
|
+
if (!url || url.pathname !== '/' || !parts || parts[1] !== String(pull.number))
|
|
130
|
+
continue;
|
|
131
|
+
const site = parts[2];
|
|
132
|
+
const canonical = urls(pull, site);
|
|
133
|
+
if (!canonical || seen.has(`${pull.number}:${site}`))
|
|
134
|
+
continue;
|
|
135
|
+
seen.add(`${pull.number}:${site}`);
|
|
136
|
+
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
137
|
+
status: null, ...canonical, source: 'bot-comment', matchesHead: false });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (dependencies.signal?.aborted)
|
|
142
|
+
incomplete = true;
|
|
143
|
+
return { coverage: COVERAGE, previews, ...(warnings.length ? { warnings } : {}), ...(incomplete ? { error: FAILURE } : {}) };
|
|
144
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function redirectGate(location, source) {
|
|
2
|
+
if (!location)
|
|
3
|
+
return 'redirected';
|
|
4
|
+
try {
|
|
5
|
+
const target = new URL(location, source);
|
|
6
|
+
if (target.protocol !== 'https:')
|
|
7
|
+
return 'redirected';
|
|
8
|
+
if (target.origin === 'https://vercel.com' && target.pathname === '/sso-api'
|
|
9
|
+
&& target.searchParams.has('url'))
|
|
10
|
+
return 'vercel-protection';
|
|
11
|
+
if ((target.hostname === 'vercel.com' || target.hostname.endsWith('.vercel.com'))
|
|
12
|
+
&& /^\/login(?:\/|$)/.test(target.pathname))
|
|
13
|
+
return 'vercel-protection';
|
|
14
|
+
if (target.hostname.endsWith('.cloudflareaccess.com'))
|
|
15
|
+
return 'cloudflare-access';
|
|
16
|
+
if (target.origin === source.origin && /^\/(?:login|sign-in|signin)(?:\/|$)/.test(target.pathname)) {
|
|
17
|
+
return 'app-sign-in';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// A malformed redirect reveals nothing about preview protection.
|
|
22
|
+
}
|
|
23
|
+
return 'redirected';
|
|
24
|
+
}
|
|
25
|
+
/** An unauthenticated HTTP check only: reachable does not prove this is the intended app. */
|
|
26
|
+
export async function probePreview(url, dependencies = {}) {
|
|
27
|
+
let target;
|
|
28
|
+
try {
|
|
29
|
+
target = new URL(url);
|
|
30
|
+
if (!isPlatformPreview(target)
|
|
31
|
+
|| /[?#\\\s]/.test(url) || !/^https:\/\//i.test(url))
|
|
32
|
+
throw new Error();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { status: null, gate: 'unavailable', error: 'invalid-preview-url' };
|
|
36
|
+
}
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
39
|
+
const signal = dependencies.signal
|
|
40
|
+
? AbortSignal.any([controller.signal, dependencies.signal])
|
|
41
|
+
: controller.signal;
|
|
42
|
+
try {
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
const response = dependencies.fetch ? await dependencies.fetch(target, {
|
|
45
|
+
method: 'GET',
|
|
46
|
+
headers: { accept: 'text/html' },
|
|
47
|
+
credentials: 'omit',
|
|
48
|
+
redirect: 'manual',
|
|
49
|
+
signal,
|
|
50
|
+
}) : await publicPreviewRequest(target, signal);
|
|
51
|
+
const status = response.status;
|
|
52
|
+
const gate = status === 401 && response.headers.get('x-vercel-error') === 'UNAUTHORIZED'
|
|
53
|
+
? 'vercel-protection' : status >= 300 && status < 400
|
|
54
|
+
? redirectGate(response.headers.get('location'), target)
|
|
55
|
+
: status >= 200 && status < 300 ? 'reachable' : 'unavailable';
|
|
56
|
+
// Bodies and redirect details may contain credentials; never read or return them.
|
|
57
|
+
await response.body?.cancel().catch(() => undefined);
|
|
58
|
+
return { status, gate };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { status: null, gate: 'unavailable', error: 'request-failed' };
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
import { isPlatformPreview, publicPreviewRequest } from './public-preview-request.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Register credential-shaped write fields before a message can be recorded.
|
|
2
|
+
* Do not trust the requested path: even a refused or malformed destination must
|
|
3
|
+
* keep its supplied credentials private. Values remain unchanged in memory.
|
|
4
|
+
*/
|
|
5
|
+
export function protectionWriteSecrets(input) {
|
|
6
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
7
|
+
return [];
|
|
8
|
+
const body = input.body;
|
|
9
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
10
|
+
return [];
|
|
11
|
+
return ['value', 'clientId', 'clientSecret']
|
|
12
|
+
.map((key) => body[key])
|
|
13
|
+
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
14
|
+
.sort((a, b) => b.length - a.length);
|
|
15
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { request } from 'node:https';
|
|
3
|
+
import { BlockList, isIP } from 'node:net';
|
|
4
|
+
const blockedV4 = new BlockList();
|
|
5
|
+
for (const [address, prefix] of [
|
|
6
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
7
|
+
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
8
|
+
['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
|
|
9
|
+
['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
10
|
+
])
|
|
11
|
+
blockedV4.addSubnet(address, prefix, 'ipv4');
|
|
12
|
+
const publicV6 = new BlockList();
|
|
13
|
+
publicV6.addSubnet('2000::', 3, 'ipv6');
|
|
14
|
+
const blockedV6 = new BlockList();
|
|
15
|
+
// Conservatively exclude special-purpose, transition and documentation prefixes.
|
|
16
|
+
for (const [address, prefix] of [
|
|
17
|
+
['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
|
|
18
|
+
])
|
|
19
|
+
blockedV6.addSubnet(address, prefix, 'ipv6');
|
|
20
|
+
export function isPlatformPreview(target) {
|
|
21
|
+
return target.protocol === 'https:' && !target.username && !target.password && !target.port
|
|
22
|
+
&& !/[?#]/.test(target.href)
|
|
23
|
+
&& /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:vercel|netlify)\.app$/.test(target.hostname);
|
|
24
|
+
}
|
|
25
|
+
function isPublicAddress(value) {
|
|
26
|
+
if (!value || typeof value.address !== 'string' || value.address.includes('%'))
|
|
27
|
+
return false;
|
|
28
|
+
const family = isIP(value.address);
|
|
29
|
+
if (family !== value.family)
|
|
30
|
+
return false;
|
|
31
|
+
if (family === 4)
|
|
32
|
+
return !blockedV4.check(value.address, 'ipv4');
|
|
33
|
+
return family === 6 && publicV6.check(value.address, 'ipv6') && !blockedV6.check(value.address, 'ipv6');
|
|
34
|
+
}
|
|
35
|
+
/** Resolve once, validate every answer, then connect only to the pinned public addresses. */
|
|
36
|
+
export async function publicPreviewRequest(target, signal, dependencies = {}) {
|
|
37
|
+
try {
|
|
38
|
+
if (!isPlatformPreview(target))
|
|
39
|
+
throw new Error();
|
|
40
|
+
signal.throwIfAborted();
|
|
41
|
+
let abortDns = () => { };
|
|
42
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
43
|
+
abortDns = () => reject(new Error());
|
|
44
|
+
signal.addEventListener('abort', abortDns, { once: true });
|
|
45
|
+
});
|
|
46
|
+
const addresses = await Promise.race([
|
|
47
|
+
(dependencies.lookup ?? lookup)(target.hostname, { all: true, verbatim: true }), aborted,
|
|
48
|
+
]).finally(() => signal.removeEventListener('abort', abortDns));
|
|
49
|
+
signal.throwIfAborted();
|
|
50
|
+
if (!Array.isArray(addresses) || !addresses.length || !addresses.every(isPublicAddress))
|
|
51
|
+
throw new Error();
|
|
52
|
+
const pinned = addresses.map(({ address, family }) => ({ address, family }));
|
|
53
|
+
return await new Promise((resolve, reject) => {
|
|
54
|
+
const options = {
|
|
55
|
+
method: 'GET', headers: { accept: 'text/html' }, signal,
|
|
56
|
+
agent: false, autoSelectFamily: true, rejectUnauthorized: true,
|
|
57
|
+
lookup(hostname, options, callback) {
|
|
58
|
+
if (hostname !== target.hostname)
|
|
59
|
+
return callback(new Error('Preview request failed'), '');
|
|
60
|
+
if (options.all)
|
|
61
|
+
callback(null, pinned.map((address) => ({ ...address })));
|
|
62
|
+
else
|
|
63
|
+
callback(null, pinned[0].address, pinned[0].family);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const req = (dependencies.request ?? request)(target, options, (response) => {
|
|
67
|
+
response.once('error', () => { });
|
|
68
|
+
try {
|
|
69
|
+
const status = response.statusCode;
|
|
70
|
+
const headers = new Headers();
|
|
71
|
+
for (const name of ['location', 'x-vercel-error']) {
|
|
72
|
+
const value = response.headers[name];
|
|
73
|
+
if (typeof value === 'string')
|
|
74
|
+
headers.set(name, value);
|
|
75
|
+
}
|
|
76
|
+
if (!status)
|
|
77
|
+
reject(new Error());
|
|
78
|
+
else
|
|
79
|
+
resolve({ status, headers, body: null });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
reject(new Error());
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
response.destroy();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
req.once('error', () => reject(new Error()));
|
|
89
|
+
req.once('upgrade', (_response, socket) => { socket.destroy(); reject(new Error()); });
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error('Preview request failed');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
const INTERVAL_MS = 30_000;
|
|
3
|
+
const DEADLINE_MS = 8 * 60_000;
|
|
4
|
+
/** One publication deadline per setup PR, including time spent checking the preview. */
|
|
5
|
+
export function createPublicationWait(dependencies = {}) {
|
|
6
|
+
const now = dependencies.now ?? (() => performance.now());
|
|
7
|
+
const pause = dependencies.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
8
|
+
const starts = new Map();
|
|
9
|
+
return {
|
|
10
|
+
begin(pr) {
|
|
11
|
+
if (!starts.has(pr))
|
|
12
|
+
starts.set(pr, now());
|
|
13
|
+
},
|
|
14
|
+
remainingMs(pr) {
|
|
15
|
+
const start = starts.get(pr);
|
|
16
|
+
return start === undefined ? null : Math.max(0, DEADLINE_MS - (now() - start));
|
|
17
|
+
},
|
|
18
|
+
async wait(pr, signal) {
|
|
19
|
+
const start = starts.get(pr);
|
|
20
|
+
if (start === undefined)
|
|
21
|
+
return null;
|
|
22
|
+
const result = (status) => {
|
|
23
|
+
const elapsed = Math.max(0, now() - start);
|
|
24
|
+
return {
|
|
25
|
+
status,
|
|
26
|
+
waited_seconds: Math.floor(elapsed / 1000),
|
|
27
|
+
remaining_seconds: Math.ceil(Math.max(0, DEADLINE_MS - elapsed) / 1000),
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
if (signal?.aborted)
|
|
31
|
+
return result('cancelled');
|
|
32
|
+
const remaining = DEADLINE_MS - (now() - start);
|
|
33
|
+
if (remaining <= 0)
|
|
34
|
+
return result('timed-out');
|
|
35
|
+
try {
|
|
36
|
+
await pause(Math.min(INTERVAL_MS, remaining), signal);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return result('cancelled');
|
|
40
|
+
}
|
|
41
|
+
if (signal?.aborted)
|
|
42
|
+
return result('cancelled');
|
|
43
|
+
return result(now() - start >= DEADLINE_MS ? 'timed-out' : 'retry');
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|