@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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * How the draft service worker turns draft files into modules the page can
3
+ * import. Pure functions, shared by the worker and the tests.
4
+ *
5
+ * URLs, all under the game's base (`BASE`):
6
+ * - `BASE__draft/g<generation>/<repo path>`: a draft file as an ES module.
7
+ * TypeScript is stripped (sucrase keeps line numbers, so stack traces point
8
+ * at the draft file); every import is rewritten to another draft URL of the
9
+ * same generation, a host shim, or a module that throws a clear error.
10
+ * - `BASE__draft/host/<specifier>.js`: a shim re-exporting the page's own
11
+ * module for a bare or framework import, so a draft scene shares `three`,
12
+ * the framework and its singletons with the running game.
13
+ * - `BASE__draft/missing/<specifier>`: an import the draft cannot satisfy.
14
+ *
15
+ * A new generation re-imports the whole draft graph under new URLs; the
16
+ * browser's module map caches the old one until the page reloads.
17
+ */
18
+ // The pure-JS build: no WebAssembly (a `script-src 'self'` page cannot compile it) and no init.
19
+ import { parse } from 'es-module-lexer/js';
20
+ import { transform } from 'sucrase';
21
+
22
+ import type { PreviewError } from './content';
23
+
24
+ export const DRAFT_SEGMENT = '__draft/';
25
+ /** The service worker's file name, next to the game's index.html. */
26
+ export const DRAFT_WORKER_FILE = 'construct-draft-sw.js';
27
+ /** The content entry every generation imports. */
28
+ export const CONTENT_ENTRY = 'src/game/content.ts';
29
+
30
+ const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.js', '.mjs', '.json'];
31
+ const SCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.js', '.mjs']);
32
+
33
+ export type DraftRequest =
34
+ | { kind: 'module'; generation: number; path: string }
35
+ | { kind: 'host'; specifier: string }
36
+ | { kind: 'missing'; specifier: string; importer: string };
37
+
38
+ /** What a pathname under the draft segment asks for, or null when it is not a draft URL. */
39
+ export function parseDraftUrl(pathname: string, base: string): DraftRequest | null {
40
+ const prefix = `${base}${DRAFT_SEGMENT}`;
41
+ if (!pathname.startsWith(prefix)) return null;
42
+ const rest = pathname.slice(prefix.length);
43
+ const generation = /^g(\d+)\/(.+)$/.exec(rest);
44
+ if (generation) {
45
+ const path = decodeURIComponent(generation[2] ?? '');
46
+ if (!isDraftPath(path)) return null;
47
+ return { kind: 'module', generation: Number(generation[1]), path };
48
+ }
49
+ const host = /^host\/(.+)\.js$/.exec(rest);
50
+ if (host) return { kind: 'host', specifier: decodeURIComponent(host[1] ?? '') };
51
+ const missing = /^missing\/([^/]+)\/(.+)$/.exec(rest);
52
+ if (missing) {
53
+ return {
54
+ kind: 'missing',
55
+ importer: decodeURIComponent(missing[1] ?? ''),
56
+ specifier: decodeURIComponent(missing[2] ?? ''),
57
+ };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function draftModuleUrl(base: string, generation: number, path: string): string {
63
+ return `${base}${DRAFT_SEGMENT}g${generation}/${path.split('/').map(encodeURIComponent).join('/')}`;
64
+ }
65
+
66
+ function hostShimUrl(base: string, specifier: string): string {
67
+ return `${base}${DRAFT_SEGMENT}host/${encodeURIComponent(specifier)}.js`;
68
+ }
69
+
70
+ function missingUrl(base: string, importer: string, specifier: string): string {
71
+ return `${base}${DRAFT_SEGMENT}missing/${encodeURIComponent(importer)}/${encodeURIComponent(specifier)}`;
72
+ }
73
+
74
+ /** A relative repository path with no traversal: what a draft URL may name. */
75
+ function isDraftPath(path: string): boolean {
76
+ return (
77
+ path.length > 0 &&
78
+ !path.startsWith('/') &&
79
+ !path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
80
+ );
81
+ }
82
+
83
+ function normalize(path: string): string | null {
84
+ const out: string[] = [];
85
+ for (const segment of path.split('/')) {
86
+ if (segment === '' || segment === '.') continue;
87
+ if (segment === '..') {
88
+ if (out.length === 0) return null;
89
+ out.pop();
90
+ } else out.push(segment);
91
+ }
92
+ return out.join('/');
93
+ }
94
+
95
+ function extensionOf(path: string): string {
96
+ const name = path.slice(path.lastIndexOf('/') + 1);
97
+ const dot = name.lastIndexOf('.');
98
+ return dot > 0 ? name.slice(dot) : '';
99
+ }
100
+
101
+ export type Resolution =
102
+ { kind: 'draft'; path: string } | { kind: 'host'; specifier: string } | { kind: 'missing' };
103
+
104
+ /**
105
+ * Resolve an import written in a draft file: `./`, `../`, `/` and the
106
+ * template's `@/` (src/) alias name draft files; anything else is the host's.
107
+ * Extensions resolve as Vite does for this template.
108
+ */
109
+ export function resolveSpecifier(
110
+ specifier: string,
111
+ importer: string,
112
+ paths: ReadonlySet<string>,
113
+ ): Resolution {
114
+ let target: string | null;
115
+ if (specifier.startsWith('./') || specifier.startsWith('../')) {
116
+ target = normalize(`${importer.slice(0, importer.lastIndexOf('/') + 1)}${specifier}`);
117
+ } else if (specifier.startsWith('@/')) {
118
+ target = normalize(`src/${specifier.slice(2)}`);
119
+ } else if (specifier.startsWith('/')) {
120
+ target = normalize(specifier.slice(1));
121
+ } else {
122
+ return { kind: 'host', specifier };
123
+ }
124
+ if (!target) return { kind: 'missing' };
125
+ const candidates = [
126
+ target,
127
+ ...RESOLVE_EXTENSIONS.map((extension) => `${target}${extension}`),
128
+ ...RESOLVE_EXTENSIONS.map((extension) => `${target}/index${extension}`),
129
+ ];
130
+ for (const candidate of candidates) {
131
+ if (paths.has(candidate)) return { kind: 'draft', path: candidate };
132
+ }
133
+ return { kind: 'missing' };
134
+ }
135
+
136
+ export class DraftTransformError extends Error {
137
+ constructor(
138
+ message: string,
139
+ readonly error: PreviewError,
140
+ ) {
141
+ super(message);
142
+ this.name = 'DraftTransformError';
143
+ }
144
+ }
145
+
146
+ /**
147
+ * A draft file as a module of generation `generation`. Throws a
148
+ * `DraftTransformError` carrying the file and line for a syntax error.
149
+ */
150
+ export async function transformDraftModule(options: {
151
+ source: string;
152
+ path: string;
153
+ generation: number;
154
+ base: string;
155
+ paths: ReadonlySet<string>;
156
+ }): Promise<string> {
157
+ const { source, path, generation, base, paths } = options;
158
+ const extension = extensionOf(path);
159
+ if (extension === '.json') {
160
+ try {
161
+ return `export default ${JSON.stringify(JSON.parse(source))};\n`;
162
+ } catch (error) {
163
+ throw new DraftTransformError(`${path}: ${(error as Error).message}`, {
164
+ path,
165
+ message: (error as Error).message,
166
+ });
167
+ }
168
+ }
169
+ if (!SCRIPT_EXTENSIONS.has(extension)) {
170
+ const message = `${path} cannot be previewed (${extension || 'no extension'}): only TypeScript, JavaScript and JSON load from the draft.`;
171
+ throw new DraftTransformError(message, { path, message });
172
+ }
173
+ let code = source;
174
+ if (extension === '.ts' || extension === '.tsx' || extension === '.mts') {
175
+ try {
176
+ code = transform(source, {
177
+ transforms: extension === '.tsx' ? ['typescript', 'jsx'] : ['typescript'],
178
+ disableESTransforms: true,
179
+ preserveDynamicImport: true,
180
+ production: true,
181
+ filePath: path,
182
+ }).code;
183
+ } catch (error) {
184
+ const loc = (error as { loc?: { line: number; column: number } }).loc;
185
+ const message = (error as Error).message;
186
+ throw new DraftTransformError(
187
+ `${path}${loc ? `:${loc.line}:${loc.column + 1}` : ''}: ${message}`,
188
+ {
189
+ path,
190
+ ...(loc ? { line: loc.line, column: loc.column + 1 } : {}),
191
+ message,
192
+ },
193
+ );
194
+ }
195
+ }
196
+ let imports: ReturnType<typeof parse>[0];
197
+ try {
198
+ [imports] = parse(code, path);
199
+ } catch (error) {
200
+ const idx = (error as { idx?: number }).idx;
201
+ const line = idx === undefined ? undefined : code.slice(0, idx).split('\n').length;
202
+ const message = (error as Error).message;
203
+ throw new DraftTransformError(`${path}${line ? `:${line}` : ''}: ${message}`, {
204
+ path,
205
+ ...(line ? { line } : {}),
206
+ message,
207
+ });
208
+ }
209
+ const rewriteSpecifier = (specifier: string): string => {
210
+ const resolution = resolveSpecifier(specifier, path, paths);
211
+ if (resolution.kind === 'draft') return draftModuleUrl(base, generation, resolution.path);
212
+ if (resolution.kind === 'host') return hostShimUrl(base, resolution.specifier);
213
+ return missingUrl(base, path, specifier);
214
+ };
215
+ // Back to front, so earlier offsets stay valid.
216
+ for (const entry of [...imports].sort((a, b) => b.s - a.s)) {
217
+ if (entry.d === -2) {
218
+ const after = code.slice(entry.e);
219
+ if (after.startsWith('.env')) {
220
+ code = `${code.slice(0, entry.s)}globalThis.__constructDraftEnv${code.slice(entry.e + 4)}`;
221
+ } else if (after.startsWith('.hot')) {
222
+ code = `${code.slice(0, entry.s)}(void 0)${code.slice(entry.e + 4)}`;
223
+ }
224
+ continue;
225
+ }
226
+ if (entry.n === undefined) continue;
227
+ const written = code.slice(entry.s, entry.e);
228
+ const quote = written[0] === '"' || written[0] === "'" || written[0] === '`' ? written[0] : '';
229
+ const url = rewriteSpecifier(entry.n);
230
+ code = `${code.slice(0, entry.s)}${quote ? JSON.stringify(url) : url}${code.slice(entry.e)}`;
231
+ }
232
+ return code;
233
+ }
234
+
235
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
236
+ const RESERVED = new Set(
237
+ 'break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await arguments eval'.split(
238
+ ' ',
239
+ ),
240
+ );
241
+
242
+ /** A shim re-exporting the page's module for `specifier` with these export names. */
243
+ export function hostShimSource(specifier: string, names: readonly string[]): string {
244
+ const lines = [`const m = await globalThis.__constructDraftHost(${JSON.stringify(specifier)});`];
245
+ names.forEach((name, index) => {
246
+ const local = `__e${index}`;
247
+ lines.push(`const ${local} = m[${JSON.stringify(name)}];`);
248
+ const exported =
249
+ name === 'default' || (IDENTIFIER.test(name) && !RESERVED.has(name))
250
+ ? name
251
+ : JSON.stringify(name);
252
+ lines.push(`export { ${local} as ${exported} };`);
253
+ });
254
+ return `${lines.join('\n')}\n`;
255
+ }
256
+
257
+ /** A module that fails to load with `error`, so the import names the file and line. */
258
+ export function errorModuleSource(error: PreviewError): string {
259
+ const where = error.path
260
+ ? `${error.path}${error.line ? `:${error.line}${error.column ? `:${error.column}` : ''}` : ''}: `
261
+ : '';
262
+ return `const error = new Error(${JSON.stringify(`${where}${error.message}`)});\nerror.preview = ${JSON.stringify(error)};\nthrow error;\n`;
263
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The draft service worker: serves Crowdy Studio's game-editor draft as ES
3
+ * modules, so a hosted game previews edits without a build and without
4
+ * loosening its CSP (`script-src 'self'`: these are same-origin module URLs;
5
+ * nothing is evaluated from a string or a blob).
6
+ *
7
+ * It controls the game's pages (its scope is the game's base) because a
8
+ * service worker sees only the requests of the pages it controls, but it
9
+ * answers nothing outside `BASE__draft/`: navigations, the harness, the API
10
+ * and the game's own assets go to the network untouched. The page that
11
+ * imports a draft module answers for it (`./draftLoader`): sources, the
12
+ * generation's file list and the host's export names. A page that is not
13
+ * running the game editor never answers, and its requests fail.
14
+ *
15
+ * Bundled as a classic script by `constructDraftPlugin` (node/vite-plugins).
16
+ */
17
+ import type { PreviewError } from './content';
18
+ import {
19
+ DraftTransformError,
20
+ errorModuleSource,
21
+ hostShimSource,
22
+ parseDraftUrl,
23
+ transformDraftModule,
24
+ type DraftRequest,
25
+ } from './draftModules';
26
+
27
+ declare const self: ServiceWorkerGlobalScope;
28
+
29
+ const ANSWER_TIMEOUT_MS = 10_000;
30
+ const KEPT_GENERATIONS = 4;
31
+
32
+ type PageAnswer =
33
+ | { ok: true; paths?: string[]; source?: string; names?: string[] }
34
+ | { ok: false; message: string };
35
+
36
+ const base = new URL(self.registration.scope).pathname;
37
+ const manifests = new Map<string, Promise<Set<string>>>();
38
+
39
+ self.addEventListener('install', () => {
40
+ void self.skipWaiting();
41
+ });
42
+
43
+ self.addEventListener('activate', (event) => {
44
+ event.waitUntil(self.clients.claim());
45
+ });
46
+
47
+ self.addEventListener('fetch', (event) => {
48
+ const url = new URL(event.request.url);
49
+ if (url.origin !== self.location.origin || event.request.method !== 'GET') return;
50
+ const request = parseDraftUrl(url.pathname, base);
51
+ if (!request) return;
52
+ event.respondWith(answer(request, event.clientId));
53
+ });
54
+
55
+ async function ask(clientId: string, message: Record<string, unknown>): Promise<PageAnswer> {
56
+ const client = clientId ? await self.clients.get(clientId) : undefined;
57
+ if (!client) return { ok: false, message: 'No game page is waiting for this draft module.' };
58
+ const channel = new MessageChannel();
59
+ return new Promise<PageAnswer>((resolve) => {
60
+ const timer = setTimeout(() => {
61
+ channel.port1.close();
62
+ resolve({
63
+ ok: false,
64
+ message: 'The page did not answer for the draft (is the game editor open?).',
65
+ });
66
+ }, ANSWER_TIMEOUT_MS);
67
+ channel.port1.onmessage = (event: MessageEvent<PageAnswer>) => {
68
+ clearTimeout(timer);
69
+ channel.port1.close();
70
+ resolve(event.data);
71
+ };
72
+ client.postMessage({ type: 'construct-draft', ...message }, [channel.port2]);
73
+ });
74
+ }
75
+
76
+ function manifestOf(clientId: string, generation: number): Promise<Set<string>> {
77
+ const key = `${clientId}:${generation}`;
78
+ let manifest = manifests.get(key);
79
+ if (!manifest) {
80
+ manifest = ask(clientId, { request: 'manifest', generation }).then((reply) => {
81
+ if (!reply.ok || !reply.paths)
82
+ throw new Error(reply.ok ? 'The page sent no file list.' : reply.message);
83
+ return new Set(reply.paths);
84
+ });
85
+ manifest.catch(() => manifests.delete(key));
86
+ manifests.set(key, manifest);
87
+ while (manifests.size > KEPT_GENERATIONS)
88
+ manifests.delete(manifests.keys().next().value as string);
89
+ }
90
+ return manifest;
91
+ }
92
+
93
+ function moduleResponse(code: string, status = 200): Response {
94
+ return new Response(code, {
95
+ status,
96
+ headers: {
97
+ 'Content-Type': 'text/javascript; charset=utf-8',
98
+ 'Cache-Control': 'no-store',
99
+ 'Cross-Origin-Resource-Policy': 'same-origin',
100
+ 'X-Content-Type-Options': 'nosniff',
101
+ },
102
+ });
103
+ }
104
+
105
+ /** A module that throws, after telling the page what failed (it keeps the location). */
106
+ async function failure(
107
+ clientId: string,
108
+ error: PreviewError,
109
+ generation?: number,
110
+ ): Promise<Response> {
111
+ const client = clientId ? await self.clients.get(clientId) : undefined;
112
+ client?.postMessage({ type: 'construct-draft-error', generation, error });
113
+ return moduleResponse(errorModuleSource(error));
114
+ }
115
+
116
+ async function answer(request: DraftRequest, clientId: string): Promise<Response> {
117
+ try {
118
+ switch (request.kind) {
119
+ case 'module': {
120
+ const paths = await manifestOf(clientId, request.generation);
121
+ if (!paths.has(request.path)) {
122
+ return failure(
123
+ clientId,
124
+ { path: request.path, message: `${request.path} is not in the draft.` },
125
+ request.generation,
126
+ );
127
+ }
128
+ const reply = await ask(clientId, {
129
+ request: 'source',
130
+ generation: request.generation,
131
+ path: request.path,
132
+ });
133
+ if (!reply.ok || reply.source === undefined) {
134
+ return failure(
135
+ clientId,
136
+ { path: request.path, message: reply.ok ? 'The page sent no source.' : reply.message },
137
+ request.generation,
138
+ );
139
+ }
140
+ const code = await transformDraftModule({
141
+ source: reply.source,
142
+ path: request.path,
143
+ generation: request.generation,
144
+ base,
145
+ paths,
146
+ });
147
+ return moduleResponse(code);
148
+ }
149
+ case 'host': {
150
+ const reply = await ask(clientId, { request: 'host', specifier: request.specifier });
151
+ if (!reply.ok || !reply.names) {
152
+ return failure(clientId, {
153
+ message: reply.ok
154
+ ? `The page sent no exports for ${request.specifier}.`
155
+ : `${request.specifier}: ${reply.message}`,
156
+ });
157
+ }
158
+ return moduleResponse(hostShimSource(request.specifier, reply.names));
159
+ }
160
+ case 'missing':
161
+ return failure(clientId, {
162
+ path: request.importer,
163
+ message: `Cannot find "${request.specifier}" in the draft (imported by ${request.importer}).`,
164
+ });
165
+ }
166
+ } catch (error) {
167
+ if (error instanceof DraftTransformError) {
168
+ return failure(
169
+ clientId,
170
+ error.error,
171
+ request.kind === 'module' ? request.generation : undefined,
172
+ );
173
+ }
174
+ return failure(clientId, { message: (error as Error).message ?? String(error) });
175
+ }
176
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The game editor's slice of CrowdyJS 18.23 (`@crowdedkingdoms/crowdyjs/crowdy-studio`):
3
+ * the draft-store contract, the hosted store and its storage.
4
+ *
5
+ * The types are the SDK's own. The classes are looked up at runtime, so a page
6
+ * bundled with an SDK that predates them opens a bound project in the mod
7
+ * editor instead of failing to load.
8
+ */
9
+ import * as studio from '@crowdedkingdoms/crowdyjs/crowdy-studio';
10
+ import type {
11
+ GitHubDraftTransport,
12
+ MountCrowdyStudioOptions,
13
+ } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
14
+
15
+ export type {
16
+ DraftChangeEvent,
17
+ DraftCommitResult,
18
+ DraftConflict,
19
+ DraftDeleteResult,
20
+ DraftEntry,
21
+ DraftErrorCode,
22
+ DraftPrecondition,
23
+ DraftReadResult,
24
+ DraftSnapshot,
25
+ DraftSource,
26
+ DraftStore,
27
+ DraftWriteResult,
28
+ } from '@crowdedkingdoms/crowdyjs/crowdy-studio';
29
+
30
+ /** What the Studio controller's `draftStore` option is given. */
31
+ export type DraftProject = Parameters<NonNullable<MountCrowdyStudioOptions['draftStore']>>[0];
32
+
33
+ /** The commit path of the SDK's GitHub transport (`game.crowdyStudioGitHub`). */
34
+ export type DraftCommitTransport = Pick<GitHubDraftTransport, 'commitFiles'>;
35
+
36
+ export type LiveCodeSdk = Pick<
37
+ typeof studio,
38
+ 'GitHubDraftStore' | 'IndexedDbDraftOverlayStorage' | 'DraftError' | 'isRepoPath'
39
+ >;
40
+
41
+ /** The SDK's game-editor classes, or null when the installed SDK predates them. */
42
+ export function liveCodeSdk(): LiveCodeSdk | null {
43
+ const sdk = studio as Partial<LiveCodeSdk>;
44
+ return typeof sdk.GitHubDraftStore === 'function' &&
45
+ typeof sdk.IndexedDbDraftOverlayStorage === 'function' &&
46
+ typeof sdk.DraftError === 'function' &&
47
+ typeof sdk.isRepoPath === 'function'
48
+ ? (sdk as LiveCodeSdk)
49
+ : null;
50
+ }
@@ -0,0 +1,5 @@
1
+ /** The lazy host-module registry `constructDraftPlugin` writes at build time (see draftLoader.ts). */
2
+ declare module 'virtual:construct-draft-host' {
3
+ const modules: Readonly<Record<string, () => Promise<unknown>>>;
4
+ export default modules;
5
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A world without Buddy, for trying the game editor where no realtime cluster
3
+ * runs. Set `VITE_LOCAL_LIVE_CODE=1` on the dev server: the page skips the
4
+ * world join and the grid claim, and the world hub answers locally instead of
5
+ * dialling ck-exec. Off unless that flag is set. The game editor itself does
6
+ * not need it: `LIVE_DRAFT=1` turns the checkout into the draft.
7
+ */
8
+ export function localLiveCode(): boolean {
9
+ const env = import.meta.env as { VITE_LOCAL_LIVE_CODE?: string } | undefined;
10
+ return env?.VITE_LOCAL_LIVE_CODE === '1';
11
+ }