@bitmagic/cli 0.1.1 → 0.1.2
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/dist/cli.d.ts +30 -0
- package/dist/cli.js +23 -11
- package/dist/cli.js.map +1 -1
- package/dist/commands/forge.d.ts +107 -0
- package/dist/commands/forge.js +393 -0
- package/dist/commands/forge.js.map +1 -0
- package/dist/commands/generate.js +58 -45
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/verify.js +16 -16
- package/dist/commands/verify.js.map +1 -1
- package/dist/forge/apply-modifications.d.ts +19 -0
- package/dist/forge/apply-modifications.js +184 -0
- package/dist/forge/apply-modifications.js.map +1 -0
- package/dist/forge/bake-progress.d.ts +44 -0
- package/dist/forge/bake-progress.js +77 -0
- package/dist/forge/bake-progress.js.map +1 -0
- package/dist/forge/browser-host.d.ts +100 -0
- package/dist/forge/browser-host.js +351 -0
- package/dist/forge/browser-host.js.map +1 -0
- package/dist/forge/local-store.d.ts +30 -0
- package/dist/forge/local-store.js +130 -0
- package/dist/forge/local-store.js.map +1 -0
- package/dist/forge/run-pipeline.d.ts +105 -0
- package/dist/forge/run-pipeline.js +269 -0
- package/dist/forge/run-pipeline.js.map +1 -0
- package/dist/forge/stream.d.ts +106 -0
- package/dist/forge/stream.js +286 -0
- package/dist/forge/stream.js.map +1 -0
- package/dist/forge/transport.d.ts +58 -0
- package/dist/forge/transport.js +145 -0
- package/dist/forge/transport.js.map +1 -0
- package/dist/forge/upload-proxy.d.ts +21 -0
- package/dist/forge/upload-proxy.js +134 -0
- package/dist/forge/upload-proxy.js.map +1 -0
- package/dist/generate/stream.d.ts +1 -10
- package/dist/generate/stream.js +5 -62
- package/dist/generate/stream.js.map +1 -1
- package/dist/http/sse.d.ts +40 -0
- package/dist/http/sse.js +98 -0
- package/dist/http/sse.js.map +1 -0
- package/dist/local-port.d.ts +30 -0
- package/dist/local-port.js +53 -0
- package/dist/local-port.js.map +1 -0
- package/dist/scaffold/project-files.js +89 -9
- package/dist/scaffold/project-files.js.map +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny loopback HTTP server that lets the ENGINE reach api-server's signed-upload endpoint.
|
|
3
|
+
*
|
|
4
|
+
* The engine uploads its own `.vxl`/`.vwld` bytes — hundreds of megabytes, so they never cross the
|
|
5
|
+
* message channel — by asking for a presigned PUT first. `StorageUploadUtil.ts` does that with a
|
|
6
|
+
* hardcoded path and no credentials:
|
|
7
|
+
*
|
|
8
|
+
* fetch(`${getAgentUrl()}/api/generate-upload-url`, {
|
|
9
|
+
* headers: { 'Content-Type': 'application/json' }, // no Authorization
|
|
10
|
+
*
|
|
11
|
+
* Neither half of that reaches api-server. The path it calls is game-play-agent's
|
|
12
|
+
* (`mastra/routes.ts`); api-server serves `POST /api/cli/v1/uploads/signed-url`. And every route
|
|
13
|
+
* under `/api/cli/v1/*` is Auth0 + Admin gated, while a browser page has no token — so pointing
|
|
14
|
+
* `agentUrl` straight at api-server answers 401 to every upload, which is exactly what the first
|
|
15
|
+
* end-to-end forge did: 14 archetypes dropped and the level bake failed after voxelizing all 392
|
|
16
|
+
* chunks.
|
|
17
|
+
*
|
|
18
|
+
* So the CLI bridges it instead of the engine changing. That direction is deliberate:
|
|
19
|
+
*
|
|
20
|
+
* - **The engine cannot be the fix.** Published games carry frozen template code compiled against
|
|
21
|
+
* the current engine (see the engine-API rule in AGENTS.md), and `StorageUploadUtil` is shared
|
|
22
|
+
* by every lane. Teaching it to send a bearer token is a breaking change to all of them to serve
|
|
23
|
+
* one.
|
|
24
|
+
* - **The token must not enter the page.** It stays in this process. The forge browser runs with
|
|
25
|
+
* `--disable-web-security`, so a token in page scope is worth strictly less than one outside it.
|
|
26
|
+
*
|
|
27
|
+
* Bound to 127.0.0.1 for the same reason: this server holds a creator's access token and answers
|
|
28
|
+
* with write credentials into Bitmagic's bucket. It must not be reachable off the machine.
|
|
29
|
+
*
|
|
30
|
+
* `gameId` is FORCED from the CLI's own project context rather than taken from the request body.
|
|
31
|
+
* api-server checks ownership regardless (`assertOwnsGame`, and the key is pinned to
|
|
32
|
+
* `worlds/v3/<gameId>/`), so this is defence in depth — but it is cheap, and it means a page that
|
|
33
|
+
* somehow holds the wrong id cannot even ask for the wrong namespace.
|
|
34
|
+
*/
|
|
35
|
+
import * as http from 'http';
|
|
36
|
+
import { CliError } from '../errors.js';
|
|
37
|
+
/** The path the ENGINE calls. Not api-server's — that is what this proxy translates. */
|
|
38
|
+
export const ENGINE_UPLOAD_PATH = '/api/generate-upload-url';
|
|
39
|
+
/** The api-server route it forwards to. */
|
|
40
|
+
export const API_SERVER_UPLOAD_PATH = '/api/cli/v1/uploads/signed-url';
|
|
41
|
+
function readBody(req) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let body = '';
|
|
44
|
+
req.on('data', (chunk) => { body += String(chunk); });
|
|
45
|
+
req.on('end', () => resolve(body));
|
|
46
|
+
req.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* CORS is answered explicitly rather than relying on `--disable-web-security`. The flag is there
|
|
51
|
+
* for the presigned PUT to GCS, not as this proxy's security model, and a request the browser
|
|
52
|
+
* refuses to make is indistinguishable from a server that is down.
|
|
53
|
+
*/
|
|
54
|
+
function writeCors(res) {
|
|
55
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
56
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
57
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
58
|
+
}
|
|
59
|
+
export async function startUploadProxy(options) {
|
|
60
|
+
const log = options.log ?? (() => { });
|
|
61
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
62
|
+
const apiBase = options.apiUrl.replace(/\/$/, '');
|
|
63
|
+
const server = http.createServer((req, res) => {
|
|
64
|
+
void (async () => {
|
|
65
|
+
writeCors(res);
|
|
66
|
+
if (req.method === 'OPTIONS') {
|
|
67
|
+
res.writeHead(204).end();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (req.method !== 'POST' || req.url !== ENGINE_UPLOAD_PATH) {
|
|
71
|
+
// Named rather than silent: an engine that starts calling a different path should show up
|
|
72
|
+
// as this line in the forge log, not as a mute upload failure.
|
|
73
|
+
log(`[upload-proxy] ignoring ${req.method ?? '?'} ${req.url ?? '?'}`);
|
|
74
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
75
|
+
.end(JSON.stringify({ error: 'Not found' }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
let parsed = {};
|
|
79
|
+
try {
|
|
80
|
+
const raw = await readBody(req);
|
|
81
|
+
if (raw)
|
|
82
|
+
parsed = JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
86
|
+
.end(JSON.stringify({ error: 'Request body must be JSON' }));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const upstream = await doFetch(`${apiBase}${API_SERVER_UPLOAD_PATH}`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: {
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
Authorization: `Bearer ${options.token}`,
|
|
95
|
+
},
|
|
96
|
+
// `noCache` is dropped: api-server pins its own immutable cacheControl. gameId is ours,
|
|
97
|
+
// not the page's — see the header.
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
gameId: options.gameId,
|
|
100
|
+
filename: parsed.filename,
|
|
101
|
+
contentType: parsed.contentType,
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
const text = await upstream.text();
|
|
105
|
+
if (!upstream.ok) {
|
|
106
|
+
log(`[upload-proxy] api-server refused ${String(parsed.filename ?? '?')}: ${upstream.status} ${text.slice(0, 200)}`);
|
|
107
|
+
}
|
|
108
|
+
res.writeHead(upstream.status, { 'Content-Type': 'application/json' }).end(text);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
112
|
+
log(`[upload-proxy] could not reach api-server: ${message}`);
|
|
113
|
+
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
114
|
+
.end(JSON.stringify({ error: `Could not reach api-server: ${message}` }));
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
});
|
|
118
|
+
await new Promise((resolve, reject) => {
|
|
119
|
+
server.once('error', reject);
|
|
120
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
121
|
+
});
|
|
122
|
+
const address = server.address();
|
|
123
|
+
if (address === null || typeof address === 'string') {
|
|
124
|
+
server.close();
|
|
125
|
+
throw new CliError('The upload proxy could not bind a local port, so the level cannot be baked.');
|
|
126
|
+
}
|
|
127
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
128
|
+
log(`Upload proxy on ${url} → ${apiBase}${API_SERVER_UPLOAD_PATH}`);
|
|
129
|
+
return {
|
|
130
|
+
url,
|
|
131
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=upload-proxy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upload-proxy.js","sourceRoot":"","sources":["../../src/forge/upload-proxy.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import type { WorldPatch } from '@bitmagic/asset-core';
|
|
2
2
|
import type { Environment } from '../config/environments.js';
|
|
3
|
-
export
|
|
4
|
-
event: string;
|
|
5
|
-
data: unknown;
|
|
6
|
-
}
|
|
7
|
-
/**
|
|
8
|
-
* Pure, synchronous parse of a full set of chunks — cheap and exhaustive to test. Production
|
|
9
|
-
* streaming uses the same underlying buffer (`createSseFrameBuffer`) one network read at a time,
|
|
10
|
-
* via `requestGeneration`, so this function is not itself on that path.
|
|
11
|
-
*/
|
|
12
|
-
export declare function parseSseChunks(chunks: string[]): SseEvent[];
|
|
3
|
+
export { parseSseChunks, type SseEvent } from '../http/sse.js';
|
|
13
4
|
export interface GenerationOutcome {
|
|
14
5
|
success: boolean;
|
|
15
6
|
message: string;
|
package/dist/generate/stream.js
CHANGED
|
@@ -1,66 +1,9 @@
|
|
|
1
1
|
import { CliError } from '../errors.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
8
|
-
function parseFrame(frame) {
|
|
9
|
-
let eventName = 'message';
|
|
10
|
-
const dataLines = [];
|
|
11
|
-
for (const line of frame.split('\n')) {
|
|
12
|
-
if (line.startsWith('event:')) {
|
|
13
|
-
eventName = line.slice('event:'.length).trim();
|
|
14
|
-
}
|
|
15
|
-
else if (line.startsWith('data:')) {
|
|
16
|
-
dataLines.push(line.slice('data:'.length).trim());
|
|
17
|
-
}
|
|
18
|
-
// Any other line (including a `:`-prefixed comment, or blank) carries no event data.
|
|
19
|
-
}
|
|
20
|
-
if (dataLines.length === 0)
|
|
21
|
-
return null;
|
|
22
|
-
return { event: eventName, data: JSON.parse(dataLines.join('\n')) };
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Incremental frame buffer: SSE frames arrive in arbitrary chunks over the wire — one frame can
|
|
26
|
-
* span two reads, and two frames can arrive in one — so this accumulates bytes and only emits
|
|
27
|
-
* events once a full `\n\n`-delimited frame is available. The trailing, not-yet-delimited
|
|
28
|
-
* remainder stays in `buffer` for the next push. Shared by both `parseSseChunks` (fed the whole
|
|
29
|
-
* chunk array at once) and `requestGeneration` (fed one chunk per network read), so the two
|
|
30
|
-
* cannot drift apart.
|
|
31
|
-
*/
|
|
32
|
-
function createSseFrameBuffer() {
|
|
33
|
-
let buffer = '';
|
|
34
|
-
return {
|
|
35
|
-
push(chunk) {
|
|
36
|
-
buffer += chunk;
|
|
37
|
-
const frames = buffer.split('\n\n');
|
|
38
|
-
// The last element is either '' (buffer ended exactly on a delimiter) or an incomplete
|
|
39
|
-
// trailing frame — either way it is not yet a complete frame, so keep it buffered.
|
|
40
|
-
buffer = frames.pop() ?? '';
|
|
41
|
-
const events = [];
|
|
42
|
-
for (const frame of frames) {
|
|
43
|
-
const event = parseFrame(frame);
|
|
44
|
-
if (event)
|
|
45
|
-
events.push(event);
|
|
46
|
-
}
|
|
47
|
-
return events;
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Pure, synchronous parse of a full set of chunks — cheap and exhaustive to test. Production
|
|
53
|
-
* streaming uses the same underlying buffer (`createSseFrameBuffer`) one network read at a time,
|
|
54
|
-
* via `requestGeneration`, so this function is not itself on that path.
|
|
55
|
-
*/
|
|
56
|
-
export function parseSseChunks(chunks) {
|
|
57
|
-
const frameBuffer = createSseFrameBuffer();
|
|
58
|
-
const events = [];
|
|
59
|
-
for (const chunk of chunks) {
|
|
60
|
-
events.push(...frameBuffer.push(chunk));
|
|
61
|
-
}
|
|
62
|
-
return events;
|
|
63
|
-
}
|
|
2
|
+
import { createSseFrameBuffer } from '../http/sse.js';
|
|
3
|
+
// The frame buffer and its parser moved to ../http/sse.ts when `bitmagic forge` gained a second
|
|
4
|
+
// consumer of the identical wire format. Re-exported from here so this module's own callers and
|
|
5
|
+
// tests are unaffected by that move.
|
|
6
|
+
export { parseSseChunks } from '../http/sse.js';
|
|
64
7
|
function isRecord(value) {
|
|
65
8
|
return typeof value === 'object' && value !== null;
|
|
66
9
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/generate/stream.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"stream.js","sourceRoot":"","sources":["../../src/generate/stream.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSE wire format the CLI-lane endpoints speak (`api-server/src/cli/cli-assets.ts`,
|
|
3
|
+
* `cli-forge.ts`): `event:` / `data:` lines terminated by a blank line, with `: keepalive`
|
|
4
|
+
* comment lines interleaved.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `generate/stream.ts` — which still re-exports `parseSseChunks` so its own
|
|
7
|
+
* tests and callers are unchanged — because `forge/stream.ts` consumes the identical format
|
|
8
|
+
* from a different endpoint. Two copies of a frame buffer that must agree byte-for-byte with
|
|
9
|
+
* one server is exactly the kind of thing that drifts silently: a fix to one (a multi-line
|
|
10
|
+
* `data:`, a chunk boundary mid-frame) would not reach the other, and the symptom would be a
|
|
11
|
+
* dropped terminal frame on one command only.
|
|
12
|
+
*/
|
|
13
|
+
export interface SseEvent {
|
|
14
|
+
event: string;
|
|
15
|
+
data: unknown;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Incremental frame buffer: SSE frames arrive in arbitrary chunks over the wire — one frame can
|
|
19
|
+
* span two reads, and two frames can arrive in one — so this accumulates bytes and only emits
|
|
20
|
+
* events once a full `\n\n`-delimited frame is available. The trailing, not-yet-delimited
|
|
21
|
+
* remainder stays in `buffer` for the next push.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createSseFrameBuffer(): {
|
|
24
|
+
push: (chunk: string) => SseEvent[];
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Pure, synchronous parse of a full set of chunks — cheap and exhaustive to test. Production
|
|
28
|
+
* streaming uses the same underlying buffer one network read at a time, so this function is not
|
|
29
|
+
* itself on that path.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseSseChunks(chunks: string[]): SseEvent[];
|
|
32
|
+
/**
|
|
33
|
+
* Read an SSE response body to completion, handing every parsed event to `onEvent`. Stops early
|
|
34
|
+
* — and returns `true` — the moment `onEvent` reports the terminal frame arrived, so a server
|
|
35
|
+
* that holds the connection open after its result does not stall the command.
|
|
36
|
+
*
|
|
37
|
+
* Returns `false` when the stream ended with no terminal frame: a truncated connection, which
|
|
38
|
+
* every caller must report as "not a confirmed failure" rather than as a clean empty result.
|
|
39
|
+
*/
|
|
40
|
+
export declare function consumeSseStream(body: ReadableStream<Uint8Array>, onEvent: (event: SseEvent) => boolean): Promise<boolean>;
|
package/dist/http/sse.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSE wire format the CLI-lane endpoints speak (`api-server/src/cli/cli-assets.ts`,
|
|
3
|
+
* `cli-forge.ts`): `event:` / `data:` lines terminated by a blank line, with `: keepalive`
|
|
4
|
+
* comment lines interleaved.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `generate/stream.ts` — which still re-exports `parseSseChunks` so its own
|
|
7
|
+
* tests and callers are unchanged — because `forge/stream.ts` consumes the identical format
|
|
8
|
+
* from a different endpoint. Two copies of a frame buffer that must agree byte-for-byte with
|
|
9
|
+
* one server is exactly the kind of thing that drifts silently: a fix to one (a multi-line
|
|
10
|
+
* `data:`, a chunk boundary mid-frame) would not reach the other, and the symptom would be a
|
|
11
|
+
* dropped terminal frame on one command only.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* One `\n\n`-delimited frame. Joins multiple `data:` lines (the spec allows more than one)
|
|
15
|
+
* with `\n` before parsing as JSON. A leading `:` line (the server's `: keepalive` comment)
|
|
16
|
+
* carries no `data:`, so it naturally produces no event.
|
|
17
|
+
*/
|
|
18
|
+
function parseFrame(frame) {
|
|
19
|
+
let eventName = 'message';
|
|
20
|
+
const dataLines = [];
|
|
21
|
+
for (const line of frame.split('\n')) {
|
|
22
|
+
if (line.startsWith('event:')) {
|
|
23
|
+
eventName = line.slice('event:'.length).trim();
|
|
24
|
+
}
|
|
25
|
+
else if (line.startsWith('data:')) {
|
|
26
|
+
dataLines.push(line.slice('data:'.length).trim());
|
|
27
|
+
}
|
|
28
|
+
// Any other line (including a `:`-prefixed comment, or blank) carries no event data.
|
|
29
|
+
}
|
|
30
|
+
if (dataLines.length === 0)
|
|
31
|
+
return null;
|
|
32
|
+
return { event: eventName, data: JSON.parse(dataLines.join('\n')) };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Incremental frame buffer: SSE frames arrive in arbitrary chunks over the wire — one frame can
|
|
36
|
+
* span two reads, and two frames can arrive in one — so this accumulates bytes and only emits
|
|
37
|
+
* events once a full `\n\n`-delimited frame is available. The trailing, not-yet-delimited
|
|
38
|
+
* remainder stays in `buffer` for the next push.
|
|
39
|
+
*/
|
|
40
|
+
export function createSseFrameBuffer() {
|
|
41
|
+
let buffer = '';
|
|
42
|
+
return {
|
|
43
|
+
push(chunk) {
|
|
44
|
+
buffer += chunk;
|
|
45
|
+
const frames = buffer.split('\n\n');
|
|
46
|
+
// The last element is either '' (buffer ended exactly on a delimiter) or an incomplete
|
|
47
|
+
// trailing frame — either way it is not yet a complete frame, so keep it buffered.
|
|
48
|
+
buffer = frames.pop() ?? '';
|
|
49
|
+
const events = [];
|
|
50
|
+
for (const frame of frames) {
|
|
51
|
+
const event = parseFrame(frame);
|
|
52
|
+
if (event)
|
|
53
|
+
events.push(event);
|
|
54
|
+
}
|
|
55
|
+
return events;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Pure, synchronous parse of a full set of chunks — cheap and exhaustive to test. Production
|
|
61
|
+
* streaming uses the same underlying buffer one network read at a time, so this function is not
|
|
62
|
+
* itself on that path.
|
|
63
|
+
*/
|
|
64
|
+
export function parseSseChunks(chunks) {
|
|
65
|
+
const frameBuffer = createSseFrameBuffer();
|
|
66
|
+
const events = [];
|
|
67
|
+
for (const chunk of chunks) {
|
|
68
|
+
events.push(...frameBuffer.push(chunk));
|
|
69
|
+
}
|
|
70
|
+
return events;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read an SSE response body to completion, handing every parsed event to `onEvent`. Stops early
|
|
74
|
+
* — and returns `true` — the moment `onEvent` reports the terminal frame arrived, so a server
|
|
75
|
+
* that holds the connection open after its result does not stall the command.
|
|
76
|
+
*
|
|
77
|
+
* Returns `false` when the stream ended with no terminal frame: a truncated connection, which
|
|
78
|
+
* every caller must report as "not a confirmed failure" rather than as a clean empty result.
|
|
79
|
+
*/
|
|
80
|
+
export async function consumeSseStream(body, onEvent) {
|
|
81
|
+
const reader = body.getReader();
|
|
82
|
+
const decoder = new TextDecoder();
|
|
83
|
+
const frameBuffer = createSseFrameBuffer();
|
|
84
|
+
for (;;) {
|
|
85
|
+
const { value, done } = await reader.read();
|
|
86
|
+
if (done)
|
|
87
|
+
return false;
|
|
88
|
+
// `{ stream: true }` retains any trailing partial multi-byte UTF-8 sequence inside the
|
|
89
|
+
// decoder itself, so a character split across two network chunks decodes correctly once its
|
|
90
|
+
// remaining bytes arrive in the next read, instead of emitting U+FFFD.
|
|
91
|
+
const text = decoder.decode(value, { stream: true });
|
|
92
|
+
for (const event of frameBuffer.push(text)) {
|
|
93
|
+
if (onEvent(event))
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=sse.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.js","sourceRoot":"","sources":["../../src/http/sse.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The port window the platform's bucket CORS policy allowlists as an origin.
|
|
3
|
+
*
|
|
4
|
+
* A scaffolded project fetches its forged level (`.vwld`) and its GLBs from the portal CDN, so a
|
|
5
|
+
* project served on localhost is making CROSS-ORIGIN requests for them. The bucket answers with
|
|
6
|
+
* `Access-Control-Allow-Origin` only for the origins named in its CORS config — the portal and
|
|
7
|
+
* creator hosts, plus `http://localhost:3000`–`3199` (roleverse-infra,
|
|
8
|
+
* the portal-cdn terragrunt config per environment). `bitmagic dev` sits inside that window at
|
|
9
|
+
* 3010; anything that asks the OS for an ephemeral port (`listen(0)` → 49xxx+) falls outside it.
|
|
10
|
+
*
|
|
11
|
+
* The failure is quiet and easy to misread. The fetch is blocked, `WorldGenerator` falls back to
|
|
12
|
+
* an empty voxel world, the level's terrain simply is not there, and the symptom is a player
|
|
13
|
+
* falling through the floor forever — which reads as a broken forge, not a blocked request. The
|
|
14
|
+
* forge's own browser hides it too, because it runs with `--disable-web-security` for the presigned
|
|
15
|
+
* PUTs, so uploads and reads both succeed there while a normal browser fails.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CORS_ALLOWED_PORT_MIN = 3000;
|
|
18
|
+
export declare const CORS_ALLOWED_PORT_MAX = 3199;
|
|
19
|
+
/**
|
|
20
|
+
* A free port the CDN will serve assets to, or `null` when the whole window is taken.
|
|
21
|
+
*
|
|
22
|
+
* Returning `null` rather than silently falling back to an ephemeral port is deliberate: the
|
|
23
|
+
* caller can then say what the consequence is, instead of the run proceeding to a level with no
|
|
24
|
+
* terrain and no explanation.
|
|
25
|
+
*
|
|
26
|
+
* The scan starts at a pseudo-random offset so two concurrent runs do not both take 3000 and race;
|
|
27
|
+
* `tryListen` closing before the caller binds leaves a window either way, which is why callers
|
|
28
|
+
* must handle a bind failure rather than trusting this.
|
|
29
|
+
*/
|
|
30
|
+
export declare function reserveCorsAllowedPort(): Promise<number | null>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as net from 'net';
|
|
2
|
+
/**
|
|
3
|
+
* The port window the platform's bucket CORS policy allowlists as an origin.
|
|
4
|
+
*
|
|
5
|
+
* A scaffolded project fetches its forged level (`.vwld`) and its GLBs from the portal CDN, so a
|
|
6
|
+
* project served on localhost is making CROSS-ORIGIN requests for them. The bucket answers with
|
|
7
|
+
* `Access-Control-Allow-Origin` only for the origins named in its CORS config — the portal and
|
|
8
|
+
* creator hosts, plus `http://localhost:3000`–`3199` (roleverse-infra,
|
|
9
|
+
* the portal-cdn terragrunt config per environment). `bitmagic dev` sits inside that window at
|
|
10
|
+
* 3010; anything that asks the OS for an ephemeral port (`listen(0)` → 49xxx+) falls outside it.
|
|
11
|
+
*
|
|
12
|
+
* The failure is quiet and easy to misread. The fetch is blocked, `WorldGenerator` falls back to
|
|
13
|
+
* an empty voxel world, the level's terrain simply is not there, and the symptom is a player
|
|
14
|
+
* falling through the floor forever — which reads as a broken forge, not a blocked request. The
|
|
15
|
+
* forge's own browser hides it too, because it runs with `--disable-web-security` for the presigned
|
|
16
|
+
* PUTs, so uploads and reads both succeed there while a normal browser fails.
|
|
17
|
+
*/
|
|
18
|
+
export const CORS_ALLOWED_PORT_MIN = 3000;
|
|
19
|
+
export const CORS_ALLOWED_PORT_MAX = 3199;
|
|
20
|
+
function tryListen(port) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const server = net.createServer();
|
|
23
|
+
server.once('error', () => resolve(null));
|
|
24
|
+
server.listen(port, '127.0.0.1', () => {
|
|
25
|
+
const address = server.address();
|
|
26
|
+
const bound = address !== null && typeof address !== 'string' ? address.port : null;
|
|
27
|
+
server.close(() => resolve(bound));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A free port the CDN will serve assets to, or `null` when the whole window is taken.
|
|
33
|
+
*
|
|
34
|
+
* Returning `null` rather than silently falling back to an ephemeral port is deliberate: the
|
|
35
|
+
* caller can then say what the consequence is, instead of the run proceeding to a level with no
|
|
36
|
+
* terrain and no explanation.
|
|
37
|
+
*
|
|
38
|
+
* The scan starts at a pseudo-random offset so two concurrent runs do not both take 3000 and race;
|
|
39
|
+
* `tryListen` closing before the caller binds leaves a window either way, which is why callers
|
|
40
|
+
* must handle a bind failure rather than trusting this.
|
|
41
|
+
*/
|
|
42
|
+
export async function reserveCorsAllowedPort() {
|
|
43
|
+
const span = CORS_ALLOWED_PORT_MAX - CORS_ALLOWED_PORT_MIN + 1;
|
|
44
|
+
const start = Math.floor(Math.random() * span);
|
|
45
|
+
for (let i = 0; i < span; i += 1) {
|
|
46
|
+
const port = CORS_ALLOWED_PORT_MIN + ((start + i) % span);
|
|
47
|
+
const bound = await tryListen(port);
|
|
48
|
+
if (bound !== null)
|
|
49
|
+
return bound;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=local-port.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-port.js","sourceRoot":"","sources":["../src/local-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AAE3B;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAC1C,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAE1C,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAG,GAAG,CAAC,YAAY,EAAE,CAAC;QAClC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QAC1C,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE;YACpC,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;YACjC,MAAM,KAAK,GAAG,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;YACpF,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;QACrC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB;IAC1C,MAAM,IAAI,GAAG,qBAAqB,GAAG,qBAAqB,GAAG,CAAC,CAAC;IAC/D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC;IAC/C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,GAAG,qBAAqB,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;QAC1D,MAAM,KAAK,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,CAAC;QACpC,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;IACnC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -8,20 +8,60 @@ const DEPENDENCIES = {
|
|
|
8
8
|
i18next: '^24.2.3',
|
|
9
9
|
'i18next-browser-languagedetector': '^8.0.4',
|
|
10
10
|
jszip: '^3.10.1',
|
|
11
|
-
three: '^0.
|
|
11
|
+
three: '^0.185.1',
|
|
12
12
|
};
|
|
13
13
|
const DEV_DEPENDENCIES = {
|
|
14
14
|
'@types/node': '^24.10.0',
|
|
15
|
-
'@types/three': '^0.
|
|
15
|
+
'@types/three': '^0.185.4',
|
|
16
16
|
// engine/types/ammo.d.ts opens with a triple-slash reference to this package's ambient types.
|
|
17
17
|
'ammojs-typed': '^1.0.6',
|
|
18
|
+
// Declares `navigator.gpu`, which the engine reads to choose its renderer backend. Named in
|
|
19
|
+
// tsconfig's `types` too — see the note there for why installing it is not enough.
|
|
20
|
+
'@webgpu/types': '^0.1.69',
|
|
18
21
|
typescript: '^5.9.2',
|
|
19
22
|
vite: '^7.3.1',
|
|
20
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* The three.js release every `three*` import map entry below resolves to. Written once because
|
|
26
|
+
* the entries repeat it eight times, including four times inside the `three` shim, and a bump
|
|
27
|
+
* that updates some copies but not others splits the engine across two module instances — the
|
|
28
|
+
* exact failure the comments below describe.
|
|
29
|
+
*/
|
|
30
|
+
const THREE_VERSION = '0.185.1';
|
|
31
|
+
/**
|
|
32
|
+
* `three` resolves to a shim, not straight to the webgpu build. r185's three.webgpu.js exports
|
|
33
|
+
* neither `UniformsUtils` (which every classic postprocessing pass imports from `three`) nor
|
|
34
|
+
* `ShaderLib`/`UniformsLib` (`lines/LineMaterial.js`), so the vendored engine — which imports
|
|
35
|
+
* those passes statically in GameEngine.ts — fails to load with "The requested module 'three'
|
|
36
|
+
* does not provide an export named 'UniformsUtils'". The shim re-exports the webgpu build from
|
|
37
|
+
* the SAME URL as the `three/webgpu` entry, so it stays ONE module instance, and adds the
|
|
38
|
+
* missing names from three's own src. Absolute URLs only: a data: module has no base URL and so
|
|
39
|
+
* cannot resolve bare or relative specifiers. Keep in step with game/index.html.
|
|
40
|
+
*/
|
|
41
|
+
const THREE_SHIM = [
|
|
42
|
+
'data:text/javascript,',
|
|
43
|
+
`export*from'https://esm.sh/three@${THREE_VERSION}/webgpu';`,
|
|
44
|
+
`export{UniformsUtils}from'https://esm.sh/three@${THREE_VERSION}/src/renderers/shaders/UniformsUtils.js';`,
|
|
45
|
+
`export{ShaderLib}from'https://esm.sh/three@${THREE_VERSION}/src/renderers/shaders/ShaderLib.js';`,
|
|
46
|
+
`export{UniformsLib}from'https://esm.sh/three@${THREE_VERSION}/src/renderers/shaders/UniformsLib.js'`,
|
|
47
|
+
].join('');
|
|
21
48
|
/** Resolved in the browser from a CDN, not from node_modules — see the design's import-map note. */
|
|
22
49
|
const CDN_IMPORTS = {
|
|
23
|
-
three
|
|
24
|
-
'
|
|
50
|
+
// `three` and `three/webgpu` are ONE module instance (see THREE_SHIM). Pointed at separate
|
|
51
|
+
// builds (core + webgpu) they are two, and r185's LightsNode then fails to recognise lights
|
|
52
|
+
// built by the other instance — the scene renders unlit. Keep in step with game/index.html.
|
|
53
|
+
three: THREE_SHIM,
|
|
54
|
+
'three/webgpu': `https://esm.sh/three@${THREE_VERSION}/webgpu`,
|
|
55
|
+
'three/tsl': `https://esm.sh/three@${THREE_VERSION}/tsl`,
|
|
56
|
+
// The addon entries use esm.sh's `*` build, which keeps `three` a BARE specifier so addons
|
|
57
|
+
// resolve back through this map. Plain esm.sh inlines its own copy of three, which shares no
|
|
58
|
+
// module with the webgpu build: GLTFLoader then hands back Meshes from a second Mesh class and
|
|
59
|
+
// the engine's `instanceof THREE.Mesh` scans find nothing. Same one-instance rule as above.
|
|
60
|
+
// The classic WebGL addons are not excepted — the shim, not a pinned inlined-three build, is
|
|
61
|
+
// what gives them the UniformsUtils / ShaderLib / UniformsLib they import from bare `three`.
|
|
62
|
+
'three/addons/': `https://esm.sh/*three@${THREE_VERSION}/addons/`,
|
|
63
|
+
'three/examples/jsm/': `https://esm.sh/*three@${THREE_VERSION}/examples/jsm/`,
|
|
64
|
+
'three/': `https://esm.sh/three@${THREE_VERSION}/`,
|
|
25
65
|
'@dimforge/rapier3d-compat': 'https://esm.sh/@dimforge/rapier3d-compat@0.19.3',
|
|
26
66
|
'@dimforge/rapier2d-compat': 'https://esm.sh/@dimforge/rapier2d-compat@0.19.3',
|
|
27
67
|
i18next: 'https://esm.sh/i18next@24.2.3',
|
|
@@ -72,7 +112,14 @@ export function renderTsconfig() {
|
|
|
72
112
|
noImplicitAny: true,
|
|
73
113
|
resolveJsonModule: true,
|
|
74
114
|
sourceMap: true,
|
|
75
|
-
types: [],
|
|
115
|
+
// `types: []` opts out of auto-including every @types package — deliberate, because the
|
|
116
|
+
// vendored engine ships its own and a stray global would shadow them. The consequence is
|
|
117
|
+
// that a package whose whole job is a GLOBAL augmentation has to be named here or it is
|
|
118
|
+
// invisible: `@webgpu/types` declares `navigator.gpu`, which the engine reads to pick its
|
|
119
|
+
// renderer backend (`engine/GameEngine.ts`). Without it a scaffolded project fails to
|
|
120
|
+
// type-check with "Property 'gpu' does not exist on type 'Navigator'" — the engine's own
|
|
121
|
+
// package.json carries the dependency, so nothing catches this in the monorepo.
|
|
122
|
+
types: ['@webgpu/types'],
|
|
76
123
|
typeRoots: ['engine/types', 'node_modules/@types', 'node_modules/ammojs-typed/ammo/ambient'],
|
|
77
124
|
},
|
|
78
125
|
include: ['src/**/*', 'engine/**/*'],
|
|
@@ -245,10 +292,43 @@ Four things worth knowing before you use these:
|
|
|
245
292
|
4. **A running \`bitmagic dev\` will not show the new asset until the browser reloads.** The
|
|
246
293
|
command reminds you.
|
|
247
294
|
|
|
248
|
-
**Not available
|
|
249
|
-
browser to voxelize and upload
|
|
250
|
-
|
|
251
|
-
|
|
295
|
+
**Not available as \`generate\` subcommands:** individual 3D models, voxel assets and block types.
|
|
296
|
+
Those need a browser to voxelize and upload. Do not work around it by hand-editing
|
|
297
|
+
\`world.json\` to point at a model you have not generated — the asset will not exist and the game
|
|
298
|
+
will fail to load it.
|
|
299
|
+
|
|
300
|
+
## Forging a whole level
|
|
301
|
+
|
|
302
|
+
\`\`\`
|
|
303
|
+
bitmagic forge --prompt "a ruined desert temple with a central courtyard"
|
|
304
|
+
\`\`\`
|
|
305
|
+
|
|
306
|
+
This designs a scene, builds its geometry, bakes it to voxels and writes the finished level —
|
|
307
|
+
terrain, props, spawn points — into \`src/work/world.json\`. Add \`--city\`, \`--dungeon\`,
|
|
308
|
+
\`--platformer\` or \`--freeform\` (at most one) for a different kind of place; omit them all for
|
|
309
|
+
natural terrain.
|
|
310
|
+
|
|
311
|
+
Five things worth knowing:
|
|
312
|
+
|
|
313
|
+
1. **It is slow and it is expensive.** A forge runs for many minutes — the level bake alone can
|
|
314
|
+
take twenty — and costs considerably more than any single \`generate\`. Run it once,
|
|
315
|
+
deliberately. Progress is printed throughout, the bake included.
|
|
316
|
+
2. **It needs Google Chrome installed.** The bake runs in a real headless browser, because
|
|
317
|
+
voxelizing is the engine's own code. The command builds and serves the project itself, so you
|
|
318
|
+
do not need \`bitmagic dev\` running and it will not collide with one that is.
|
|
319
|
+
3. **It rewrites large parts of \`world.json\`** (assets, environmentObjects, the level registry,
|
|
320
|
+
spawn points), so re-read the file afterwards if you were editing it. The command prints which
|
|
321
|
+
paths it changed. It also drops a \`forged-heightfield.json\` beside it, which later placements
|
|
322
|
+
use to sit objects on the new terrain.
|
|
323
|
+
4. **Every failure says whether \`world.json\` changed** — and it only ever changes on the final
|
|
324
|
+
step, all at once. A failure before that leaves the project exactly as it was.
|
|
325
|
+
5. **Resume rather than re-run.** The command prints a job id. If it fails after the design
|
|
326
|
+
succeeded, \`bitmagic forge --resume <jobId>\` skips the steps already paid for. The failure
|
|
327
|
+
message says when resuming is worth it, and when the failure is deterministic and you should
|
|
328
|
+
change the prompt instead.
|
|
329
|
+
|
|
330
|
+
Afterwards run \`bitmagic dev\` and actually walk the level — one that loads is not necessarily
|
|
331
|
+
one you can move through.
|
|
252
332
|
`;
|
|
253
333
|
}
|
|
254
334
|
export function renderGenerateSkill() {
|