intacto 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -2
  3. package/intacto.mjs +316 -0
  4. package/package.json +15 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the intacto authors
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,230 @@
1
- # Temporary Holding Version
1
+ # intacto
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
+ **Check that a CDN serves every file of your npm package exactly as published.**
4
+
5
+ CDNs such as jsDelivr and unpkg serve npm packages to browsers straight from their caches. A CDN can refuse some files, for example past a package size limit, or serve bytes that differ from the published ones, and nothing says so until a page that loads them breaks. intacto downloads the package's tarball, requests every file in it from each CDN and compares their SHA-256 hashes.
6
+
7
+ intacto supports three CDNs, and only these three: **jsDelivr**, **unpkg** and **hopjs**.
8
+
9
+ ## Quick start
10
+
11
+ ```sh
12
+ npx intacto is-number
13
+ ```
14
+
15
+ No install, no configuration and no account. A clean run ends like this:
16
+
17
+ ```text
18
+ $ npx intacto is-number@7.0.0 --cdn unpkg
19
+ https://unpkg.com/is-number@7.0.0/
20
+ .... 4 of 4 100% 0:00 left
21
+ 4 files, 0.01 MB in 0.6 s
22
+ 4 requests: 4 succeeded, 0 failed (0.0%)
23
+ OK: every file matches
24
+ ```
25
+
26
+ ## Supported CDNs
27
+
28
+ intacto supports exactly these three CDNs. Any other value of `--cdn` is an argument error, and no other CDN can be added from the command line.
29
+
30
+ | `--cdn` | CDN | Each file is requested at |
31
+ | ---------- | -------- | ------------------------------------------------------ |
32
+ | `jsdelivr` | jsDelivr | `https://cdn.jsdelivr.net/npm/<name>@<version>/<path>` |
33
+ | `unpkg` | unpkg | `https://unpkg.com/<name>@<version>/<path>` |
34
+ | `hopjs` | hopjs | `https://cdn.hopjs.net/npm/<name>@<version>/<path>` |
35
+
36
+ Without `--cdn`, intacto checks all three, one after another.
37
+
38
+ ## Usage
39
+
40
+ ```sh
41
+ npx intacto <package>[@<version>] [--cdn jsdelivr|unpkg|hopjs]
42
+ ```
43
+
44
+ | Argument | Meaning |
45
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------- |
46
+ | `<package>` | The package name, scoped or not, such as `is-number` or `@scope/name`. |
47
+ | `@<version>` | Anything npm accepts after the name: an exact version, a dist-tag such as `next` or a range such as `^7`. Default: `latest`. |
48
+ | `--cdn <name>` | Check one CDN only: `jsdelivr`, `unpkg` or `hopjs`. Default: all three. |
49
+ | `-h`, `--help` | Print the usage line. |
50
+
51
+ npm resolves the version once, at the start, and every request names that exact version, so a dist-tag that moves during the run changes nothing.
52
+
53
+ ## Recipes
54
+
55
+ ### Check a library before you load it from a CDN
56
+
57
+ Before a `<script>` tag or an `import` points at a CDN, make sure the CDN serves the version you pinned, byte for byte:
58
+
59
+ ```sh
60
+ npx intacto some-library@3.2.1 --cdn jsdelivr
61
+ ```
62
+
63
+ ### Check every release automatically
64
+
65
+ Add a `postpublish` script to your package. npm runs it right after `npm publish` succeeds, with the name and version it just published in the environment:
66
+
67
+ ```json
68
+ {
69
+ "scripts": {
70
+ "postpublish": "npx --yes intacto $npm_package_name@$npm_package_version"
71
+ }
72
+ }
73
+ ```
74
+
75
+ `--yes` skips npx's install prompt. If a file fails, `npm publish` ends with an error; the package is published by then, so the error is the alert that a CDN needs a look.
76
+
77
+ Right after a publish, the registry or a CDN can take a moment to see the new version. If intacto reports it missing, run the same check again a minute later.
78
+
79
+ ### Run it in CI after publishing
80
+
81
+ In GitHub Actions, add a step after the one that publishes:
82
+
83
+ ```yaml
84
+ - run: npx intacto "$(jq -r '.name + "@" + .version' package.json)"
85
+ ```
86
+
87
+ The step fails when any file fails, because intacto exits with status 1. npx doesn't prompt in CI.
88
+
89
+ ### Watch a package every day
90
+
91
+ A scheduled workflow catches a CDN that starts serving a file wrong after a purge or an outage:
92
+
93
+ ```yaml
94
+ name: CDN check
95
+
96
+ on:
97
+ schedule:
98
+ - cron: "0 6 * * *"
99
+ workflow_dispatch:
100
+
101
+ jobs:
102
+ intacto:
103
+ runs-on: ubuntu-latest
104
+ steps:
105
+ - uses: actions/setup-node@v7
106
+ with:
107
+ node-version: 24
108
+ - run: npx intacto your-package
109
+ ```
110
+
111
+ ### Check a pre-release before you announce it
112
+
113
+ ```sh
114
+ npx intacto your-package@next
115
+ ```
116
+
117
+ ### Check one CDN after purging its cache
118
+
119
+ ```sh
120
+ npx intacto your-package@1.2.3 --cdn jsdelivr
121
+ ```
122
+
123
+ ### Check several versions
124
+
125
+ ```sh
126
+ for v in 1.0.0 1.1.0 1.2.0; do
127
+ npx --yes intacto "your-package@$v" || echo "your-package@$v failed"
128
+ done
129
+ ```
130
+
131
+ Each check downloads every file of that version from each CDN, so keep the list short.
132
+
133
+ ### Keep the report
134
+
135
+ ```sh
136
+ npx intacto your-package > intacto-report.txt || echo "intacto found a problem, see intacto-report.txt"
137
+ ```
138
+
139
+ The progress marks go to stderr, so the file holds just the report.
140
+
141
+ ## Install
142
+
143
+ npx is enough for an occasional check. To run intacto often, or to pin its version:
144
+
145
+ ```sh
146
+ npm install --global intacto # then run: intacto your-package
147
+ npm install --save-dev intacto # then use intacto in your package.json scripts
148
+ ```
149
+
150
+ With intacto as a dev dependency, the `postpublish` script above becomes `intacto $npm_package_name@$npm_package_version`, and nothing is downloaded at publish time.
151
+
152
+ ## How it works
153
+
154
+ 1. **Downloads the tarball.** `npm pack` fetches the package from the registry your npm configuration points to and checks it against the registry's integrity hash. None of the package's scripts run.
155
+ 2. **Hashes every file.** intacto unpacks the tarball into a temporary directory, takes the SHA-256 of every file and deletes the directory.
156
+ 3. **Requests every file from the CDN,** 128 at a time over 4 HTTP/2 connections. It asks for the encodings browsers accept, decodes gzip, deflate, Brotli or zstd, and compares the SHA-256 of the result with the tarball's.
157
+ 4. **Retries what may be temporary.** A network error, 60 seconds without data, a 429 or a 5xx is tried again once every other file has had its try: after 10 seconds, then 20 seconds after that, three tries in all. Any other status, and any file with different bytes, is final.
158
+ 5. **Prints the report,** then moves on to the next CDN.
159
+
160
+ ## Reading the output
161
+
162
+ ```text
163
+ $ npx intacto some-package@1.2.3 --cdn unpkg
164
+ https://unpkg.com/some-package@1.2.3/
165
+ ...........X.....................X................. 51 of 100 51% 0:01 left
166
+ ...............X................................. 100 of 100 100% 0:00 left
167
+ 2 failed, trying again in 10 s (2 of 3)
168
+ .X 2 of 2 100% 0:00 left
169
+ 1 failed, trying again in 20 s (3 of 3)
170
+ X 1 of 1 100% 0:00 left
171
+ 100 files, 2.10 MB in 34.2 s
172
+ 103 requests: 98 succeeded, 5 failed (4.9%)
173
+
174
+ Files with errors: 1
175
+ 500 Internal Server Error (1)
176
+ dist/bundle.js
177
+
178
+ Files that don't match: 1
179
+ dist/index.css
180
+
181
+ FAIL: 2 of 100 files
182
+ ```
183
+
184
+ Each request prints a mark: a dot when the file comes back with a 200 and the right hash, an X for anything else, retries included. Requests go out in npm's file order, which is by path, so a run of X's points at one directory. Each line of marks ends with the round's progress and its time left. In a terminal the line grows in place; in a log, each line is written once it is full.
185
+
186
+ The report follows:
187
+
188
+ - **The totals:** the files checked, the bytes received, the time taken and how many requests succeeded. Retries are requests too, so there can be more requests than files.
189
+ - **Files with errors:** the files the CDN would not serve, grouped by HTTP status, or by the network error when there was no status. When the CDN gives its reason as plain text, such as a size limit, the first line of it is part of the group's name.
190
+ - **Files that don't match:** the files that came back with a 200 but different bytes.
191
+ - **The verdict:** `OK: every file matches`, or `FAIL:` with the number of files that failed.
192
+
193
+ The marks and the retry notices go to stderr and the report to stdout.
194
+
195
+ ## Exit status
196
+
197
+ | Status | Meaning |
198
+ | ------ | --------------------------------------------------------------------------------------------------------- |
199
+ | `0` | Every file matches on every CDN checked. |
200
+ | `1` | At least one file failed or doesn't match, or something else failed, such as npm not finding the package. |
201
+ | `2` | The arguments are wrong, including a CDN other than `jsdelivr`, `unpkg` or `hopjs`. |
202
+
203
+ ## Requirements
204
+
205
+ - Node.js 22.15 or later, the first 22 release with zstd.
206
+ - Linux or macOS, with `npm` and `tar` on the `PATH`. Windows is not supported.
207
+ - Network access to the npm registry, and a direct HTTP/2 connection to each CDN checked. intacto never goes through an HTTP proxy.
208
+
209
+ ## Limits
210
+
211
+ - intacto sees only the CDN locations that answer your machine. Run it from elsewhere to check other regions.
212
+ - It checks the files in the tarball, not files the CDN makes itself, such as minified copies or directory listings.
213
+ - Every run downloads the whole unpacked package from each CDN it checks, so a large package means a large download.
214
+ - The time left covers the current round, not the retries that may follow.
215
+
216
+ ## FAQ
217
+
218
+ **Which CDNs does it support?** jsDelivr, unpkg and hopjs, and no others.
219
+
220
+ **Does intacto change anything?** It sends only GET requests, so a CDN treats them like any other download: as a side effect, the locations that answer may cache the files they serve. The only thing it writes locally is a temporary copy of the tarball, deleted once every file is hashed, before the first request goes out.
221
+
222
+ **Does it need an npm account?** No. It only downloads the package, which works without an account for any public package.
223
+
224
+ **How many requests does it make?** One per file per CDN, plus one per retry. A package of 500 files checked on all three CDNs takes at least 1,500 requests.
225
+
226
+ **Why does it decode the responses?** CDNs compress what they send. intacto asks for the same encodings a browser does and compares what a browser would end up with, not the compressed bytes.
227
+
228
+ ## License
229
+
230
+ MIT
package/intacto.mjs ADDED
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ // intacto checks that a CDN serves every file of a published npm package
3
+ // exactly as the registry holds it.
4
+ // It reports every file the CDN will not serve, with the CDN's reason when it
5
+ // gives one, such as a package size limit, and every file it serves with
6
+ // other bytes.
7
+ //
8
+ // Usage:
9
+ // npx intacto <package>[@<version>] [--cdn jsdelivr|unpkg|hopjs]
10
+ // npx intacto is-number
11
+ // npx intacto is-number@7.0.0 --cdn unpkg
12
+ // npx intacto @scope/name@next --cdn jsdelivr
13
+ // The version is anything npm takes after the @: an exact version, a dist-tag
14
+ // or a range, and latest when there is none. npm resolves it once at the start
15
+ // and every request names the exact version, so a tag that moves during the
16
+ // run changes nothing. Without --cdn it checks every CDN in CDNS, one after
17
+ // another; --help prints the usage line. Needs Linux or macOS and Node 22.15
18
+ // or later, which added zstd, with npm and tar on the PATH.
19
+ //
20
+ // What it does:
21
+ // 1. npm pack downloads the tarball and checks it against the registry's
22
+ // integrity hash. None of the package's scripts run.
23
+ // 2. It unpacks the tarball into a temporary directory, takes the SHA-256 of
24
+ // every file and deletes the directory.
25
+ // 3. It requests every file from <base><name>@<version>/<path>, where <base>
26
+ // is the CDN's entry in CDNS, 128 at a time over 4 HTTP/2 connections. It
27
+ // asks for the encodings browsers accept, undoes gzip, deflate, Brotli or
28
+ // zstd and compares the SHA-256 of the result with the tarball's. As a
29
+ // side effect, like any download, the CDN location that answers may cache
30
+ // the file.
31
+ // 4. A network error, a timeout (60 s without data), a 429 or a 5xx is tried
32
+ // again once every other file has had its try: after 10 s, then after 20 s
33
+ // more, three tries in all. Any other status and any mismatch are final.
34
+ // 5. It prints the CDN's report, then moves on to the next CDN.
35
+ // Each of those numbers comes from a constant in the code.
36
+ //
37
+ // Output:
38
+ // For each CDN it prints the base URL, then one mark per request: a dot when
39
+ // the file comes back with a 200 and the right hash, an X for anything else,
40
+ // retried tries included. Requests go out in npm's file order, which is by
41
+ // path, so a run of X's points at one directory. Each line of marks ends with
42
+ // the round's progress and its time left, from the pace of its last 128
43
+ // files. In a terminal the line grows in place; in a log each line is written
44
+ // once full. The marks and the retry notices go to stderr and the report to
45
+ // stdout, so redirecting stdout to a file saves just the report. For example:
46
+ // $ npx intacto some-package@1.2.3 --cdn unpkg
47
+ // https://unpkg.com/some-package@1.2.3/
48
+ // ...........X.....................X................. 51 of 100 51% 0:01 left
49
+ // ...............X................................. 100 of 100 100% 0:00 left
50
+ // 2 failed, trying again in 10 s (2 of 3)
51
+ // .X 2 of 2 100% 0:00 left
52
+ // 1 failed, trying again in 20 s (3 of 3)
53
+ // X 1 of 1 100% 0:00 left
54
+ // 100 files, 2.10 MB in 34.2 s
55
+ // 103 requests: 98 succeeded, 5 failed (4.9%)
56
+ //
57
+ // Files with errors: 1
58
+ // 500 Internal Server Error (1)
59
+ // dist/bundle.js
60
+ //
61
+ // Files that don't match: 1
62
+ // dist/index.css
63
+ //
64
+ // FAIL: 2 of 100 files
65
+ // Files with errors are grouped by HTTP status, or by the network error when
66
+ // there was no status, and by the first line of the CDN's reason when it sent
67
+ // one as plain text.
68
+ //
69
+ // Exit status: 0 when every file matches on every CDN it checks; 1 when any
70
+ // file does not, or when something else fails, such as npm not finding the
71
+ // package; 2 when the arguments are wrong.
72
+ //
73
+ // Limits:
74
+ // - It sees only the CDN locations that answer this machine. Run it from
75
+ // elsewhere to check other regions.
76
+ // - It checks the files in the tarball, not files the CDN makes itself, such
77
+ // as minified copies or directory listings.
78
+ // - Every run downloads the whole unpacked package from each CDN it checks.
79
+ // - It connects to each CDN directly over HTTP/2, never through an HTTP proxy.
80
+ // - The time left covers the current round, not the retries that may follow.
81
+ //
82
+ // To add a CDN, add its base URL to CDNS. It has to speak HTTP/2 and serve
83
+ // <base><name>@<version>/<path>.
84
+ import { parseArgs } from "node:util";
85
+ import { connect, constants } from "node:http2";
86
+ import { brotliDecompressSync, gunzipSync, inflateSync, zstdDecompressSync } from "node:zlib";
87
+ import { spawnSync } from "node:child_process";
88
+ import { createHash } from "node:crypto";
89
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
90
+ import { tmpdir } from "node:os";
91
+ import { join } from "node:path";
92
+ const ACCEPT = "gzip, deflate, br, zstd";
93
+ const DECODE = {
94
+ br: brotliDecompressSync,
95
+ gzip: gunzipSync,
96
+ deflate: inflateSync,
97
+ zstd: zstdDecompressSync
98
+ };
99
+ const IDLE_MS = 6e4;
100
+ function get(session, path) {
101
+ return new Promise((resolve, reject) => {
102
+ const stream = session.request({
103
+ ":path": path,
104
+ "accept-encoding": ACCEPT
105
+ });
106
+ const chunks = [];
107
+ let headers;
108
+ stream.setTimeout(IDLE_MS, () => {
109
+ reject(/* @__PURE__ */ new Error(`nothing for ${IDLE_MS / 1e3} s`));
110
+ stream.close(constants.NGHTTP2_CANCEL);
111
+ });
112
+ stream.on("response", (h) => headers = h);
113
+ stream.on("data", (chunk) => chunks.push(chunk));
114
+ stream.on("end", () => {
115
+ try {
116
+ if (!headers?.[":status"]) throw new Error("no response");
117
+ const encoding = headers["content-encoding"];
118
+ if (encoding && !DECODE[encoding]) throw new Error(`unknown content-encoding ${encoding}`);
119
+ const body = Buffer.concat(chunks);
120
+ resolve({
121
+ status: headers[":status"],
122
+ type: headers["content-type"],
123
+ body: encoding ? DECODE[encoding](body) : body
124
+ });
125
+ } catch (error) {
126
+ reject(error);
127
+ }
128
+ });
129
+ stream.on("error", reject);
130
+ stream.on("close", () => reject(/* @__PURE__ */ new Error(`stream closed with code ${stream.rstCode}`)));
131
+ stream.end();
132
+ });
133
+ }
134
+ const sha256 = (data) => createHash("sha256").update(data).digest("base64");
135
+ function pack(spec) {
136
+ const dir = mkdtempSync(join(tmpdir(), "intacto-"));
137
+ const run = (command, args) => {
138
+ const result = spawnSync(command, args, {
139
+ encoding: "utf8",
140
+ maxBuffer: 1 << 26,
141
+ stdio: [
142
+ "ignore",
143
+ "pipe",
144
+ "inherit"
145
+ ]
146
+ });
147
+ if (result.error) throw result.error;
148
+ if (result.status !== 0) {
149
+ rmSync(dir, {
150
+ recursive: true,
151
+ force: true
152
+ });
153
+ process.exit(1);
154
+ }
155
+ return result.stdout;
156
+ };
157
+ try {
158
+ const [tarball] = Object.values(JSON.parse(run("npm", [
159
+ "pack",
160
+ spec,
161
+ "--json",
162
+ "--ignore-scripts",
163
+ "--pack-destination",
164
+ dir
165
+ ])));
166
+ mkdirSync(join(dir, "package"));
167
+ run("tar", [
168
+ "-xzf",
169
+ join(dir, tarball.filename),
170
+ "-C",
171
+ join(dir, "package"),
172
+ "--strip-components=1"
173
+ ]);
174
+ const hashes = tarball.files.map(({ path }) => [path, sha256(readFileSync(join(dir, "package", path)))]);
175
+ return {
176
+ name: tarball.name,
177
+ version: tarball.version,
178
+ hashes
179
+ };
180
+ } finally {
181
+ rmSync(dir, {
182
+ recursive: true,
183
+ force: true
184
+ });
185
+ }
186
+ }
187
+ const clock = (ms) => `${Math.floor(ms / 6e4)}:${String(Math.floor(ms / 1e3) % 60).padStart(2, "0")}`;
188
+ function progress(count, total, window) {
189
+ const perLine = Math.max(10, (process.stderr.columns || 80) - ` ${total} of ${total} 100% 999:59 left`.length - 1);
190
+ let line = "";
191
+ const stamps = [performance.now()];
192
+ return (char) => {
193
+ line += char;
194
+ const tried = stamps.push(performance.now()) - 1;
195
+ const from = Math.max(0, tried - window);
196
+ const left = (stamps[tried] - stamps[from]) / (tried - from) * (count - tried);
197
+ const text = `${line} ${tried} of ${count} ${Math.floor(100 * tried / count)}% ${clock(left)} left`;
198
+ const full = line.length === perLine || tried === count;
199
+ if (process.stderr.isTTY) process.stderr.write(`\r\x1b[K${text}${full ? "\n" : ""}`);
200
+ else if (full) process.stderr.write(`${text}\n`);
201
+ if (full) line = "";
202
+ };
203
+ }
204
+ const CONNECTIONS = 4;
205
+ const TRIES = 3;
206
+ const RETRY_WAIT_MS = 1e4;
207
+ async function check(base, hashes) {
208
+ console.log(`${base}`);
209
+ const sessions = [];
210
+ function session(i) {
211
+ if (!sessions[i] || sessions[i].closed || sessions[i].destroyed) {
212
+ sessions[i] = connect(base.origin, { settings: { initialWindowSize: 8 << 20 } });
213
+ sessions[i].on("connect", (s) => s.setLocalWindowSize(64 << 20));
214
+ sessions[i].on("error", () => {});
215
+ }
216
+ return sessions[i];
217
+ }
218
+ const errors = {};
219
+ const different = [];
220
+ let bytes = 0;
221
+ let sent = 0;
222
+ let lost = 0;
223
+ const start = performance.now();
224
+ let files = hashes;
225
+ for (let round = 1; files.length; round++) {
226
+ if (round > 1) {
227
+ const wait = RETRY_WAIT_MS * 2 ** (round - 2);
228
+ console.error(`${files.length} failed, trying again in ${wait / 1e3} s (${round} of ${TRIES})`);
229
+ await new Promise((resolve) => setTimeout(resolve, wait));
230
+ }
231
+ const retry = [];
232
+ const mark = progress(files.length, hashes.length, 128);
233
+ let next = 0;
234
+ await Promise.all(Array.from({ length: 128 }, async (_, worker) => {
235
+ for (let i; (i = next++) < files.length;) {
236
+ const [path, hash] = files[i];
237
+ sent++;
238
+ const { status, type, body } = await get(session(worker % CONNECTIONS), base.pathname + path.split("/").map(encodeURIComponent).join("/")).catch((error) => ({
239
+ status: error.code ?? error.message,
240
+ body: Buffer.alloc(0)
241
+ }));
242
+ const whole = status === 200 && sha256(body) === hash;
243
+ if (!whole) lost++;
244
+ if (status === 200) {
245
+ bytes += body.length;
246
+ if (!whole) different.push(path);
247
+ } else if (round < TRIES && (typeof status !== "number" || status === 429 || status >= 500)) retry.push(files[i]);
248
+ else {
249
+ const why = type?.startsWith("text/plain") ? body.toString().trim().split("\n")[0].slice(0, 200) : "";
250
+ (errors[why ? `${status} ${why}` : status] ??= []).push(path);
251
+ }
252
+ mark(whole ? "." : "X");
253
+ }
254
+ }));
255
+ files = retry;
256
+ }
257
+ for (const s of sessions) s?.close();
258
+ const seconds = (performance.now() - start) / 1e3;
259
+ const failed = Object.values(errors).flat().length;
260
+ console.log(`${hashes.length} files, ${(bytes / 1e6).toFixed(2)} MB in ${seconds.toFixed(1)} s`);
261
+ console.log(`${sent} requests: ${sent - lost} succeeded, ${lost} failed (${(100 * lost / sent).toFixed(1)}%)`);
262
+ if (failed) {
263
+ console.log(`\nFiles with errors: ${failed}`);
264
+ for (const [reason, paths] of Object.entries(errors).sort((a, b) => b[1].length - a[1].length)) {
265
+ console.log(`${reason} (${paths.length})`);
266
+ for (const path of paths.sort()) console.log(` ${path}`);
267
+ }
268
+ }
269
+ if (different.length) {
270
+ console.log(`\nFiles that don't match: ${different.length}`);
271
+ for (const path of different.sort()) console.log(` ${path}`);
272
+ }
273
+ if (failed || different.length) {
274
+ console.log(`\nFAIL: ${failed + different.length} of ${hashes.length} files`);
275
+ return false;
276
+ }
277
+ console.log("OK: every file matches");
278
+ return true;
279
+ }
280
+ const CDNS = {
281
+ jsdelivr: "https://cdn.jsdelivr.net/npm/",
282
+ unpkg: "https://unpkg.com/",
283
+ hopjs: "https://cdn.hopjs.net/npm/"
284
+ };
285
+ const usage = (status) => {
286
+ (status ? console.error : console.log)(`usage: intacto <package>[@<version>] [--cdn ${Object.keys(CDNS).join("|")}]`);
287
+ process.exit(status);
288
+ };
289
+ function options() {
290
+ try {
291
+ return parseArgs({
292
+ options: {
293
+ cdn: { type: "string" },
294
+ help: {
295
+ type: "boolean",
296
+ short: "h"
297
+ }
298
+ },
299
+ allowPositionals: true
300
+ });
301
+ } catch (error) {
302
+ console.error(error.message);
303
+ return usage(2);
304
+ }
305
+ }
306
+ const { values, positionals } = options();
307
+ if (values.help) usage(0);
308
+ const cdns = values.cdn === void 0 ? Object.keys(CDNS) : [values.cdn];
309
+ if (positionals.length !== 1 || !cdns.every((cdn) => Object.hasOwn(CDNS, cdn))) usage(2);
310
+ const [spec] = positionals;
311
+ const { name, version, hashes } = pack(spec);
312
+ for (const [i, cdn] of cdns.entries()) {
313
+ if (i) console.log();
314
+ if (!await check(new URL(`${CDNS[cdn]}${name}@${version}/`), hashes)) process.exitCode = 1;
315
+ }
316
+ export {};
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
- "name": "intacto",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "intacto",
3
+ "version": "1.0.0",
4
+ "description": "Checks that a CDN serves every file of an npm package exactly as published",
5
+ "author": "<city-savage-word@duck.com>",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "intacto": "intacto.mjs"
9
+ },
10
+ "files": [
11
+ "intacto.mjs"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.15"
15
+ }
16
+ }