@lhechenberger/check-links 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lukas Hechenberger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # @lhechenberger/check-links
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Crawls a deployed site for broken links and tells real failures apart from flaky ones. Made for CI checks against Vercel preview deployments, built on [linkinator](https://github.com/JustinBeckwith/linkinator).
4
+
5
+ Fresh deployments answer some first requests with errors: image optimization (`/_next/image?url=…`) from a function that has just started often returns `400`, hundreds of URLs at once. A plain crawl fails on these. This tool:
6
+
7
+ 1. crawls the site (with linkinator's own retries for 5xx and network errors),
8
+ 2. waits a few seconds,
9
+ 3. requests every broken link **again, at low concurrency**,
10
+ 4. reports links that work now as **warnings** ("flaky") and only fails (exit code `1`) for links that are still broken.
11
+
12
+ It also handles Vercel's deployment protection (bypass header, and a check that the start URL isn't the login page) and can downgrade known-unfixable links (e.g. third-party content) to warnings.
13
+
14
+ Requires [Bun](https://bun.sh) (the package ships TypeScript source and runs with a Bun shebang).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ bun add -d @lhechenberger/check-links
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ bunx check-links https://my-preview.example.vercel.app
26
+ bunx check-links # uses `url` from the config, e.g. the local dev server
27
+ bunx check-links <url> --config ci/check-links.config.ts
28
+ ```
29
+
30
+ Set `VERCEL_AUTOMATION_BYPASS_SECRET` (Vercel project → Deployment Protection → Protection Bypass for Automation) to check protected deployments. The secret is sent as `x-vercel-protection-bypass` header, also to the second requests (same-origin only, never to third parties).
31
+
32
+ The last line of the output is a one-line summary (`Checked N links on <origin>: X broken, Y warnings`); the exit code is `1` if `X > 0`.
33
+
34
+ ## Configuration
35
+
36
+ Optional `check-links.config.ts` in the working directory:
37
+
38
+ ```ts
39
+ import { defineConfig } from '@lhechenberger/check-links';
40
+
41
+ export default defineConfig({
42
+ url: 'http://localhost:3000',
43
+ // Never checked. Strings are regular expression sources.
44
+ skip: [/^https:\/\/www\.instagram\.com\//, String.raw`^https://example\.org/`],
45
+ // Broken links that must not fail the check, e.g. content you can't fix. Not re-requested.
46
+ isWarningOnly: (link, { origin, url }) =>
47
+ !link.url.startsWith(origin) && new URL(link.parent ?? url).pathname.startsWith('/docs/'),
48
+ warningTitle: 'Broken links in embedded READMEs',
49
+ retryAfterMs: 5000, // wait before the second request
50
+ recheckConcurrency: 5, // parallel second requests
51
+ concurrency: 20, // parallel requests of the crawl
52
+ timeout: 30_000,
53
+ });
54
+ ```
55
+
56
+ | Option | Default | Description |
57
+ | -------------------- | ------- | ---------------------------------------------------- |
58
+ | `url` | – | Used when no URL is passed on the command line |
59
+ | `skip` | `[]` | Links not to check |
60
+ | `isWarningOnly` | – | `(link, { origin, url }) => boolean` |
61
+ | `warningTitle` | generic | Heading for the `isWarningOnly` links |
62
+ | `retryAfterMs` | `5000` | Wait before broken links are requested a second time |
63
+ | `recheckConcurrency` | `5` | Parallel second requests; keep low (see above) |
64
+ | `concurrency` | `20` | Parallel requests of the crawl |
65
+ | `timeout` | `30000` | Timeout per request in ms |
66
+
67
+ ## GitHub workflow: check every Vercel deployment
68
+
69
+ The package contains a workflow template that runs the check when Vercel reports a ready deployment, shows the result as a **"Broken Links" check run** on the deployed commit (so it appears on the pull request, with the full output) and reports the commit status that Vercel's Deployment Checks can require before a production deployment gets its domain.
70
+
71
+ ```bash
72
+ bunx check-links init-workflow # writes .github/workflows/broken-links.yml
73
+ ```
74
+
75
+ Setup:
76
+
77
+ 1. Add the repository secret `VERCEL_AUTOMATION_BYPASS_SECRET` (same value as in the Vercel project's protection bypass).
78
+ 2. In Vercel, enable Deployment Checks / the `repository_dispatch` events for the project, so it sends `vercel.deployment.ready`.
79
+ 3. Commit the workflow to the **default branch** (GitHub runs `repository_dispatch` workflows only from there).
80
+ 4. Adjust the "Check for broken links" step if the package isn't a dependency of the repository root (e.g. `bun --cwd=apps/web run …`), and optionally remove the "Report to Vercel" step.
81
+ 5. Run it once by hand: Actions → Broken Links → Run workflow, with a deployment URL.
82
+
83
+ Without the secret, production deployments fail the job (they would be held back anyway) and previews are skipped with a warning.
84
+
85
+ ## Local development of the checks
86
+
87
+ `bun test` runs the unit tests of the classification (flaky vs. real errors).
88
+
89
+ ## Programmatic API
90
+
91
+ ```ts
92
+ import { classify, run, defineConfig } from '@lhechenberger/check-links';
93
+ ```
94
+
95
+ `run(url, config)` crawls and prints, and resolves to the exit code; `classify` is the pure part that sorts broken links into `errors`, `flaky` and `warnings`.
96
+
97
+ ## License
98
+
99
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@lhechenberger/check-links",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "Crawls a deployed site for broken links, separating flaky failures from real ones. Built for Vercel preview deployments.",
5
+ "keywords": [
6
+ "broken-links",
7
+ "link-checker",
8
+ "linkinator",
9
+ "vercel",
10
+ "ci"
11
+ ],
12
+ "homepage": "https://github.com/LukasHechenberger/workbench/tree/main/packages/check-links#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/LukasHechenberger/workbench/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/LukasHechenberger/workbench.git",
19
+ "directory": "packages/check-links"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Lukas Hechenberger",
23
+ "type": "module",
24
+ "exports": {
25
+ ".": "./src/index.ts"
26
+ },
27
+ "bin": {
28
+ "check-links": "src/cli.ts"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "!src/**/*.test.ts",
33
+ "templates"
34
+ ],
35
+ "scripts": {
36
+ "test": "bun test"
37
+ },
38
+ "dependencies": {
39
+ "linkinator": "^8.1.0"
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.2"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }
@@ -0,0 +1,45 @@
1
+ import type { LinkResult } from 'linkinator';
2
+
3
+ export interface Classification {
4
+ /** Still broken on the second request */
5
+ errors: LinkResult[];
6
+ /** Worked on the second request */
7
+ flaky: LinkResult[];
8
+ /** Broken, but allowed to be (see `isWarningOnly`) */
9
+ warnings: LinkResult[];
10
+ }
11
+
12
+ export interface ClassifyOptions {
13
+ isWarningOnly: (link: LinkResult) => boolean;
14
+ /** Requests `link` again and tells whether it works now */
15
+ recovers: (link: LinkResult) => Promise<boolean>;
16
+ /** Waits before the second pass; only called if there is something to re-request */
17
+ wait: () => Promise<void>;
18
+ /** Parallel `recovers` calls */
19
+ concurrency: number;
20
+ }
21
+
22
+ /**
23
+ * Sorts broken links into real errors, flaky links (fine on a second request) and tolerated
24
+ * warnings. Fresh deployments answer some first requests with errors, e.g. image optimizations
25
+ * from a function that has just started, so a failure on the first request isn't proof.
26
+ */
27
+ export async function classify(
28
+ broken: LinkResult[],
29
+ { isWarningOnly, recovers, wait, concurrency }: ClassifyOptions,
30
+ ): Promise<Classification> {
31
+ const warnings = broken.filter((link) => isWarningOnly(link));
32
+ const toRecheck = broken.filter((link) => !isWarningOnly(link));
33
+ const errors: LinkResult[] = [];
34
+ const flaky: LinkResult[] = [];
35
+ if (toRecheck.length === 0) return { errors, flaky, warnings };
36
+
37
+ await wait();
38
+ // A burst of requests is what failed in the first place, so the second pass is gentle
39
+ for (let i = 0; i < toRecheck.length; i += concurrency) {
40
+ const batch = toRecheck.slice(i, i + concurrency);
41
+ const results = await Promise.all(batch.map((link) => recovers(link)));
42
+ for (const [index, link] of batch.entries()) (results[index] ? flaky : errors).push(link);
43
+ }
44
+ return { errors, flaky, warnings };
45
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import type { CheckLinksConfig } from './config.ts';
6
+ import { run } from './run.ts';
7
+
8
+ const usage = `Usage:
9
+ check-links [url] [--config check-links.config.ts]
10
+ check-links init-workflow [path] Writes the GitHub workflow template (default .github/workflows/broken-links.yml)`;
11
+
12
+ const { positionals, values } = parseArgs({
13
+ allowPositionals: true,
14
+ options: { config: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
15
+ });
16
+
17
+ if (values.help) {
18
+ console.log(usage);
19
+ process.exit(0);
20
+ }
21
+
22
+ if (positionals[0] === 'init-workflow') {
23
+ const target = resolve(positionals[1] ?? '.github/workflows/broken-links.yml');
24
+ if (existsSync(target)) {
25
+ console.error(`${target} exists already`);
26
+ process.exit(1);
27
+ }
28
+ mkdirSync(dirname(target), { recursive: true });
29
+ writeFileSync(
30
+ target,
31
+ readFileSync(new URL('../templates/broken-links.yml', import.meta.url), 'utf8'),
32
+ );
33
+ console.log(`Wrote ${target}. See the README for the setup it needs.`);
34
+ process.exit(0);
35
+ }
36
+
37
+ const configPath = resolve(values.config ?? 'check-links.config.ts');
38
+ let config: CheckLinksConfig = {};
39
+ if (existsSync(configPath)) {
40
+ config = ((await import(configPath)) as { default: CheckLinksConfig }).default;
41
+ } else if (values.config) {
42
+ console.error(`Config ${configPath} not found`);
43
+ process.exit(1);
44
+ }
45
+
46
+ const url = positionals[0] ?? config.url;
47
+ if (!url) {
48
+ console.error(`No URL given.\n\n${usage}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ process.exit(await run(url, config));
package/src/config.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { LinkResult } from 'linkinator';
2
+
3
+ export interface LinkContext {
4
+ /** Origin of the checked site, e.g. `https://preview.example.com` */
5
+ origin: string;
6
+ /** The URL the crawl started at */
7
+ url: string;
8
+ }
9
+
10
+ export interface CheckLinksConfig {
11
+ /** URL to check when none is passed on the command line (e.g. the local dev server) */
12
+ url?: string;
13
+ /** Links not to check. Strings are matched as regular expression sources. */
14
+ skip?: (string | RegExp)[];
15
+ /**
16
+ * Broken links that only count as warnings (e.g. content you don't control). They are not
17
+ * requested a second time.
18
+ */
19
+ isWarningOnly?: (link: LinkResult, context: LinkContext) => boolean;
20
+ /** Heading printed above the `isWarningOnly` links */
21
+ warningTitle?: string;
22
+ /** Wait before broken links are requested a second time (ms), default 5000 */
23
+ retryAfterMs?: number;
24
+ /** Parallel requests of the second pass, default 5. Low on purpose: see the README. */
25
+ recheckConcurrency?: number;
26
+ /** Parallel requests of the crawl, default 20 */
27
+ concurrency?: number;
28
+ /** Timeout per request (ms), default 30000 */
29
+ timeout?: number;
30
+ }
31
+
32
+ /** Type helper for `check-links.config.ts` */
33
+ export const defineConfig = (config: CheckLinksConfig): CheckLinksConfig => config;
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { classify, type Classification, type ClassifyOptions } from './classify.ts';
2
+ export { defineConfig, type CheckLinksConfig, type LinkContext } from './config.ts';
3
+ export { run } from './run.ts';
package/src/run.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { check, type LinkResult, LinkState } from 'linkinator';
2
+ import { classify } from './classify.ts';
3
+ import type { CheckLinksConfig } from './config.ts';
4
+
5
+ const BYPASS_ENV = 'VERCEL_AUTOMATION_BYPASS_SECRET';
6
+
7
+ /** Crawls `url` and prints the result. Returns the process exit code. */
8
+ export async function run(url: string, config: CheckLinksConfig): Promise<number> {
9
+ const { origin } = new URL(url);
10
+ const bypassSecret = process.env[BYPASS_ENV];
11
+ // Lets the crawler through Vercel's deployment protection
12
+ const headers: Record<string, string> = bypassSecret
13
+ ? { 'x-vercel-protection-bypass': bypassSecret }
14
+ : {};
15
+
16
+ // A protected deployment redirects to Vercel's login page, which linkinator would happily
17
+ // check instead of the site
18
+ const start = await fetch(url, { headers, redirect: 'manual' });
19
+ if (!start.ok) {
20
+ console.error(
21
+ `${url} answered with ${start.status} ${start.headers.get('location') ?? ''}`.trim() +
22
+ (bypassSecret ? '' : `\nIf the deployment is protected, set ${BYPASS_ENV}.`),
23
+ );
24
+ return 1;
25
+ }
26
+
27
+ const skip = (config.skip ?? []).map((pattern) =>
28
+ typeof pattern === 'string' ? new RegExp(pattern) : pattern,
29
+ );
30
+ const { links } = await check({
31
+ path: url,
32
+ recurse: true,
33
+ concurrency: config.concurrency ?? 20,
34
+ timeout: config.timeout ?? 30_000,
35
+ // Retries 5xx responses and network errors, which are mostly temporary
36
+ retryErrors: true,
37
+ retryErrorsCount: 2,
38
+ retry: true,
39
+ headers,
40
+ linksToSkip: async (link) => skip.some((pattern) => pattern.test(link)),
41
+ });
42
+
43
+ const isWarningOnly = (link: LinkResult) =>
44
+ config.isWarningOnly?.(link, { origin, url }) ?? false;
45
+
46
+ /** Whether `link` works when requested again, a while after the crawl */
47
+ const recovers = async (link: LinkResult) => {
48
+ try {
49
+ const response = await fetch(link.url, {
50
+ headers: link.url.startsWith(origin) ? headers : {},
51
+ });
52
+ await response.body?.cancel();
53
+ return response.ok;
54
+ } catch {
55
+ return false;
56
+ }
57
+ };
58
+
59
+ const broken = links.filter((link) => link.state === LinkState.BROKEN);
60
+ const { errors, flaky, warnings } = await classify(broken, {
61
+ isWarningOnly,
62
+ recovers,
63
+ wait: () => new Promise((resolve) => setTimeout(resolve, config.retryAfterMs ?? 5000)),
64
+ concurrency: config.recheckConcurrency ?? 5,
65
+ });
66
+
67
+ const print = (title: string, results: LinkResult[]) => {
68
+ if (results.length === 0) return;
69
+ console.log(`\n${title}`);
70
+ for (const link of results) {
71
+ console.log(` [${link.status ?? 'error'}] ${link.url}\n on ${link.parent}`);
72
+ }
73
+ };
74
+
75
+ print(`${config.warningTitle ?? 'Broken links that are only warnings'} (warnings):`, warnings);
76
+ print('Links that worked on a second request (warnings):', flaky);
77
+ print('Broken links:', errors);
78
+ // The last line is the summary the workflow shows as the check run's title
79
+ console.log(
80
+ `\nChecked ${links.length} links on ${origin}: ${errors.length} broken, ${warnings.length + flaky.length} warnings`,
81
+ );
82
+
83
+ return errors.length > 0 ? 1 : 0;
84
+ }
@@ -0,0 +1,116 @@
1
+ name: Broken Links
2
+
3
+ on:
4
+ # Sent by Vercel once a deployment is ready. Runs the workflow file on the default branch.
5
+ repository_dispatch:
6
+ types:
7
+ - 'vercel.deployment.ready'
8
+ workflow_dispatch:
9
+ inputs:
10
+ url:
11
+ type: string
12
+ description: The URL of the deployed site to check
13
+ required: true
14
+
15
+ permissions:
16
+ contents: read
17
+ checks: write
18
+ statuses: write
19
+
20
+ jobs:
21
+ broken-links:
22
+ # Differs from the check run's name below, so the two don't collide
23
+ name: Crawl deployment
24
+ runs-on: ubuntu-latest
25
+ timeout-minutes: 15
26
+ env:
27
+ URL: ${{ github.event.client_payload.url || inputs.url }}
28
+ SHA: ${{ github.event.client_payload.git.sha || github.sha }}
29
+ ENVIRONMENT: ${{ github.event.client_payload.environment || 'manual' }}
30
+ VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
31
+ steps:
32
+ # Sets the commit status "Broken Links | broken-links (<project> - <environment>)" that the
33
+ # project's Deployment Checks can require before production deployments get the domain:
34
+ # pending now, and failed or passed once the job is done. Remove if you don't use them.
35
+ - name: Report to Vercel
36
+ uses: vercel/repository-dispatch/actions/status@v1
37
+
38
+ # Vercel sends the deployment's own URL, which is protected for production deployments too.
39
+ # Without the secret, production fails and previews are skipped.
40
+ - name: Require a bypass secret for deployments
41
+ id: guard
42
+ run: |
43
+ echo "Checking $URL ($ENVIRONMENT) at $SHA"
44
+ if [ "${{ github.event_name }}" = "repository_dispatch" ] && [ -z "$VERCEL_AUTOMATION_BYPASS_SECRET" ]; then
45
+ if [ "$ENVIRONMENT" = "production" ]; then
46
+ echo "::error::Set the VERCEL_AUTOMATION_BYPASS_SECRET secret to check production deployments"
47
+ exit 1
48
+ fi
49
+ echo "::warning::Set the VERCEL_AUTOMATION_BYPASS_SECRET secret to check preview deployments"
50
+ echo "skip=true" >> "$GITHUB_OUTPUT"
51
+ fi
52
+
53
+ - name: Check out code
54
+ if: steps.guard.outputs.skip != 'true'
55
+ uses: actions/checkout@v5
56
+ with:
57
+ ref: ${{ env.SHA }}
58
+
59
+ - name: Setup Bun
60
+ if: steps.guard.outputs.skip != 'true'
61
+ uses: oven-sh/setup-bun@v2
62
+
63
+ - name: Install dependencies
64
+ if: steps.guard.outputs.skip != 'true'
65
+ run: bun install --frozen-lockfile
66
+
67
+ # Shows up next to the other checks of the deployed commit, also on its pull request
68
+ - name: Create check run
69
+ if: steps.guard.outputs.skip != 'true'
70
+ id: check_run
71
+ uses: actions/github-script@v8
72
+ with:
73
+ script: |
74
+ const { data } = await github.rest.checks.create({
75
+ ...context.repo,
76
+ name: 'Broken Links',
77
+ head_sha: process.env.SHA,
78
+ status: 'in_progress',
79
+ details_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
80
+ });
81
+ return data.id;
82
+
83
+ - name: Check for broken links
84
+ if: steps.guard.outputs.skip != 'true'
85
+ # Adjust to where @lhechenberger/check-links is installed (or add a package script)
86
+ run: |
87
+ set -o pipefail
88
+ bunx check-links "$URL" | tee broken-links.txt
89
+
90
+ - name: Complete check run
91
+ if: ${{ !cancelled() && steps.check_run.outputs.result }}
92
+ uses: actions/github-script@v8
93
+ env:
94
+ CHECK_RUN_ID: ${{ steps.check_run.outputs.result }}
95
+ OUTCOME: ${{ job.status }}
96
+ with:
97
+ script: |
98
+ const fs = require('node:fs');
99
+ const output = fs.existsSync('broken-links.txt')
100
+ ? fs.readFileSync('broken-links.txt', 'utf8')
101
+ : 'The check did not run.';
102
+ const conclusion = process.env.OUTCOME === 'success' ? 'success' : 'failure';
103
+ const summary = output.trim().split('\n').at(-1);
104
+
105
+ await github.rest.checks.update({
106
+ ...context.repo,
107
+ check_run_id: Number(process.env.CHECK_RUN_ID),
108
+ status: 'completed',
109
+ conclusion,
110
+ output: {
111
+ title: summary,
112
+ summary: `${process.env.URL} (${process.env.ENVIRONMENT})`,
113
+ // Check run texts are limited to 65,535 characters
114
+ text: '```\n' + output.slice(-60_000) + '\n```',
115
+ },
116
+ });