@crowdedkingdoms/construct 0.3.11-test.1 → 0.4.0-dev.1

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/README.md CHANGED
@@ -10,8 +10,9 @@ and the games built on it share.
10
10
  | `platform/` | Hosted sign-in and app entry (`AuthService`, `NetworkManager`), `GameSession`, World Stores presence and the pose codec, chat, voice, webcam, envScope storage |
11
11
  | `platform/exec/`, `platform/model/` | The game's world hub on ck-exec (`WorldHub`, one connection per page as `NetworkManager.worldHub`) and `ModelService`, which reads the pulse, programs and progression from it |
12
12
  | `platform/studio/` | The Crowdy Studio dock (a project runs as a ck-exec mod and its CLIENT half), grid claims and the world hub's claim registry (`GridService`), the CLIENT halves of the grid the player stands in (`GridClientHalves`, over CrowdyJS's `ExecClientHalves`) and the host calls they reach, the agent's player host |
13
+ | `platform/live/` | Crowdy Studio's game editor: the content-layer contract and host (`content`), the draft stores (the dev server's, or CrowdyJS's hosted one), and the hosted preview (`draftLoader` and its service worker) |
13
14
  | `grid/` | JS grid programs (`GridProgramRunner`): player JavaScript with the full CrowdyJS SDK, sandboxed, relayed with a grid-scoped token |
14
- | `node/` | `security-headers` (site, `/dsh/`, `/grid-program.html`), `vite-plugins`, `construct-copy-dsh` |
15
+ | `node/` | `security-headers` (site, `/dsh/`, `/grid-program.html`), `vite-plugins` (headers, and `constructDraftPlugin` for the hosted preview), `live-draft` (the dev server's draft), `construct-copy-dsh` |
15
16
  | `sandbox/boot` | The grid program sandbox bootstrap |
16
17
 
17
18
  It ships **TypeScript source** for Vite (it uses `?worker&url` and
@@ -72,6 +73,18 @@ plugins: constructHeaderPlugins({
72
73
 
73
74
  - `construct-copy-dsh` (in `predev` / `prebuild`) copies the agent harness
74
75
  into `public/dsh/`.
76
+ - For Crowdy Studio's game editor (0.4.0; CrowdyJS 18.23 and crowdy-dsh 0.5):
77
+ default-export a `GameContent` from the game's content entry
78
+ (`src/game/content.ts`, `contentApi: 1`), load it in `main.ts` through
79
+ `GameContentHost` with an HMR accept on that module, pass the host to
80
+ `session.studio.attach({ ..., content })`, and add the two plugins:
81
+
82
+ ```ts
83
+ import { liveDraftPlugin } from '@crowdedkingdoms/construct/node/live-draft';
84
+ import { constructDraftPlugin } from '@crowdedkingdoms/construct/node/vite-plugins';
85
+
86
+ plugins: [...headerPlugins, constructDraftPlugin(), liveDraftPlugin({ enabled: env.LIVE_DRAFT === '1' })],
87
+ ```
75
88
 
76
89
  ## Setup scripts
77
90
 
@@ -0,0 +1,17 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ /** A file of the bound repository the game editor may list and write (the CrowdyJS policy). */
4
+ export function isRepoPath(filePath: string): boolean;
5
+ /** Git's blob id for these bytes. */
6
+ export function gitBlobSha(bytes: Uint8Array): string;
7
+
8
+ /**
9
+ * The dev-server backend of Crowdy Studio's game editor: the checkout is the
10
+ * draft. Does nothing unless `enabled` (default: `LIVE_DRAFT=1`); answers this
11
+ * machine only unless `allowRemote` (default: `LIVE_DRAFT_REMOTE=1`).
12
+ */
13
+ export function liveDraftPlugin(options?: {
14
+ root?: string;
15
+ enabled?: boolean;
16
+ allowRemote?: boolean;
17
+ }): Plugin;
@@ -0,0 +1,618 @@
1
+ /**
2
+ * The dev-server backend of Crowdy Studio's game editor: on a local dev
3
+ * server the game's own checkout is the draft. The page's
4
+ * `DevServerDraftStore` (src/platform/live) talks to this middleware, and
5
+ * Vite HMR previews each change through the content layer.
6
+ *
7
+ * - The draft lists the files git would commit (`git ls-files -co
8
+ * --exclude-standard`) with their git blob ids; a file is dirty when it
9
+ * differs from HEAD, and deleted when HEAD has it and the disk does not.
10
+ * - Writes carry the editor's preconditions (`createIfAbsent`, `ifHash`) and
11
+ * fail with 409 DRAFT_STALE instead of overwriting a newer file.
12
+ * - Edits made on disk (your editor, a checkout) reach the page as
13
+ * `construct:draft` events on Vite's websocket; `construct:preview` events
14
+ * say when a change is outside what the running game loads.
15
+ * - After Studio commits the draft to GitHub, `sync` moves HEAD to that
16
+ * commit (`git fetch`, then `git reset --mixed` when HEAD is its
17
+ * ancestor), so the checkout is clean again and no file is touched.
18
+ * - Requests must come from this machine (the dev server also listens on the
19
+ * network) and from this dev server's own page: same-origin
20
+ * (`Sec-Fetch-Site`, else `Origin`), the per-server token the page carries
21
+ * (`X-Construct-Draft-Token`), and JSON bodies only. `LIVE_DRAFT_REMOTE=1`
22
+ * lets another machine in; the token is then all that stands between it
23
+ * and your checkout, so use it on a network you trust.
24
+ *
25
+ * Opt-in: `liveDraftPlugin()` in the game's vite config; it does nothing
26
+ * unless the server runs with `LIVE_DRAFT=1`.
27
+ */
28
+ import { execFile } from 'node:child_process';
29
+ import { createHash, randomBytes } from 'node:crypto';
30
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
31
+ import path from 'node:path';
32
+ import { promisify } from 'node:util';
33
+
34
+ const run = promisify(execFile);
35
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
36
+
37
+ const SAFE_PATH = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
38
+ const MAX_TEXT_BYTES = 512 * 1024;
39
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
40
+ const MAX_READ_PATHS = 100;
41
+ const TOKEN_META = 'construct-live-draft';
42
+ const TOKEN_HEADER = 'x-construct-draft-token';
43
+ const CONTENT_ENTRY = 'src/game/content.ts';
44
+ /** How long a write by this server counts as its own when the watcher reports it. */
45
+ const OWN_WRITE_MS = 5_000;
46
+
47
+ function deniedSegment(segment) {
48
+ return (
49
+ segment === '.git' ||
50
+ segment === 'node_modules' ||
51
+ segment === '.env' ||
52
+ segment.startsWith('.env.')
53
+ );
54
+ }
55
+
56
+ /**
57
+ * A file of the bound repository the editor may list and write: the same
58
+ * policy as CrowdyJS `isRepoPath` (root dotfiles, `.git`, `node_modules`,
59
+ * dotenv files and workflows are out).
60
+ */
61
+ export function isRepoPath(filePath) {
62
+ if (
63
+ typeof filePath !== 'string' ||
64
+ !SAFE_PATH.test(filePath) ||
65
+ filePath.includes('..') ||
66
+ filePath.endsWith('/')
67
+ )
68
+ return false;
69
+ if (filePath.split('/').some((segment) => segment === '' || deniedSegment(segment))) return false;
70
+ return !/^\.github\/workflows(\/|$)/i.test(filePath);
71
+ }
72
+
73
+ /** Git's blob id for these bytes. */
74
+ export function gitBlobSha(bytes) {
75
+ return createHash('sha1').update(`blob ${bytes.byteLength}\0`).update(bytes).digest('hex');
76
+ }
77
+
78
+ function textOrNull(bytes) {
79
+ if (bytes.byteLength > MAX_TEXT_BYTES || bytes.includes(0)) return null;
80
+ try {
81
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ class DraftHttpError extends Error {
88
+ constructor(status, code, message) {
89
+ super(message);
90
+ this.status = status;
91
+ this.code = code;
92
+ }
93
+ }
94
+
95
+ /** A full commit sha, or a refusal: a value that could read as a git option never reaches git. */
96
+ function commitShaOf(value) {
97
+ if (typeof value !== 'string' || !COMMIT_SHA.test(value)) {
98
+ throw new DraftHttpError(400, 'DRAFT_PATH_REFUSED', 'sync needs the 40-character commit sha.');
99
+ }
100
+ return value;
101
+ }
102
+
103
+ /**
104
+ * The checkout as a draft. Exported for tests; the plugin below is the way in.
105
+ */
106
+ export class CheckoutDraft {
107
+ /** @param {string} root */
108
+ constructor(root) {
109
+ this.root = path.resolve(root);
110
+ this.revision = 0;
111
+ /** @type {Map<string, { mtimeMs: number, size: number, hash: string, binary: boolean }>} */
112
+ this.stats = new Map();
113
+ /** @type {{ sha: string, blobs: Map<string, string> } | null} */
114
+ this.head = null;
115
+ /** Writes this server made (path -> hash, or null for a delete, and when), so the watcher does not announce them twice. */
116
+ this.ownWrites = new Map();
117
+ }
118
+
119
+ /** git with a fixed subcommand; a revision follows `--end-of-options` (or precedes `--`), so none reads as an option. */
120
+ async git(subcommand, ...args) {
121
+ const { stdout } = await run('git', [subcommand, ...args], {
122
+ cwd: this.root,
123
+ maxBuffer: 64 * 1024 * 1024,
124
+ });
125
+ return stdout;
126
+ }
127
+
128
+ abs(filePath) {
129
+ if (!isRepoPath(filePath))
130
+ throw new DraftHttpError(
131
+ 403,
132
+ 'DRAFT_PATH_REFUSED',
133
+ `${filePath} is not a file in the repository.`,
134
+ );
135
+ const abs = path.resolve(this.root, filePath);
136
+ if (!abs.startsWith(this.root + path.sep)) {
137
+ throw new DraftHttpError(403, 'DRAFT_PATH_REFUSED', `${filePath} is outside the repository.`);
138
+ }
139
+ return abs;
140
+ }
141
+
142
+ async headTree() {
143
+ const sha = (await this.git('rev-parse', 'HEAD')).trim();
144
+ if (this.head?.sha === sha) return this.head;
145
+ const blobs = new Map();
146
+ for (const row of (await this.git('ls-tree', '-r', '-z', '--full-tree', 'HEAD')).split('\0')) {
147
+ const tab = row.indexOf('\t');
148
+ if (tab < 0) continue;
149
+ const [, type, blob] = row.slice(0, tab).split(' ');
150
+ if (type === 'blob') blobs.set(row.slice(tab + 1), blob);
151
+ }
152
+ this.head = { sha, blobs };
153
+ return this.head;
154
+ }
155
+
156
+ /** Hash and kind of a file on disk, cached by mtime and size; null when it is gone. */
157
+ async fileInfo(filePath) {
158
+ let info;
159
+ try {
160
+ info = await stat(this.abs(filePath));
161
+ } catch {
162
+ this.stats.delete(filePath);
163
+ return null;
164
+ }
165
+ if (!info.isFile()) return null;
166
+ const cached = this.stats.get(filePath);
167
+ if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached;
168
+ const bytes = await readFile(this.abs(filePath));
169
+ const entry = {
170
+ mtimeMs: info.mtimeMs,
171
+ size: bytes.byteLength,
172
+ hash: gitBlobSha(bytes),
173
+ binary: textOrNull(bytes) === null,
174
+ };
175
+ this.stats.set(filePath, entry);
176
+ return entry;
177
+ }
178
+
179
+ async tree() {
180
+ const head = await this.headTree();
181
+ const listed = (await this.git('ls-files', '-z', '-c', '-o', '--exclude-standard'))
182
+ .split('\0')
183
+ .filter((filePath) => filePath && isRepoPath(filePath));
184
+ const entries = [];
185
+ const present = new Set();
186
+ for (const filePath of [...new Set(listed)].sort()) {
187
+ const info = await this.fileInfo(filePath);
188
+ if (!info) continue;
189
+ present.add(filePath);
190
+ entries.push({
191
+ path: filePath,
192
+ hash: info.hash,
193
+ size: info.size,
194
+ binary: info.binary,
195
+ dirty: head.blobs.get(filePath) !== info.hash,
196
+ });
197
+ }
198
+ const deleted = [...head.blobs.keys()]
199
+ .filter((filePath) => isRepoPath(filePath) && !present.has(filePath))
200
+ .sort();
201
+ return { baseCommitSha: head.sha, revision: this.revision, entries, deleted, truncated: false };
202
+ }
203
+
204
+ async read(paths) {
205
+ if (!Array.isArray(paths) || paths.length > MAX_READ_PATHS) {
206
+ throw new DraftHttpError(
207
+ 400,
208
+ 'DRAFT_TOO_LARGE',
209
+ `Read at most ${MAX_READ_PATHS} paths at a time.`,
210
+ );
211
+ }
212
+ const files = [];
213
+ const missing = [];
214
+ const binary = [];
215
+ for (const filePath of paths) {
216
+ if (!isRepoPath(filePath)) {
217
+ missing.push(filePath);
218
+ continue;
219
+ }
220
+ let bytes;
221
+ try {
222
+ bytes = await readFile(this.abs(filePath));
223
+ } catch {
224
+ missing.push(filePath);
225
+ continue;
226
+ }
227
+ const text = textOrNull(bytes);
228
+ if (text === null) binary.push(filePath);
229
+ else files.push({ path: filePath, hash: gitBlobSha(bytes), content: text });
230
+ }
231
+ return { files, missing, binary };
232
+ }
233
+
234
+ async checkPrecondition(filePath, precondition) {
235
+ if (!precondition) return;
236
+ const current = await this.fileInfo(filePath);
237
+ if (precondition.kind === 'createIfAbsent' && current) {
238
+ throw new DraftHttpError(
239
+ 409,
240
+ 'DRAFT_STALE',
241
+ `${filePath} already exists; read it before replacing it.`,
242
+ );
243
+ }
244
+ if (precondition.kind === 'ifHash' && current?.hash !== precondition.hash) {
245
+ throw new DraftHttpError(
246
+ 409,
247
+ 'DRAFT_STALE',
248
+ `${filePath} changed since it was read; read it again.`,
249
+ );
250
+ }
251
+ }
252
+
253
+ async write(filePath, content, precondition) {
254
+ if (typeof content !== 'string')
255
+ throw new DraftHttpError(400, 'DRAFT_PATH_REFUSED', 'A write needs text content.');
256
+ const bytes = Buffer.from(content, 'utf8');
257
+ if (bytes.byteLength > MAX_TEXT_BYTES) {
258
+ throw new DraftHttpError(
259
+ 413,
260
+ 'DRAFT_TOO_LARGE',
261
+ `${filePath} is larger than the editor writes (512 KiB).`,
262
+ );
263
+ }
264
+ const abs = this.abs(filePath);
265
+ await this.checkPrecondition(filePath, precondition);
266
+ const previous = await this.fileInfo(filePath);
267
+ await mkdir(path.dirname(abs), { recursive: true });
268
+ const hash = gitBlobSha(bytes);
269
+ this.markOwn(filePath, hash);
270
+ await writeFile(abs, bytes);
271
+ this.stats.delete(filePath);
272
+ this.revision += 1;
273
+ const head = await this.headTree();
274
+ return {
275
+ path: filePath,
276
+ hash,
277
+ previousHash: previous?.hash ?? null,
278
+ revision: this.revision,
279
+ dirty: head.blobs.get(filePath) !== hash,
280
+ };
281
+ }
282
+
283
+ async remove(filePath, precondition) {
284
+ const abs = this.abs(filePath);
285
+ const previous = await this.fileInfo(filePath);
286
+ if (!previous)
287
+ throw new DraftHttpError(404, 'DRAFT_NOT_FOUND', `${filePath} is not in the draft.`);
288
+ await this.checkPrecondition(filePath, precondition);
289
+ this.markOwn(filePath, null);
290
+ await rm(abs);
291
+ this.stats.delete(filePath);
292
+ this.revision += 1;
293
+ const head = await this.headTree();
294
+ return {
295
+ path: filePath,
296
+ previousHash: previous.hash,
297
+ revision: this.revision,
298
+ inHead: head.blobs.has(filePath),
299
+ };
300
+ }
301
+
302
+ /** Put `paths` (default: every dirty or deleted file) back as HEAD has them. */
303
+ async discard(paths) {
304
+ const head = await this.headTree();
305
+ const tree = await this.tree();
306
+ const changed = [
307
+ ...tree.entries.filter((entry) => entry.dirty).map((entry) => entry.path),
308
+ ...tree.deleted,
309
+ ];
310
+ const wanted = Array.isArray(paths)
311
+ ? changed.filter((filePath) => paths.includes(filePath))
312
+ : changed;
313
+ for (const filePath of wanted) {
314
+ const abs = this.abs(filePath);
315
+ if (head.blobs.has(filePath)) {
316
+ const { stdout } = await run('git', ['cat-file', 'blob', head.blobs.get(filePath)], {
317
+ cwd: this.root,
318
+ encoding: 'buffer',
319
+ maxBuffer: 64 * 1024 * 1024,
320
+ });
321
+ this.markOwn(filePath, head.blobs.get(filePath));
322
+ await mkdir(path.dirname(abs), { recursive: true });
323
+ await writeFile(abs, stdout);
324
+ } else {
325
+ this.markOwn(filePath, null);
326
+ await rm(abs, { force: true });
327
+ }
328
+ this.stats.delete(filePath);
329
+ }
330
+ this.revision += 1;
331
+ return { revision: this.revision, discarded: wanted };
332
+ }
333
+
334
+ markOwn(filePath, hash) {
335
+ const now = Date.now();
336
+ for (const [key, write] of this.ownWrites)
337
+ if (now - write.at > OWN_WRITE_MS) this.ownWrites.delete(key);
338
+ this.ownWrites.set(filePath, { hash, at: now });
339
+ }
340
+
341
+ /** Whether this server wrote `filePath` moments ago (with `hash`, when given). */
342
+ isOwnWrite(filePath, hash) {
343
+ const write = this.ownWrites.get(filePath);
344
+ if (!write || Date.now() - write.at > OWN_WRITE_MS) return false;
345
+ return hash === undefined || write.hash === hash;
346
+ }
347
+
348
+ /** Move HEAD to the commit Studio just made from this draft, leaving every file as it is. */
349
+ async sync(value) {
350
+ const commitSha = commitShaOf(value);
351
+ try {
352
+ await this.git('cat-file', '-e', '--end-of-options', `${commitSha}^{commit}`);
353
+ } catch {
354
+ try {
355
+ await this.git('fetch', '--quiet', 'origin');
356
+ } catch (error) {
357
+ throw new DraftHttpError(
358
+ 409,
359
+ 'DRAFT_UNAVAILABLE',
360
+ `Committed as ${commitSha.slice(0, 7)}, but this checkout could not fetch it (${error.message.trim()}). Pull to update it.`,
361
+ );
362
+ }
363
+ }
364
+ try {
365
+ await this.git('merge-base', '--is-ancestor', '--end-of-options', 'HEAD', commitSha);
366
+ } catch {
367
+ throw new DraftHttpError(
368
+ 409,
369
+ 'DRAFT_CONFLICT',
370
+ `Committed as ${commitSha.slice(0, 7)}, but HEAD is not behind it; pull to update this checkout.`,
371
+ );
372
+ }
373
+ await this.git('reset', '--mixed', '--quiet', commitSha, '--');
374
+ this.head = null;
375
+ this.revision += 1;
376
+ return { baseCommitSha: commitSha, revision: this.revision };
377
+ }
378
+
379
+ /** A watcher event for `file`: the change to announce, or null when it is ours or not a repository file. */
380
+ async diskChange(file) {
381
+ const rel = path.relative(this.root, file).split(path.sep).join('/');
382
+ if (rel.startsWith('..') || !isRepoPath(rel)) return null;
383
+ this.stats.delete(rel);
384
+ const info = await this.fileInfo(rel);
385
+ const hash = info?.hash ?? null;
386
+ if (this.isOwnWrite(rel, hash)) return null;
387
+ this.revision += 1;
388
+ return { revision: this.revision, changes: [{ path: rel, hash }] };
389
+ }
390
+ }
391
+
392
+ function loopback(address) {
393
+ return typeof address === 'string' && (address === '::1' || /^(::ffff:)?127\./.test(address));
394
+ }
395
+
396
+ function sameOrigin(req) {
397
+ const site = req.headers['sec-fetch-site'];
398
+ if (typeof site === 'string') return site === 'same-origin';
399
+ const origin = req.headers.origin;
400
+ const host = req.headers.host;
401
+ if (typeof origin !== 'string' || typeof host !== 'string') return false;
402
+ try {
403
+ return new URL(origin).host === host;
404
+ } catch {
405
+ return false;
406
+ }
407
+ }
408
+
409
+ function send(res, status, body) {
410
+ res.statusCode = status;
411
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
412
+ res.setHeader('Cache-Control', 'no-store');
413
+ res.end(JSON.stringify(body));
414
+ }
415
+
416
+ function readJson(req) {
417
+ return new Promise((resolve, reject) => {
418
+ const type = String(req.headers['content-type'] ?? '');
419
+ if (!type.toLowerCase().startsWith('application/json')) {
420
+ reject(new DraftHttpError(415, 'DRAFT_PATH_REFUSED', 'The draft API takes JSON only.'));
421
+ return;
422
+ }
423
+ const chunks = [];
424
+ let size = 0;
425
+ req.on('data', (chunk) => {
426
+ size += chunk.length;
427
+ if (size > MAX_BODY_BYTES) {
428
+ reject(new DraftHttpError(413, 'DRAFT_TOO_LARGE', 'The request is too large.'));
429
+ req.destroy();
430
+ return;
431
+ }
432
+ chunks.push(chunk);
433
+ });
434
+ req.on('end', () => {
435
+ try {
436
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
437
+ } catch {
438
+ reject(new DraftHttpError(400, 'DRAFT_PATH_REFUSED', 'The body is not JSON.'));
439
+ }
440
+ });
441
+ req.on('error', reject);
442
+ });
443
+ }
444
+
445
+ /** Whether a change to `mod` stops at an accept boundary (the content entry, or a self-accepting module). */
446
+ function reachesBoundary(mod, contentEntry, seen = new Set()) {
447
+ if (mod.file === contentEntry || mod.isSelfAccepting) return true;
448
+ if (seen.has(mod)) return true;
449
+ seen.add(mod);
450
+ if (mod.importers.size === 0) return false;
451
+ for (const importer of mod.importers) {
452
+ if (importer.acceptedHmrDeps?.has(mod)) continue;
453
+ if (!reachesBoundary(importer, contentEntry, seen)) return false;
454
+ }
455
+ return true;
456
+ }
457
+
458
+ /**
459
+ * @param {{ root?: string, enabled?: boolean, allowRemote?: boolean }} [options] `root` defaults
460
+ * to the Vite root; `enabled` to `LIVE_DRAFT=1` and `allowRemote` to `LIVE_DRAFT_REMOTE=1` in
461
+ * the environment.
462
+ * @returns {import('vite').Plugin}
463
+ */
464
+ export function liveDraftPlugin(options = {}) {
465
+ const enabled = options.enabled ?? process.env.LIVE_DRAFT === '1';
466
+ const allowRemote = options.allowRemote ?? process.env.LIVE_DRAFT_REMOTE === '1';
467
+ const token = randomBytes(24).toString('base64url');
468
+ /** @type {CheckoutDraft | null} */
469
+ let draft = null;
470
+ let contentEntry = '';
471
+ return {
472
+ name: 'construct-live-draft',
473
+ apply: 'serve',
474
+ configResolved(config) {
475
+ if (!enabled) return;
476
+ draft = new CheckoutDraft(options.root ?? config.root);
477
+ contentEntry = path.join(draft.root, CONTENT_ENTRY);
478
+ },
479
+ transformIndexHtml() {
480
+ if (!draft) return undefined;
481
+ return [{ tag: 'meta', attrs: { name: TOKEN_META, content: token }, injectTo: 'head' }];
482
+ },
483
+ configureServer(server) {
484
+ if (!draft) return;
485
+ const checkout = draft;
486
+ const prefix = `${server.config.base}__live-draft/`;
487
+ server.config.logger.info(`construct live draft: the game editor edits ${checkout.root}`);
488
+ const announce = async (file) => {
489
+ const change = await checkout.diskChange(file).catch(() => null);
490
+ if (change)
491
+ server.ws.send({
492
+ type: 'custom',
493
+ event: 'construct:draft',
494
+ data: { ...change, source: 'player' },
495
+ });
496
+ };
497
+ server.watcher.on('change', announce);
498
+ server.watcher.on('add', announce);
499
+ server.watcher.on('unlink', announce);
500
+ server.middlewares.use(async (req, res, next) => {
501
+ const url = new URL(req.url ?? '/', 'http://dev.invalid');
502
+ if (!url.pathname.startsWith(prefix)) return next();
503
+ try {
504
+ if (!allowRemote && !loopback(req.socket?.remoteAddress)) {
505
+ throw new DraftHttpError(
506
+ 403,
507
+ 'DRAFT_PATH_REFUSED',
508
+ 'The draft API answers this machine only (LIVE_DRAFT_REMOTE=1 lets others in).',
509
+ );
510
+ }
511
+ if (!sameOrigin(req) || req.headers[TOKEN_HEADER] !== token) {
512
+ throw new DraftHttpError(
513
+ 403,
514
+ 'DRAFT_PATH_REFUSED',
515
+ 'The draft API answers this dev server’s own page only.',
516
+ );
517
+ }
518
+ const action = url.pathname.slice(prefix.length);
519
+ if (req.method === 'GET' && action === 'tree')
520
+ return send(res, 200, await checkout.tree());
521
+ if (req.method !== 'POST')
522
+ throw new DraftHttpError(405, 'DRAFT_PATH_REFUSED', 'Use POST.');
523
+ const body = await readJson(req);
524
+ switch (action) {
525
+ case 'read':
526
+ return send(res, 200, await checkout.read(body.paths));
527
+ case 'write': {
528
+ const result = await checkout.write(
529
+ String(body.path ?? ''),
530
+ body.content,
531
+ body.precondition,
532
+ );
533
+ server.ws.send({
534
+ type: 'custom',
535
+ event: 'construct:draft',
536
+ data: {
537
+ revision: result.revision,
538
+ changes: [{ path: result.path, hash: result.hash }],
539
+ source: body.source === 'agent' ? 'agent' : 'player',
540
+ writer: body.writer,
541
+ },
542
+ });
543
+ return send(res, 200, result);
544
+ }
545
+ case 'delete': {
546
+ const result = await checkout.remove(String(body.path ?? ''), body.precondition);
547
+ server.ws.send({
548
+ type: 'custom',
549
+ event: 'construct:draft',
550
+ data: {
551
+ revision: result.revision,
552
+ changes: [{ path: result.path, hash: null }],
553
+ source: body.source === 'agent' ? 'agent' : 'player',
554
+ writer: body.writer,
555
+ },
556
+ });
557
+ return send(res, 200, result);
558
+ }
559
+ case 'discard': {
560
+ const result = await checkout.discard(body.paths);
561
+ server.ws.send({
562
+ type: 'custom',
563
+ event: 'construct:draft',
564
+ data: {
565
+ revision: result.revision,
566
+ changes: [],
567
+ source: 'reload',
568
+ writer: body.writer,
569
+ },
570
+ });
571
+ return send(res, 200, result);
572
+ }
573
+ case 'sync': {
574
+ const result = await checkout.sync(body.commitSha);
575
+ server.ws.send({
576
+ type: 'custom',
577
+ event: 'construct:draft',
578
+ data: { ...result, changes: [], source: 'commit', writer: body.writer },
579
+ });
580
+ return send(res, 200, result);
581
+ }
582
+ default:
583
+ throw new DraftHttpError(404, 'DRAFT_NOT_FOUND', `No draft action ${action}.`);
584
+ }
585
+ } catch (error) {
586
+ if (error instanceof DraftHttpError)
587
+ return send(res, error.status, { code: error.code, message: error.message });
588
+ return send(res, 500, {
589
+ code: 'DRAFT_UNAVAILABLE',
590
+ message: error instanceof Error ? error.message : String(error),
591
+ });
592
+ }
593
+ });
594
+ },
595
+ handleHotUpdate({ file, modules, server }) {
596
+ if (!draft) return undefined;
597
+ const rel = path.relative(draft.root, file).split(path.sep).join('/');
598
+ if (rel.startsWith('..') || !isRepoPath(rel)) return undefined;
599
+ const ours = draft.isOwnWrite(rel);
600
+ if (modules.length === 0) {
601
+ server.ws.send({
602
+ type: 'custom',
603
+ event: 'construct:preview',
604
+ data: { path: rel, kind: 'not-loaded', revision: draft.revision },
605
+ });
606
+ return undefined;
607
+ }
608
+ if (modules.every((mod) => reachesBoundary(mod, contentEntry))) return undefined;
609
+ server.ws.send({
610
+ type: 'custom',
611
+ event: 'construct:preview',
612
+ data: { path: rel, kind: 'needs-reload', revision: draft.revision },
613
+ });
614
+ // A full reload would end the Studio agent's turn: hold it for the editor's own writes.
615
+ return ours ? [] : undefined;
616
+ },
617
+ };
618
+ }