@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/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`
@@ -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
+ }