@joystack/cli 0.0.0-stage → 0.1.43
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 +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Keep the `joystack` command itself up to date — the third lane.
|
|
2
|
+
//
|
|
3
|
+
// THE BUG THIS EXISTS FOR. A game's skills and its engine ride one artifact, so
|
|
4
|
+
// they can't drift. The `joystack` command does not: it's an npm devDependency
|
|
5
|
+
// pinned in the game's own lockfile, and `npx joystack` runs THAT copy, not npm's
|
|
6
|
+
// latest. `joystack update` used to move only the engine — so a real game (riven)
|
|
7
|
+
// ended up on engine 0.1.14, whose skills tell the agent to run
|
|
8
|
+
// `joystack changelog`, while its CLI was still 0.1.6:
|
|
9
|
+
//
|
|
10
|
+
// $ npx joystack changelog
|
|
11
|
+
// Unknown command: changelog
|
|
12
|
+
//
|
|
13
|
+
// The agent surface is shipped BY the engine and can name any `joystack` command it
|
|
14
|
+
// likes. Nothing kept the CLI in step with it. So `joystack update` must mean
|
|
15
|
+
// "update Joystack" — engine, skills, AND the command — or the next skill that
|
|
16
|
+
// mentions a new command breaks the same way.
|
|
17
|
+
//
|
|
18
|
+
// FAIL SOFT, ALWAYS. The engine update is the important half and it has already
|
|
19
|
+
// happened by the time we get here. A CLI bump that cannot be done (offline, npm
|
|
20
|
+
// down, no write access) must warn and step aside — never take the command down
|
|
21
|
+
// with it. Nothing in here throws.
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { runNpm } from './npm.mjs';
|
|
25
|
+
import { LEGACY_CLI_PACKAGE } from './legacy-names.mjs';
|
|
26
|
+
|
|
27
|
+
export const CLI_PKG = '@joystack/cli';
|
|
28
|
+
|
|
29
|
+
// What `npx joystack` will run NEXT TIME — the copy on disk in this game, which is
|
|
30
|
+
// not necessarily the copy running right now (`npx @joystack/cli@x update` in
|
|
31
|
+
// a repo pinned to something older runs the newer one but must still fix the
|
|
32
|
+
// repo).
|
|
33
|
+
export function projectCliVersion(projectDir) {
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(
|
|
36
|
+
readFileSync(join(projectDir, 'node_modules', ...CLI_PKG.split('/'), 'package.json'), 'utf8'),
|
|
37
|
+
);
|
|
38
|
+
return typeof pkg.version === 'string' ? pkg.version : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isOlder(a, b) {
|
|
45
|
+
const pa = a.split('.').map(Number);
|
|
46
|
+
const pb = b.split('.').map(Number);
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Whether the game's package.json still names the CLI by its name from before
|
|
54
|
+
// the rename.
|
|
55
|
+
export function dependsOnLegacyCli(projectDir) {
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
|
58
|
+
return Boolean(pkg.devDependencies?.[LEGACY_CLI_PACKAGE] ?? pkg.dependencies?.[LEGACY_CLI_PACKAGE]);
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A game made with the CLI under its old name: swap that dependency for this
|
|
65
|
+
// CLI. The game's scripts keep working afterwards: this CLI also installs the
|
|
66
|
+
// command name they call. The new CLI is installed at the version npm calls
|
|
67
|
+
// latest, or, when npm cannot say, at the version running now.
|
|
68
|
+
async function switchFromLegacyCli({ projectDir, npm, log }) {
|
|
69
|
+
let to = runningCliVersion();
|
|
70
|
+
try {
|
|
71
|
+
to = JSON.parse(npm(['view', CLI_PKG, 'version', '--json'], { cwd: projectDir }).stdout.toString());
|
|
72
|
+
} catch { /* keep the running version */ }
|
|
73
|
+
const spec = `${CLI_PKG}@^${to}`;
|
|
74
|
+
log('');
|
|
75
|
+
log(` This game used ${LEGACY_CLI_PACKAGE}, the CLI's name before Joystack. Moving it to ${spec}…`);
|
|
76
|
+
try {
|
|
77
|
+
npm(['uninstall', LEGACY_CLI_PACKAGE], { cwd: projectDir });
|
|
78
|
+
npm(['install', '--save-dev', spec], { cwd: projectDir });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
log(` The move did not finish (npm failed). Run these two, then \`npx joystack update\` again:`);
|
|
81
|
+
log(` npm uninstall ${LEGACY_CLI_PACKAGE}`);
|
|
82
|
+
log(` npm install --save-dev ${spec}`);
|
|
83
|
+
return { from: LEGACY_CLI_PACKAGE, to, updated: false, error: err };
|
|
84
|
+
}
|
|
85
|
+
return { from: LEGACY_CLI_PACKAGE, to, updated: true, switched: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function syncCli({ projectDir, npm = runNpm, log = console.log } = {}) {
|
|
89
|
+
if (dependsOnLegacyCli(projectDir)) return switchFromLegacyCli({ projectDir, npm, log });
|
|
90
|
+
const from = projectCliVersion(projectDir);
|
|
91
|
+
|
|
92
|
+
// Nothing installed: `create` and `dev` own that. There is nothing to bring up
|
|
93
|
+
// to date, and force-installing here would be a surprise.
|
|
94
|
+
if (!from) return { from: null, to: null, updated: false, skipped: 'not-installed' };
|
|
95
|
+
|
|
96
|
+
let latest;
|
|
97
|
+
try {
|
|
98
|
+
const out = npm(['view', CLI_PKG, 'version', '--json'], { cwd: projectDir });
|
|
99
|
+
latest = JSON.parse(out.stdout.toString());
|
|
100
|
+
} catch (err) {
|
|
101
|
+
log('');
|
|
102
|
+
log(` Could not check for a newer joystack command (npm did not answer). Your engine is`);
|
|
103
|
+
log(` up to date; the command stayed at ${from}. Try again later, or run:`);
|
|
104
|
+
log(` npm update ${CLI_PKG}`);
|
|
105
|
+
return { from, to: null, updated: false, error: err };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Never downgrade. A local dev build (or a version newer than the registry
|
|
109
|
+
// knows about) is deliberate — leave it alone.
|
|
110
|
+
if (!isOlder(from, latest)) return { from, to: latest, updated: false };
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// --save-dev so the DECLARED range moves too. Without it the next plain
|
|
114
|
+
// `npm install` in that repo happily resolves back down to the old pin, and
|
|
115
|
+
// the update silently un-does itself.
|
|
116
|
+
npm(['install', '--save-dev', `${CLI_PKG}@${latest}`], { cwd: projectDir });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log('');
|
|
119
|
+
log(` The joystack command could not be updated to ${latest} (npm install failed).`);
|
|
120
|
+
log(` Your engine is up to date; the command is still ${from}. Run this to retry:`);
|
|
121
|
+
log(` npm install --save-dev ${CLI_PKG}@${latest}`);
|
|
122
|
+
return { from, to: latest, updated: false, error: err };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { from, to: latest, updated: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The version of the CLI EXECUTING right now — which is not always the one the
|
|
129
|
+
// project has installed. `npx joystack` runs the local install (so they match), but
|
|
130
|
+
// `npx @joystack/cli@latest update` runs a newer one against an older repo.
|
|
131
|
+
export function runningCliVersion() {
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')).version ?? null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// What `update` prints. Separate so the wording is testable.
|
|
140
|
+
//
|
|
141
|
+
// The caveat — "you're still running the old one" — is TRUE only when the process
|
|
142
|
+
// executing is the copy we just replaced. An earlier version asserted it
|
|
143
|
+
// unconditionally and told a `npx @joystack/cli@latest update` run that it was
|
|
144
|
+
// "still running 0.1.6", which it plainly was not. Caught by reading the output of
|
|
145
|
+
// a real run against riven. Say it only when it's true.
|
|
146
|
+
export function reportCli(log, cli, { running = runningCliVersion() } = {}) {
|
|
147
|
+
if (!cli?.updated) return;
|
|
148
|
+
log('');
|
|
149
|
+
if (cli.switched) {
|
|
150
|
+
log(`✅ This game now uses ${CLI_PKG} ${cli.to} in place of ${cli.from}.`);
|
|
151
|
+
log(' Your package.json scripts keep working as they are.');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
log(`✅ The joystack command updated: ${cli.from} → ${cli.to}`);
|
|
155
|
+
if (running === cli.from) {
|
|
156
|
+
log(` You're still running ${cli.from} in this process — any new commands work from`);
|
|
157
|
+
log(` your next \`npx joystack\`.`);
|
|
158
|
+
} else {
|
|
159
|
+
log(` New commands are available from your next \`npx joystack\`.`);
|
|
160
|
+
}
|
|
161
|
+
log(` Its own changes are in node_modules/${CLI_PKG}/CHANGELOG.md.`);
|
|
162
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Shadowing, for the BUNDLER.
|
|
2
|
+
//
|
|
3
|
+
// `overrides/shared/<engine path>` works in the browser because the dev server
|
|
4
|
+
// (and, in production, the import map) resolves each `/shared/...` request
|
|
5
|
+
// against the game's overrides FIRST and the engine SECOND — per file. A game's
|
|
6
|
+
// override sits at the engine's own URL, so its neighbours resolve to the engine's.
|
|
7
|
+
//
|
|
8
|
+
// esbuild has no such notion. It resolves relative imports against the real
|
|
9
|
+
// filesystem, where the game's overrides folder holds only the one or two files
|
|
10
|
+
// the creator actually wrote. So this:
|
|
11
|
+
//
|
|
12
|
+
// // overrides/shared/ui/room/primitives/player-body.js
|
|
13
|
+
// import { PlayerBody } from './engine.js'; // ← the escape hatch
|
|
14
|
+
//
|
|
15
|
+
// resolves in the browser (engine.js is right there at /shared/ui/room/primitives/)
|
|
16
|
+
// and dies in the bundler ("Could not resolve ./engine.js") — the game's folder
|
|
17
|
+
// has no engine.js, and never will: it's the engine's.
|
|
18
|
+
//
|
|
19
|
+
// That import is not incidental. It is the ONLY way an override can subclass the
|
|
20
|
+
// primitive it overrides — it cannot import its own path, which is itself — and
|
|
21
|
+
// "change one method" is the most common reason to override anything. So the
|
|
22
|
+
// bundler has to learn the same fall-through the servers already do:
|
|
23
|
+
//
|
|
24
|
+
// a relative import from inside overrides/shared/…
|
|
25
|
+
// → the game's file, if it has one
|
|
26
|
+
// → otherwise the engine's file at the mirrored path
|
|
27
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
28
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
29
|
+
|
|
30
|
+
const OVERRIDES_ROOT = join('overrides', 'shared');
|
|
31
|
+
|
|
32
|
+
// esbuild hands us importer paths with symlinks already resolved, while the
|
|
33
|
+
// project dir arrives as the caller spelled it. On macOS that alone is enough to
|
|
34
|
+
// break the comparison below — a temp dir is `/var/…` to us and `/private/var/…`
|
|
35
|
+
// to esbuild — and the fall-through would silently never fire, leaving the
|
|
36
|
+
// creator with "Could not resolve ./engine.js" and no idea why.
|
|
37
|
+
function realOrSelf(p) {
|
|
38
|
+
try {
|
|
39
|
+
return realpathSync(p);
|
|
40
|
+
} catch {
|
|
41
|
+
return p; // doesn't exist yet — compare as given
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Is `file` inside the game's overrides/shared/ tree?
|
|
46
|
+
function underOverrides(file, overridesRoot) {
|
|
47
|
+
const rel = relative(overridesRoot, file);
|
|
48
|
+
return rel !== '' && !rel.startsWith('..') && !rel.startsWith(sep);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// engineExternal: at PUBLISH the engine is never bundled in — it is marked
|
|
52
|
+
// external and rewritten to the release's pre-bundled `rooms-runtime.js`, so the
|
|
53
|
+
// worker's room code is always ours. Pass the module specifier to rewrite to.
|
|
54
|
+
// In DEV we build the whole room server ourselves, so the engine's real file on
|
|
55
|
+
// disk is exactly what we want.
|
|
56
|
+
export function shadowResolvePlugin({ projectDir, sharedDir, engineExternal = null, onEngineHit = () => {} }) {
|
|
57
|
+
const overridesRoot = realOrSelf(join(projectDir, OVERRIDES_ROOT));
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
name: 'joystack-shadow-resolve',
|
|
61
|
+
setup(build) {
|
|
62
|
+
build.onResolve({ filter: /^\.{1,2}\// }, (args) => {
|
|
63
|
+
if (!args.importer) return null;
|
|
64
|
+
// Everything below is computed in real-path space, so the overrides-root
|
|
65
|
+
// comparison and the shadow mapping can't disagree about the same file.
|
|
66
|
+
const importer = realOrSelf(args.importer);
|
|
67
|
+
if (!underOverrides(importer, overridesRoot)) return null;
|
|
68
|
+
|
|
69
|
+
const candidate = resolve(dirname(importer), args.path);
|
|
70
|
+
|
|
71
|
+
// The game has this file — its own primitive, its own helper. Use it.
|
|
72
|
+
if (existsSync(candidate)) return { path: candidate };
|
|
73
|
+
|
|
74
|
+
// It doesn't. In the browser this would have fallen through to the
|
|
75
|
+
// engine, so do the same here: map the path back across the shadow.
|
|
76
|
+
const rel = relative(overridesRoot, candidate);
|
|
77
|
+
const engineFile = join(sharedDir, rel);
|
|
78
|
+
if (!existsSync(engineFile)) return null; // let esbuild report it honestly
|
|
79
|
+
|
|
80
|
+
onEngineHit(`/shared/${rel.split(sep).join('/')}`);
|
|
81
|
+
return engineExternal ? { path: engineExternal, external: true } : { path: engineFile };
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/lib/share.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `joystack dev --share` — a public link to this dev server, for testers.
|
|
2
|
+
//
|
|
3
|
+
// A Cloudflare quick tunnel (`cloudflared tunnel --url`, no account needed)
|
|
4
|
+
// gives a random `https://<words>.trycloudflare.com` name that forwards every
|
|
5
|
+
// request to the dev server's port. A tester opens it on their phone and plays;
|
|
6
|
+
// their recording and their device's performance land in this game's
|
|
7
|
+
// .joystack/replays/ exactly as the creator's own sessions do. The dev server
|
|
8
|
+
// trusts that one hostname (static-server.mjs, trustedHost) and forwards the
|
|
9
|
+
// room's websocket (its upgrade handler), so multiplayer works through the same
|
|
10
|
+
// link.
|
|
11
|
+
//
|
|
12
|
+
// The link lasts as long as `joystack dev` runs. Holding it is the same as being
|
|
13
|
+
// on the creator's wifi: nothing on the dev server is locked away from it.
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { Resolver } from 'node:dns/promises';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { withoutAgentSession } from './agent-session.mjs';
|
|
19
|
+
|
|
20
|
+
const LINK_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/g;
|
|
21
|
+
// cloudflared's own API, which it names in the error it logs when it cannot get
|
|
22
|
+
// a tunnel. Never a link.
|
|
23
|
+
const API_HOST = 'api.trycloudflare.com';
|
|
24
|
+
|
|
25
|
+
// Whether the engine a game is pinned to can be shared. The page decides it is
|
|
26
|
+
// a dev page from the flag the dev server writes into it (the engine's
|
|
27
|
+
// dev-host.js, SHARE_FLAG); an engine from before that decides by address
|
|
28
|
+
// alone, so through the link it would record nothing and send a multiplayer
|
|
29
|
+
// page to the production room server.
|
|
30
|
+
export function engineCanShare(sharedDir) {
|
|
31
|
+
try {
|
|
32
|
+
return readFileSync(join(sharedDir, 'ui/room/lib/dev-host.js'), 'utf8').includes('JOYSTACK_DEV_SHARE');
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// How to get cloudflared, per platform, for the error a missing one produces.
|
|
39
|
+
const INSTALL_HINT = [
|
|
40
|
+
'cloudflared is not installed. It is the free Cloudflare program that opens the link. Install it, then run `joystack dev --share` again:',
|
|
41
|
+
' macOS: brew install cloudflared',
|
|
42
|
+
' Windows: winget install --id Cloudflare.cloudflared',
|
|
43
|
+
' Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/',
|
|
44
|
+
].join('\n');
|
|
45
|
+
|
|
46
|
+
// A fresh tunnel's name is registered with DNS a few seconds after cloudflared
|
|
47
|
+
// prints it. A device that looks it up in that gap gets "not found" and may
|
|
48
|
+
// keep that answer cached for minutes, so the link is printed only once a
|
|
49
|
+
// public resolver answers for it.
|
|
50
|
+
function publicResolver() {
|
|
51
|
+
const r = new Resolver();
|
|
52
|
+
r.setServers(['1.1.1.1', '8.8.8.8']);
|
|
53
|
+
return (hostname) => r.resolve4(hostname);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open a quick tunnel to `http://localhost:<port>`.
|
|
58
|
+
* `exited` settles when cloudflared stops, for whatever reason, after the link
|
|
59
|
+
* was given; the link is dead from then on.
|
|
60
|
+
* @returns {Promise<{ url: string, hostname: string, resolved: boolean, close: () => void, exited: Promise<void> }>}
|
|
61
|
+
*/
|
|
62
|
+
export function openTunnel({
|
|
63
|
+
port,
|
|
64
|
+
spawnImpl = spawn,
|
|
65
|
+
resolveHost = publicResolver(),
|
|
66
|
+
timeoutMs = 45_000,
|
|
67
|
+
dnsTimeoutMs = 30_000,
|
|
68
|
+
pollMs = 1_000,
|
|
69
|
+
} = {}) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawnImpl('cloudflared', ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], {
|
|
72
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
73
|
+
env: withoutAgentSession(process.env),
|
|
74
|
+
});
|
|
75
|
+
const tail = [];
|
|
76
|
+
let settled = false;
|
|
77
|
+
const exited = new Promise((r) => child.on('exit', () => r()));
|
|
78
|
+
const close = () => {
|
|
79
|
+
if (child.killed) return;
|
|
80
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
81
|
+
};
|
|
82
|
+
const fail = (err) => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true;
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
close();
|
|
87
|
+
reject(err);
|
|
88
|
+
};
|
|
89
|
+
const timer = setTimeout(
|
|
90
|
+
() => fail(new Error(`cloudflared gave no link within ${Math.round(timeoutMs / 1000)}s.${lastWords()}`)),
|
|
91
|
+
timeoutMs,
|
|
92
|
+
);
|
|
93
|
+
const lastWords = () => (tail.length ? `\ncloudflared said:\n ${tail.slice(-5).join('\n ')}` : '');
|
|
94
|
+
|
|
95
|
+
child.on('error', (err) => fail(err?.code === 'ENOENT' ? new Error(INSTALL_HINT) : err));
|
|
96
|
+
child.on('exit', (code, signal) => {
|
|
97
|
+
if (!settled) fail(new Error(`cloudflared exited before giving a link (${signal ?? `code ${code}`}).${lastWords()}`));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const onOutput = (chunk) => {
|
|
101
|
+
for (const line of String(chunk).split('\n')) {
|
|
102
|
+
const trimmed = line.trim();
|
|
103
|
+
if (trimmed) tail.push(trimmed);
|
|
104
|
+
if (tail.length > 20) tail.shift();
|
|
105
|
+
for (const [link] of line.matchAll(LINK_RE)) {
|
|
106
|
+
if (!settled && new URL(link).hostname !== API_HOST) found(link);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
child.stdout?.on('data', onOutput);
|
|
111
|
+
child.stderr?.on('data', onOutput);
|
|
112
|
+
|
|
113
|
+
async function found(url) {
|
|
114
|
+
settled = true;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
const hostname = new URL(url).hostname;
|
|
117
|
+
const deadline = Date.now() + dnsTimeoutMs;
|
|
118
|
+
let resolved = false;
|
|
119
|
+
while (!resolved && Date.now() < deadline) {
|
|
120
|
+
try {
|
|
121
|
+
resolved = (await resolveHost(hostname)).length > 0;
|
|
122
|
+
} catch { /* not registered yet */ }
|
|
123
|
+
if (!resolved) await new Promise((r) => setTimeout(r, pollMs));
|
|
124
|
+
}
|
|
125
|
+
resolve({ url, hostname, resolved, close, exited });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What `joystack dev --share` prints: the link to send, and what sending it gives.
|
|
132
|
+
*/
|
|
133
|
+
export function shareBanner({ url, gamePath, resolved, frozen = false }) {
|
|
134
|
+
const lines = [
|
|
135
|
+
`🔗 ${url}${gamePath} (send this to your testers)`,
|
|
136
|
+
'',
|
|
137
|
+
'⚠ Anyone with this link can reach this dev server as if they were on your wifi:',
|
|
138
|
+
' play the game, read any of the files your game folder serves, and list, keep or',
|
|
139
|
+
' delete your recordings. Send it only to people you trust. The link stops',
|
|
140
|
+
' working when you stop `joystack dev`.',
|
|
141
|
+
' Every session they play is recorded into .joystack/replays/, with their',
|
|
142
|
+
' device\'s performance, as yours are.',
|
|
143
|
+
...(frozen
|
|
144
|
+
? [' The game is frozen as it was when you started: nothing you save reaches', ' them until you restart `joystack dev`, which makes a new link.']
|
|
145
|
+
: [' Saving a file reloads their page too; start with --no-reload to freeze', ' the game for everyone while you edit.']),
|
|
146
|
+
];
|
|
147
|
+
if (!resolved) lines.splice(1, 0, ' (the link is new: it may take a minute before it opens everywhere)');
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Preloaded before every browser smoke via `node --import`. Its whole job is to
|
|
2
|
+
// make headless Chromium render on the real GPU instead of SwiftShader.
|
|
3
|
+
//
|
|
4
|
+
// Why it matters: a headless browser has no GPU, so WebGL falls back to
|
|
5
|
+
// SwiftShader — software rasterization spread across a thread pool sized to the
|
|
6
|
+
// machine's core count. A single 3D-game smoke can pin 8+ cores that way, which
|
|
7
|
+
// on a modest laptop means a frozen machine, not just a slow test. Rendering on
|
|
8
|
+
// the actual GPU (Metal on macOS, D3D11 on Windows, GL or Vulkan on Linux — see
|
|
9
|
+
// `angleBackend` below for which of these is named outright) moves that work
|
|
10
|
+
// off the CPU entirely: measured ~8.5 cores → ~0.1, with pixel-identical output.
|
|
11
|
+
//
|
|
12
|
+
// It runs as a preload so an UNMODIFIED smoke needs no change: it patches the
|
|
13
|
+
// `chromium.launch` of the GAME's own playwright (resolved from cwd, the same
|
|
14
|
+
// instance the smoke imports) to append the flags. Fetch-only smokes that never
|
|
15
|
+
// import playwright, and machines with no usable GPU, both fall through
|
|
16
|
+
// untouched — ANGLE simply falls back to software, so this is never worse than
|
|
17
|
+
// before. Opt out with JOYSTACK_TEST_NO_GPU=1.
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { pathToFileURL } from 'node:url';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
|
+
import { readEnv } from './legacy-names.mjs';
|
|
23
|
+
|
|
24
|
+
// The ANGLE backend to name, or null to let Chromium choose. Windows is named
|
|
25
|
+
// explicitly: headless Chromium there does not reach the GPU through its
|
|
26
|
+
// default backend even with the two GPU flags set, and renders slower than
|
|
27
|
+
// software; naming D3D11 is what engages the GPU (measured on one scene:
|
|
28
|
+
// 5.8 fps → 57 fps). macOS (Metal) and Linux pick a working backend unaided.
|
|
29
|
+
// JOYSTACK_TEST_ANGLE=<backend> overrides on any platform; `default` removes the
|
|
30
|
+
// flag, for a Windows machine whose driver misbehaves under D3D11.
|
|
31
|
+
function angleBackend(platform) {
|
|
32
|
+
const override = readEnv('JOYSTACK_TEST_ANGLE');
|
|
33
|
+
if (override) return override === 'default' ? null : override;
|
|
34
|
+
return platform === 'win32' ? 'd3d11' : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function gpuArgs(platform = process.platform) {
|
|
38
|
+
if (readEnv('JOYSTACK_TEST_NO_GPU') === '1') return [];
|
|
39
|
+
const angle = angleBackend(platform);
|
|
40
|
+
return ['--enable-gpu', '--ignore-gpu-blocklist', ...(angle ? [`--use-angle=${angle}`] : [])];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Append `extra` to whatever args a caller already passed to chromium.launch(),
|
|
44
|
+
// preserving theirs. Idempotent enough for our use: called once per smoke process.
|
|
45
|
+
export function patchLaunch(chromium, extra = gpuArgs()) {
|
|
46
|
+
if (!chromium || typeof chromium.launch !== 'function' || extra.length === 0) return false;
|
|
47
|
+
const orig = chromium.launch.bind(chromium);
|
|
48
|
+
chromium.launch = (opts = {}) => orig({ ...opts, args: [...(opts.args || []), ...extra] });
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve the SAME playwright module object a smoke's `import 'playwright'` gets.
|
|
53
|
+
// The catch: a bare ESM import resolves via the package's "import" export
|
|
54
|
+
// (index.mjs), while createRequire().resolve() returns the "require" main
|
|
55
|
+
// (index.js) — a DIFFERENT module instance. Patching that one would miss, and the
|
|
56
|
+
// smoke would silently fall back to software with no error. So resolve the
|
|
57
|
+
// package dir, then import its ESM entry explicitly.
|
|
58
|
+
//
|
|
59
|
+
// Resolve from the SMOKE FILE's directory first (process.argv[1] under
|
|
60
|
+
// `node --import <preload> <smoke>`), then cwd. A smoke normally runs from its
|
|
61
|
+
// own game folder (cwd == the smoke's dir), but a runner may invoke it from a
|
|
62
|
+
// different working directory — resolving off the smoke's own location makes the
|
|
63
|
+
// same playwright install resolve either way.
|
|
64
|
+
async function loadGamePlaywright() {
|
|
65
|
+
const bases = [];
|
|
66
|
+
const smoke = process.argv[1];
|
|
67
|
+
if (smoke) bases.push(join(dirname(smoke), 'package.json'));
|
|
68
|
+
bases.push(join(process.cwd(), 'package.json'));
|
|
69
|
+
let lastErr;
|
|
70
|
+
for (const base of bases) {
|
|
71
|
+
try {
|
|
72
|
+
const require = createRequire(base);
|
|
73
|
+
const pkgDir = dirname(require.resolve('playwright')); // .../node_modules/playwright
|
|
74
|
+
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
75
|
+
const entry = pkg.exports?.['.']?.import ?? pkg.module ?? pkg.main ?? 'index.js';
|
|
76
|
+
return import(pathToFileURL(join(pkgDir, entry)).href);
|
|
77
|
+
} catch (e) { lastErr = e; }
|
|
78
|
+
}
|
|
79
|
+
throw lastErr;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Self-execute on preload. Kept in try/catch so a smoke without playwright (a
|
|
83
|
+
// fetch-only boot smoke) or a missing install never turns a preload into a crash.
|
|
84
|
+
if (gpuArgs().length > 0) {
|
|
85
|
+
try {
|
|
86
|
+
const pw = await loadGamePlaywright();
|
|
87
|
+
patchLaunch(pw.chromium ?? pw.default?.chromium);
|
|
88
|
+
} catch {
|
|
89
|
+
// No playwright here, or it couldn't be patched — leave the smoke unchanged.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Where a piece of code lives, named the way a creator would look for it:
|
|
2
|
+
// relative to the game folder, or inside the engine's package as
|
|
3
|
+
// `@joystack/engine/shared/…`, which is the same in every install.
|
|
4
|
+
//
|
|
5
|
+
// Paths are compared as the disk sees them, through any link: an engine
|
|
6
|
+
// reached through a symlinked folder (a linked install, `JOYSTACK_ENGINE_DIR`)
|
|
7
|
+
// is still the engine.
|
|
8
|
+
import { realpathSync } from 'node:fs';
|
|
9
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
12
|
+
|
|
13
|
+
// `path` relative to `root` with `/` separators, or null when it is not inside.
|
|
14
|
+
function within(root, path) {
|
|
15
|
+
const rel = relative(root, path);
|
|
16
|
+
return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel.split(sep).join('/') : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A file's name for people: `@joystack/engine/shared/<path>` inside the
|
|
21
|
+
* engine, `<path>` inside the game, or null when it is in neither.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} path - absolute, or relative to `dir`
|
|
24
|
+
* @param {{ dir?: string|null, sharedDir?: string|null }} where - the game
|
|
25
|
+
* folder, and the engine's shared/ tree
|
|
26
|
+
*/
|
|
27
|
+
export function sourcePath(path, { dir = null, sharedDir = null } = {}) {
|
|
28
|
+
if (typeof path !== 'string' || !path) return null;
|
|
29
|
+
const abs = real(dir ? resolve(dir, path) : resolve(path));
|
|
30
|
+
const inEngine = sharedDir ? within(real(sharedDir), abs) : null;
|
|
31
|
+
if (inEngine) return `@joystack/engine/shared/${inEngine}`;
|
|
32
|
+
return dir ? within(real(dir), abs) : null;
|
|
33
|
+
}
|