c3b-assets 0.2.0 → 0.3.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/README.md +18 -6
- package/c3b-assets.js +60 -22
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ Run `c3b-assets u` in a project root whenever you add, change or remove an image
|
|
|
12
12
|
`colab-asset-manager/hero-banner.3f2a9c1e.webp`. Change the image and it gets a new name automatically, so it can be
|
|
13
13
|
cached forever and a stale copy can never be served.
|
|
14
14
|
2. Uploads the ones the storage zone doesn't have yet (checksum-verified).
|
|
15
|
-
3. Writes `d8e-asset-manifest.json`: each image's path under `src/` and its CDN URL. **Commit this file** — it's a few
|
|
15
|
+
3. Writes `d8e-asset-manifest.json`: each image's path under `src/` and its CDN URL. **Commit this file** — it's a few
|
|
16
|
+
KB.
|
|
16
17
|
4. Deletes files nobody references any more, **7 days** after they stopped being referenced, so pages in browser
|
|
17
18
|
caches, sent emails and older staging builds keep working in the meantime.
|
|
18
19
|
|
|
@@ -21,7 +22,8 @@ local dev still works by opening `src/`. At deploy, d8e rewrites those reference
|
|
|
21
22
|
images out of `dist/`.
|
|
22
23
|
|
|
23
24
|
It only ever lists, uploads to and deletes inside the zone's `colab-asset-manager/` folder (the tool's old name,
|
|
24
|
-
kept so existing zones need no re-upload) — files uploaded by hand anywhere else in the zone are never touched.
|
|
25
|
+
kept so existing zones need no re-upload) — files uploaded by hand anywhere else in the zone are never touched.
|
|
26
|
+
Supported: `avif`, `gif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.
|
|
25
27
|
|
|
26
28
|
It runs on your machine only, never at deploy: the storage key stays off Heroku and Vercel, and staging can't delete
|
|
27
29
|
production's images.
|
|
@@ -73,7 +75,7 @@ your shell profile or a password manager — never in the repo.
|
|
|
73
75
|
|
|
74
76
|
```
|
|
75
77
|
c3b-assets # status: src/asset/ against the manifest — new, changed, missing, retired. Changes nothing, no key needed
|
|
76
|
-
c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json
|
|
78
|
+
c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json (deletes need --allow-removals)
|
|
77
79
|
c3b-assets s # sync: download the images the manifest lists that are missing locally, then upload as "u"
|
|
78
80
|
c3b-assets --help # the commands and flags
|
|
79
81
|
```
|
|
@@ -89,7 +91,8 @@ upload or delete afterwards. So a fresh clone on a machine without the key still
|
|
|
89
91
|
1. **Fresh clone?** Run `c3b-assets s` first — images aren't in git, so `src/asset/` starts empty.
|
|
90
92
|
2. Replace, add or remove the file in `src/asset/` (kebab-case names). Keeping the same name for a replacement is
|
|
91
93
|
fine: the fingerprint changes anyway. Check it locally by opening `src/`.
|
|
92
|
-
3. `c3b-assets` to see what will happen, `c3b-assets u --dry-run` if you want the exact Bunny paths, then
|
|
94
|
+
3. `c3b-assets` to see what will happen, `c3b-assets u --dry-run` if you want the exact Bunny paths, then
|
|
95
|
+
`c3b-assets u`.
|
|
93
96
|
4. Commit `d8e-asset-manifest.json` (plus any code change that references a new image) and deploy as usual.
|
|
94
97
|
|
|
95
98
|
If two people sync on different branches, `d8e-asset-manifest.json` conflicts on merge: pull both sets of images
|
|
@@ -100,10 +103,19 @@ into `src/asset/` (`c3b-assets s` on each) and run `c3b-assets u` again — it r
|
|
|
100
103
|
- **Nothing is deleted just because it's missing locally.** If images the manifest lists aren't in `src/asset/` (a
|
|
101
104
|
fresh clone, a half-copied folder), `u` refuses and says to run `s`. If you removed them on purpose, add
|
|
102
105
|
`--allow-removals`.
|
|
103
|
-
- The download step of `s` never overwrites a local file that differs from Bunny — that's usually a change that hasn't
|
|
106
|
+
- The download step of `s` never overwrites a local file that differs from Bunny — that's usually a change that hasn't
|
|
107
|
+
been uploaded
|
|
104
108
|
yet. It only fills in missing ones, and checks each download against its fingerprint.
|
|
105
109
|
- Deletions only happen inside the zone's `colab-asset-manager/` folder, 7 days after a file stopped being
|
|
106
|
-
referenced
|
|
110
|
+
referenced, and only with `--allow-removals`: "unreferenced" is judged from the checkout you run in, which may be
|
|
111
|
+
a branch that dropped images `main` still serves. A plain `u` reports what is due and stops. A folder left empty by
|
|
112
|
+
those deletions is deleted with them (Bunny keeps empty folders otherwise).
|
|
113
|
+
- Only files this project's manifest has listed are ever retired or deleted. If two projects share a storage zone,
|
|
114
|
+
neither touches the other's images.
|
|
115
|
+
- The download step of `s` only fetches image files, and only from this project's `cdnUrl` folder — the manifest is
|
|
116
|
+
a committed file, so a tampered entry can't plant something else in `src/asset/`.
|
|
117
|
+
- `storageHost` must be a Bunny storage endpoint (`storage.bunnycdn.com`, optionally region-prefixed): the storage
|
|
118
|
+
key is sent to it, and the config is committed.
|
|
107
119
|
|
|
108
120
|
## Limitations
|
|
109
121
|
|
package/c3b-assets.js
CHANGED
|
@@ -37,17 +37,18 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
37
37
|
// anything not listed here is rejected rather than silently ignored.
|
|
38
38
|
const COMMANDS = {
|
|
39
39
|
'u': 'upload: fingerprint src/asset/, upload new images, write d8e-asset-manifest.json, delete retired files',
|
|
40
|
-
's': 'sync: download images the manifest lists that are missing locally, then upload (as "u")'
|
|
40
|
+
's': 'sync: download images the manifest lists that are missing locally, then upload (as "u")'
|
|
41
41
|
};
|
|
42
42
|
const FLAGS = {
|
|
43
43
|
'--dry-run': 'report what would be uploaded, downloaded, deleted and written, change nothing',
|
|
44
|
-
'--allow-removals': 'allow manifest entries with no local file to be dropped'
|
|
45
|
-
|
|
44
|
+
'--allow-removals': 'allow manifest entries with no local file to be dropped, and retired files past the grace '
|
|
45
|
+
+ 'period to be deleted from Bunny',
|
|
46
|
+
'--help': 'print this help'
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
function usage() {
|
|
49
50
|
console.log('Usage: c3b-assets [command] [flags]\n\nWith no command, prints the status of src/asset/ against '
|
|
50
|
-
|
|
51
|
+
+ 'd8e-asset-manifest.json and changes nothing.\n\nCommands:');
|
|
51
52
|
for (const [command, description] of Object.entries(COMMANDS)) {
|
|
52
53
|
console.log(` ${ command.padEnd(18) }${ description }`);
|
|
53
54
|
}
|
|
@@ -99,9 +100,16 @@ function readConfig() {
|
|
|
99
100
|
fail('c3b-assets.json needs "storageZone" and "cdnUrl"');
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// The storage key goes to this host in every request, and the config is a committed file: only
|
|
104
|
+
// Bunny's own storage endpoints (optionally region-prefixed) may receive it.
|
|
105
|
+
const storageHost = config.storageHost ?? 'storage.bunnycdn.com';
|
|
106
|
+
if (!/^([a-z]{2,3}\.)?storage\.bunnycdn\.com$/.test(storageHost)) {
|
|
107
|
+
fail(`c3b-assets.json "storageHost" must be a Bunny storage endpoint, not ${ storageHost }`);
|
|
108
|
+
}
|
|
109
|
+
|
|
102
110
|
return {
|
|
103
111
|
storageZone: config.storageZone,
|
|
104
|
-
storageHost
|
|
112
|
+
storageHost,
|
|
105
113
|
cdnUrl: config.cdnUrl.replace(/\/+$/, '')
|
|
106
114
|
};
|
|
107
115
|
}
|
|
@@ -130,13 +138,15 @@ function scanImages() {
|
|
|
130
138
|
|
|
131
139
|
// Which unreferenced files to delete now, and the updated record of when each one stopped being
|
|
132
140
|
// referenced. That record is kept in the manifest, so the grace period survives between runs and
|
|
133
|
-
// machines.
|
|
134
|
-
|
|
141
|
+
// machines. Only files this project's manifest has known about (`known`) are candidates: a zone
|
|
142
|
+
// shared with another project holds files this checkout never referenced, and they are not ours
|
|
143
|
+
// to delete.
|
|
144
|
+
function planDeletions(remoteFiles, referenced, known, previouslyRetired, now) {
|
|
135
145
|
const retired = {};
|
|
136
146
|
const toDelete = [];
|
|
137
147
|
|
|
138
148
|
for (const path of remoteFiles) {
|
|
139
|
-
if (referenced.has(path)) {
|
|
149
|
+
if (referenced.has(path) || !known.has(path)) {
|
|
140
150
|
continue;
|
|
141
151
|
}
|
|
142
152
|
|
|
@@ -162,6 +172,10 @@ function sortedObject(entries) {
|
|
|
162
172
|
|
|
163
173
|
const config = readConfig();
|
|
164
174
|
|
|
175
|
+
// Every manifest URL is `${ cdnUrl }/${ encodeURI(remotePath) }`; anything else is not ours.
|
|
176
|
+
const CDN_PREFIX = `${ config.cdnUrl }/${ FOLDER }/`;
|
|
177
|
+
const remotePathOf = (url) => (url.startsWith(CDN_PREFIX) ? decodeURI(url.slice(config.cdnUrl.length + 1)) : null);
|
|
178
|
+
|
|
165
179
|
// Where src/asset/ stands against the manifest, without touching Bunny: images not in the manifest
|
|
166
180
|
// yet, images whose content no longer matches the URL the manifest holds, and manifest entries with
|
|
167
181
|
// no local file. The manifest is only written after every upload succeeded, so "in the manifest"
|
|
@@ -197,6 +211,7 @@ function status() {
|
|
|
197
211
|
console.log(`zone ${ config.storageZone } -> ${ config.cdnUrl }`);
|
|
198
212
|
if (!existsSync(ASSET_DIR)) {
|
|
199
213
|
console.log(`${ ASSET_DIR } not found — run "c3b-assets s" to download the manifest's images`);
|
|
214
|
+
|
|
200
215
|
return;
|
|
201
216
|
}
|
|
202
217
|
|
|
@@ -225,10 +240,10 @@ function status() {
|
|
|
225
240
|
console.log('Everything in src/asset/ is uploaded and in the manifest.');
|
|
226
241
|
} else if (missing.length > 0) {
|
|
227
242
|
console.log(`Run "c3b-assets s" to download ${ missing.length } missing image(s)`
|
|
228
|
-
|
|
243
|
+
+ (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
|
|
229
244
|
} else {
|
|
230
245
|
console.log(`Run "c3b-assets u" to upload ${ toUpload } image(s)`
|
|
231
|
-
|
|
246
|
+
+ (due > 0 ? `, with --allow-removals to delete ${ due } retired file(s).` : '.'));
|
|
232
247
|
}
|
|
233
248
|
}
|
|
234
249
|
|
|
@@ -247,17 +262,28 @@ async function upload(accessKey) {
|
|
|
247
262
|
const missing = Object.keys(previous.assets ?? {}).filter((key) => !localKeys.has(key));
|
|
248
263
|
if (missing.length > 0 && !allowRemovals) {
|
|
249
264
|
fail(`${ missing.length } image(s) in the manifest aren't in src/asset/ (e.g. ${ missing[0] }). `
|
|
250
|
-
|
|
265
|
+
+ 'Run "c3b-assets s" to download them, or add --allow-removals if you removed them on purpose.');
|
|
251
266
|
}
|
|
252
267
|
|
|
253
268
|
const remoteFiles = new Set(await storage.listFiles(FOLDER));
|
|
254
269
|
|
|
255
270
|
const toUpload = images.filter((image) => !remoteFiles.has(image.remotePath));
|
|
256
271
|
const referenced = new Set(images.map((image) => image.remotePath));
|
|
257
|
-
const
|
|
272
|
+
const known = new Set([
|
|
273
|
+
...Object.values(previous.assets ?? {}).map(remotePathOf),
|
|
274
|
+
...Object.keys(previous.retired ?? {})
|
|
275
|
+
]);
|
|
276
|
+
const { retired, toDelete } = planDeletions(remoteFiles, referenced, known, previous.retired ?? {}, new Date());
|
|
277
|
+
|
|
278
|
+
// Deleting is driven by this checkout, which may be a branch that dropped images main still
|
|
279
|
+
// serves — so it is never a side effect of a plain upload.
|
|
280
|
+
if (toDelete.length > 0 && !allowRemovals) {
|
|
281
|
+
fail(`${ toDelete.length } retired file(s) are past the ${ GRACE_DAYS }-day grace period. `
|
|
282
|
+
+ 'Add --allow-removals to delete them from Bunny, once this branch is what production serves.');
|
|
283
|
+
}
|
|
258
284
|
|
|
259
285
|
console.log(`${ images.length } images, ${ toUpload.length } to upload, ${ toDelete.length } to delete, `
|
|
260
|
-
|
|
286
|
+
+ `${ Object.keys(retired).length } waiting out the ${ GRACE_DAYS }-day grace period`);
|
|
261
287
|
|
|
262
288
|
for (const image of toUpload) {
|
|
263
289
|
console.log(` upload ${ image.remotePath }`);
|
|
@@ -285,9 +311,11 @@ async function upload(accessKey) {
|
|
|
285
311
|
}
|
|
286
312
|
|
|
287
313
|
// Bunny leaves a folder behind when its last file goes, so the zone slowly fills with empty
|
|
288
|
-
// ones. Deepest first, so a parent is empty by the time it is checked.
|
|
314
|
+
// ones. Deepest first, so a parent is empty by the time it is checked. The listing predates
|
|
315
|
+
// this run's uploads, so they are added back in — a folder that only just received a new
|
|
316
|
+
// image is not empty.
|
|
289
317
|
const deleted = new Set(toDelete);
|
|
290
|
-
const remaining = [...remoteFiles].filter((path) => !deleted.has(path));
|
|
318
|
+
const remaining = [...remoteFiles, ...referenced].filter((path) => !deleted.has(path));
|
|
291
319
|
for (const folder of emptiedFolders(toDelete, remaining)) {
|
|
292
320
|
console.log(` delete ${ folder }/`);
|
|
293
321
|
if (!dryRun) {
|
|
@@ -311,8 +339,8 @@ function emptiedFolders(deleted, remaining) {
|
|
|
311
339
|
}
|
|
312
340
|
|
|
313
341
|
return [...candidates]
|
|
314
|
-
|
|
315
|
-
|
|
342
|
+
.sort((a, b) => b.length - a.length)
|
|
343
|
+
.filter((folder) => !remaining.some((path) => path.startsWith(folder + '/')));
|
|
316
344
|
}
|
|
317
345
|
|
|
318
346
|
// Pull, then upload. The pull needs no storage key (the CDN is public), so a fresh clone without the
|
|
@@ -323,16 +351,17 @@ async function sync() {
|
|
|
323
351
|
const accessKey = process.env.BUNNY_STORAGE_KEY;
|
|
324
352
|
if (accessKey) {
|
|
325
353
|
await upload(accessKey);
|
|
354
|
+
|
|
326
355
|
return;
|
|
327
356
|
}
|
|
328
357
|
|
|
329
358
|
const previous = existsSync(MANIFEST) ? readJson(MANIFEST) : {};
|
|
330
359
|
const { added, changed } = localChanges(existsSync(ASSET_DIR) ? scanImages() : [], previous);
|
|
331
360
|
const due = Object.values(previous.retired ?? {})
|
|
332
|
-
|
|
361
|
+
.filter((since) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
|
|
333
362
|
if (added.length + changed.length + due > 0) {
|
|
334
363
|
fail(`${ added.length + changed.length } image(s) to upload and ${ due } retired file(s) to delete — `
|
|
335
|
-
|
|
364
|
+
+ 'set BUNNY_STORAGE_KEY to the storage zone password (Bunny dashboard: Storage > FTP & API Access)');
|
|
336
365
|
}
|
|
337
366
|
console.log('Nothing to upload.');
|
|
338
367
|
}
|
|
@@ -343,6 +372,7 @@ async function sync() {
|
|
|
343
372
|
async function pullImages() {
|
|
344
373
|
if (!existsSync(MANIFEST)) {
|
|
345
374
|
console.log('No manifest yet — nothing to download.');
|
|
375
|
+
|
|
346
376
|
return;
|
|
347
377
|
}
|
|
348
378
|
|
|
@@ -356,6 +386,14 @@ async function pullImages() {
|
|
|
356
386
|
if (!target.startsWith(ASSET_DIR + sep)) {
|
|
357
387
|
fail(`refusing to write outside src/asset/: ${ key }`);
|
|
358
388
|
}
|
|
389
|
+
// The manifest is a committed file, so what it points at is only trusted as far as this
|
|
390
|
+
// project's own CDN folder, and only ever written as an image.
|
|
391
|
+
if (!IMAGE_EXTENSIONS.includes(extname(key).slice(1).toLowerCase())) {
|
|
392
|
+
fail(`refusing to download ${ key }: not an image`);
|
|
393
|
+
}
|
|
394
|
+
if (remotePathOf(url) === null) {
|
|
395
|
+
fail(`refusing to download ${ key } from outside ${ CDN_PREFIX }: ${ url }`);
|
|
396
|
+
}
|
|
359
397
|
|
|
360
398
|
// the fingerprint in the stored name (hero.0100d254.webp) is the content hash we expect
|
|
361
399
|
const expected = decodeURI(url).match(/\.([0-9a-f]{8})\.[^./]+$/)?.[1];
|
|
@@ -391,11 +429,11 @@ async function pullImages() {
|
|
|
391
429
|
}
|
|
392
430
|
|
|
393
431
|
console.log(`${ downloaded } downloaded, ${ upToDate } already up to date, ${ kept } kept (local changes)`
|
|
394
|
-
|
|
432
|
+
+ (dryRun ? ' — dry run, nothing written' : ''));
|
|
395
433
|
}
|
|
396
434
|
|
|
397
435
|
// Bunny API errors (wrong key, wrong zone or region) print as one line rather than a stack trace
|
|
398
436
|
const run = command === 'u' ? () => upload(requireStorageKey())
|
|
399
|
-
|
|
400
|
-
|
|
437
|
+
: command === 's' ? sync
|
|
438
|
+
: async () => status();
|
|
401
439
|
run().catch((error) => fail(error.message));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c3b-assets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Fingerprints a project's images and syncs them to a Bunny storage zone, writing the d8e-asset-manifest.json d8e reads.",
|
|
5
5
|
"license": "GPL-3.0",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
"registry": "https://registry.npmjs.org"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"test": "node --test test/*.test.js",
|
|
25
|
+
"test": "c3b-lint && node --test test/*.test.js",
|
|
26
26
|
"deploy": "npm run test && npm publish"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"c3b-lint": "0.0.8"
|
|
27
30
|
}
|
|
28
31
|
}
|