@joystack/cli 0.1.45 → 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 +23 -0
- package/lib/dev.mjs +268 -113
- package/lib/freeze.mjs +81 -25
- package/lib/live-versions.mjs +212 -0
- 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`
|
|
@@ -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
|
+
}
|