@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/CHANGELOG.md +43 -0
- package/lib/dev.mjs +268 -113
- package/lib/freeze.mjs +81 -25
- package/lib/inject.mjs +1 -1
- package/lib/legacy-names.mjs +6 -0
- package/lib/live-versions.mjs +212 -0
- package/lib/publish-checks.mjs +75 -0
- package/lib/publish-upload.mjs +163 -0
- package/lib/publish.mjs +32 -14
- package/lib/static-server.mjs +208 -110
- package/package.json +1 -1
- package/lib/room-reload.mjs +0 -168
package/lib/freeze.mjs
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
//
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
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
|
|
33
|
-
* played on the copy is stamped with
|
|
34
|
-
*
|
|
35
|
-
* running it is swept by the
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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',
|
package/lib/legacy-names.mjs
CHANGED
|
@@ -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
|
+
}
|