@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 +14 -1
- package/node/live-draft.d.mts +17 -0
- package/node/live-draft.mjs +618 -0
- package/node/live-draft.test.mjs +273 -0
- package/node/vite-plugins.d.mts +11 -0
- package/node/vite-plugins.mjs +201 -5
- package/node/vite-plugins.test.mjs +51 -0
- package/package.json +11 -3
- package/src/engine/SceneRouter.test.ts +14 -0
- package/src/engine/SceneRouter.ts +10 -0
- package/src/platform/exec/worldHub.test.ts +10 -1
- package/src/platform/exec/worldHub.ts +29 -0
- package/src/platform/live/content.test.ts +139 -0
- package/src/platform/live/content.ts +284 -0
- package/src/platform/live/devServerDraftStore.ts +330 -0
- package/src/platform/live/draftLoader.test.ts +166 -0
- package/src/platform/live/draftLoader.ts +265 -0
- package/src/platform/live/draftModules.test.ts +195 -0
- package/src/platform/live/draftModules.ts +263 -0
- package/src/platform/live/draftServiceWorker.ts +176 -0
- package/src/platform/live/sdk.ts +50 -0
- package/src/platform/live/virtual.d.ts +5 -0
- package/src/platform/localLiveCode.ts +11 -0
- package/src/platform/studio/StudioService.ts +232 -17
- package/src/platform/studio/studioQuery.test.ts +65 -0
- package/src/platform/studio/studioQuery.ts +74 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
|
|
9
|
+
import { CheckoutDraft, gitBlobSha, isRepoPath, liveDraftPlugin } from './live-draft.mjs';
|
|
10
|
+
|
|
11
|
+
function git(cwd, ...args) {
|
|
12
|
+
return execFileSync('git', args, {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
GIT_AUTHOR_NAME: 't',
|
|
18
|
+
GIT_AUTHOR_EMAIL: 't@t',
|
|
19
|
+
GIT_COMMITTER_NAME: 't',
|
|
20
|
+
GIT_COMMITTER_EMAIL: 't@t',
|
|
21
|
+
},
|
|
22
|
+
}).trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A bare origin and a clone of it with one commit. */
|
|
26
|
+
function checkout() {
|
|
27
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'live-draft-'));
|
|
28
|
+
const origin = path.join(dir, 'origin.git');
|
|
29
|
+
const work = path.join(dir, 'work');
|
|
30
|
+
git(dir, 'init', '--quiet', '--bare', '-b', 'main', origin);
|
|
31
|
+
git(dir, 'clone', '--quiet', origin, work);
|
|
32
|
+
mkdirSync(path.join(work, 'src/game'), { recursive: true });
|
|
33
|
+
writeFileSync(path.join(work, 'src/game/content.ts'), 'export default 1;\n');
|
|
34
|
+
writeFileSync(path.join(work, 'README.md'), '# Game\n');
|
|
35
|
+
writeFileSync(path.join(work, '.gitignore'), 'dist/\n');
|
|
36
|
+
git(work, 'add', '.');
|
|
37
|
+
git(work, 'commit', '--quiet', '-m', 'first');
|
|
38
|
+
git(work, 'push', '--quiet', 'origin', 'HEAD:main');
|
|
39
|
+
return { dir, origin, work, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('the editor sees repository files only, by the same policy as CrowdyJS', () => {
|
|
43
|
+
assert.equal(isRepoPath('src/scenes/race/RaceScene.ts'), true);
|
|
44
|
+
assert.equal(isRepoPath('src/.eslintrc.json'), true);
|
|
45
|
+
for (const refused of [
|
|
46
|
+
'.github/dependabot.yml',
|
|
47
|
+
'.gitignore',
|
|
48
|
+
'.github/workflows/ci.yml',
|
|
49
|
+
'.git/config',
|
|
50
|
+
'node_modules/x/index.js',
|
|
51
|
+
'src/.env',
|
|
52
|
+
'.env.local',
|
|
53
|
+
'../x',
|
|
54
|
+
'src/../x',
|
|
55
|
+
'/etc/passwd',
|
|
56
|
+
'a//b',
|
|
57
|
+
]) {
|
|
58
|
+
assert.equal(isRepoPath(refused), false, refused);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('lists what git would commit, dirty against HEAD, with blob ids', async (t) => {
|
|
63
|
+
const repo = checkout();
|
|
64
|
+
t.after(repo.cleanup);
|
|
65
|
+
const draft = new CheckoutDraft(repo.work);
|
|
66
|
+
mkdirSync(path.join(repo.work, 'dist'));
|
|
67
|
+
writeFileSync(path.join(repo.work, 'dist/bundle.js'), 'ignored');
|
|
68
|
+
writeFileSync(path.join(repo.work, 'src/new.ts'), 'export {};\n');
|
|
69
|
+
rmSync(path.join(repo.work, 'README.md'));
|
|
70
|
+
const tree = await draft.tree();
|
|
71
|
+
assert.equal(tree.baseCommitSha, git(repo.work, 'rev-parse', 'HEAD'));
|
|
72
|
+
const paths = tree.entries.map((entry) => entry.path);
|
|
73
|
+
assert.deepEqual(paths, ['src/game/content.ts', 'src/new.ts']);
|
|
74
|
+
assert.deepEqual(tree.deleted, ['README.md']);
|
|
75
|
+
const content = tree.entries.find((entry) => entry.path === 'src/game/content.ts');
|
|
76
|
+
assert.equal(content.hash, git(repo.work, 'hash-object', 'src/game/content.ts'));
|
|
77
|
+
assert.equal(content.dirty, false);
|
|
78
|
+
assert.equal(tree.entries.find((entry) => entry.path === 'src/new.ts').dirty, true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('writes keep their preconditions, and the dirty set shrinks again', async (t) => {
|
|
82
|
+
const repo = checkout();
|
|
83
|
+
t.after(repo.cleanup);
|
|
84
|
+
const draft = new CheckoutDraft(repo.work);
|
|
85
|
+
const original = readFileSync(path.join(repo.work, 'src/game/content.ts'));
|
|
86
|
+
await assert.rejects(draft.write('src/game/content.ts', 'x', { kind: 'createIfAbsent' }), {
|
|
87
|
+
code: 'DRAFT_STALE',
|
|
88
|
+
});
|
|
89
|
+
await assert.rejects(
|
|
90
|
+
draft.write('src/game/content.ts', 'x', { kind: 'ifHash', hash: 'f'.repeat(40) }),
|
|
91
|
+
{ code: 'DRAFT_STALE' },
|
|
92
|
+
);
|
|
93
|
+
const written = await draft.write('src/game/content.ts', 'export default 2;\n', {
|
|
94
|
+
kind: 'ifHash',
|
|
95
|
+
hash: gitBlobSha(original),
|
|
96
|
+
});
|
|
97
|
+
assert.equal(written.previousHash, gitBlobSha(original));
|
|
98
|
+
assert.equal(written.revision, 1);
|
|
99
|
+
assert.equal(
|
|
100
|
+
(await draft.tree()).entries.find((entry) => entry.path === 'src/game/content.ts').dirty,
|
|
101
|
+
true,
|
|
102
|
+
);
|
|
103
|
+
const restored = await draft.write('src/game/content.ts', original.toString('utf8'));
|
|
104
|
+
assert.equal(restored.dirty, false);
|
|
105
|
+
assert.equal(
|
|
106
|
+
(await draft.tree()).entries.find((entry) => entry.path === 'src/game/content.ts').dirty,
|
|
107
|
+
false,
|
|
108
|
+
);
|
|
109
|
+
await assert.rejects(draft.write('.github/workflows/ci.yml', 'on: push'), {
|
|
110
|
+
code: 'DRAFT_PATH_REFUSED',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('discard puts files back as HEAD has them and drops new ones', async (t) => {
|
|
115
|
+
const repo = checkout();
|
|
116
|
+
t.after(repo.cleanup);
|
|
117
|
+
const draft = new CheckoutDraft(repo.work);
|
|
118
|
+
await draft.write('src/game/content.ts', 'changed');
|
|
119
|
+
await draft.write('src/new.ts', 'new');
|
|
120
|
+
const removed = await draft.remove('README.md');
|
|
121
|
+
assert.equal(removed.inHead, true);
|
|
122
|
+
const result = await draft.discard();
|
|
123
|
+
assert.deepEqual(result.discarded.sort(), ['README.md', 'src/game/content.ts', 'src/new.ts']);
|
|
124
|
+
const tree = await draft.tree();
|
|
125
|
+
assert.equal(
|
|
126
|
+
tree.entries.some((entry) => entry.dirty),
|
|
127
|
+
false,
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(tree.deleted, []);
|
|
130
|
+
assert.equal(readFileSync(path.join(repo.work, 'README.md'), 'utf8'), '# Game\n');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('reads in batches, and says which files are missing or binary', async (t) => {
|
|
134
|
+
const repo = checkout();
|
|
135
|
+
t.after(repo.cleanup);
|
|
136
|
+
writeFileSync(path.join(repo.work, 'logo.png'), Buffer.from([0x89, 0x50, 0x00, 0x01]));
|
|
137
|
+
const draft = new CheckoutDraft(repo.work);
|
|
138
|
+
const result = await draft.read(['README.md', 'logo.png', 'nope.ts', '../outside']);
|
|
139
|
+
assert.deepEqual(
|
|
140
|
+
result.files.map((file) => file.path),
|
|
141
|
+
['README.md'],
|
|
142
|
+
);
|
|
143
|
+
assert.deepEqual(result.binary, ['logo.png']);
|
|
144
|
+
assert.deepEqual(result.missing, ['nope.ts', '../outside']);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('after a commit made elsewhere, sync fast-forwards HEAD and leaves the files alone', async (t) => {
|
|
148
|
+
const repo = checkout();
|
|
149
|
+
t.after(repo.cleanup);
|
|
150
|
+
const draft = new CheckoutDraft(repo.work);
|
|
151
|
+
await draft.write('src/game/content.ts', 'export default 3;\n');
|
|
152
|
+
await draft.write('src/other.ts', 'export const later = true;\n');
|
|
153
|
+
// Studio commits content.ts alone, through GitHub: model that with another clone.
|
|
154
|
+
const elsewhere = path.join(repo.dir, 'elsewhere');
|
|
155
|
+
git(repo.dir, 'clone', '--quiet', repo.origin, elsewhere);
|
|
156
|
+
writeFileSync(path.join(elsewhere, 'src/game/content.ts'), 'export default 3;\n');
|
|
157
|
+
git(elsewhere, 'commit', '--quiet', '-am', 'from studio');
|
|
158
|
+
git(elsewhere, 'push', '--quiet', 'origin', 'HEAD:main');
|
|
159
|
+
const commitSha = git(elsewhere, 'rev-parse', 'HEAD');
|
|
160
|
+
const synced = await draft.sync(commitSha);
|
|
161
|
+
assert.equal(synced.baseCommitSha, commitSha);
|
|
162
|
+
assert.equal(git(repo.work, 'rev-parse', 'HEAD'), commitSha);
|
|
163
|
+
const tree = await draft.tree();
|
|
164
|
+
assert.equal(tree.entries.find((entry) => entry.path === 'src/game/content.ts').dirty, false);
|
|
165
|
+
assert.equal(tree.entries.find((entry) => entry.path === 'src/other.ts').dirty, true);
|
|
166
|
+
assert.equal(
|
|
167
|
+
readFileSync(path.join(repo.work, 'src/other.ts'), 'utf8'),
|
|
168
|
+
'export const later = true;\n',
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** The plugin's middleware with a fake Vite server around it. */
|
|
173
|
+
function middlewareFor(root) {
|
|
174
|
+
const plugin = liveDraftPlugin({ root, enabled: true });
|
|
175
|
+
plugin.configResolved({ root });
|
|
176
|
+
const [meta] = plugin.transformIndexHtml();
|
|
177
|
+
const sent = [];
|
|
178
|
+
let middleware;
|
|
179
|
+
plugin.configureServer({
|
|
180
|
+
config: { base: '/', logger: { info() {} } },
|
|
181
|
+
watcher: { on() {} },
|
|
182
|
+
ws: { send: (message) => sent.push(message) },
|
|
183
|
+
middlewares: { use: (fn) => (middleware = fn) },
|
|
184
|
+
});
|
|
185
|
+
const call = (method, url, { headers = {}, body, remote = '127.0.0.1' } = {}) =>
|
|
186
|
+
new Promise((resolve) => {
|
|
187
|
+
const req = Readable.from(body === undefined ? [] : [Buffer.from(JSON.stringify(body))]);
|
|
188
|
+
Object.assign(req, {
|
|
189
|
+
method,
|
|
190
|
+
url,
|
|
191
|
+
headers: { host: 'localhost:5175', ...headers },
|
|
192
|
+
socket: { remoteAddress: remote },
|
|
193
|
+
});
|
|
194
|
+
const res = {
|
|
195
|
+
statusCode: 200,
|
|
196
|
+
headers: {},
|
|
197
|
+
setHeader(name, value) {
|
|
198
|
+
this.headers[name.toLowerCase()] = value;
|
|
199
|
+
},
|
|
200
|
+
end(payload) {
|
|
201
|
+
resolve({ status: this.statusCode, body: JSON.parse(payload) });
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
void middleware(req, res, () => resolve({ status: 'next' }));
|
|
205
|
+
});
|
|
206
|
+
return { token: meta.attrs.content, call, sent };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test('the middleware answers only its own page, with the token, in JSON', async (t) => {
|
|
210
|
+
const repo = checkout();
|
|
211
|
+
t.after(repo.cleanup);
|
|
212
|
+
const { token, call, sent } = middlewareFor(repo.work);
|
|
213
|
+
const own = { 'sec-fetch-site': 'same-origin', 'x-construct-draft-token': token };
|
|
214
|
+
const json = { ...own, 'content-type': 'application/json' };
|
|
215
|
+
const write = { path: 'README.md', content: 'pwned' };
|
|
216
|
+
|
|
217
|
+
assert.equal(
|
|
218
|
+
(
|
|
219
|
+
await call('POST', '/__live-draft/write', {
|
|
220
|
+
headers: {
|
|
221
|
+
'sec-fetch-site': 'cross-site',
|
|
222
|
+
'x-construct-draft-token': token,
|
|
223
|
+
'content-type': 'application/json',
|
|
224
|
+
},
|
|
225
|
+
body: write,
|
|
226
|
+
})
|
|
227
|
+
).status,
|
|
228
|
+
403,
|
|
229
|
+
);
|
|
230
|
+
assert.equal(
|
|
231
|
+
(
|
|
232
|
+
await call('POST', '/__live-draft/write', {
|
|
233
|
+
headers: {
|
|
234
|
+
origin: 'http://evil.example',
|
|
235
|
+
'x-construct-draft-token': token,
|
|
236
|
+
'content-type': 'application/json',
|
|
237
|
+
},
|
|
238
|
+
body: write,
|
|
239
|
+
})
|
|
240
|
+
).status,
|
|
241
|
+
403,
|
|
242
|
+
);
|
|
243
|
+
assert.equal(
|
|
244
|
+
(
|
|
245
|
+
await call('POST', '/__live-draft/write', {
|
|
246
|
+
headers: { 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' },
|
|
247
|
+
body: write,
|
|
248
|
+
})
|
|
249
|
+
).status,
|
|
250
|
+
403,
|
|
251
|
+
);
|
|
252
|
+
assert.equal(
|
|
253
|
+
(
|
|
254
|
+
await call('POST', '/__live-draft/write', {
|
|
255
|
+
headers: { ...own, 'content-type': 'text/plain' },
|
|
256
|
+
body: write,
|
|
257
|
+
})
|
|
258
|
+
).status,
|
|
259
|
+
415,
|
|
260
|
+
);
|
|
261
|
+
assert.equal(readFileSync(path.join(repo.work, 'README.md'), 'utf8'), '# Game\n');
|
|
262
|
+
|
|
263
|
+
const tree = await call('GET', '/__live-draft/tree', { headers: own });
|
|
264
|
+
assert.equal(tree.status, 200);
|
|
265
|
+
const written = await call('POST', '/__live-draft/write', {
|
|
266
|
+
headers: json,
|
|
267
|
+
body: { ...write, source: 'agent', writer: 'tab-1' },
|
|
268
|
+
});
|
|
269
|
+
assert.equal(written.status, 200);
|
|
270
|
+
assert.equal(sent.at(-1).data.source, 'agent');
|
|
271
|
+
assert.equal(sent.at(-1).data.writer, 'tab-1');
|
|
272
|
+
assert.equal((await call('GET', '/elsewhere')).status, 'next');
|
|
273
|
+
});
|
package/node/vite-plugins.d.mts
CHANGED
|
@@ -4,3 +4,14 @@ export function constructHeaderPlugins(options: {
|
|
|
4
4
|
dshHeaders?: Record<string, string>;
|
|
5
5
|
gridProgramHeaders?: Record<string, string>;
|
|
6
6
|
}): Plugin[];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Crowdy Studio's hosted game-editor preview: the `virtual:construct-draft-host`
|
|
10
|
+
* registry and the `construct-draft-sw.js` service worker.
|
|
11
|
+
*/
|
|
12
|
+
export function constructDraftPlugin(options?: {
|
|
13
|
+
/** Game directories whose imports a draft may reuse (default `src`, `model`). */
|
|
14
|
+
sourceRoots?: string[];
|
|
15
|
+
/** More module specifiers a draft may import from the page. */
|
|
16
|
+
extraModules?: string[];
|
|
17
|
+
}): Plugin;
|
package/node/vite-plugins.mjs
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vite plugins every Construct game needs: path-scoped security headers for
|
|
3
3
|
* the two same-origin pages the game frames, the in-browser DeepSeek Harness
|
|
4
|
-
* (`/dsh/*`) and the JS grid program sandbox (`/grid-program.html`)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* (`/dsh/*`) and the JS grid program sandbox (`/grid-program.html`), and the
|
|
5
|
+
* hosted preview of Crowdy Studio's game editor (`constructDraftPlugin`). The
|
|
6
|
+
* site headers (securityHeaders) are applied by Vite's `server.headers`; the
|
|
7
|
+
* header plugins override them for their own paths.
|
|
7
8
|
*/
|
|
9
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* @param {string} name
|
|
@@ -15,8 +20,12 @@
|
|
|
15
20
|
function pathHeadersPlugin(name, match, headers, options = {}) {
|
|
16
21
|
/** @param {import('vite').ViteDevServer | import('vite').PreviewServer} server */
|
|
17
22
|
const attach = (server) => {
|
|
23
|
+
// Matched against the path under the game's base, so `vite build --base /game/`
|
|
24
|
+
// previews `/game/dsh/` with the harness's headers.
|
|
25
|
+
const base = server.config?.base ?? '/';
|
|
18
26
|
server.middlewares.use((req, res, next) => {
|
|
19
|
-
const
|
|
27
|
+
const raw = (req.url ?? '').split('?')[0] ?? '';
|
|
28
|
+
const pathname = base !== '/' && raw.startsWith(base) ? `/${raw.slice(base.length)}` : raw;
|
|
20
29
|
if (match(pathname)) {
|
|
21
30
|
// The harness's packed VFS image is a .tar.gz its worker inflates with
|
|
22
31
|
// DecompressionStream. Vite's static server adds `Content-Encoding: gzip`
|
|
@@ -25,11 +34,17 @@ function pathHeadersPlugin(name, match, headers, options = {}) {
|
|
|
25
34
|
const isGzipArchive = options.keepGzipArchives && /\.gz$/i.test(pathname);
|
|
26
35
|
const origSetHeader = res.setHeader.bind(res);
|
|
27
36
|
res.setHeader = (header, value) => {
|
|
28
|
-
|
|
37
|
+
const lower = header.toLowerCase();
|
|
38
|
+
if (isGzipArchive && lower === 'content-encoding') return res;
|
|
39
|
+
// Its own type, so `vite preview`'s compression (which takes an untyped body
|
|
40
|
+
// for text/plain) leaves the archive alone instead of gzipping it twice.
|
|
41
|
+
if (isGzipArchive && lower === 'content-type')
|
|
42
|
+
return origSetHeader(header, 'application/gzip');
|
|
29
43
|
const pinned = headers[header];
|
|
30
44
|
return origSetHeader(header, pinned !== undefined ? pinned : value);
|
|
31
45
|
};
|
|
32
46
|
for (const [header, value] of Object.entries(headers)) origSetHeader(header, value);
|
|
47
|
+
if (isGzipArchive) origSetHeader('Content-Type', 'application/gzip');
|
|
33
48
|
}
|
|
34
49
|
next();
|
|
35
50
|
});
|
|
@@ -100,3 +115,184 @@ export function constructHeaderPlugins({ dshHeaders, gridProgramHeaders }) {
|
|
|
100
115
|
}
|
|
101
116
|
return plugins;
|
|
102
117
|
}
|
|
118
|
+
|
|
119
|
+
const DRAFT_HOST_MODULES = 'virtual:construct-draft-host';
|
|
120
|
+
const DRAFT_WORKER_FILE = 'construct-draft-sw.js';
|
|
121
|
+
const FRAMEWORK = '@crowdedkingdoms/construct';
|
|
122
|
+
const FRAMEWORK_SRC = fileURLToPath(new URL('../src/', import.meta.url));
|
|
123
|
+
const DRAFT_WORKER_ENTRY = fileURLToPath(
|
|
124
|
+
new URL('../src/platform/live/draftServiceWorker.ts', import.meta.url),
|
|
125
|
+
);
|
|
126
|
+
/**
|
|
127
|
+
* The game editor's own machinery (the loader, the worker, the transform, the
|
|
128
|
+
* dev-server store) is not for drafts; `platform/live/content` is the contract
|
|
129
|
+
* a game's content imports.
|
|
130
|
+
*/
|
|
131
|
+
const forDrafts = (rel) => !rel.startsWith('platform/live/') || rel === 'platform/live/content';
|
|
132
|
+
const SOURCE_FILE = /\.(ts|tsx|mts|js|mjs)$/;
|
|
133
|
+
/** A bare module specifier as npm and the framework spell them. */
|
|
134
|
+
const MODULE_SPECIFIER = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(\/[\w.-]+)*$/i;
|
|
135
|
+
|
|
136
|
+
/** A JavaScript string literal for `value`, safe to splice into generated code. */
|
|
137
|
+
function jsString(value) {
|
|
138
|
+
return JSON.stringify(value).replace(
|
|
139
|
+
/[<>/\u2028\u2029]/g,
|
|
140
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const TEST_FILE = /\.test\.(ts|tsx|mts|js|mjs)$/;
|
|
144
|
+
|
|
145
|
+
/** @param {string} dir @returns {Promise<string[]>} */
|
|
146
|
+
async function walk(dir) {
|
|
147
|
+
let entries;
|
|
148
|
+
try {
|
|
149
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const files = [];
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
156
|
+
const full = path.join(dir, entry.name);
|
|
157
|
+
if (entry.isDirectory()) files.push(...(await walk(full)));
|
|
158
|
+
else files.push(full);
|
|
159
|
+
}
|
|
160
|
+
return files;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Every public framework module, as the specifier a game writes. */
|
|
164
|
+
async function frameworkModules() {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const area of ['engine', 'platform', 'grid']) {
|
|
167
|
+
for (const file of await walk(path.join(FRAMEWORK_SRC, area))) {
|
|
168
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts') || TEST_FILE.test(file)) continue;
|
|
169
|
+
const rel = path.relative(FRAMEWORK_SRC, file).split(path.sep).join('/').replace(/\.ts$/, '');
|
|
170
|
+
if (forDrafts(rel)) out.push(`${FRAMEWORK}/${rel}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Bare imports in the game's content sources (relative, alias and virtual ones excluded). */
|
|
177
|
+
async function packageImports(root, sourceRoots) {
|
|
178
|
+
const require = createRequire(import.meta.url);
|
|
179
|
+
const { transform } = require('sucrase');
|
|
180
|
+
const { init, parse } = await import('es-module-lexer');
|
|
181
|
+
await init;
|
|
182
|
+
const found = new Set();
|
|
183
|
+
for (const sourceRoot of sourceRoots) {
|
|
184
|
+
for (const file of await walk(path.resolve(root, sourceRoot))) {
|
|
185
|
+
if (!SOURCE_FILE.test(file) || TEST_FILE.test(file)) continue;
|
|
186
|
+
let code = await readFile(file, 'utf8');
|
|
187
|
+
if (/\.(ts|tsx|mts)$/.test(file)) {
|
|
188
|
+
code = transform(code, {
|
|
189
|
+
transforms: file.endsWith('.tsx') ? ['typescript', 'jsx'] : ['typescript'],
|
|
190
|
+
disableESTransforms: true,
|
|
191
|
+
preserveDynamicImport: true,
|
|
192
|
+
}).code;
|
|
193
|
+
}
|
|
194
|
+
let imports;
|
|
195
|
+
try {
|
|
196
|
+
[imports] = parse(code, file);
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const { n } of imports) {
|
|
201
|
+
if (!n || /^(\.|\/|@\/|\0|virtual:|node:|https?:|data:)/.test(n)) continue;
|
|
202
|
+
found.add(n);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return [...found];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The draft service worker as one classic script (no module service worker needed). */
|
|
210
|
+
async function bundleDraftWorker() {
|
|
211
|
+
const { build } = await import('vite');
|
|
212
|
+
const result = await build({
|
|
213
|
+
configFile: false,
|
|
214
|
+
logLevel: 'warn',
|
|
215
|
+
root: path.dirname(DRAFT_WORKER_ENTRY),
|
|
216
|
+
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
|
|
217
|
+
build: {
|
|
218
|
+
write: false,
|
|
219
|
+
minify: true,
|
|
220
|
+
sourcemap: false,
|
|
221
|
+
emptyOutDir: false,
|
|
222
|
+
copyPublicDir: false,
|
|
223
|
+
lib: {
|
|
224
|
+
entry: DRAFT_WORKER_ENTRY,
|
|
225
|
+
formats: ['iife'],
|
|
226
|
+
name: 'ConstructDraftWorker',
|
|
227
|
+
fileName: () => DRAFT_WORKER_FILE,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
const outputs = (Array.isArray(result) ? result : [result]).flatMap(
|
|
232
|
+
(output) => output.output ?? [],
|
|
233
|
+
);
|
|
234
|
+
const chunk = outputs.find((file) => file.type === 'chunk');
|
|
235
|
+
if (!chunk) throw new Error('constructDraftPlugin: the draft service worker bundle is empty');
|
|
236
|
+
return chunk.code;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Crowdy Studio's hosted game-editor preview (`platform/live/draftLoader.ts`):
|
|
241
|
+
* - `virtual:construct-draft-host`, a lazy registry of what a draft module may
|
|
242
|
+
* import from the page: every public framework module, every package the
|
|
243
|
+
* game's content sources import (`sourceRoots`, default `src` and `model`),
|
|
244
|
+
* and `extraModules`;
|
|
245
|
+
* - `construct-draft-sw.js`, the draft service worker, next to index.html in
|
|
246
|
+
* the build and on the dev server.
|
|
247
|
+
*
|
|
248
|
+
* @param {{ sourceRoots?: string[], extraModules?: string[] }} [options]
|
|
249
|
+
* @returns {import('vite').Plugin}
|
|
250
|
+
*/
|
|
251
|
+
export function constructDraftPlugin(options = {}) {
|
|
252
|
+
const resolvedId = `\0${DRAFT_HOST_MODULES}`;
|
|
253
|
+
/** @type {import('vite').ResolvedConfig | undefined} */
|
|
254
|
+
let config;
|
|
255
|
+
/** @type {Promise<string> | null} */
|
|
256
|
+
let worker = null;
|
|
257
|
+
const workerCode = () => (worker ??= bundleDraftWorker());
|
|
258
|
+
return {
|
|
259
|
+
name: 'construct-draft',
|
|
260
|
+
configResolved(resolved) {
|
|
261
|
+
config = resolved;
|
|
262
|
+
},
|
|
263
|
+
resolveId(id) {
|
|
264
|
+
return id === DRAFT_HOST_MODULES ? resolvedId : undefined;
|
|
265
|
+
},
|
|
266
|
+
async load(id) {
|
|
267
|
+
if (id !== resolvedId || !config) return undefined;
|
|
268
|
+
const specifiers = new Set([
|
|
269
|
+
...(await frameworkModules()),
|
|
270
|
+
...(await packageImports(config.root, options.sourceRoots ?? ['src', 'model'])),
|
|
271
|
+
...(options.extraModules ?? []),
|
|
272
|
+
]);
|
|
273
|
+
const rows = [...specifiers]
|
|
274
|
+
.filter((specifier) => MODULE_SPECIFIER.test(specifier))
|
|
275
|
+
.sort()
|
|
276
|
+
.map((specifier) => ` ${jsString(specifier)}: () => import(${jsString(specifier)}),`);
|
|
277
|
+
return `export default {\n${rows.join('\n')}\n};\n`;
|
|
278
|
+
},
|
|
279
|
+
async generateBundle() {
|
|
280
|
+
if (config?.command !== 'build') return;
|
|
281
|
+
this.emitFile({ type: 'asset', fileName: DRAFT_WORKER_FILE, source: await workerCode() });
|
|
282
|
+
},
|
|
283
|
+
configureServer(server) {
|
|
284
|
+
server.middlewares.use((req, res, next) => {
|
|
285
|
+
const pathname = (req.url ?? '').split('?')[0] ?? '';
|
|
286
|
+
if (pathname !== `${server.config.base}${DRAFT_WORKER_FILE}`) return next();
|
|
287
|
+
workerCode().then(
|
|
288
|
+
(code) => {
|
|
289
|
+
res.setHeader('Content-Type', 'text/javascript; charset=utf-8');
|
|
290
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
291
|
+
res.end(code);
|
|
292
|
+
},
|
|
293
|
+
(error) => next(error),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { constructHeaderPlugins } from './vite-plugins.mjs';
|
|
5
|
+
|
|
6
|
+
test('the harness headers follow the game under a Vite base', () => {
|
|
7
|
+
const [plugin] = constructHeaderPlugins({
|
|
8
|
+
dshHeaders: { 'Content-Security-Policy': "frame-ancestors 'self'" },
|
|
9
|
+
});
|
|
10
|
+
for (const [base, url, harness] of [
|
|
11
|
+
['/the-construct/', '/the-construct/dsh/index.html', true],
|
|
12
|
+
['/', '/dsh/index.html', true],
|
|
13
|
+
['/the-construct/', '/the-construct/index.html', false],
|
|
14
|
+
]) {
|
|
15
|
+
let middleware;
|
|
16
|
+
plugin.configurePreviewServer({
|
|
17
|
+
config: { base },
|
|
18
|
+
middlewares: { use: (fn) => (middleware = fn) },
|
|
19
|
+
});
|
|
20
|
+
const set = {};
|
|
21
|
+
const res = {
|
|
22
|
+
setHeader(name, value) {
|
|
23
|
+
set[name] = value;
|
|
24
|
+
return res;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
middleware({ url }, res, () => {});
|
|
28
|
+
assert.equal('Content-Security-Policy' in set, harness, `${base} ${url}`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('the packed harness image reaches the worker as stored: typed, never re-encoded', () => {
|
|
33
|
+
const [plugin] = constructHeaderPlugins({ dshHeaders: {} });
|
|
34
|
+
let middleware;
|
|
35
|
+
plugin.configurePreviewServer({
|
|
36
|
+
config: { base: '/game/' },
|
|
37
|
+
middlewares: { use: (fn) => (middleware = fn) },
|
|
38
|
+
});
|
|
39
|
+
const set = {};
|
|
40
|
+
const res = {
|
|
41
|
+
setHeader(name, value) {
|
|
42
|
+
set[name.toLowerCase()] = value;
|
|
43
|
+
return res;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
middleware({ url: '/game/dsh/preview/vfs-image.tar.gz' }, res, () => {});
|
|
47
|
+
res.setHeader('Content-Type', '');
|
|
48
|
+
res.setHeader('Content-Encoding', 'gzip');
|
|
49
|
+
assert.equal(set['content-type'], 'application/gzip');
|
|
50
|
+
assert.equal('content-encoding' in set, false);
|
|
51
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/construct",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-dev.1",
|
|
4
4
|
"description": "The Construct framework: the Crowded Kingdoms game platform layer (hosted sign-in, World Stores, voice/webcam/chat, Crowdy Studio dock, app and grid mods, grid programs) and engine loop, shared by the-construct starter and the games built on it. Ships TypeScript source for Vite.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,18 +39,26 @@
|
|
|
39
39
|
"types": "./node/vite-plugins.d.mts",
|
|
40
40
|
"default": "./node/vite-plugins.mjs"
|
|
41
41
|
},
|
|
42
|
+
"./node/live-draft": {
|
|
43
|
+
"types": "./node/live-draft.d.mts",
|
|
44
|
+
"default": "./node/live-draft.mjs"
|
|
45
|
+
},
|
|
42
46
|
"./sandbox/boot": "./sandbox/boot.ts",
|
|
43
47
|
"./engine/*": "./src/engine/*.ts",
|
|
44
48
|
"./platform/*": "./src/platform/*.ts",
|
|
45
49
|
"./grid/*": "./src/grid/*.ts"
|
|
46
50
|
},
|
|
47
51
|
"peerDependencies": {
|
|
48
|
-
"@crowdedkingdoms/
|
|
49
|
-
"@crowdedkingdoms/
|
|
52
|
+
"@crowdedkingdoms/crowdy-dsh": ">=0.4.0-dev.0 <1 || >=0.4.1-dev.0 <1 || >=0.4.2-dev.0 <1 || >=0.4.3-dev.0 <1 || >=0.4.4-dev.0 <1 || >=0.4.5-dev.0 <1 || >=0.5.0-dev.0 <1",
|
|
53
|
+
"@crowdedkingdoms/crowdyjs": ">=18.23.0-dev.0 <19"
|
|
50
54
|
},
|
|
51
55
|
"peerDependenciesMeta": {
|
|
52
56
|
"@crowdedkingdoms/crowdy-dsh": {
|
|
53
57
|
"optional": true
|
|
54
58
|
}
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"es-module-lexer": "^2.3.2",
|
|
62
|
+
"sucrase": "^3.35.1"
|
|
55
63
|
}
|
|
56
64
|
}
|
|
@@ -77,4 +77,18 @@ describe('SceneRouter', () => {
|
|
|
77
77
|
router.bind(context, size);
|
|
78
78
|
await expect(router.load('nope')).rejects.toThrow(/No scene registered/);
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
it('replaces every factory, so the next load after dispose builds the new content', async () => {
|
|
82
|
+
const router = new SceneRouter();
|
|
83
|
+
router.bind(context, size);
|
|
84
|
+
router.register('hub', () => fakeScene('hub', 0));
|
|
85
|
+
router.register('paint', () => fakeScene('paint', 1));
|
|
86
|
+
await router.load('hub');
|
|
87
|
+
const fresh = fakeScene('hub', 0);
|
|
88
|
+
router.replace({ hub: () => fresh, race: () => fakeScene('race', 2) });
|
|
89
|
+
expect(router.ids().sort()).toEqual(['hub', 'race']);
|
|
90
|
+
router.dispose();
|
|
91
|
+
expect(await router.load('hub')).toBe(fresh);
|
|
92
|
+
await expect(router.load('paint')).rejects.toThrow(/No scene registered as "paint"/);
|
|
93
|
+
});
|
|
80
94
|
});
|
|
@@ -26,6 +26,16 @@ export class SceneRouter {
|
|
|
26
26
|
this.factories.set(id, factory);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Swap every factory for a new set, e.g. when the game's content changed.
|
|
31
|
+
* Mounted and cached scenes stay until `dispose()`; the next `load()` then
|
|
32
|
+
* builds from the new factories.
|
|
33
|
+
*/
|
|
34
|
+
replace(factories: Readonly<Record<string, SceneFactory>>): void {
|
|
35
|
+
this.factories.clear();
|
|
36
|
+
for (const [id, factory] of Object.entries(factories)) this.factories.set(id, factory);
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
ids(): string[] {
|
|
30
40
|
return [...this.factories.keys()];
|
|
31
41
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { WorldHub, configureWorldHub, worldHubAddress } from './worldHub';
|
|
3
|
+
import { WorldHub, configureWorldHub, localWorldHubReply, worldHubAddress } from './worldHub';
|
|
4
4
|
|
|
5
5
|
function fakeClient(connectResults: Array<'fail' | 'ok'> = ['ok']) {
|
|
6
6
|
const connection = {
|
|
@@ -16,6 +16,15 @@ function fakeClient(connectResults: Array<'fail' | 'ok'> = ['ok']) {
|
|
|
16
16
|
return { client: { exec } as never, exec, connection };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
describe('local world hub mock', () => {
|
|
20
|
+
it('answers the reads the page polls without a hub', () => {
|
|
21
|
+
expect(localWorldHubReply('world')).toEqual({ pulses: 0 });
|
|
22
|
+
expect(localWorldHubReply('claims')).toEqual({ claims: [], total: 0 });
|
|
23
|
+
expect(localWorldHubReply('programs')).toEqual({ programs: [] });
|
|
24
|
+
expect(localWorldHubReply('status')).toMatchObject({ local: true });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
19
28
|
describe('WorldHub', () => {
|
|
20
29
|
it('opens one connection, on the host running the hub, for every call', async () => {
|
|
21
30
|
const { client, exec, connection } = fakeClient();
|