@joystack/cli 0.1.54 → 0.1.56
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 +79 -0
- package/bin/joystack.mjs +39 -2
- package/lib/assets-export.mjs +210 -0
- package/lib/assets-json.mjs +1 -1
- package/lib/assets-page.mjs +26 -11
- package/lib/assets-sheet.mjs +25 -17
- package/lib/dev.mjs +21 -11
- package/lib/engine.mjs +35 -2
- package/lib/fingerprint/README.md +22 -0
- package/lib/fingerprint/fingerprint.mjs +235 -0
- package/lib/fingerprint/warm.mjs +60 -0
- package/lib/inject.mjs +78 -158
- package/lib/lint.mjs +15 -0
- package/lib/maker.mjs +16 -2
- package/lib/mime.mjs +28 -0
- package/lib/relay/cli.mjs +37 -88
- package/lib/relay/connection.mjs +2 -3
- package/lib/relay/conversation.mjs +15 -15
- package/lib/relay/engine.mjs +15 -0
- package/lib/relay/http.mjs +57 -0
- package/lib/relay/relay.mjs +267 -257
- package/lib/reload-client.mjs +58 -0
- package/lib/room-server.mjs +10 -0
- package/lib/shot/README.md +75 -0
- package/lib/shot/check.mjs +57 -0
- package/lib/shot/lock.mjs +109 -0
- package/lib/shot/ready.mjs +45 -0
- package/lib/shot/run.mjs +217 -0
- package/lib/shot/shot.mjs +320 -0
- package/lib/start-failed.mjs +110 -0
- package/lib/static-server.mjs +184 -110
- package/lib/test-cmd.mjs +41 -12
- package/package.json +3 -2
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// `joystack shot <script>`: a short Playwright script of the agent's own, run
|
|
2
|
+
// against the running game, with the parts that go wrong when every script
|
|
3
|
+
// does them for itself done once here.
|
|
4
|
+
//
|
|
5
|
+
// joystack shot .joystack/look.mjs
|
|
6
|
+
//
|
|
7
|
+
// The script exports one function. It is handed a page already open on the
|
|
8
|
+
// game, at a phone's size, under a player name of its own, with the game
|
|
9
|
+
// started; it drives the game to the moment it wants and calls
|
|
10
|
+
// `shot(name)` for each picture:
|
|
11
|
+
//
|
|
12
|
+
// export default async ({ page, game, shot }) => {
|
|
13
|
+
// await page.selectOption('#jump', '6');
|
|
14
|
+
// await game.waitFor((w) => w.query('boss').length > 0);
|
|
15
|
+
// await shot('boss');
|
|
16
|
+
// };
|
|
17
|
+
//
|
|
18
|
+
// What the command owns, so a script never does it:
|
|
19
|
+
// - the check before anything starts: the file parses, exports a function, and
|
|
20
|
+
// neither launches a browser nor opens an address of its own (check.mjs);
|
|
21
|
+
// - the dev stack: the one running for the game (in the maker, the draft's), or
|
|
22
|
+
// one started for the length of the command;
|
|
23
|
+
// - the moment the page opens: only once the dev server has finished updating
|
|
24
|
+
// after an edit (ready.mjs), and the run is stopped, saying so, when the game
|
|
25
|
+
// updates under it;
|
|
26
|
+
// - the browser: one, with the same flags every time, closed afterwards, and
|
|
27
|
+
// one script at a time on a computer (lock.mjs);
|
|
28
|
+
// - time: every step of the page waits at most a few seconds, the whole script
|
|
29
|
+
// a minute unless --timeout says otherwise.
|
|
30
|
+
//
|
|
31
|
+
// The script runs in a child process (run.mjs) with the same preloads a
|
|
32
|
+
// browser smoke gets, so it can import the joystack doors and a stuck script
|
|
33
|
+
// is stopped by ending that process.
|
|
34
|
+
|
|
35
|
+
import { spawn } from 'node:child_process';
|
|
36
|
+
import { resolve } from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { findProject } from '../project.mjs';
|
|
39
|
+
import { stackOrigin, stateFolderIn } from '../assets-sheet.mjs';
|
|
40
|
+
import { makerDevPort, makerDraft } from '../maker.mjs';
|
|
41
|
+
import { joystackTestPreload, SMOKE_PRELOAD } from '../test-cmd.mjs';
|
|
42
|
+
import { checkScript } from './check.mjs';
|
|
43
|
+
import { devState, untilReady } from './ready.mjs';
|
|
44
|
+
import { takeLock } from './lock.mjs';
|
|
45
|
+
|
|
46
|
+
export const SHOT_HELP = `joystack shot <script.mjs> [--size phone|tablet|desktop] [--landscape] [--query <k=v&…>] [--timeout <seconds>] [--out <dir>] [--port <n>]
|
|
47
|
+
|
|
48
|
+
Runs a short Playwright script against the running game and writes the
|
|
49
|
+
pictures it takes to .joystack/shots/<name>.png. The script exports one
|
|
50
|
+
function, handed a page already open on the game:
|
|
51
|
+
|
|
52
|
+
export default async ({ page, game, shot, log }) => {
|
|
53
|
+
await page.keyboard.press('Space'); // real input
|
|
54
|
+
await game.waitFor((w) => w.query('enemy').length >= 3);
|
|
55
|
+
await shot('three-enemies');
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
page the Playwright page, on the game, started and showing what a player
|
|
59
|
+
sees; never goto or launch a browser of your own
|
|
60
|
+
game the world handle openPage gives a smoke (not the page's window.game):
|
|
61
|
+
me(), mine(), ownIds(), entity(), snapshot(), query(selector: a component
|
|
62
|
+
name, tag:<name> or def:<name>), get(), set() (a dev room only),
|
|
63
|
+
send(), driveTo(), waitFor(), pause(), resume(), setTimeScale(),
|
|
64
|
+
players(). Every read is a promise: await game.query('boss')
|
|
65
|
+
shot shot(name) writes the page as it is now; the dev tools are left out.
|
|
66
|
+
game.pause() first makes it a picture of one instant
|
|
67
|
+
log log(...) prints a line
|
|
68
|
+
|
|
69
|
+
Wait for the moment, not for a time: game.waitFor or page.waitForSelector
|
|
70
|
+
wait only until it comes, where page.waitForTimeout is padded for the
|
|
71
|
+
slowest computer and still misses on it. A debug overlay the game has is
|
|
72
|
+
turned on as a player would (its key, or --query), then pictured.
|
|
73
|
+
|
|
74
|
+
--size phone (390×844, the default), tablet (820×1180) or desktop
|
|
75
|
+
(1280×800); phone and tablet at twice the pixels
|
|
76
|
+
--landscape the screen turned on its side
|
|
77
|
+
--query added to the game's address: --query "level=3"
|
|
78
|
+
--timeout seconds the whole script may take (default 60, at most 300);
|
|
79
|
+
each step on the page, and game.waitFor and driveTo, wait at
|
|
80
|
+
most 5 s
|
|
81
|
+
--out the folder the pictures go to (outside the game's own files,
|
|
82
|
+
or each picture is an edit the game reloads for)
|
|
83
|
+
--port the dev server's port, when this game's is not the one found
|
|
84
|
+
|
|
85
|
+
Write the script under .joystack/ (.joystack/look.mjs), which is not part
|
|
86
|
+
of the game. It is checked before anything starts, and the page opens only
|
|
87
|
+
once the dev server has finished updating after an edit; the run stops if
|
|
88
|
+
the game updates while it runs, and fails if the game throws. One page, one
|
|
89
|
+
player: in a multiplayer game it joins the game's one room, beside anyone
|
|
90
|
+
playing, so set(), pause() and setTimeScale() change their game too (a
|
|
91
|
+
time scale is put back to 1 when the script ends). It cannot stage an
|
|
92
|
+
entity the game does not spawn; that is a smoke's, openPage({ stage }). One
|
|
93
|
+
script at a time on a computer. Uses the dev stack already running for this
|
|
94
|
+
game, or starts one for the length of the command. Needs playwright in the
|
|
95
|
+
game (npm i -D playwright).`;
|
|
96
|
+
|
|
97
|
+
export const SIZES = {
|
|
98
|
+
phone: { width: 390, height: 844, scale: 2, touch: true },
|
|
99
|
+
tablet: { width: 820, height: 1180, scale: 2, touch: true },
|
|
100
|
+
desktop: { width: 1280, height: 800, scale: 1, touch: false },
|
|
101
|
+
};
|
|
102
|
+
export const TIMEOUT_DEFAULT_S = 60;
|
|
103
|
+
export const TIMEOUT_MOST_S = 300;
|
|
104
|
+
// How long the game has to start once the page is opened, and how often the
|
|
105
|
+
// dev server is asked whether it moved on while the script runs.
|
|
106
|
+
export const START_MOST_MS = 45_000;
|
|
107
|
+
const WATCH_MS = 500;
|
|
108
|
+
|
|
109
|
+
const VALUED = {
|
|
110
|
+
'--size': ['size', String],
|
|
111
|
+
'--query': ['query', (v) => v.replace(/^\?/, '')],
|
|
112
|
+
'--timeout': ['timeout', Number],
|
|
113
|
+
'--out': ['out', String],
|
|
114
|
+
'--port': ['port', Number],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// The arguments after `joystack shot`.
|
|
118
|
+
export function parseShotArgs(rest = []) {
|
|
119
|
+
const opts = { script: null, size: 'phone', landscape: false, query: '', timeout: TIMEOUT_DEFAULT_S, out: null, port: null };
|
|
120
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
121
|
+
const a = rest[i];
|
|
122
|
+
if (a === '--landscape') { opts.landscape = true; continue; }
|
|
123
|
+
if (a === '--help') continue;
|
|
124
|
+
if (!a.startsWith('--')) {
|
|
125
|
+
if (opts.script) throw new Error(`joystack shot runs one script, given two: ${opts.script} and ${a}`);
|
|
126
|
+
opts.script = a;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!VALUED[a]) throw new Error(`joystack shot has no option ${a}.\n\n${SHOT_HELP}`);
|
|
130
|
+
const v = rest[i + 1];
|
|
131
|
+
if (v == null || v.startsWith('--')) throw new Error(`${a} needs a value.\n\n${SHOT_HELP}`);
|
|
132
|
+
const [key, read] = VALUED[a];
|
|
133
|
+
opts[key] = read(v);
|
|
134
|
+
i += 1;
|
|
135
|
+
}
|
|
136
|
+
if (!opts.script) throw new Error(`joystack shot needs the script to run.\n\n${SHOT_HELP}`);
|
|
137
|
+
if (!SIZES[opts.size]) throw new Error(`--size is phone, tablet or desktop, not "${opts.size}"`);
|
|
138
|
+
if (!Number.isFinite(opts.timeout) || opts.timeout <= 0 || opts.timeout > TIMEOUT_MOST_S) throw new Error(`--timeout is a number of seconds above 0 and at most ${TIMEOUT_MOST_S}`);
|
|
139
|
+
if (opts.port !== null && !(Number.isInteger(opts.port) && opts.port > 0 && opts.port < 65536)) throw new Error('--port needs a port number');
|
|
140
|
+
return opts;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The browser context's options for a size.
|
|
144
|
+
export function viewFor(size, landscape) {
|
|
145
|
+
const s = SIZES[size];
|
|
146
|
+
const [width, height] = landscape ? [s.height, s.width] : [s.width, s.height];
|
|
147
|
+
return { viewport: { width, height }, deviceScaleFactor: s.scale, isMobile: s.touch, hasTouch: s.touch };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The dev stack the script runs against: the one running for the game (in the
|
|
151
|
+
// maker, the draft's, on a port of the maker's choosing), or one started with
|
|
152
|
+
// its room server for the length of the command. In the maker none is
|
|
153
|
+
// started: a second dev server beside the draft's would show nobody anything.
|
|
154
|
+
async function stackFor({ cwd, project, port, startDev, env }) {
|
|
155
|
+
const at = makerDraft(env);
|
|
156
|
+
const devPort = port ?? (at ? await makerDevPort(at) : null);
|
|
157
|
+
try {
|
|
158
|
+
return await stackOrigin({ cwd, projectDir: project.dir, port: devPort, startDev: at ? null : startDev, mp: true });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (!at) throw err;
|
|
161
|
+
throw new Error(`the draft's game is not running yet (the maker is starting it): try again in a moment (${String(err?.message ?? err).split('\n')[0].slice(0, 200)})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Whether the dev server moved on from the version the script started on.
|
|
166
|
+
export const moved = (now, version) => !!now && (now.state !== 'ready' || (now.pending && now.auto !== false) || (version != null && now.version !== version));
|
|
167
|
+
|
|
168
|
+
const RUNNER = fileURLToPath(new URL('./run.mjs', import.meta.url));
|
|
169
|
+
// How long the runner has to close its browser after being asked to stop,
|
|
170
|
+
// before it is killed outright.
|
|
171
|
+
const STOP_GRACE_MS = 2000;
|
|
172
|
+
// How long a stop waits for a dev stack still starting, to stop it as well.
|
|
173
|
+
const STACK_WAIT_MS = 10_000;
|
|
174
|
+
|
|
175
|
+
// Runs the checked script in its own process. Resolves with how it ended:
|
|
176
|
+
// { code } when it exited by itself, { signal } when something else killed it,
|
|
177
|
+
// { moved: true } when the game updated under it, { timedOut: true } when it ran
|
|
178
|
+
// past its limit, { stopped: true } after `stop()`. Returns { done, stop }.
|
|
179
|
+
export function runChild({ projectDir, job, timeoutMs, origin, version, touch = () => {}, env, runner = RUNNER, graceMs = START_MOST_MS + 15_000, watchMs = WATCH_MS }) {
|
|
180
|
+
const preload = joystackTestPreload(projectDir);
|
|
181
|
+
const args = ['--import', SMOKE_PRELOAD, ...(preload ? ['--import', preload] : []), runner];
|
|
182
|
+
const child = spawn(process.execPath, args, { cwd: projectDir, env: { ...env, JOYSTACK_SHOT_JOB: JSON.stringify(job) }, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
183
|
+
let ended = null;
|
|
184
|
+
let resolveP;
|
|
185
|
+
const done = new Promise((r) => { resolveP = r; });
|
|
186
|
+
let asking = false;
|
|
187
|
+
const watch = setInterval(async () => {
|
|
188
|
+
touch();
|
|
189
|
+
// One question at a time: a busy server answers slower than the ticks.
|
|
190
|
+
if (asking) return;
|
|
191
|
+
asking = true;
|
|
192
|
+
try {
|
|
193
|
+
if (moved(await devState(origin), version)) end({ moved: true });
|
|
194
|
+
} finally { asking = false; }
|
|
195
|
+
}, watchMs);
|
|
196
|
+
// The runner keeps its own limit on the script; this one is for a runner
|
|
197
|
+
// stuck past it (a page that stopped answering).
|
|
198
|
+
const limit = setTimeout(() => end({ timedOut: true }), timeoutMs + graceMs);
|
|
199
|
+
let kill = null;
|
|
200
|
+
function end(how) {
|
|
201
|
+
if (ended) return;
|
|
202
|
+
ended = how;
|
|
203
|
+
clearInterval(watch);
|
|
204
|
+
clearTimeout(limit);
|
|
205
|
+
if (how.code === undefined && how.signal === undefined) {
|
|
206
|
+
// Asked first, so Playwright closes its browser; then made to.
|
|
207
|
+
child.kill('SIGTERM');
|
|
208
|
+
kill = setTimeout(() => child.kill('SIGKILL'), STOP_GRACE_MS);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
child.on('exit', (code, signal) => {
|
|
212
|
+
end(signal && code === null ? { signal } : { code: code ?? 0 });
|
|
213
|
+
clearTimeout(kill);
|
|
214
|
+
resolveP(ended);
|
|
215
|
+
});
|
|
216
|
+
child.on('error', (err) => { end({ code: 1, error: err }); resolveP(ended); });
|
|
217
|
+
return { done, stop: () => end({ stopped: true }) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The error a run's end makes, or null when it succeeded.
|
|
221
|
+
function failureOf(how, { timeout, interrupted }) {
|
|
222
|
+
if (how.stopped) return Object.assign(new Error(`stopped (${interrupted ?? 'asked'}); the script's browser was closed`), { exitCode: 130 });
|
|
223
|
+
if (how.moved) return new Error('the game updated while the script ran (a file of the game changed), so its page reloaded: run it again once nothing is being edited');
|
|
224
|
+
if (how.timedOut) return new Error(`the script did not finish in ${timeout} s and was stopped`);
|
|
225
|
+
if (how.error) return how.error;
|
|
226
|
+
if (how.signal) return new Error(`the script's process was killed (${how.signal}), most often for want of memory`);
|
|
227
|
+
// The runner has said what went wrong in its own words.
|
|
228
|
+
if (how.code !== 0) return Object.assign(new Error('the script failed'), { reported: true });
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A stack still starting when a stop came, waited for a while so it can be
|
|
233
|
+
// stopped too (in the maker none is ever started), or null.
|
|
234
|
+
async function lateStack(stacking, { interrupted, log }) {
|
|
235
|
+
if (interrupted) log(' stopping the dev server it was starting…');
|
|
236
|
+
const late = await Promise.race([stacking.catch(() => null), new Promise((r) => setTimeout(r, STACK_WAIT_MS).unref())]);
|
|
237
|
+
return late?.started ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Run one script. `startDev` starts a dev stack when none is running for the
|
|
241
|
+
// game; `origin` is for a caller that already has one, and `runner` a stand-in
|
|
242
|
+
// for run.mjs (both a test's). Throws with a line fit to print when the run
|
|
243
|
+
// fails; resolves when it succeeded.
|
|
244
|
+
export async function shot({
|
|
245
|
+
cwd = process.cwd(), script, size = 'phone', landscape = false, query = '', timeout = TIMEOUT_DEFAULT_S, out = null, port = null,
|
|
246
|
+
startDev, log = console.log, origin, env = process.env, lockDir, runner,
|
|
247
|
+
} = {}) {
|
|
248
|
+
const file = resolve(cwd, script);
|
|
249
|
+
// Before anything starts, so a script that cannot work fails in a moment.
|
|
250
|
+
checkScript(file);
|
|
251
|
+
const project = findProject(cwd);
|
|
252
|
+
let started = null;
|
|
253
|
+
let stacking = null;
|
|
254
|
+
let stackSettled = false;
|
|
255
|
+
let release = null;
|
|
256
|
+
let child = null;
|
|
257
|
+
let interrupted = null;
|
|
258
|
+
// Ctrl-C, or a tool's limit on the command: the runner and its browser, the
|
|
259
|
+
// stack started for the command and the lock go with it. A wait in progress
|
|
260
|
+
// (the stack starting, an update, the lock) is given up at once. A second
|
|
261
|
+
// signal, while the first is still being acted on, ends the command there
|
|
262
|
+
// and then, giving back the lock.
|
|
263
|
+
let stop;
|
|
264
|
+
const stopped = new Promise((_, reject) => { stop = reject; });
|
|
265
|
+
stopped.catch(() => {});
|
|
266
|
+
const onSignal = (signal) => {
|
|
267
|
+
if (interrupted) {
|
|
268
|
+
// Its stop is begun, not waited for: a started stack's processes are
|
|
269
|
+
// signalled before it returns.
|
|
270
|
+
Promise.resolve().then(() => started?.stop()).catch(() => {});
|
|
271
|
+
release?.();
|
|
272
|
+
process.exit(130);
|
|
273
|
+
}
|
|
274
|
+
interrupted = signal;
|
|
275
|
+
child?.stop();
|
|
276
|
+
stop(Object.assign(new Error(`stopped (${signal})`), { exitCode: 130 }));
|
|
277
|
+
};
|
|
278
|
+
const orStop = (p) => Promise.race([p, stopped]);
|
|
279
|
+
process.on('SIGINT', onSignal);
|
|
280
|
+
process.on('SIGTERM', onSignal);
|
|
281
|
+
try {
|
|
282
|
+
if (!origin) {
|
|
283
|
+
stacking = stackFor({ cwd, project, port, startDev, env });
|
|
284
|
+
// Whatever it started is kept the moment it is, a stop or not.
|
|
285
|
+
stacking.then((r) => { started ??= r?.started ?? null; stackSettled = true; }, () => { stackSettled = true; });
|
|
286
|
+
({ origin, started } = await orStop(stacking));
|
|
287
|
+
}
|
|
288
|
+
// This game's update is waited out before the lock is taken, so another
|
|
289
|
+
// game's script never waits on it.
|
|
290
|
+
await orStop(untilReady(origin, { log }));
|
|
291
|
+
const taking = takeLock({ dir: lockDir, log });
|
|
292
|
+
// A lock taken after a stop is given back with the rest.
|
|
293
|
+
taking.then((r) => { release = r; if (interrupted) r(); }, () => {});
|
|
294
|
+
release = await orStop(taking);
|
|
295
|
+
const version = await orStop(untilReady(origin, { log }));
|
|
296
|
+
const job = {
|
|
297
|
+
script: file,
|
|
298
|
+
origin,
|
|
299
|
+
slug: project.slug,
|
|
300
|
+
query,
|
|
301
|
+
view: viewFor(size, landscape),
|
|
302
|
+
out: out ? resolve(cwd, out) : stateFolderIn(project.dir, 'shots'),
|
|
303
|
+
projectDir: project.dir,
|
|
304
|
+
timeoutMs: timeout * 1000,
|
|
305
|
+
startMs: START_MOST_MS,
|
|
306
|
+
maker: !!makerDraft(env),
|
|
307
|
+
};
|
|
308
|
+
child = runChild({ projectDir: project.dir, job, timeoutMs: job.timeoutMs, origin, version, touch: release.touch, env, runner });
|
|
309
|
+
const failed = failureOf(await child.done, { timeout, interrupted });
|
|
310
|
+
if (failed) throw failed;
|
|
311
|
+
} finally {
|
|
312
|
+
if (!started && stacking && !stackSettled && !makerDraft(env)) started = await lateStack(stacking, { interrupted, log });
|
|
313
|
+
// Each step on its own, so one that fails, even at once, still lets the
|
|
314
|
+
// next run.
|
|
315
|
+
await Promise.resolve().then(() => started?.stop()).catch(() => {});
|
|
316
|
+
release?.();
|
|
317
|
+
process.removeListener('SIGINT', onSignal);
|
|
318
|
+
process.removeListener('SIGTERM', onSignal);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// POST /__joystack/dev/start-failed { message, file, line, column, stack }
|
|
2
|
+
//
|
|
3
|
+
// A start that failed in a browser, which the dev tools runtime posts (the
|
|
4
|
+
// injected tag names this route, inject.mjs), written to the dev server's log
|
|
5
|
+
// as one block. `joystack dev` keeps that log in `.joystack/dev/last.log`
|
|
6
|
+
// (dev-log.mjs), where the game's agent reads it: a failure on a phone, or in
|
|
7
|
+
// the maker's Play, is otherwise seen only by the person holding it.
|
|
8
|
+
//
|
|
9
|
+
// The body is written by a page, which may be anyone's on a shared link: it is
|
|
10
|
+
// taken only from a page the dev server served, size-limited, every field cut
|
|
11
|
+
// and cleaned of control characters, and at most PER_MINUTE are written a
|
|
12
|
+
// minute.
|
|
13
|
+
import { relative, sep } from 'node:path';
|
|
14
|
+
import { canonicalDevPath } from './legacy-names.mjs';
|
|
15
|
+
|
|
16
|
+
export const START_FAILED_PATH = '/__joystack/dev/start-failed';
|
|
17
|
+
const MAX_BYTES = 16 * 1024;
|
|
18
|
+
const PER_MINUTE = 10;
|
|
19
|
+
const STACK_LINES = 30;
|
|
20
|
+
const SHAPE = 'expected { message, file, line, column, stack }';
|
|
21
|
+
|
|
22
|
+
// Text from a page, for one line of the log: no control characters (an
|
|
23
|
+
// escape sequence would move the terminal, a newline would forge a line of
|
|
24
|
+
// the log), no bidirectional or zero-width marks (a line would read one way
|
|
25
|
+
// in a terminal and another to the agent), cut to `max`.
|
|
26
|
+
function logLine(value, max) {
|
|
27
|
+
const s = String(value ?? '').replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').trim();
|
|
28
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The block a failed start is written as: what failed, where (the file as it
|
|
32
|
+
// is in the game's folder, or on disk for an engine file), the browser, who
|
|
33
|
+
// sent it, and the stack when there is one, indented under it.
|
|
34
|
+
export function startFailedBlock({ message, file, line, column, stack = [], browser, from }) {
|
|
35
|
+
const place = file ? `${file}${line ? `:${line}${column ? `:${column}` : ''}` : ''}` : '';
|
|
36
|
+
const lines = [`✗ The game didn't start in a browser: ${message || 'unknown error'}`];
|
|
37
|
+
if (place) lines.push(` at ${place}`);
|
|
38
|
+
if (browser) lines.push(` browser: ${browser}`);
|
|
39
|
+
if (from) lines.push(` from: ${from}`);
|
|
40
|
+
for (const l of stack) lines.push(` ${l}`);
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `log(block)`: where the block goes. `allowed(req)`: whether the request
|
|
45
|
+
// came from a page the dev server served, to the creator. `from(req)`: who
|
|
46
|
+
// sent it, said in the block. `ownHost(host)`: whether a URL's
|
|
47
|
+
// host is the dev server's own. `slug`, `projectDir`, `resolveUrl(urlPath)`:
|
|
48
|
+
// the game served, its folder, and the file a URL serves, to name the file
|
|
49
|
+
// the way the agent finds it.
|
|
50
|
+
export function createStartFailed({ log, allowed, from = () => '', ownHost, slug, projectDir, resolveUrl }) {
|
|
51
|
+
const times = [];
|
|
52
|
+
|
|
53
|
+
// A file the page names, as the agent finds it: a game file by its path in
|
|
54
|
+
// the game's folder (the server may serve a copy of it), a file of the
|
|
55
|
+
// server's otherwise by where it is on disk (from the game's folder when
|
|
56
|
+
// inside it), anything else as it was given.
|
|
57
|
+
function placeOnDisk(file) {
|
|
58
|
+
if (!file) return '';
|
|
59
|
+
let urlPath;
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(file, 'http://x');
|
|
62
|
+
if (!file.startsWith('/') && !ownHost(url.host)) return file;
|
|
63
|
+
urlPath = canonicalDevPath(decodeURIComponent(url.pathname));
|
|
64
|
+
} catch { return file; }
|
|
65
|
+
const inGame = `/games/${slug}/`;
|
|
66
|
+
if (urlPath.startsWith(inGame)) return urlPath.slice(inGame.length);
|
|
67
|
+
const fsPath = resolveUrl(urlPath);
|
|
68
|
+
if (!fsPath) return urlPath;
|
|
69
|
+
const rel = projectDir ? relative(projectDir, fsPath) : '';
|
|
70
|
+
return (rel && !rel.startsWith('..') && !/^[A-Za-z]:/.test(rel) ? rel : fsPath).split(sep).join('/');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Whether another block may be written now.
|
|
74
|
+
function room() {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
while (times.length && now - times[0] > 60_000) times.shift();
|
|
77
|
+
if (times.length >= PER_MINUTE) return false;
|
|
78
|
+
times.push(now);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return async function handle(req, res) {
|
|
83
|
+
const answer = (status, body) => {
|
|
84
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(body ? {} : { 'Content-Length': 0 }) });
|
|
85
|
+
res.end(body ? JSON.stringify(body) : undefined);
|
|
86
|
+
};
|
|
87
|
+
if (req.method !== 'POST') return answer(405, { error: 'POST only' });
|
|
88
|
+
if (!allowed(req)) return answer(403, { error: 'only from a page this server served' });
|
|
89
|
+
let text = '';
|
|
90
|
+
for await (const c of req) {
|
|
91
|
+
text += c;
|
|
92
|
+
if (text.length > MAX_BYTES) return answer(413, { error: 'body too large' });
|
|
93
|
+
}
|
|
94
|
+
let body;
|
|
95
|
+
try { body = JSON.parse(text); } catch { return answer(400, { error: SHAPE }); }
|
|
96
|
+
if (!body || typeof body !== 'object') return answer(400, { error: SHAPE });
|
|
97
|
+
if (!room()) return answer(204);
|
|
98
|
+
const count = (v) => (Number.isInteger(v) && v > 0 && v < 1e7 ? v : 0);
|
|
99
|
+
log(startFailedBlock({
|
|
100
|
+
message: logLine(body.message, 500),
|
|
101
|
+
file: placeOnDisk(logLine(body.file, 500)),
|
|
102
|
+
line: count(body.line),
|
|
103
|
+
column: count(body.column),
|
|
104
|
+
stack: String(body.stack ?? '').split('\n').map((l) => logLine(l, 300)).filter(Boolean).slice(0, STACK_LINES),
|
|
105
|
+
browser: logLine(req.headers['user-agent'], 200),
|
|
106
|
+
from: from(req),
|
|
107
|
+
}));
|
|
108
|
+
return answer(204);
|
|
109
|
+
};
|
|
110
|
+
}
|