@bitmagic/cli 0.1.0 → 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.d.ts +1 -0
- package/dist/scaffold/project-files.js +175 -5
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +2 -1
- package/dist/scaffold/project.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"}
|
|
@@ -11,5 +11,6 @@ export interface ProjectMetadata {
|
|
|
11
11
|
}
|
|
12
12
|
export declare function renderBitmagicJson(metadata: ProjectMetadata): string;
|
|
13
13
|
export declare function renderAgentsMd(): string;
|
|
14
|
+
export declare function renderGenerateSkill(): string;
|
|
14
15
|
export declare function renderClaudeMd(): string;
|
|
15
16
|
export declare function renderSkillsReadme(): string;
|