@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 +20 -0
- package/lib/inject.mjs +1 -1
- package/lib/legacy-names.mjs +6 -0
- package/lib/publish-checks.mjs +75 -0
- package/lib/publish-upload.mjs +163 -0
- package/lib/publish.mjs +32 -14
- package/package.json +1 -1
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',
|
package/lib/legacy-names.mjs
CHANGED
|
@@ -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
|
-
//
|
|
9
|
-
// one
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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