@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,539 @@
|
|
|
1
|
+
// The dev room server — the half of "you can run your own code on the server"
|
|
2
|
+
// that was never built.
|
|
3
|
+
//
|
|
4
|
+
// `joystack publish` has always assembled a per-game Worker from a creator's
|
|
5
|
+
// primitives. `joystack dev` never did: it picked its room server by looking for a
|
|
6
|
+
// `partykit.json`, a file nothing in the creator CLI writes, so it always fell
|
|
7
|
+
// through to the engine's stock room server (whose `extraPrimitives()` returns
|
|
8
|
+
// null). The creator's primitive was skipped with an `unknown primitive` warning
|
|
9
|
+
// swallowed in partykit's stderr, while the client predicted it anyway. The
|
|
10
|
+
// mechanic worked in production and not on the creator's machine — which is the
|
|
11
|
+
// worst way round, because they only discover it after shipping.
|
|
12
|
+
//
|
|
13
|
+
// So: when a game has primitives, we generate a partykit project for it and
|
|
14
|
+
// point dev at that instead.
|
|
15
|
+
//
|
|
16
|
+
// WHY WE BUNDLE IT OURSELVES. A creator's primitive imports engine helpers the
|
|
17
|
+
// way everything else in a game does — `import { runEffect } from
|
|
18
|
+
// '/shared/ui/room/primitives/effects.js'`. That leading `/shared/` is a
|
|
19
|
+
// dev-server alias, not a path on disk, and partykit's bundler has never heard
|
|
20
|
+
// of it. So we esbuild the room server here with a plugin that resolves
|
|
21
|
+
// `/shared/*` to the installed engine, and hand partykit one self-contained
|
|
22
|
+
// module. (The publish path solves the same problem differently: it marks those
|
|
23
|
+
// imports EXTERNAL and rewrites them to the release's pre-bundled
|
|
24
|
+
// `rooms-runtime.js`, because the publish endpoint is a Worker with no bundler.
|
|
25
|
+
// Same seam, two deliveries.)
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
29
|
+
import { pathToFileURL } from 'node:url';
|
|
30
|
+
import {
|
|
31
|
+
assertNoOverrideCycle,
|
|
32
|
+
isFrameworkV2,
|
|
33
|
+
isServerBacked,
|
|
34
|
+
renderBarrel,
|
|
35
|
+
renderBrowserRegistry,
|
|
36
|
+
scanPrimitives,
|
|
37
|
+
} from './primitives.mjs';
|
|
38
|
+
import { shadowResolvePlugin } from './shadow-resolve.mjs';
|
|
39
|
+
import { DOOR_FILTER, canonicalSpecifier, stateDir } from './legacy-names.mjs';
|
|
40
|
+
|
|
41
|
+
// Build output, not source: gitignored by the scaffold and skipped by publish's
|
|
42
|
+
// file collection. The creator never opens it.
|
|
43
|
+
export const DEV_ROOM_SERVER_DIR = '.joystack/room-server';
|
|
44
|
+
|
|
45
|
+
// The generated BROWSER registry lands here, and `joystack dev` mounts it at
|
|
46
|
+
// `/shared/` AHEAD of both the creator's own overrides/ and the engine — so the
|
|
47
|
+
// client's primitive registry is `{ ...engine, ...the game's }`.
|
|
48
|
+
//
|
|
49
|
+
// It cannot live in the creator's `overrides/shared/` for two reasons:
|
|
50
|
+
// `index.js` there is RESERVED (a hand-written one would silently unregister
|
|
51
|
+
// every engine primitive), and a build artifact has no business in their repo.
|
|
52
|
+
// At publish the same bytes are injected straight into the upload, so neither
|
|
53
|
+
// path ever touches their git.
|
|
54
|
+
export const DEV_OVERRIDES_DIR = '.joystack/overrides/shared';
|
|
55
|
+
|
|
56
|
+
// Resolve the `/shared/...` dev alias to the installed engine. Everything a
|
|
57
|
+
// primitive imports through it — and the room server itself — comes from the
|
|
58
|
+
// exact engine version this game is pinned to.
|
|
59
|
+
function engineAliasPlugin(sharedDir) {
|
|
60
|
+
return {
|
|
61
|
+
name: 'joystack-shared-alias',
|
|
62
|
+
setup(build) {
|
|
63
|
+
build.onResolve({ filter: /^\/shared\// }, (args) => ({
|
|
64
|
+
path: join(sharedDir, args.path.slice('/shared/'.length)),
|
|
65
|
+
}));
|
|
66
|
+
// Workers-runtime built-ins are provided at execution; never bundle them.
|
|
67
|
+
build.onResolve({ filter: /^cloudflare:/ }, (args) => ({ path: args.path, external: true }));
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Entity components take the same trip as vendored primitives: scanned from
|
|
73
|
+
// the game tree, bundled, handed to the host — through the sibling
|
|
74
|
+
// `extraEntityComponents()` seam. The room bundle carries each component's
|
|
75
|
+
// MANIFEST (so channel gates are trusted — a world spec arrives from a
|
|
76
|
+
// client and may not define them) and its SIM entry: `server/sim.js` when
|
|
77
|
+
// the component has one, else `shared/sim.js` (predicted sims live in
|
|
78
|
+
// shared/ so the browser can run them too; server/ is for genuinely
|
|
79
|
+
// server-only adjudication and may import shared/ itself).
|
|
80
|
+
//
|
|
81
|
+
// The scan implementation lives in the ENGINE (shared/ui/room/entity/) at
|
|
82
|
+
// the same relative path in the repo and the built artifact. An engine too
|
|
83
|
+
// old to carry it means no entity components exist for that game — skip.
|
|
84
|
+
export async function scanEntityComponents(projectDir, sharedDir) {
|
|
85
|
+
const scanPath = join(sharedDir, 'ui', 'room', 'entity', 'scan.js');
|
|
86
|
+
if (!existsSync(scanPath)) return [];
|
|
87
|
+
const { scanGame } = await import(pathToFileURL(scanPath).href);
|
|
88
|
+
const { components, errors } = scanGame(projectDir);
|
|
89
|
+
if (errors.length) {
|
|
90
|
+
throw new Error(`this game's entity components cannot build a room server:\n - ${errors.join('\n - ')}`);
|
|
91
|
+
}
|
|
92
|
+
const posix = (p) => relative(projectDir, p).split('\\').join('/');
|
|
93
|
+
return [...components.values()].map((c) => {
|
|
94
|
+
const serverSim = join(c.dir, 'server', 'sim.js');
|
|
95
|
+
const sharedSim = join(c.dir, 'shared', 'sim.js');
|
|
96
|
+
const code = existsSync(serverSim) ? serverSim : existsSync(sharedSim) ? sharedSim : null;
|
|
97
|
+
return {
|
|
98
|
+
name: c.name,
|
|
99
|
+
manifestRel: posix(c.manifestPath),
|
|
100
|
+
codeRel: code ? posix(code) : null,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderEntityComponents(components) {
|
|
106
|
+
if (!components.length) return 'export const extraEntityComponents = null;';
|
|
107
|
+
const lines = [];
|
|
108
|
+
const manifests = [];
|
|
109
|
+
const code = [];
|
|
110
|
+
components.forEach((c, i) => {
|
|
111
|
+
lines.push(`import entityManifest${i} from './${c.manifestRel}';`);
|
|
112
|
+
manifests.push(`entityManifest${i}`);
|
|
113
|
+
if (c.codeRel) {
|
|
114
|
+
lines.push(`import * as entityCode${i} from './${c.codeRel}';`);
|
|
115
|
+
code.push(` '${c.name}': entityCode${i},`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return [
|
|
119
|
+
...lines,
|
|
120
|
+
'export const extraEntityComponents = {',
|
|
121
|
+
` manifests: [${manifests.join(', ')}],`,
|
|
122
|
+
' code: {',
|
|
123
|
+
...code,
|
|
124
|
+
' },',
|
|
125
|
+
'};',
|
|
126
|
+
].join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── framework v2 ─────────────────────────────────────────────────────────
|
|
130
|
+
//
|
|
131
|
+
// A v2 game (package.json `joystack.framework === 'v2'`) authors entities + traits
|
|
132
|
+
// + a world.js; there is no overrides/ or components/ folder. It runs on v1's
|
|
133
|
+
// runtime by LOWERING to v1 artifacts. That lowering is a real compiler with a
|
|
134
|
+
// filesystem+AST front end (expand + the analyzer), so it runs HERE, in node,
|
|
135
|
+
// at build time — never in the workerd room. What ships to the room is the
|
|
136
|
+
// serializable SKELETON (names + schedule, no closures); the room re-runs the
|
|
137
|
+
// game's own closures and zips them back onto it, then lowers. See the engine's
|
|
138
|
+
// shared/framework/README.md ("build/ vs runtime/").
|
|
139
|
+
//
|
|
140
|
+
// So the generated room module has two halves: the SKELETON as an embedded JSON
|
|
141
|
+
// literal (computed here), and an import of the game's world + entity modules so
|
|
142
|
+
// the room can re-run their closures. At module load it calls assembleGraph +
|
|
143
|
+
// lowerGraph (both filesystem-free `runtime/` modules) to rebuild the lowered
|
|
144
|
+
// trio, and hands it to the host through the `frameworkV2()` seam JoystackRoom reads.
|
|
145
|
+
// The generated v2 room module source. `skeleton` is emitSkeleton()'s output;
|
|
146
|
+
// its `kinds` carry each entity's export name + game-root-relative module, in
|
|
147
|
+
// the order the room must re-run them (assembleGraph zips row-for-row by this
|
|
148
|
+
// order). The world root is always `./world.js` (the v2 one-world-per-game
|
|
149
|
+
// convention). Imports are aliased K0/K1/… so a kind name can never collide
|
|
150
|
+
// with a keyword or another binding.
|
|
151
|
+
// The shared front matter both v2 room modules emit: import the framework
|
|
152
|
+
// runtime (assemble + lower) and the game's world + kinds, embed the shipped
|
|
153
|
+
// skeleton, and rebuild the lowered v1 trio at module load. The two callers add
|
|
154
|
+
// only how the trio is EXPOSED — dev subclasses JoystackRoom and returns it from
|
|
155
|
+
// frameworkV2(); publish exports it as `frameworkV2Bundle` for the endpoint's
|
|
156
|
+
// generated entry to wrap. Kept as one function so the kind-aliasing, the
|
|
157
|
+
// `./world.js` root convention, and the assemble→lower call can never drift
|
|
158
|
+
// between the dev and publish builds.
|
|
159
|
+
// A relative path with forward slashes whichever host produced it.
|
|
160
|
+
const posixPath = (p) => p.split('\\').join('/');
|
|
161
|
+
|
|
162
|
+
// The first line of every generated room module, dev and publish alike.
|
|
163
|
+
//
|
|
164
|
+
// Installing deterministic transcendental math happens by import side effect,
|
|
165
|
+
// so the guarantee is about ORDER: any module evaluated ahead of it can capture
|
|
166
|
+
// a native `Math.sin` (or precompute a table with one) and go on using it for
|
|
167
|
+
// the life of the process. A room whose math differs by one bit from the
|
|
168
|
+
// browser's diverges from every replay of its ticks and every predicted tick,
|
|
169
|
+
// hundreds of ticks after the difference is introduced.
|
|
170
|
+
//
|
|
171
|
+
// It is emitted by each caller rather than by the shared preamble so that
|
|
172
|
+
// "first" is true of the emitted text itself. The dev entry would otherwise
|
|
173
|
+
// inherit the install through `/shared/ui/room/server.js`, which is correct
|
|
174
|
+
// today only because that file's own first import is this one — a guarantee
|
|
175
|
+
// living in a different module, one reorder away from silently ending.
|
|
176
|
+
const DETERMINISTIC_MATH_MODULE = 'ui/room/deterministic-math.js';
|
|
177
|
+
const INSTALL_DETERMINISTIC_MATH = `import '/shared/${DETERMINISTIC_MATH_MODULE}';`;
|
|
178
|
+
|
|
179
|
+
// …but only against an engine that HAS it.
|
|
180
|
+
//
|
|
181
|
+
// The CLI and the engine are separate releases, and a game pins the engine
|
|
182
|
+
// exactly while taking the CLI through a caret range — so an ordinary `npm
|
|
183
|
+
// install` routinely produces a new CLI driving an older engine. `/shared/…`
|
|
184
|
+
// specifiers are resolved against that pinned engine's directory and bundled,
|
|
185
|
+
// not left external, so emitting this import unconditionally makes esbuild fail
|
|
186
|
+
// to read a file that release does not contain, and BOTH `joystack dev` and
|
|
187
|
+
// `joystack publish` die at the bundle step for a game that was building fine.
|
|
188
|
+
//
|
|
189
|
+
// Feature-detecting the file is the whole fix: an older engine simply generates
|
|
190
|
+
// the room it generated before. It also degrades in the right direction — such
|
|
191
|
+
// a room runs on native math, which is what it was already doing.
|
|
192
|
+
function installDeterministicMath(sharedDir) {
|
|
193
|
+
if (!sharedDir) return []; // no engine to interrogate: emit nothing rather than a guess
|
|
194
|
+
return existsSync(join(sharedDir, DETERMINISTIC_MATH_MODULE)) ? [INSTALL_DETERMINISTIC_MATH] : [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderV2Preamble(raw) {
|
|
198
|
+
// Kind modules are game-relative paths the engine relativized on the
|
|
199
|
+
// creator's machine. An engine whose framework build predates the
|
|
200
|
+
// forward-slash fix emits them with the host separator, so on Windows they
|
|
201
|
+
// arrive as `entities\saucer.js`. Dropped raw into a string literal, `\s` is
|
|
202
|
+
// an escape sequence and esbuild looks for `./entitiessaucer.js`. The CLI and
|
|
203
|
+
// the engine ship separately, so a fixed CLI on an unfixed engine is a real
|
|
204
|
+
// pairing; import specifiers and the URLs the client's kind loader builds
|
|
205
|
+
// from the embedded skeleton are always forward-slash, so normalize both.
|
|
206
|
+
const skeleton = { ...raw, kinds: raw.kinds.map((k) => ({ ...k, module: posixPath(k.module) })) };
|
|
207
|
+
const imports = skeleton.kinds.map(
|
|
208
|
+
(k, i) => `import { ${k.name} as K${i} } from './${k.module}';`,
|
|
209
|
+
);
|
|
210
|
+
const kindList = skeleton.kinds.map((_, i) => `K${i}`).join(', ');
|
|
211
|
+
return [
|
|
212
|
+
"import { assembleGraph } from '/shared/framework/runtime/assemble.js';",
|
|
213
|
+
"import { lowerGraph } from '/shared/framework/runtime/lower.js';",
|
|
214
|
+
"import World from './world.js';",
|
|
215
|
+
...imports,
|
|
216
|
+
'',
|
|
217
|
+
`const SKELETON = ${JSON.stringify(skeleton)};`,
|
|
218
|
+
'',
|
|
219
|
+
'// Re-run the game closures in the room and zip them onto the shipped',
|
|
220
|
+
'// skeleton, then lower to v1 artifacts. Runs once, at module load.',
|
|
221
|
+
`const GRAPH = assembleGraph(SKELETON, { world: World, kinds: [${kindList}] });`,
|
|
222
|
+
'const TRIO = lowerGraph(GRAPH, SKELETON.schedule);',
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function renderV2Entry(skeleton, { sharedDir } = {}) {
|
|
227
|
+
return [
|
|
228
|
+
...installDeterministicMath(sharedDir),
|
|
229
|
+
"import JoystackRoom from '/shared/ui/room/server.js';",
|
|
230
|
+
...renderV2Preamble(skeleton),
|
|
231
|
+
'const BUNDLE = { ...TRIO, config: GRAPH.config };',
|
|
232
|
+
'',
|
|
233
|
+
'export default class GameRoom extends JoystackRoom {',
|
|
234
|
+
' frameworkV2() { return BUNDLE; }',
|
|
235
|
+
'}',
|
|
236
|
+
'',
|
|
237
|
+
].join('\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Alias the `joystack` doors a v2 game's world/entities/traits import from to the
|
|
241
|
+
// engine's runtime modules — resolved to the exact engine version this game
|
|
242
|
+
// pins, same discipline as the `/shared/` alias. Only the SERVER-valid doors are
|
|
243
|
+
// here: `joystack` (the authoring verbs) and `joystack/traits` (the composable traits).
|
|
244
|
+
// The browser-only doors (`joystack/client`, `joystack/renderers/*`) are deliberately
|
|
245
|
+
// absent — server code that imports one should fail to resolve LOUDLY, not bundle
|
|
246
|
+
// DOM/renderer code into the room worker. This is one of five door-resolution
|
|
247
|
+
// contexts (the others: shared/framework/build/expand.js, packages/cli/lib/
|
|
248
|
+
// inject.mjs, packages/cli/lib/joystack-node-doors.mjs, and
|
|
249
|
+
// backend/api/src/lib/inject.js); the full import surface is
|
|
250
|
+
// shared/docs/api.md. A browser door reaching here is regression-tested in
|
|
251
|
+
// packages/cli/lib/room-server-v2.test.mjs.
|
|
252
|
+
const V2_SERVER_DOORS = {
|
|
253
|
+
joystack: 'joystack.js',
|
|
254
|
+
'joystack/traits': 'traits.js',
|
|
255
|
+
};
|
|
256
|
+
function joystackAliasPlugin(sharedDir) {
|
|
257
|
+
return {
|
|
258
|
+
name: 'joystack-framework-alias',
|
|
259
|
+
setup(build) {
|
|
260
|
+
build.onResolve({ filter: DOOR_FILTER }, (args) => {
|
|
261
|
+
const rel = V2_SERVER_DOORS[canonicalSpecifier(args.path)];
|
|
262
|
+
// A browser-only or unknown door is refused HERE, by name. Leaving it to
|
|
263
|
+
// esbuild stopped working once a game carried a real node_modules/joystack
|
|
264
|
+
// (joystack-node-doors.mjs): the specifier resolves now, so esbuild would
|
|
265
|
+
// either bundle a stub or fail on that package's own imports, and either
|
|
266
|
+
// way the reader would never see which door was wrong.
|
|
267
|
+
if (!rel) {
|
|
268
|
+
return {
|
|
269
|
+
errors: [{
|
|
270
|
+
text: `\`${args.path}\` cannot be used in room code — it is a browser door. `
|
|
271
|
+
+ `The room may import ${Object.keys(V2_SERVER_DOORS).map((d) => `\`${d}\``).join(' and ')}; `
|
|
272
|
+
+ 'everything else runs on the page. See shared/docs/api.md.',
|
|
273
|
+
}],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return { path: join(sharedDir, 'framework', 'runtime', rel) };
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Build the v2 room server: expand + lower the game HERE (node), emit the
|
|
283
|
+
// skeleton, codegen the room module, esbuild it for workerd. Returns the same
|
|
284
|
+
// `{ cwd, inputs }` shape as the primitive path so dev watches the right files.
|
|
285
|
+
export async function buildV2RoomServer({ projectDir, engine, esbuildImpl }) {
|
|
286
|
+
// The skeleton is emitted in a FRESH child process, never in-process. `joystack
|
|
287
|
+
// dev` is long-lived and rebuilds on every save; the gate loads the game via
|
|
288
|
+
// import(), so an in-process re-run would read Node's cached (pre-edit)
|
|
289
|
+
// modules and emit a STALE skeleton while esbuild bundles the fresh code — the
|
|
290
|
+
// room's embedded skeleton and its re-run code then disagree and the room
|
|
291
|
+
// crashes on reload (the "assemble: kind X re-ran N ... but the skeleton has
|
|
292
|
+
// M" error). A child starts with an empty module cache, so the skeleton always
|
|
293
|
+
// matches the code on disk. (The publish path builds once per process, so it
|
|
294
|
+
// stays in-process — see buildV2PublishModule.)
|
|
295
|
+
const gateRun = join(engine.sharedDir, 'framework', 'build', 'gate-run.mjs');
|
|
296
|
+
const res = spawnSync(process.execPath, [gateRun, projectDir, '--skeleton'], {
|
|
297
|
+
encoding: 'utf8',
|
|
298
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
299
|
+
});
|
|
300
|
+
if (res.status !== 0) {
|
|
301
|
+
throw new Error(`this v2 game does not build:\n${(res.stderr || res.stdout || '(see .joystack/diagnostics.json)').trim()}`);
|
|
302
|
+
}
|
|
303
|
+
const skeleton = JSON.parse(res.stdout);
|
|
304
|
+
|
|
305
|
+
const esbuild = esbuildImpl ?? (await import('esbuild'));
|
|
306
|
+
const outDir = join(stateDir(projectDir), 'room-server');
|
|
307
|
+
mkdirSync(outDir, { recursive: true });
|
|
308
|
+
|
|
309
|
+
const result = await esbuild.build({
|
|
310
|
+
stdin: {
|
|
311
|
+
contents: renderV2Entry(skeleton, { sharedDir: engine.sharedDir }),
|
|
312
|
+
resolveDir: projectDir,
|
|
313
|
+
sourcefile: 'joystack-room-server.js',
|
|
314
|
+
loader: 'js',
|
|
315
|
+
},
|
|
316
|
+
outfile: join(outDir, 'server.js'),
|
|
317
|
+
bundle: true,
|
|
318
|
+
format: 'esm',
|
|
319
|
+
platform: 'browser',
|
|
320
|
+
target: 'esnext',
|
|
321
|
+
legalComments: 'none',
|
|
322
|
+
conditions: ['workerd', 'worker'],
|
|
323
|
+
loader: { '.wasm': 'binary' },
|
|
324
|
+
metafile: true,
|
|
325
|
+
absWorkingDir: projectDir,
|
|
326
|
+
plugins: [engineAliasPlugin(engine.sharedDir), joystackAliasPlugin(engine.sharedDir)],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
writeFileSync(
|
|
330
|
+
join(outDir, 'partykit.json'),
|
|
331
|
+
JSON.stringify(
|
|
332
|
+
{
|
|
333
|
+
$schema: 'https://www.partykit.io/schema.json',
|
|
334
|
+
name: 'joystack-dev',
|
|
335
|
+
main: 'server.js',
|
|
336
|
+
compatibilityDate: '2024-09-23',
|
|
337
|
+
},
|
|
338
|
+
null,
|
|
339
|
+
2,
|
|
340
|
+
) + '\n',
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const inputs = Object.keys(result.metafile?.inputs ?? {})
|
|
344
|
+
.filter((p) => p !== 'joystack-room-server.js' && !p.startsWith('<'))
|
|
345
|
+
.map((p) => (isAbsolute(p) ? p : join(projectDir, p)));
|
|
346
|
+
|
|
347
|
+
// The CLIENT needs the SAME skeleton the room embeds: the serve path injects it
|
|
348
|
+
// as `globalThis.__JOYSTACK_SKELETON__` before game.js runs, so startGame() can
|
|
349
|
+
// re-run the game's closures and rebuild the trio in the browser. Hand it up.
|
|
350
|
+
return { cwd: outDir, inputs, skeleton };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The PUBLISH counterpart of renderV2Entry. Publish and dev diverge in one way:
|
|
354
|
+
// dev bundles the WHOLE room (JoystackRoom included) into one self-contained module
|
|
355
|
+
// partykit runs directly; publish uploads a THREE-module worker (a generated
|
|
356
|
+
// entry + the release's pre-bundled rooms-runtime.js + this game module), so
|
|
357
|
+
// this module must NOT bundle JoystackRoom — the release runtime provides it, and
|
|
358
|
+
// re-bundling the creator's local copy would drift from the pinned release and
|
|
359
|
+
// duplicate partyserver. So it imports only the framework runtime (assemble +
|
|
360
|
+
// lower) and the game's own world + kinds, computes the lowered v1 trio at load,
|
|
361
|
+
// and EXPORTS it as `frameworkV2Bundle`. The generated entry
|
|
362
|
+
// (renderV2GameServerEntry, builder side) imports that export and wraps it with
|
|
363
|
+
// the release's JoystackRoom.
|
|
364
|
+
export function renderV2RoomModule(skeleton, { sharedDir } = {}) {
|
|
365
|
+
return [
|
|
366
|
+
...installDeterministicMath(sharedDir),
|
|
367
|
+
...renderV2Preamble(skeleton),
|
|
368
|
+
'export const frameworkV2Bundle = { ...TRIO, config: GRAPH.config };',
|
|
369
|
+
'',
|
|
370
|
+
].join('\n');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Build the v2 game module the publish endpoint uploads: run the gate (expand +
|
|
374
|
+
// lower + checks) HERE in node, emit the skeleton, codegen the room module, and
|
|
375
|
+
// esbuild it into ONE self-contained module (JoystackRoom NOT bundled — see
|
|
376
|
+
// renderV2RoomModule). Returns the bundled source text plus the skeleton (the
|
|
377
|
+
// client needs the SAME skeleton, injected as globalThis.__JOYSTACK_SKELETON__).
|
|
378
|
+
// `write: false` — the bytes go into the publish upload, never to disk.
|
|
379
|
+
export async function buildV2PublishModule({ projectDir, engine, esbuildImpl }) {
|
|
380
|
+
const buildUrl = (rel) => pathToFileURL(join(engine.sharedDir, 'framework', rel)).href;
|
|
381
|
+
const { runGate } = await import(buildUrl('build/gate.js'));
|
|
382
|
+
const { emitSkeleton } = await import(buildUrl('build/emit.js'));
|
|
383
|
+
|
|
384
|
+
const { ok, graph, report } = await runGate(projectDir, { write: false });
|
|
385
|
+
if (!ok) {
|
|
386
|
+
const errs = (report?.errors ?? []).map((e) => ` - ${e.message ?? JSON.stringify(e)}`).join('\n');
|
|
387
|
+
throw new Error(`this v2 game does not build:\n${errs || ' (see .joystack/diagnostics.json)'}`);
|
|
388
|
+
}
|
|
389
|
+
const skeleton = emitSkeleton(graph);
|
|
390
|
+
|
|
391
|
+
const esbuild = esbuildImpl ?? (await import('esbuild'));
|
|
392
|
+
const result = await esbuild.build({
|
|
393
|
+
stdin: {
|
|
394
|
+
contents: renderV2RoomModule(skeleton, { sharedDir: engine.sharedDir }),
|
|
395
|
+
resolveDir: projectDir,
|
|
396
|
+
sourcefile: 'joystack-v2-room.js',
|
|
397
|
+
loader: 'js',
|
|
398
|
+
},
|
|
399
|
+
bundle: true,
|
|
400
|
+
write: false,
|
|
401
|
+
format: 'esm',
|
|
402
|
+
platform: 'browser',
|
|
403
|
+
target: 'esnext',
|
|
404
|
+
legalComments: 'none',
|
|
405
|
+
conditions: ['workerd', 'worker'],
|
|
406
|
+
loader: { '.wasm': 'binary' },
|
|
407
|
+
absWorkingDir: projectDir,
|
|
408
|
+
plugins: [engineAliasPlugin(engine.sharedDir), joystackAliasPlugin(engine.sharedDir)],
|
|
409
|
+
});
|
|
410
|
+
return { source: result.outputFiles[0].text, skeleton };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The room the game actually gets: the engine's room class, subclassed to hand
|
|
414
|
+
// the host the creator's primitives through the `extraPrimitives()` seam the
|
|
415
|
+
// engine already reads (server.js). Identical in shape to what the publish
|
|
416
|
+
// endpoint generates for the per-game Worker — one contract, two builders.
|
|
417
|
+
export function renderV1Entry(primitives, entityComponents, { sharedDir } = {}) {
|
|
418
|
+
return [
|
|
419
|
+
// Before the barrel and before the entity components — both of which emit
|
|
420
|
+
// imports of the CREATOR's own simulation code, which is exactly the code
|
|
421
|
+
// that must not be evaluated on native transcendental math.
|
|
422
|
+
...installDeterministicMath(sharedDir),
|
|
423
|
+
renderBarrel(primitives),
|
|
424
|
+
renderEntityComponents(entityComponents),
|
|
425
|
+
"import JoystackRoom from '/shared/ui/room/server.js';",
|
|
426
|
+
'',
|
|
427
|
+
'export default class GameRoom extends JoystackRoom {',
|
|
428
|
+
' extraPrimitives() { return extraPrimitives; }',
|
|
429
|
+
' extraEntityComponents() { return extraEntityComponents; }',
|
|
430
|
+
'}',
|
|
431
|
+
'',
|
|
432
|
+
].join('\n');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// → `{ cwd, inputs }`: the cwd to run partykit in — the engine's stock room
|
|
436
|
+
// server when the game has no primitives of its own, a freshly-built one when
|
|
437
|
+
// it does — plus the absolute paths of every file bundled into it. The room
|
|
438
|
+
// bundle is frozen at build time, so `inputs` is what dev watches to know the
|
|
439
|
+
// running room no longer matches the files on disk.
|
|
440
|
+
//
|
|
441
|
+
// Throws if the primitives dir is malformed (e.g. a hand-written index.js) —
|
|
442
|
+
// dev must not quietly serve a room that is missing the creator's code.
|
|
443
|
+
export async function buildDevRoomServer({ projectDir, engine, esbuildImpl }) {
|
|
444
|
+
// A v2 game runs its own (lowered) code on the server, so it gets its own room
|
|
445
|
+
// server — but built by the framework compiler, not the primitive scanner.
|
|
446
|
+
if (isFrameworkV2(projectDir)) {
|
|
447
|
+
return buildV2RoomServer({ projectDir, engine, esbuildImpl });
|
|
448
|
+
}
|
|
449
|
+
const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
|
|
450
|
+
const entityComponents = await scanEntityComponents(projectDir, engine.sharedDir); // throws on a malformed component
|
|
451
|
+
if (!primitives.length && !entityComponents.length) {
|
|
452
|
+
// Not server-backed (any more). A PREVIOUS run may have generated a room
|
|
453
|
+
// server and a registry shadow; both must go, or dev keeps mounting a stale
|
|
454
|
+
// `.joystack/overrides/shared/.../index.js` that imports a primitive the creator
|
|
455
|
+
// has since deleted — an ES-import 404 that stops the whole room library
|
|
456
|
+
// loading. The mount is unconditional (dev.mjs), so the cleanup has to be too.
|
|
457
|
+
rmSync(join(stateDir(projectDir), 'room-server'), { recursive: true, force: true });
|
|
458
|
+
rmSync(join(stateDir(projectDir), 'overrides', 'shared'), { recursive: true, force: true });
|
|
459
|
+
return { cwd: engine.roomServerDir, inputs: [] };
|
|
460
|
+
}
|
|
461
|
+
assertNoOverrideCycle(primitives, projectDir, engine.sharedDir);
|
|
462
|
+
|
|
463
|
+
const esbuild = esbuildImpl ?? (await import('esbuild'));
|
|
464
|
+
const outDir = join(stateDir(projectDir), 'room-server');
|
|
465
|
+
mkdirSync(outDir, { recursive: true });
|
|
466
|
+
|
|
467
|
+
const result = await esbuild.build({
|
|
468
|
+
// No entry file on disk: the barrel + subclass are fed straight in, resolved
|
|
469
|
+
// against the game root, so `./overrides/...` means what it says and no
|
|
470
|
+
// machine-specific absolute path can leak into the output.
|
|
471
|
+
stdin: {
|
|
472
|
+
contents: renderV1Entry(primitives, entityComponents, { sharedDir: engine.sharedDir }),
|
|
473
|
+
resolveDir: projectDir,
|
|
474
|
+
sourcefile: 'joystack-room-server.js',
|
|
475
|
+
loader: 'js',
|
|
476
|
+
},
|
|
477
|
+
outfile: join(outDir, 'server.js'),
|
|
478
|
+
bundle: true,
|
|
479
|
+
format: 'esm',
|
|
480
|
+
// workerd is browser-shaped, and so is the engine's own room runtime build.
|
|
481
|
+
platform: 'browser',
|
|
482
|
+
target: 'esnext',
|
|
483
|
+
legalComments: 'none',
|
|
484
|
+
conditions: ['workerd', 'worker'],
|
|
485
|
+
loader: { '.wasm': 'binary' },
|
|
486
|
+
metafile: true,
|
|
487
|
+
absWorkingDir: projectDir,
|
|
488
|
+
plugins: [
|
|
489
|
+
// Shadowing, for the bundler: an override's relative imports fall through
|
|
490
|
+
// to the engine exactly as they do in the browser. This is what lets an
|
|
491
|
+
// override subclass the primitive it overrides (`import { PlayerBody }
|
|
492
|
+
// from './engine.js'`) — it cannot import its own path.
|
|
493
|
+
shadowResolvePlugin({ projectDir, sharedDir: engine.sharedDir }),
|
|
494
|
+
engineAliasPlugin(engine.sharedDir),
|
|
495
|
+
],
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
writeFileSync(
|
|
499
|
+
join(outDir, 'partykit.json'),
|
|
500
|
+
JSON.stringify(
|
|
501
|
+
{
|
|
502
|
+
$schema: 'https://www.partykit.io/schema.json',
|
|
503
|
+
name: 'joystack-dev',
|
|
504
|
+
main: 'server.js',
|
|
505
|
+
compatibilityDate: '2024-09-23',
|
|
506
|
+
},
|
|
507
|
+
null,
|
|
508
|
+
2,
|
|
509
|
+
) + '\n',
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
// The BROWSER half. Building the room server fixes the SERVER; without this
|
|
513
|
+
// the client would still predict with the ENGINE's class of the same name,
|
|
514
|
+
// disagree with the server every tick, and rubber-band. Served by dev at
|
|
515
|
+
// `/shared/ui/room/primitives/index.js`, shadowing the engine's. Its imports
|
|
516
|
+
// are ABSOLUTE `/shared/...` (see renderBrowserRegistry) so they resolve the
|
|
517
|
+
// same in dev (static mount fall-through) and in production (the import map):
|
|
518
|
+
// /shared/ui/room/primitives/engine.js → not shadowed → the engine's list
|
|
519
|
+
// /shared/ui/room/primitives/beacon.js → shadowed → the game's primitive
|
|
520
|
+
if (primitives.length) {
|
|
521
|
+
const registryDir = join(stateDir(projectDir), 'overrides', 'shared', 'ui/room/primitives');
|
|
522
|
+
mkdirSync(registryDir, { recursive: true });
|
|
523
|
+
writeFileSync(join(registryDir, 'index.js'), renderBrowserRegistry(primitives));
|
|
524
|
+
} else {
|
|
525
|
+
// Entity components alone need no primitive-registry shadow — and a stale
|
|
526
|
+
// one from a removed primitive must not stay mounted.
|
|
527
|
+
rmSync(join(stateDir(projectDir), 'overrides', 'shared'), { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Metafile inputs are relative to absWorkingDir (the game root); the stdin
|
|
531
|
+
// entry (keyed by its `sourcefile` name, no file on disk) is excluded.
|
|
532
|
+
const inputs = Object.keys(result.metafile?.inputs ?? {})
|
|
533
|
+
.filter((p) => p !== 'joystack-room-server.js' && !p.startsWith('<'))
|
|
534
|
+
.map((p) => (isAbsolute(p) ? p : join(projectDir, p)));
|
|
535
|
+
|
|
536
|
+
return { cwd: outDir, inputs };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export { isServerBacked };
|