@joystack/cli 0.1.44 → 0.1.45

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 CHANGED
@@ -14,6 +14,26 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.45] - 2026-10-09
18
+
19
+ ### Changed
20
+
21
+ - `joystack publish` uploads only the files Joystack does not already have,
22
+ one file per request and several at once, then puts the game live. A large game publishes reliably
23
+ instead of failing with HTTP 503; a copy under a new name (a review link, a
24
+ lane's copy) sends only what changed; a publish cut off halfway picks up
25
+ where it stopped; and a passing failure (a dropped connection, the platform
26
+ busy, a rate limit) is retried instead of failing the publish.
27
+ - `joystack publish` checks your game's code against its engine before
28
+ uploading anything: a `/shared/…` path or a `joystack…` import your engine
29
+ does not have stops the publish with the same message as before, without
30
+ waiting for the upload.
31
+
32
+ ### Fixed
33
+
34
+ - A publish that fails no longer leaves its name claimed, or a multiplayer
35
+ game's server running with no page in front of it.
36
+
17
37
  ## [0.1.44] - 2026-10-09
18
38
 
19
39
  ### Added
package/lib/inject.mjs CHANGED
@@ -34,7 +34,7 @@ function jsJson(value) {
34
34
  // resolve them to the framework runtime under the (already-mounted) /shared/
35
35
  // tree. This set is the import surface (shared/docs/api.md); keep it in sync
36
36
  // with V2_BARE_SPECIFIERS in backend/api/src/lib/inject.js.
37
- const V2_IMPORTS = {
37
+ export const V2_IMPORTS = {
38
38
  joystack: '/shared/framework/runtime/joystack.js',
39
39
  'joystack/traits': '/shared/framework/runtime/traits.js',
40
40
  'joystack/client': '/shared/framework/runtime/client.js',
@@ -93,6 +93,12 @@ export function canonicalSpecifier(specifier) {
93
93
  return specifier;
94
94
  }
95
95
 
96
+ /**
97
+ * The engine file the root door resolves to in an engine released before the
98
+ * rename, which has no `shared/framework/runtime/joystack.js`.
99
+ */
100
+ export const LEGACY_ROOT_DOOR_FILE = `shared/framework/runtime/${OLD}.js`;
101
+
96
102
  /** A browser import map extended with the old name of every joystack door it maps. */
97
103
  export function withLegacyDoors(imports) {
98
104
  const out = { ...imports };
@@ -0,0 +1,75 @@
1
+ // The checks of a game's code against its engine, run before a publish uploads
2
+ // anything: every `/shared/…` path the game's code names, and every door it
3
+ // imports (`joystack`, `joystack/client`, `three`, …), must exist in the
4
+ // engine the game is pinned to. Without them the published page fails to load
5
+ // a module.
6
+ //
7
+ // The publish server runs the same checks on the single-request route
8
+ // (backend/api/src/routes/publish.js, codeError). The file-by-file route does
9
+ // not read the game's files back, so the CLI runs them here, with the same
10
+ // rules and the same messages.
11
+
12
+ import { readdirSync } from 'node:fs';
13
+ import { join, relative } from 'node:path';
14
+ import { V2_IMPORTS } from './inject.mjs';
15
+ import { LEGACY_ROOT_DOOR_FILE, withLegacyDoors } from './legacy-names.mjs';
16
+
17
+ const SHARED_REF_RE = /["'(=]\s*\/(shared\/[A-Za-z0-9_@./-]+)/g;
18
+ const CODE_FILE_RE = /\.(js|mjs|html|css)$/i;
19
+ const ROOT_DOOR = 'joystack';
20
+
21
+ function decodeText(bytes) {
22
+ try {
23
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Throws when the game's code names an engine file or door its engine does not
31
+ * ship. `files` maps each path to its bytes; `engine` is the installed engine
32
+ * ({ version, sharedDir }).
33
+ */
34
+ export function checkGameCode(files, engine) {
35
+ // The engine's files by exact path, as the platform's release lists them: a
36
+ // folder, a `..` path or a different letter case is not a file it serves.
37
+ let entries = [];
38
+ try {
39
+ entries = readdirSync(engine.sharedDir, { recursive: true, withFileTypes: true });
40
+ } catch (err) {
41
+ if (err.code !== 'ENOENT') throw err;
42
+ }
43
+ const shipped = new Set(
44
+ entries.filter((e) => e.isFile()).map((e) => `shared/${relative(engine.sharedDir, join(e.parentPath ?? e.path, e.name)).split('\\').join('/')}`),
45
+ );
46
+ const ships = (path) => shipped.has(path);
47
+ const texts = new Map();
48
+ for (const [path, bytes] of files) texts.set(path, decodeText(bytes));
49
+
50
+ for (const [path, text] of texts) {
51
+ if (text == null || !CODE_FILE_RE.test(path)) continue;
52
+ for (const m of text.matchAll(SHARED_REF_RE)) {
53
+ if (!ships(m[1])) throw new Error(`game references /${m[1]}, which engine ${engine.version} does not ship`);
54
+ }
55
+ }
56
+
57
+ // Whether each door's engine file ships, under the door's current name and
58
+ // its old one. An engine from before the rename has the root door under its
59
+ // old file name.
60
+ const available = {};
61
+ for (const [door, url] of Object.entries(V2_IMPORTS)) {
62
+ available[door] = ships(url.replace(/^\//, '')) || (door === ROOT_DOOR && ships(LEGACY_ROOT_DOOR_FILE));
63
+ }
64
+ const doors = withLegacyDoors(available);
65
+ const used = new Set([ROOT_DOOR, `${ROOT_DOOR}/client`]);
66
+ for (const text of texts.values()) {
67
+ if (!text) continue;
68
+ for (const door of Object.keys(doors)) {
69
+ if (text.includes(`'${door}'`) || text.includes(`"${door}"`)) used.add(door);
70
+ }
71
+ }
72
+ for (const door of used) {
73
+ if (!doors[door]) throw new Error(`engine ${engine.version} does not ship the \`${door}\` import`);
74
+ }
75
+ }
@@ -0,0 +1,163 @@
1
+ // The file-by-file publish: ask which files the platform lacks, upload those
2
+ // one per request, then ask it to put the game live.
3
+ //
4
+ // POST /api/creator/publish/missing fingerprints, CHECK_BATCH at a time
5
+ // PUT /api/creator/publish/file/<sha256> one file each, UPLOAD_CONCURRENCY at once
6
+ // POST /api/creator/publish/finish the publish's meta
7
+ //
8
+ // The platform stores files by fingerprint and shares them across every game,
9
+ // so a publish to a new name (a review copy, a lane's copy) uploads only what no
10
+ // earlier publish stored, and a publish cut off halfway resumes where it
11
+ // stopped. Each request is small, so none runs into the platform's
12
+ // per-request limits. A request refused for a passing reason (a rate limit, a
13
+ // network error, the platform busy or restarting) is retried on its own with a
14
+ // growing wait; anything else fails the publish with the platform's message.
15
+
16
+ // Fingerprints asked about per request. The platform answers up to 250
17
+ // (MAX_CHECK_BATCH in backend/api/src/routes/publish.js), but each request
18
+ // looks them up in storage only six at a time, each lookup a round trip of
19
+ // hundreds of milliseconds from far away; small batches in parallel requests
20
+ // answer a large game in a few seconds.
21
+ export const CHECK_BATCH = 25;
22
+ // Each upload request spends most of its time waiting on round trips between
23
+ // the platform and its database and storage, about 1.4 s from far away, so
24
+ // many overlap. Measured from Mauritius on 120 small files: 29 s at 6 at once,
25
+ // 8 s at 24, 5 s at 48.
26
+ export const UPLOAD_CONCURRENCY = 32;
27
+ // Batches of the "which are missing" question in flight at once.
28
+ export const CHECK_CONCURRENCY = 32;
29
+ const ATTEMPTS = 6;
30
+ const UPLOAD_TIMEOUT_MS = 5 * 60_000;
31
+ const REQUEST_TIMEOUT_MS = 2 * 60_000;
32
+ // The platform answers 503 for a failure that may pass (storage busy, a
33
+ // publish that failed and changed nothing); Cloudflare's edge answers 503 when
34
+ // a request ran out of CPU, and 52x when it could not reach the platform. A 500
35
+ // is retried only when it is the platform's unexpected-error answer: its other
36
+ // 500s name a fault in its own setup, which a retry does not fix. A 502 means
37
+ // a game's server failed to deploy.
38
+ const RETRYABLE = new Set([408, 429, 503, 504, 520, 521, 522, 523, 524]);
39
+ const UNEXPECTED_ERROR = '{"error":"internal error"}';
40
+
41
+ export class LegacyPlatform extends Error {}
42
+
43
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+
45
+ /**
46
+ * Uploads what the platform lacks and finishes the publish. `files` maps each
47
+ * path to its bytes, `meta.files` each path to its SHA-256. Returns the
48
+ * platform's answer ({ slug, title, … }) with `uploaded` and `alreadyStored`
49
+ * counts. Throws LegacyPlatform when the platform predates these routes.
50
+ */
51
+ export async function publishFileByFile({ apiBase, token, files, meta, log = console.log, fetchImpl = fetch, sleep = defaultSleep }) {
52
+ const auth = token ? { Authorization: `Bearer ${token}` } : {};
53
+
54
+ // One request, retried while the reason passes. Returns the response of the
55
+ // first attempt that is not retryable.
56
+ const request = async (what, url, init, timeoutMs = REQUEST_TIMEOUT_MS) => {
57
+ for (let attempt = 1; ; attempt++) {
58
+ let res = null;
59
+ let error = null;
60
+ try {
61
+ res = await fetchImpl(url, { ...init, headers: { ...auth, ...init.headers }, signal: AbortSignal.timeout(timeoutMs) });
62
+ } catch (err) {
63
+ error = err;
64
+ }
65
+ if (res?.status === 500) {
66
+ const body = await res.text();
67
+ if (body.trim() !== UNEXPECTED_ERROR) return new Response(body, { status: 500 });
68
+ res = new Response(body, { status: 500 });
69
+ } else if (res && !RETRYABLE.has(res.status)) return res;
70
+ if (attempt === ATTEMPTS) {
71
+ if (res) return res;
72
+ throw new Error(`${what} failed after ${ATTEMPTS} attempts: ${error?.cause?.message ?? error?.message ?? error}`);
73
+ }
74
+ const retryAfter = Number(res?.headers.get('retry-after'));
75
+ const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** (attempt - 1);
76
+ if (res?.status === 429) log(` The platform asked to slow down; continuing in ${Math.round(wait / 1000)} s…`);
77
+ await res?.body?.cancel().catch(() => {});
78
+ await sleep(wait);
79
+ }
80
+ };
81
+ const failed = async (what, res) => {
82
+ const detail = await res.text().catch(() => '');
83
+ return new Error(`${what} failed (HTTP ${res.status}): ${detail.slice(0, 300)}`);
84
+ };
85
+
86
+ const byHash = new Map();
87
+ for (const [path, bytes] of files) byHash.set(meta.files[path], bytes);
88
+ const hashes = [...byHash.keys()];
89
+
90
+ const batches = [];
91
+ for (let i = 0; i < hashes.length; i += CHECK_BATCH) batches.push(hashes.slice(i, i + CHECK_BATCH));
92
+ const missing = [];
93
+ const ask = async (batch) => {
94
+ const res = await request('checking which files the platform has', `${apiBase}/api/creator/publish/missing`, {
95
+ method: 'POST',
96
+ headers: { 'content-type': 'application/json' },
97
+ body: JSON.stringify({ files: batch }),
98
+ });
99
+ if (res.status === 404) throw new LegacyPlatform('the platform has no file-by-file publish');
100
+ if (!res.ok) throw await failed('publish', res);
101
+ missing.push(...(await res.json()).missing);
102
+ };
103
+ // The first batch alone, so a platform without these routes is found before
104
+ // anything else is sent.
105
+ await ask(batches[0]);
106
+ const rest = batches.slice(1);
107
+ await Promise.all(Array.from({ length: Math.min(CHECK_CONCURRENCY, rest.length) }, async () => {
108
+ for (let batch = rest.shift(); batch; batch = rest.shift()) await ask(batch);
109
+ }));
110
+
111
+ const bytesOf = (hash) => byHash.get(hash).byteLength;
112
+ const total = missing.reduce((n, h) => n + bytesOf(h), 0);
113
+ const mb = (n) => (n / 1024 / 1024).toFixed(1);
114
+ log(
115
+ missing.length
116
+ ? `Uploading ${missing.length} of ${hashes.length} files (${mb(total)} MB); the platform already has the rest.`
117
+ : `The platform already has all ${hashes.length} files; nothing to upload.`,
118
+ );
119
+
120
+ let done = 0;
121
+ let sent = 0;
122
+ let nextReport = 0.25;
123
+ const queue = [...missing];
124
+ let stopped = false;
125
+ const worker = async () => {
126
+ for (let hash = queue.shift(); hash && !stopped; hash = queue.shift()) {
127
+ let res;
128
+ try {
129
+ res = await request(
130
+ 'uploading a file',
131
+ `${apiBase}/api/creator/publish/file/${hash}`,
132
+ { method: 'PUT', headers: { 'content-type': 'application/octet-stream' }, body: byHash.get(hash) },
133
+ UPLOAD_TIMEOUT_MS,
134
+ );
135
+ } catch (err) {
136
+ stopped = true;
137
+ throw err;
138
+ }
139
+ if (!res.ok) {
140
+ stopped = true;
141
+ const path = Object.keys(meta.files).find((p) => meta.files[p] === hash);
142
+ throw await failed(`uploading ${path}`, res);
143
+ }
144
+ await res.body?.cancel().catch(() => {});
145
+ done++;
146
+ sent += bytesOf(hash);
147
+ // Progress on a long upload, at each quarter.
148
+ if (missing.length >= 40 && done / missing.length >= nextReport && done < missing.length) {
149
+ log(` ${done} of ${missing.length} files uploaded (${mb(sent)} of ${mb(total)} MB)…`);
150
+ while (done / missing.length >= nextReport) nextReport += 0.25;
151
+ }
152
+ }
153
+ };
154
+ await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, missing.length) }, worker));
155
+
156
+ const res = await request('publish', `${apiBase}/api/creator/publish/finish`, {
157
+ method: 'POST',
158
+ headers: { 'content-type': 'application/json' },
159
+ body: JSON.stringify(meta),
160
+ });
161
+ if (!res.ok) throw await failed('publish', res);
162
+ return { ...(await res.json()), uploaded: missing.length, alreadyStored: hashes.length - missing.length };
163
+ }
package/lib/publish.mjs CHANGED
@@ -5,10 +5,11 @@
5
5
  // release registry. The client never stages the shared library itself — that
6
6
  // trust hole is what this design replaces.
7
7
  //
8
- // Target: /api/creator/publish — the token-authenticated creator lane, the
9
- // one publish route reachable on the public play host (the legacy
10
- // /api/publish stays behind the builder's Access wall). The endpoint
11
- // requires the creator token; without one it 401s with a login hint.
8
+ // The upload is file by file (publish-upload.mjs): the platform says which
9
+ // files it lacks, those go up one per request, and a last request puts the
10
+ // game live. A platform without those routes gets the single request
11
+ // /api/creator/publish with every file. Both need the creator token; without
12
+ // one the platform answers 401 with a login hint.
12
13
  import { createHash } from 'node:crypto';
13
14
  import { findProject } from './project.mjs';
14
15
  import { isLane, laneParentSlug } from './lane.mjs';
@@ -32,6 +33,8 @@ export const playUrlFor = (slug) => `https://${slug}.joystack.games/`;
32
33
  export { collectGameFiles, gitIgnored } from './game-files.mjs';
33
34
  import { collectGameFiles } from './game-files.mjs';
34
35
  import { gameBuild } from './build-id.mjs';
36
+ import { checkGameCode } from './publish-checks.mjs';
37
+ import { LegacyPlatform, publishFileByFile } from './publish-upload.mjs';
35
38
 
36
39
  export async function publish({
37
40
  cwd = process.cwd(),
@@ -154,15 +157,33 @@ export async function publish({
154
157
  }
155
158
 
156
159
  const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, build, files: {}, ...frameworkMeta, ...(devTools ? { devTools: true } : {}) };
157
- const form = new FormData();
158
- for (const [path, bytes] of files) {
159
- meta.files[path] = createHash('sha256').update(bytes).digest('hex');
160
- form.append(path, new Blob([bytes]), path);
161
- }
162
- form.append('meta', JSON.stringify(meta));
160
+ for (const [path, bytes] of files) meta.files[path] = createHash('sha256').update(bytes).digest('hex');
161
+
162
+ // The platform only publishes framework-v2 games, and checks their code
163
+ // against the engine only on the single-request route; these are the same
164
+ // checks, before anything is uploaded.
165
+ if (isFrameworkV2(project.dir)) checkGameCode(files, engine);
163
166
 
164
167
  log(`Publishing ${targetSlug} (${files.size} files, engine ${engine.version}, build ${build.id}) → ${apiBase}…`);
165
168
  const token = getToken();
169
+ let out;
170
+ try {
171
+ out = await publishFileByFile({ apiBase, token, files, meta, log });
172
+ } catch (err) {
173
+ if (!(err instanceof LegacyPlatform)) throw err;
174
+ out = await publishInOneRequest({ apiBase, token, files, meta });
175
+ }
176
+ const url = playUrlFor(out.slug);
177
+ log(devTools ? `✅ Live (review build, Tweaks on): ${url}` : `✅ Live: ${url}`);
178
+ return { slug: out.slug, url };
179
+ }
180
+
181
+ // The single-request publish, for a platform without the file-by-file routes:
182
+ // every file in one multipart request.
183
+ async function publishInOneRequest({ apiBase, token, files, meta }) {
184
+ const form = new FormData();
185
+ for (const [path, bytes] of files) form.append(path, new Blob([bytes]), path);
186
+ form.append('meta', JSON.stringify(meta));
166
187
  const res = await fetch(`${apiBase}/api/creator/publish`, {
167
188
  method: 'POST',
168
189
  headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -172,8 +193,5 @@ export async function publish({
172
193
  const detail = await res.text().catch(() => '');
173
194
  throw new Error(`publish failed (HTTP ${res.status}): ${detail.slice(0, 300)}`);
174
195
  }
175
- const out = await res.json();
176
- const url = playUrlFor(out.slug);
177
- log(devTools ? `✅ Live (review build, Tweaks on): ${url}` : `✅ Live: ${url}`);
178
- return { slug: out.slug, url };
196
+ return res.json();
179
197
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joystack/cli",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "description": "Joystack game development CLI \u2014 dev server, login, and publishing for standalone Joystack games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",