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.
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/intacto.mjs +316 -0
- 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
|
-
#
|
|
1
|
+
# intacto
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|