c3b-assets 0.3.0 → 0.5.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 +19 -5
- package/adopt.js +34 -0
- package/bunny.js +10 -5
- package/c3b-assets.js +46 -15
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# c3b-assets
|
|
2
2
|
|
|
3
|
-
Fingerprints a project's images and syncs them to a [Bunny](https://bunny.net) storage zone, so
|
|
4
|
-
git and never
|
|
3
|
+
Fingerprints a project's images, audio and video and syncs them to a [Bunny](https://bunny.net) storage zone, so
|
|
4
|
+
media never lives in git and never needs uploading by hand. Writes `d8e-asset-manifest.json`, which [d8e](https://codeberg.org/colab47/d8e)
|
|
5
5
|
(1.2.0+) uses to point every reference at the CDN.
|
|
6
6
|
|
|
7
7
|
## How it works
|
|
@@ -23,7 +23,8 @@ images out of `dist/`.
|
|
|
23
23
|
|
|
24
24
|
It only ever lists, uploads to and deletes inside the zone's `colab-asset-manager/` folder (the tool's old name,
|
|
25
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
|
|
26
|
+
Supported: images `avif`, `gif`, `jpeg`, `jpg`, `png`, `svg`, `webp`; audio `mp3`, `m4a`, `ogg`, `wav`; video `mp4`,
|
|
27
|
+
`webm`. Anything else under `src/asset/` (fonts, say) stays in git and in `dist/`.
|
|
27
28
|
|
|
28
29
|
It runs on your machine only, never at deploy: the storage key stays off Heroku and Vercel, and staging can't delete
|
|
29
30
|
production's images.
|
|
@@ -51,7 +52,7 @@ npm install -g c3b-assets@latest
|
|
|
51
52
|
`uk.`, `ny.`, `la.`, `sg.`, `se.`, `br.`, `jh.` (Johannesburg) or `syd.storage.bunnycdn.com`.
|
|
52
53
|
- `cdnUrl`: the pull zone connected to that storage zone.
|
|
53
54
|
|
|
54
|
-
### 3. Gitignore the
|
|
55
|
+
### 3. Gitignore the media
|
|
55
56
|
|
|
56
57
|
```
|
|
57
58
|
src/asset/**/*.avif
|
|
@@ -61,6 +62,12 @@ src/asset/**/*.jpg
|
|
|
61
62
|
src/asset/**/*.png
|
|
62
63
|
src/asset/**/*.svg
|
|
63
64
|
src/asset/**/*.webp
|
|
65
|
+
src/asset/**/*.mp3
|
|
66
|
+
src/asset/**/*.m4a
|
|
67
|
+
src/asset/**/*.ogg
|
|
68
|
+
src/asset/**/*.wav
|
|
69
|
+
src/asset/**/*.mp4
|
|
70
|
+
src/asset/**/*.webm
|
|
64
71
|
```
|
|
65
72
|
|
|
66
73
|
If the images are already committed, also run `git rm -r --cached src/asset` once (it keeps your local copies). Their
|
|
@@ -77,9 +84,16 @@ your shell profile or a password manager — never in the repo.
|
|
|
77
84
|
c3b-assets # status: src/asset/ against the manifest — new, changed, missing, retired. Changes nothing, no key needed
|
|
78
85
|
c3b-assets u # upload: fingerprint, upload new images, write d8e-asset-manifest.json (deletes need --allow-removals)
|
|
79
86
|
c3b-assets s # sync: download the images the manifest lists that are missing locally, then upload as "u"
|
|
87
|
+
c3b-assets a # adopt: no manifest yet (a repo started over)? rebuild it from what the zone holds, then download
|
|
80
88
|
c3b-assets --help # the commands and flags
|
|
81
89
|
```
|
|
82
90
|
|
|
91
|
+
`a` is for a checkout that has no `d8e-asset-manifest.json` but whose zone already has the images — a repo
|
|
92
|
+
started over, or a project joining an existing zone: it lists the tool's folder in the zone, reads every stored
|
|
93
|
+
image back to its manifest key (`colab-asset-manager/arch/x.2ed3ab61.webp` → `asset/arch/x.webp`), writes the
|
|
94
|
+
manifest and downloads as `s` does. A key stored under several fingerprints keeps the most recently changed copy
|
|
95
|
+
and retires the rest from today. It refuses to run over an existing manifest.
|
|
96
|
+
|
|
83
97
|
Flags: `--dry-run` (report, change nothing), `--allow-removals` (see Safety checks). The version is printed first, so
|
|
84
98
|
you can tell which install is running.
|
|
85
99
|
|
|
@@ -112,7 +126,7 @@ into `src/asset/` (`c3b-assets s` on each) and run `c3b-assets u` again — it r
|
|
|
112
126
|
those deletions is deleted with them (Bunny keeps empty folders otherwise).
|
|
113
127
|
- Only files this project's manifest has listed are ever retired or deleted. If two projects share a storage zone,
|
|
114
128
|
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
|
|
129
|
+
- The download step of `s` only fetches image, audio and video files, and only from this project's `cdnUrl` folder — the manifest is
|
|
116
130
|
a committed file, so a tampered entry can't plant something else in `src/asset/`.
|
|
117
131
|
- `storageHost` must be a Bunny storage endpoint (`storage.bunnycdn.com`, optionally region-prefixed): the storage
|
|
118
132
|
key is sent to it, and the config is committed.
|
package/adopt.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// `c3b-assets a` (adopt): the manifest rebuilt from what the zone holds, for a checkout that has
|
|
2
|
+
// none — a repo started over, or a project joining a zone that already has its images. Every
|
|
3
|
+
// file under the tool's folder is read back to its manifest key: colab-asset-manager/arch/x.2ed3ab61.webp
|
|
4
|
+
// is asset/arch/x.webp at ${ cdnUrl }/colab-asset-manager/arch/x.2ed3ab61.webp. Only images; a
|
|
5
|
+
// key stored under more than one fingerprint keeps the most recently changed copy and retires
|
|
6
|
+
// the rest from today, as an upload would have.
|
|
7
|
+
export function manifestFromZone(entries, { cdnUrl, folder, assetExtensions, today }) {
|
|
8
|
+
const chosen = new Map();
|
|
9
|
+
const retired = {};
|
|
10
|
+
const skipped = [];
|
|
11
|
+
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const match = entry.path.match(/^(.*)\.([0-9a-f]{8})\.([^./]+)$/);
|
|
14
|
+
if (!match || !assetExtensions.includes(match[3].toLowerCase()) || !entry.path.startsWith(`${ folder }/`)) {
|
|
15
|
+
skipped.push(entry.path);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const key = `asset/${ match[1].slice(folder.length + 1) }.${ match[3] }`;
|
|
19
|
+
const current = chosen.get(key);
|
|
20
|
+
if (!current) {
|
|
21
|
+
chosen.set(key, entry);
|
|
22
|
+
} else if ((entry.changed ?? '') > (current.changed ?? '')) {
|
|
23
|
+
retired[current.path] = today;
|
|
24
|
+
chosen.set(key, entry);
|
|
25
|
+
} else {
|
|
26
|
+
retired[entry.path] = today;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const assets = Object.fromEntries([...chosen].sort(([a], [b]) => a.localeCompare(b))
|
|
31
|
+
.map(([key, entry]) => [key, `${ cdnUrl }/${ encodeURI(entry.path) }`]));
|
|
32
|
+
|
|
33
|
+
return { assets, retired: Object.fromEntries(Object.entries(retired).sort(([a], [b]) => a.localeCompare(b))), skipped };
|
|
34
|
+
}
|
package/bunny.js
CHANGED
|
@@ -13,8 +13,9 @@ export function createStorage({ host, zone, accessKey }) {
|
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// Every file under `folder`, as paths relative to the zone root
|
|
17
|
-
|
|
16
|
+
// Every file under `folder`, with when it last changed, as paths relative to the zone root
|
|
17
|
+
// (e.g. colab-asset-manager/asset/logo.1a2b3c4d.svg).
|
|
18
|
+
async function listEntries(folder) {
|
|
18
19
|
const response = await request('GET', folder + '/');
|
|
19
20
|
// a folder that doesn't exist yet just means nothing has been uploaded
|
|
20
21
|
if (response.status === 404) {
|
|
@@ -28,15 +29,19 @@ export function createStorage({ host, zone, accessKey }) {
|
|
|
28
29
|
for (const entry of await response.json()) {
|
|
29
30
|
const path = folder + '/' + entry.ObjectName;
|
|
30
31
|
if (entry.IsDirectory) {
|
|
31
|
-
files.push(...await
|
|
32
|
+
files.push(...await listEntries(path));
|
|
32
33
|
} else {
|
|
33
|
-
files.push(path);
|
|
34
|
+
files.push({ path, changed: entry.LastChanged ?? null });
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
return files;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
async function listFiles(folder) {
|
|
42
|
+
return (await listEntries(folder)).map((entry) => entry.path);
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
async function upload(path, content) {
|
|
41
46
|
// Bunny rejects the upload if the stored bytes don't match this, so a truncated upload can't
|
|
42
47
|
// end up being served from a URL that's cached forever.
|
|
@@ -58,5 +63,5 @@ export function createStorage({ host, zone, accessKey }) {
|
|
|
58
63
|
}
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
return { listFiles, upload, remove };
|
|
66
|
+
return { listEntries, listFiles, upload, remove };
|
|
62
67
|
}
|
package/c3b-assets.js
CHANGED
|
@@ -4,13 +4,14 @@ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'fs
|
|
|
4
4
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { createStorage } from './bunny.js';
|
|
7
|
+
import { manifestFromZone } from './adopt.js';
|
|
7
8
|
|
|
8
9
|
// Printed first so it's obvious which installed version is running (same as d8e does).
|
|
9
10
|
const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), 'package.json');
|
|
10
11
|
const { version } = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
11
12
|
console.log(`c3b-assets version: ${ version }`);
|
|
12
13
|
|
|
13
|
-
// Fingerprints every image under src/asset/, uploads the ones Bunny doesn't have yet, and writes
|
|
14
|
+
// Fingerprints every image, audio and video file under src/asset/, uploads the ones Bunny doesn't have yet, and writes
|
|
14
15
|
// d8e-asset-manifest.json (asset path under src/ -> CDN URL) for d8e to rewrite references with.
|
|
15
16
|
// Files in Bunny that nothing references any more are deleted once they've been unreferenced
|
|
16
17
|
// for GRACE_DAYS — pages already in browser caches, sent emails and older staging builds still
|
|
@@ -23,7 +24,9 @@ const SRC = resolve('src');
|
|
|
23
24
|
const ASSET_DIR = resolve(SRC, 'asset');
|
|
24
25
|
const CONFIG = resolve('c3b-assets.json');
|
|
25
26
|
const MANIFEST = resolve('d8e-asset-manifest.json');
|
|
26
|
-
|
|
27
|
+
// what c3b-assets manages: images, audio and video; anything else under src/asset/ (fonts, say)
|
|
28
|
+
// stays in git and in dist/
|
|
29
|
+
const ASSET_EXTENSIONS = ['avif', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp', 'mp3', 'm4a', 'ogg', 'wav', 'mp4', 'webm'];
|
|
27
30
|
|
|
28
31
|
// Everything c3b-assets uploads lives under this folder in the storage zone, and it only ever
|
|
29
32
|
// lists or deletes inside it — files uploaded by hand elsewhere in the zone are never touched.
|
|
@@ -36,8 +39,9 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
36
39
|
// The commands and flags the tool accepts, each with its one-line description. `--help` prints them;
|
|
37
40
|
// anything not listed here is rejected rather than silently ignored.
|
|
38
41
|
const COMMANDS = {
|
|
39
|
-
'u': 'upload: fingerprint src/asset/, upload new
|
|
40
|
-
's': 'sync: download
|
|
42
|
+
'u': 'upload: fingerprint src/asset/, upload new files, write d8e-asset-manifest.json, delete retired files',
|
|
43
|
+
's': 'sync: download files the manifest lists that are missing locally, then upload (as "u")',
|
|
44
|
+
'a': 'adopt: for a checkout with no manifest, rebuild d8e-asset-manifest.json from what the zone holds, then download'
|
|
41
45
|
};
|
|
42
46
|
const FLAGS = {
|
|
43
47
|
'--dry-run': 'report what would be uploaded, downloaded, deleted and written, change nothing',
|
|
@@ -118,7 +122,7 @@ function readConfig() {
|
|
|
118
122
|
// Bunny. The fingerprint comes from the content, so changing an image always gives it a new name.
|
|
119
123
|
function scanImages() {
|
|
120
124
|
const images = [];
|
|
121
|
-
for (const file of globSync(`${ ASSET_DIR }/**/*.{${
|
|
125
|
+
for (const file of globSync(`${ ASSET_DIR }/**/*.{${ ASSET_EXTENSIONS.join(',') }}`)) {
|
|
122
126
|
const content = readFileSync(file);
|
|
123
127
|
const hash = fingerprint(content);
|
|
124
128
|
const ext = extname(file);
|
|
@@ -210,7 +214,7 @@ function requireStorageKey() {
|
|
|
210
214
|
function status() {
|
|
211
215
|
console.log(`zone ${ config.storageZone } -> ${ config.cdnUrl }`);
|
|
212
216
|
if (!existsSync(ASSET_DIR)) {
|
|
213
|
-
console.log(`${ ASSET_DIR } not found — run "c3b-assets s" to download the manifest's
|
|
217
|
+
console.log(`${ ASSET_DIR } not found — run "c3b-assets s" to download the manifest's files`);
|
|
214
218
|
|
|
215
219
|
return;
|
|
216
220
|
}
|
|
@@ -221,7 +225,7 @@ function status() {
|
|
|
221
225
|
const retired = Object.entries(previous.retired ?? {});
|
|
222
226
|
const due = retired.filter(([, since]) => (Date.now() - new Date(since)) / DAY_MS >= GRACE_DAYS).length;
|
|
223
227
|
|
|
224
|
-
console.log(`${ images.length }
|
|
228
|
+
console.log(`${ images.length } files in src/asset/, ${ Object.keys(previous.assets ?? {}).length } in the manifest`);
|
|
225
229
|
for (const key of added) {
|
|
226
230
|
console.log(` new ${ key }`);
|
|
227
231
|
}
|
|
@@ -239,10 +243,10 @@ function status() {
|
|
|
239
243
|
if (toUpload === 0 && missing.length === 0 && due === 0) {
|
|
240
244
|
console.log('Everything in src/asset/ is uploaded and in the manifest.');
|
|
241
245
|
} else if (missing.length > 0) {
|
|
242
|
-
console.log(`Run "c3b-assets s" to download ${ missing.length } missing
|
|
246
|
+
console.log(`Run "c3b-assets s" to download ${ missing.length } missing file(s)`
|
|
243
247
|
+ (toUpload > 0 ? ` and upload ${ toUpload }.` : '.'));
|
|
244
248
|
} else {
|
|
245
|
-
console.log(`Run "c3b-assets u" to upload ${ toUpload }
|
|
249
|
+
console.log(`Run "c3b-assets u" to upload ${ toUpload } file(s)`
|
|
246
250
|
+ (due > 0 ? `, with --allow-removals to delete ${ due } retired file(s).` : '.'));
|
|
247
251
|
}
|
|
248
252
|
}
|
|
@@ -261,7 +265,7 @@ async function upload(accessKey) {
|
|
|
261
265
|
const localKeys = new Set(images.map((image) => image.key));
|
|
262
266
|
const missing = Object.keys(previous.assets ?? {}).filter((key) => !localKeys.has(key));
|
|
263
267
|
if (missing.length > 0 && !allowRemovals) {
|
|
264
|
-
fail(`${ missing.length }
|
|
268
|
+
fail(`${ missing.length } file(s) in the manifest aren't in src/asset/ (e.g. ${ missing[0] }). `
|
|
265
269
|
+ 'Run "c3b-assets s" to download them, or add --allow-removals if you removed them on purpose.');
|
|
266
270
|
}
|
|
267
271
|
|
|
@@ -282,7 +286,7 @@ async function upload(accessKey) {
|
|
|
282
286
|
+ 'Add --allow-removals to delete them from Bunny, once this branch is what production serves.');
|
|
283
287
|
}
|
|
284
288
|
|
|
285
|
-
console.log(`${ images.length }
|
|
289
|
+
console.log(`${ images.length } files, ${ toUpload.length } to upload, ${ toDelete.length } to delete, `
|
|
286
290
|
+ `${ Object.keys(retired).length } waiting out the ${ GRACE_DAYS }-day grace period`);
|
|
287
291
|
|
|
288
292
|
for (const image of toUpload) {
|
|
@@ -387,9 +391,9 @@ async function pullImages() {
|
|
|
387
391
|
fail(`refusing to write outside src/asset/: ${ key }`);
|
|
388
392
|
}
|
|
389
393
|
// 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 (!
|
|
392
|
-
fail(`refusing to download ${ key }: not an image`);
|
|
394
|
+
// project's own CDN folder, and only ever written as an image, audio or video file.
|
|
395
|
+
if (!ASSET_EXTENSIONS.includes(extname(key).slice(1).toLowerCase())) {
|
|
396
|
+
fail(`refusing to download ${ key }: not an image, audio or video file`);
|
|
393
397
|
}
|
|
394
398
|
if (remotePathOf(url) === null) {
|
|
395
399
|
fail(`refusing to download ${ key } from outside ${ CDN_PREFIX }: ${ url }`);
|
|
@@ -432,8 +436,35 @@ async function pullImages() {
|
|
|
432
436
|
+ (dryRun ? ' — dry run, nothing written' : ''));
|
|
433
437
|
}
|
|
434
438
|
|
|
439
|
+
// A checkout with no manifest (a repo started over) adopts what the zone already holds: the
|
|
440
|
+
// manifest is rebuilt from the zone's listing and the images downloaded as "s" would.
|
|
441
|
+
async function adopt() {
|
|
442
|
+
if (existsSync(MANIFEST)) {
|
|
443
|
+
fail('d8e-asset-manifest.json already exists — "a" is for a checkout without one; use "s" to sync');
|
|
444
|
+
}
|
|
445
|
+
const storage = createStorage({ host: config.storageHost, zone: config.storageZone, accessKey: requireStorageKey() });
|
|
446
|
+
const entries = await storage.listEntries(FOLDER);
|
|
447
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
448
|
+
const manifest = manifestFromZone(entries, { cdnUrl: config.cdnUrl, folder: FOLDER, assetExtensions: ASSET_EXTENSIONS, today });
|
|
449
|
+
|
|
450
|
+
for (const key of Object.keys(manifest.assets)) {
|
|
451
|
+
console.log(` adopt ${ key }`);
|
|
452
|
+
}
|
|
453
|
+
for (const path of manifest.skipped) {
|
|
454
|
+
console.log(` skip ${ path } (not an image c3b-assets stored)`);
|
|
455
|
+
}
|
|
456
|
+
console.log(`${ Object.keys(manifest.assets).length } image(s) on Bunny, ${ Object.keys(manifest.retired).length } older copies retired`
|
|
457
|
+
+ (dryRun ? ' — dry run, nothing written' : ''));
|
|
458
|
+
if (dryRun) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
writeFileSync(MANIFEST, JSON.stringify({ assets: manifest.assets, retired: manifest.retired }, null, 2) + '\n');
|
|
462
|
+
await pullImages();
|
|
463
|
+
}
|
|
464
|
+
|
|
435
465
|
// Bunny API errors (wrong key, wrong zone or region) print as one line rather than a stack trace
|
|
436
466
|
const run = command === 'u' ? () => upload(requireStorageKey())
|
|
437
467
|
: command === 's' ? sync
|
|
438
|
-
:
|
|
468
|
+
: command === 'a' ? adopt
|
|
469
|
+
: async () => status();
|
|
439
470
|
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.5.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",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"c3b-assets.js",
|
|
12
|
-
"bunny.js"
|
|
12
|
+
"bunny.js",
|
|
13
|
+
"adopt.js"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=22"
|