@joystack/cli 0.1.44 → 0.1.46

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/lib/freeze.mjs CHANGED
@@ -1,15 +1,17 @@
1
- // `joystack dev --no-reload`: the game as it is at startup, for the whole run.
1
+ // A copy of the game as it is at one moment: one version of it, as `joystack
2
+ // dev` serves it (live-versions.mjs).
2
3
  //
3
- // The dev server reads a game's files from disk on every request and rebuilds
4
- // its room when they change, so switching the watchers off alone does not
5
- // freeze anything: a page opened after an edit gets the edited files, while the
6
- // room and the injected entity layout stay as they were, and an edit that adds
7
- // an entity makes that page fail to boot. Freezing therefore serves, and builds
8
- // the room from, a copy of the game taken once at startup.
9
- import { constants, cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
4
+ // Serving the game's own folder cannot give a page one version: the page
5
+ // fetches files for as long as it runs, while the room and the injected entity
6
+ // layout are built once, so an edit lands in some of them and not the others.
7
+ // A copy is served and built from instead, and replaced by a new copy, never
8
+ // edited.
9
+ import { constants, copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
10
10
  import { randomBytes } from 'node:crypto';
11
- import { basename, join } from 'node:path';
11
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
12
+ import { collectGameFiles } from './game-files.mjs';
12
13
  import { gameBuild } from './build-id.mjs';
14
+ import { snapshotFiles } from './live-versions.mjs';
13
15
  import { STATE_DIR_NAMES, stateDir } from './legacy-names.mjs';
14
16
 
15
17
  // Left out of the copy: recordings and generated build output (.joystack), which
@@ -27,21 +29,39 @@ export function frozenRoot(gameDir) {
27
29
  return join(stateDir(gameDir), 'frozen');
28
30
  }
29
31
 
32
+ // The files a copy of the game at `dir` is taken from, as path → `mtime:size`.
33
+ //
34
+ // Small files also carry a hash of their content. On Windows a file's
35
+ // modified time moves in steps of about 15 ms, so a rewrite to the same size
36
+ // within one step would otherwise go unseen; large files are left to time and
37
+ // size, since reading them on every poll would cost more than it finds.
38
+ export function frozenFiles(dir) {
39
+ return snapshotFiles([dir], (name, depth) => depth === 0 && LEFT_OUT.has(name), { hashBelow: 256 * 1024 });
40
+ }
41
+
30
42
  /**
31
43
  * Copies the game at `dir` into `.joystack/frozen/<pid>-<random>/<folder name>`.
32
- * `build` is the game's build at the moment of the copy, the one every session
33
- * played on the copy is stamped with. `remove()` deletes the copy; it also runs
34
- * when the process exits normally, and a copy whose process died without
35
- * running it is swept by the next freeze of the same game.
44
+ * `build()` is the game's build as the copy has it, the one every session
45
+ * played on the copy is stamped with; it is worked out the first time it is
46
+ * asked for. `remove()` deletes the copy; it also runs when the process exits
47
+ * normally, and a copy whose process died without running it is swept by the
48
+ * next freeze of the same game.
49
+ *
50
+ * `files` is the snapshot the copy is taken from (frozenFiles). With `from`, a
51
+ * previous copy and its snapshot, a file whose entry is the same in both is
52
+ * hard-linked to the previous copy's instead of copied, so a copy costs the
53
+ * files that changed. Copies are never written to after they are made, which
54
+ * is what makes sharing their files safe; the game's own files are always
55
+ * copied, never linked, since the game's are edited in place.
36
56
  *
37
57
  * Symlinks inside the game are copied as what they point to, so a linked
38
58
  * folder is frozen with the rest.
39
59
  * @param {string} dir
40
- * @returns {{ dir: string, build: object, remove: () => void }}
60
+ * @param {{ files?: Map<string, string>, from?: { dir: string, files: Map<string, string> } }} [opts]
61
+ * @returns {{ dir: string, build: () => object, remove: () => void }}
41
62
  */
42
- export function freezeGame(dir) {
63
+ export function freezeGame(dir, { files = null, from = null } = {}) {
43
64
  sweepStale(dir);
44
- const build = gameBuild(dir);
45
65
  const run = join(frozenRoot(dir), `${process.pid}-${randomBytes(3).toString('hex')}`);
46
66
  // The folder name is the game's slug unless package.json names one, so the
47
67
  // copy keeps it.
@@ -50,21 +70,54 @@ export function freezeGame(dir) {
50
70
  let removed = false;
51
71
  const remove = () => {
52
72
  if (removed) return;
53
- removed = true;
54
- process.off('exit', remove);
55
73
  // The link first, on its own: on Windows it is a junction, and a recursive
56
74
  // delete that followed it would empty the game's real node_modules.
57
75
  try { unlinkSync(modulesLink); } catch { /* never made */ }
58
- rmSync(run, { recursive: true, force: true });
76
+ // On Windows a file a process still has open cannot be deleted, and a
77
+ // stopped room server lets go of its files a moment after it exits. A copy
78
+ // that still cannot be deleted is tried again when this process exits,
79
+ // and swept by the next `joystack dev` of the game after that.
80
+ try {
81
+ rmSync(run, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
82
+ } catch {
83
+ return;
84
+ }
85
+ removed = true;
86
+ process.off('exit', remove);
59
87
  };
60
88
  process.on('exit', remove);
61
89
  try {
62
90
  mkdirSync(copy, { recursive: true });
63
- // Entry by entry, because the copy lives inside the game (under .joystack),
64
- // and a copy of a folder into its own subfolder is refused outright.
65
- for (const entry of readdirSync(dir)) {
66
- if (LEFT_OUT.has(entry)) continue;
67
- cpSync(join(dir, entry), join(copy, entry), { recursive: true, dereference: true, mode: constants.COPYFILE_FICLONE });
91
+ if (files) {
92
+ const made = new Set();
93
+ for (const [path, entry] of files) {
94
+ const rel = relative(dir, path);
95
+ if (rel.startsWith('..') || isAbsolute(rel)) continue;
96
+ // The snapshot also names files the copy leaves out: an engine
97
+ // installed in the game's node_modules is watched along with the game,
98
+ // and the copy reaches it through the node_modules link made below.
99
+ if (LEFT_OUT.has(rel.split(sep)[0])) continue;
100
+ const to = join(copy, rel);
101
+ const parent = dirname(to);
102
+ if (!made.has(parent)) {
103
+ mkdirSync(parent, { recursive: true });
104
+ made.add(parent);
105
+ }
106
+ if (from && from.files.get(path) === entry) {
107
+ try {
108
+ linkSync(join(from.dir, rel), to);
109
+ continue;
110
+ } catch { /* a disk without hard links, or the previous file is gone: copied below */ }
111
+ }
112
+ copyFileSync(path, to, constants.COPYFILE_FICLONE);
113
+ }
114
+ } else {
115
+ // Entry by entry, because the copy lives inside the game (under
116
+ // .joystack), and a copy of a folder into its own subfolder is refused.
117
+ for (const entry of readdirSync(dir)) {
118
+ if (LEFT_OUT.has(entry)) continue;
119
+ cpSync(join(dir, entry), join(copy, entry), { recursive: true, dereference: true, mode: constants.COPYFILE_FICLONE });
120
+ }
68
121
  }
69
122
  const modules = join(dir, 'node_modules');
70
123
  if (existsSync(modules)) symlinkSync(modules, modulesLink, process.platform === 'win32' ? 'junction' : 'dir');
@@ -72,7 +125,10 @@ export function freezeGame(dir) {
72
125
  remove();
73
126
  throw err;
74
127
  }
75
- return { dir: copy, build, remove };
128
+ // The id is the copy's files; the commit and whether the files differ from
129
+ // it are read from the game's folder when the build is first asked for.
130
+ let build = null;
131
+ return { dir: copy, build: () => (build ??= gameBuild(dir, { files: collectGameFiles(copy) })), remove };
76
132
  }
77
133
 
78
134
  // Deletes the copies of runs whose process no longer exists: a `joystack dev`
package/lib/inject.mjs CHANGED
@@ -34,7 +34,7 @@ function jsJson(value) {
34
34
  // resolve them to the framework runtime under the (already-mounted) /shared/
35
35
  // tree. This set is the import surface (shared/docs/api.md); keep it in sync
36
36
  // with V2_BARE_SPECIFIERS in backend/api/src/lib/inject.js.
37
- const V2_IMPORTS = {
37
+ export const V2_IMPORTS = {
38
38
  joystack: '/shared/framework/runtime/joystack.js',
39
39
  'joystack/traits': '/shared/framework/runtime/traits.js',
40
40
  'joystack/client': '/shared/framework/runtime/client.js',
@@ -93,6 +93,12 @@ export function canonicalSpecifier(specifier) {
93
93
  return specifier;
94
94
  }
95
95
 
96
+ /**
97
+ * The engine file the root door resolves to in an engine released before the
98
+ * rename, which has no `shared/framework/runtime/joystack.js`.
99
+ */
100
+ export const LEGACY_ROOT_DOOR_FILE = `shared/framework/runtime/${OLD}.js`;
101
+
96
102
  /** A browser import map extended with the old name of every joystack door it maps. */
97
103
  export function withLegacyDoors(imports) {
98
104
  const out = { ...imports };
@@ -0,0 +1,212 @@
1
+ // The versions `joystack dev` serves.
2
+ //
3
+ // A version is a copy of the game taken at one moment (freeze.mjs), with the
4
+ // room and the entity layout built from that copy. The page, every file it
5
+ // fetches later and the room all come from one version, so a page never runs
6
+ // against files that changed under it.
7
+ //
8
+ // This module decides when a new version is made and keeps the state every
9
+ // page and the control endpoints report:
10
+ //
11
+ // state 'ready' the current version is served
12
+ // 'updating' a new version is being made; game pages show "Updating…"
13
+ // 'failed' the last attempt failed; game pages show `error`
14
+ // auto whether a version is made as soon as the files settle
15
+ // pending whether the files differ from the last version attempted
16
+ //
17
+ // The operating system says when a file in a watched folder changes, and a
18
+ // version is made once no change has arrived for `quietMs`. The events only
19
+ // say when to look: what a version is made from, and whether anything is
20
+ // pending, is always a fresh snapshot of the files. A slower poll covers
21
+ // changes the events miss (a folder the system cannot watch, an engine
22
+ // checkout); a poll counts the files as settled when it finds what the
23
+ // previous poll found.
24
+ import { createHash } from 'node:crypto';
25
+ import { readdirSync, readFileSync, statSync, watch as watchFolder } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ // Every file under each root, as path → `mtime:size`, with a hash of the
29
+ // content appended for files smaller than `hashBelow` bytes. `skip(name,
30
+ // depth)` leaves an entry out; depth 0 is a root's own entries.
31
+ export function snapshotFiles(roots, skip = () => false, { hashBelow = 0 } = {}) {
32
+ const out = new Map();
33
+ const walk = (dir, depth) => {
34
+ let names;
35
+ try {
36
+ names = readdirSync(dir);
37
+ } catch {
38
+ return;
39
+ }
40
+ for (const name of names) {
41
+ if (skip(name, depth)) continue;
42
+ const p = join(dir, name);
43
+ let st;
44
+ try {
45
+ st = statSync(p);
46
+ } catch {
47
+ continue;
48
+ }
49
+ if (st.isDirectory()) walk(p, depth + 1);
50
+ else out.set(p, `${st.mtimeMs}:${st.size}${st.size < hashBelow ? `:${contentHash(p)}` : ''}`);
51
+ }
52
+ };
53
+ for (const root of roots) walk(root, 0);
54
+ return out;
55
+ }
56
+
57
+ function contentHash(path) {
58
+ try {
59
+ return createHash('sha1').update(readFileSync(path)).digest('base64');
60
+ } catch {
61
+ return 'unreadable';
62
+ }
63
+ }
64
+
65
+ export function sameFiles(a, b) {
66
+ if (!a || !b || a.size !== b.size) return false;
67
+ for (const [k, v] of a) if (b.get(k) !== v) return false;
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * @param {object} o
73
+ * @param {() => Map} o.snapshot the files a version is made from, now
74
+ * @param {(o: { files: Map, previous: object }) => Promise<object>} o.make
75
+ * makes a version from the files as they are; `files` is the snapshot
76
+ * taken just before, `previous` the version served now
77
+ * @param {(v: object) => Promise<void>} o.show makes `v` the served version; throws to fail it
78
+ * @param {(v: object) => void} o.retire the previous version, once it is no longer served
79
+ * @param {object} o.first the version served at startup, with its `files` snapshot
80
+ * @param {{ dir: string, ignore?: (rel: string) => boolean }[]} [o.watch]
81
+ * folders whose change events start a version; `rel` is the changed
82
+ * path within the folder, with `/` separators
83
+ */
84
+ export function createLiveVersions({ snapshot, make, show, retire, first, auto = true, watch = [], quietMs = 50, intervalMs = 1000, log = () => {}, onStatus = () => {} }) {
85
+ let current = first;
86
+ // The files the last attempt was made from, whether it was shown or failed.
87
+ let attempted = first.files;
88
+ let last = null;
89
+ let busy = false;
90
+ let closed = false;
91
+ let wanted = false;
92
+ let quiet = null;
93
+ let status = { state: 'ready', auto, pending: false, error: null };
94
+
95
+ const set = (next) => {
96
+ const changed = Object.keys(next).some((k) => status[k] !== next[k]);
97
+ status = { ...status, ...next };
98
+ if (changed) onStatus(status);
99
+ };
100
+
101
+ async function update() {
102
+ if (busy) {
103
+ wanted = true;
104
+ return;
105
+ }
106
+ busy = true;
107
+ try {
108
+ do {
109
+ wanted = false;
110
+ set({ state: 'updating', error: null });
111
+ const before = snapshot();
112
+ let version = null;
113
+ let error = null;
114
+ try {
115
+ version = await make({ files: before, previous: current });
116
+ } catch (err) {
117
+ error = err;
118
+ }
119
+ if (closed) {
120
+ version?.remove();
121
+ return;
122
+ }
123
+ // Files that changed while the copy was taken may have been copied
124
+ // half-written: the attempt does not count, and is made again once
125
+ // they settle.
126
+ if (!sameFiles(before, snapshot())) {
127
+ version?.remove();
128
+ await new Promise((r) => setTimeout(r, quietMs));
129
+ wanted = true;
130
+ continue;
131
+ }
132
+ attempted = before;
133
+ if (version) {
134
+ version.files = before;
135
+ try {
136
+ await show(version);
137
+ } catch (err) {
138
+ error = err;
139
+ version.remove();
140
+ version = null;
141
+ }
142
+ }
143
+ if (closed) return;
144
+ if (version) {
145
+ const previous = current;
146
+ current = version;
147
+ retire(previous);
148
+ set({ state: 'ready', error: null, pending: false });
149
+ log('→ reloaded: serving the files as they are now');
150
+ } else {
151
+ const message = String(error?.message ?? error);
152
+ set({ state: 'failed', error: message, pending: false });
153
+ log(`✗ the game does not build — the page shows this until the files change:\n${message}`);
154
+ }
155
+ } while (wanted && !closed);
156
+ } finally {
157
+ busy = false;
158
+ }
159
+ // Changes that arrived while this update ran.
160
+ check({ settled: true });
161
+ }
162
+
163
+ // Compares the files with the last attempt; `settled` says they have
164
+ // stopped changing.
165
+ function check({ settled }) {
166
+ if (closed || busy) return;
167
+ const now = snapshot();
168
+ const stable = settled || sameFiles(now, last);
169
+ last = now;
170
+ const pending = !sameFiles(now, attempted);
171
+ if (pending !== status.pending) set({ pending });
172
+ if (status.auto && pending && stable) update();
173
+ }
174
+
175
+ const watchers = [];
176
+ for (const { dir, ignore = () => false } of watch) {
177
+ try {
178
+ const w = watchFolder(dir, { recursive: true }, (_, name) => {
179
+ if (name && ignore(String(name).replace(/\\/g, '/'))) return;
180
+ clearTimeout(quiet);
181
+ quiet = setTimeout(() => check({ settled: true }), quietMs);
182
+ });
183
+ w.on('error', () => w.close());
184
+ watchers.push(w);
185
+ } catch { /* this folder is left to the poll */ }
186
+ }
187
+ const timer = setInterval(() => check({ settled: false }), intervalMs);
188
+ timer.unref();
189
+
190
+ return {
191
+ get status() {
192
+ return status;
193
+ },
194
+ get current() {
195
+ return current;
196
+ },
197
+ // Makes a version of the files as they are now, whatever the mode.
198
+ reload() {
199
+ return update();
200
+ },
201
+ setAuto(on) {
202
+ set({ auto: !!on });
203
+ if (on) check({ settled: true });
204
+ },
205
+ close() {
206
+ closed = true;
207
+ clearInterval(timer);
208
+ clearTimeout(quiet);
209
+ for (const w of watchers) w.close();
210
+ },
211
+ };
212
+ }
@@ -0,0 +1,75 @@
1
+ // The checks of a game's code against its engine, run before a publish uploads
2
+ // anything: every `/shared/…` path the game's code names, and every door it
3
+ // imports (`joystack`, `joystack/client`, `three`, …), must exist in the
4
+ // engine the game is pinned to. Without them the published page fails to load
5
+ // a module.
6
+ //
7
+ // The publish server runs the same checks on the single-request route
8
+ // (backend/api/src/routes/publish.js, codeError). The file-by-file route does
9
+ // not read the game's files back, so the CLI runs them here, with the same
10
+ // rules and the same messages.
11
+
12
+ import { readdirSync } from 'node:fs';
13
+ import { join, relative } from 'node:path';
14
+ import { V2_IMPORTS } from './inject.mjs';
15
+ import { LEGACY_ROOT_DOOR_FILE, withLegacyDoors } from './legacy-names.mjs';
16
+
17
+ const SHARED_REF_RE = /["'(=]\s*\/(shared\/[A-Za-z0-9_@./-]+)/g;
18
+ const CODE_FILE_RE = /\.(js|mjs|html|css)$/i;
19
+ const ROOT_DOOR = 'joystack';
20
+
21
+ function decodeText(bytes) {
22
+ try {
23
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Throws when the game's code names an engine file or door its engine does not
31
+ * ship. `files` maps each path to its bytes; `engine` is the installed engine
32
+ * ({ version, sharedDir }).
33
+ */
34
+ export function checkGameCode(files, engine) {
35
+ // The engine's files by exact path, as the platform's release lists them: a
36
+ // folder, a `..` path or a different letter case is not a file it serves.
37
+ let entries = [];
38
+ try {
39
+ entries = readdirSync(engine.sharedDir, { recursive: true, withFileTypes: true });
40
+ } catch (err) {
41
+ if (err.code !== 'ENOENT') throw err;
42
+ }
43
+ const shipped = new Set(
44
+ entries.filter((e) => e.isFile()).map((e) => `shared/${relative(engine.sharedDir, join(e.parentPath ?? e.path, e.name)).split('\\').join('/')}`),
45
+ );
46
+ const ships = (path) => shipped.has(path);
47
+ const texts = new Map();
48
+ for (const [path, bytes] of files) texts.set(path, decodeText(bytes));
49
+
50
+ for (const [path, text] of texts) {
51
+ if (text == null || !CODE_FILE_RE.test(path)) continue;
52
+ for (const m of text.matchAll(SHARED_REF_RE)) {
53
+ if (!ships(m[1])) throw new Error(`game references /${m[1]}, which engine ${engine.version} does not ship`);
54
+ }
55
+ }
56
+
57
+ // Whether each door's engine file ships, under the door's current name and
58
+ // its old one. An engine from before the rename has the root door under its
59
+ // old file name.
60
+ const available = {};
61
+ for (const [door, url] of Object.entries(V2_IMPORTS)) {
62
+ available[door] = ships(url.replace(/^\//, '')) || (door === ROOT_DOOR && ships(LEGACY_ROOT_DOOR_FILE));
63
+ }
64
+ const doors = withLegacyDoors(available);
65
+ const used = new Set([ROOT_DOOR, `${ROOT_DOOR}/client`]);
66
+ for (const text of texts.values()) {
67
+ if (!text) continue;
68
+ for (const door of Object.keys(doors)) {
69
+ if (text.includes(`'${door}'`) || text.includes(`"${door}"`)) used.add(door);
70
+ }
71
+ }
72
+ for (const door of used) {
73
+ if (!doors[door]) throw new Error(`engine ${engine.version} does not ship the \`${door}\` import`);
74
+ }
75
+ }
@@ -0,0 +1,163 @@
1
+ // The file-by-file publish: ask which files the platform lacks, upload those
2
+ // one per request, then ask it to put the game live.
3
+ //
4
+ // POST /api/creator/publish/missing fingerprints, CHECK_BATCH at a time
5
+ // PUT /api/creator/publish/file/<sha256> one file each, UPLOAD_CONCURRENCY at once
6
+ // POST /api/creator/publish/finish the publish's meta
7
+ //
8
+ // The platform stores files by fingerprint and shares them across every game,
9
+ // so a publish to a new name (a review copy, a lane's copy) uploads only what no
10
+ // earlier publish stored, and a publish cut off halfway resumes where it
11
+ // stopped. Each request is small, so none runs into the platform's
12
+ // per-request limits. A request refused for a passing reason (a rate limit, a
13
+ // network error, the platform busy or restarting) is retried on its own with a
14
+ // growing wait; anything else fails the publish with the platform's message.
15
+
16
+ // Fingerprints asked about per request. The platform answers up to 250
17
+ // (MAX_CHECK_BATCH in backend/api/src/routes/publish.js), but each request
18
+ // looks them up in storage only six at a time, each lookup a round trip of
19
+ // hundreds of milliseconds from far away; small batches in parallel requests
20
+ // answer a large game in a few seconds.
21
+ export const CHECK_BATCH = 25;
22
+ // Each upload request spends most of its time waiting on round trips between
23
+ // the platform and its database and storage, about 1.4 s from far away, so
24
+ // many overlap. Measured from Mauritius on 120 small files: 29 s at 6 at once,
25
+ // 8 s at 24, 5 s at 48.
26
+ export const UPLOAD_CONCURRENCY = 32;
27
+ // Batches of the "which are missing" question in flight at once.
28
+ export const CHECK_CONCURRENCY = 32;
29
+ const ATTEMPTS = 6;
30
+ const UPLOAD_TIMEOUT_MS = 5 * 60_000;
31
+ const REQUEST_TIMEOUT_MS = 2 * 60_000;
32
+ // The platform answers 503 for a failure that may pass (storage busy, a
33
+ // publish that failed and changed nothing); Cloudflare's edge answers 503 when
34
+ // a request ran out of CPU, and 52x when it could not reach the platform. A 500
35
+ // is retried only when it is the platform's unexpected-error answer: its other
36
+ // 500s name a fault in its own setup, which a retry does not fix. A 502 means
37
+ // a game's server failed to deploy.
38
+ const RETRYABLE = new Set([408, 429, 503, 504, 520, 521, 522, 523, 524]);
39
+ const UNEXPECTED_ERROR = '{"error":"internal error"}';
40
+
41
+ export class LegacyPlatform extends Error {}
42
+
43
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+
45
+ /**
46
+ * Uploads what the platform lacks and finishes the publish. `files` maps each
47
+ * path to its bytes, `meta.files` each path to its SHA-256. Returns the
48
+ * platform's answer ({ slug, title, … }) with `uploaded` and `alreadyStored`
49
+ * counts. Throws LegacyPlatform when the platform predates these routes.
50
+ */
51
+ export async function publishFileByFile({ apiBase, token, files, meta, log = console.log, fetchImpl = fetch, sleep = defaultSleep }) {
52
+ const auth = token ? { Authorization: `Bearer ${token}` } : {};
53
+
54
+ // One request, retried while the reason passes. Returns the response of the
55
+ // first attempt that is not retryable.
56
+ const request = async (what, url, init, timeoutMs = REQUEST_TIMEOUT_MS) => {
57
+ for (let attempt = 1; ; attempt++) {
58
+ let res = null;
59
+ let error = null;
60
+ try {
61
+ res = await fetchImpl(url, { ...init, headers: { ...auth, ...init.headers }, signal: AbortSignal.timeout(timeoutMs) });
62
+ } catch (err) {
63
+ error = err;
64
+ }
65
+ if (res?.status === 500) {
66
+ const body = await res.text();
67
+ if (body.trim() !== UNEXPECTED_ERROR) return new Response(body, { status: 500 });
68
+ res = new Response(body, { status: 500 });
69
+ } else if (res && !RETRYABLE.has(res.status)) return res;
70
+ if (attempt === ATTEMPTS) {
71
+ if (res) return res;
72
+ throw new Error(`${what} failed after ${ATTEMPTS} attempts: ${error?.cause?.message ?? error?.message ?? error}`);
73
+ }
74
+ const retryAfter = Number(res?.headers.get('retry-after'));
75
+ const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** (attempt - 1);
76
+ if (res?.status === 429) log(` The platform asked to slow down; continuing in ${Math.round(wait / 1000)} s…`);
77
+ await res?.body?.cancel().catch(() => {});
78
+ await sleep(wait);
79
+ }
80
+ };
81
+ const failed = async (what, res) => {
82
+ const detail = await res.text().catch(() => '');
83
+ return new Error(`${what} failed (HTTP ${res.status}): ${detail.slice(0, 300)}`);
84
+ };
85
+
86
+ const byHash = new Map();
87
+ for (const [path, bytes] of files) byHash.set(meta.files[path], bytes);
88
+ const hashes = [...byHash.keys()];
89
+
90
+ const batches = [];
91
+ for (let i = 0; i < hashes.length; i += CHECK_BATCH) batches.push(hashes.slice(i, i + CHECK_BATCH));
92
+ const missing = [];
93
+ const ask = async (batch) => {
94
+ const res = await request('checking which files the platform has', `${apiBase}/api/creator/publish/missing`, {
95
+ method: 'POST',
96
+ headers: { 'content-type': 'application/json' },
97
+ body: JSON.stringify({ files: batch }),
98
+ });
99
+ if (res.status === 404) throw new LegacyPlatform('the platform has no file-by-file publish');
100
+ if (!res.ok) throw await failed('publish', res);
101
+ missing.push(...(await res.json()).missing);
102
+ };
103
+ // The first batch alone, so a platform without these routes is found before
104
+ // anything else is sent.
105
+ await ask(batches[0]);
106
+ const rest = batches.slice(1);
107
+ await Promise.all(Array.from({ length: Math.min(CHECK_CONCURRENCY, rest.length) }, async () => {
108
+ for (let batch = rest.shift(); batch; batch = rest.shift()) await ask(batch);
109
+ }));
110
+
111
+ const bytesOf = (hash) => byHash.get(hash).byteLength;
112
+ const total = missing.reduce((n, h) => n + bytesOf(h), 0);
113
+ const mb = (n) => (n / 1024 / 1024).toFixed(1);
114
+ log(
115
+ missing.length
116
+ ? `Uploading ${missing.length} of ${hashes.length} files (${mb(total)} MB); the platform already has the rest.`
117
+ : `The platform already has all ${hashes.length} files; nothing to upload.`,
118
+ );
119
+
120
+ let done = 0;
121
+ let sent = 0;
122
+ let nextReport = 0.25;
123
+ const queue = [...missing];
124
+ let stopped = false;
125
+ const worker = async () => {
126
+ for (let hash = queue.shift(); hash && !stopped; hash = queue.shift()) {
127
+ let res;
128
+ try {
129
+ res = await request(
130
+ 'uploading a file',
131
+ `${apiBase}/api/creator/publish/file/${hash}`,
132
+ { method: 'PUT', headers: { 'content-type': 'application/octet-stream' }, body: byHash.get(hash) },
133
+ UPLOAD_TIMEOUT_MS,
134
+ );
135
+ } catch (err) {
136
+ stopped = true;
137
+ throw err;
138
+ }
139
+ if (!res.ok) {
140
+ stopped = true;
141
+ const path = Object.keys(meta.files).find((p) => meta.files[p] === hash);
142
+ throw await failed(`uploading ${path}`, res);
143
+ }
144
+ await res.body?.cancel().catch(() => {});
145
+ done++;
146
+ sent += bytesOf(hash);
147
+ // Progress on a long upload, at each quarter.
148
+ if (missing.length >= 40 && done / missing.length >= nextReport && done < missing.length) {
149
+ log(` ${done} of ${missing.length} files uploaded (${mb(sent)} of ${mb(total)} MB)…`);
150
+ while (done / missing.length >= nextReport) nextReport += 0.25;
151
+ }
152
+ }
153
+ };
154
+ await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, missing.length) }, worker));
155
+
156
+ const res = await request('publish', `${apiBase}/api/creator/publish/finish`, {
157
+ method: 'POST',
158
+ headers: { 'content-type': 'application/json' },
159
+ body: JSON.stringify(meta),
160
+ });
161
+ if (!res.ok) throw await failed('publish', res);
162
+ return { ...(await res.json()), uploaded: missing.length, alreadyStored: hashes.length - missing.length };
163
+ }