@crowdedkingdoms/construct 0.3.11 → 0.4.1-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 +15 -2
- 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/security-headers.d.mts +1 -1
- package/node/security-headers.mjs +5 -6
- 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/onboarding/steps.d.mts +1 -1
- package/src/platform/onboarding/steps.mjs +43 -10
- package/src/platform/onboarding/steps.test.ts +123 -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,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,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
|
+
}
|
|
@@ -108,7 +108,7 @@ export function ensureRedirectUris(
|
|
|
108
108
|
identity: CrowdyClient,
|
|
109
109
|
args: { appId: string; origins: string[] },
|
|
110
110
|
log?: Logger,
|
|
111
|
-
): Promise<{ redirectUris: string[]; added: string[] }>;
|
|
111
|
+
): Promise<{ redirectUris: string[]; added: string[]; launchUrl: string | null }>;
|
|
112
112
|
export function ensureConstructorTier(
|
|
113
113
|
identity: CrowdyClient,
|
|
114
114
|
input: { appId: string; userId: string },
|
|
@@ -127,6 +127,11 @@ export async function ensureApp(identity, { orgId, orgSlug, name, slug, datacent
|
|
|
127
127
|
* on the return leg and no CORS headers on anything else. Idempotent: existing
|
|
128
128
|
* entries are kept, the given ones added; origin-matched by the server, so the
|
|
129
129
|
* path does not matter but `/` keeps the list readable.
|
|
130
|
+
*
|
|
131
|
+
* An app with no launch URL also gets the first origin that is not loopback as
|
|
132
|
+
* its launch URL. Crowded Kingdoms does not host games, so that origin is where
|
|
133
|
+
* the game lives, and Crowdy Studio's Live code opens it. A launch URL the app
|
|
134
|
+
* already has is left alone.
|
|
130
135
|
*/
|
|
131
136
|
export async function ensureRedirectUris(identity, { appId, origins }, log = noop) {
|
|
132
137
|
const app = await identity.apps.app(appId);
|
|
@@ -134,14 +139,21 @@ export async function ensureRedirectUris(identity, { appId, origins }, log = noo
|
|
|
134
139
|
const have = new Set(existing.map((u) => originOf(u)).filter(Boolean));
|
|
135
140
|
const wanted = origins.map((o) => originOf(o)).filter(Boolean);
|
|
136
141
|
const missing = [...new Set(wanted)].filter((o) => !have.has(o));
|
|
137
|
-
|
|
142
|
+
const launchOrigin = app?.launchUrl ? null : wanted.find((o) => !isLoopback(o));
|
|
143
|
+
const launchUrl = launchOrigin ? `${launchOrigin}/` : null;
|
|
144
|
+
if (missing.length === 0 && !launchUrl) {
|
|
138
145
|
log(`Redirect URIs already cover ${wanted.join(', ')}`);
|
|
139
|
-
return { redirectUris: existing, added: [] };
|
|
146
|
+
return { redirectUris: existing, added: [], launchUrl: app?.launchUrl ?? null };
|
|
140
147
|
}
|
|
141
148
|
const redirectUris = [...existing, ...missing.map((o) => `${o}/`)];
|
|
142
|
-
await identity.portal.setAppClientSettings({
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
await identity.portal.setAppClientSettings({
|
|
150
|
+
appId,
|
|
151
|
+
redirectUris,
|
|
152
|
+
...(launchUrl ? { launchUrl } : {}),
|
|
153
|
+
});
|
|
154
|
+
if (missing.length) log(`Registered redirect origin(s) ${missing.join(', ')}`);
|
|
155
|
+
if (launchUrl) log(`Launch URL set to ${launchUrl} (Crowdy Studio's Live code opens it)`);
|
|
156
|
+
return { redirectUris, added: missing, launchUrl: launchUrl ?? app?.launchUrl ?? null };
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
function originOf(value) {
|
|
@@ -152,6 +164,11 @@ function originOf(value) {
|
|
|
152
164
|
}
|
|
153
165
|
}
|
|
154
166
|
|
|
167
|
+
function isLoopback(origin) {
|
|
168
|
+
const host = new URL(origin).hostname;
|
|
169
|
+
return host === 'localhost' || host === '[::1]' || host.startsWith('127.');
|
|
170
|
+
}
|
|
171
|
+
|
|
155
172
|
export async function ensureConstructorTier(identity, { appId, userId }, log = noop) {
|
|
156
173
|
const tiers = await identity.appAccess.tiers(appId);
|
|
157
174
|
|
|
@@ -350,9 +367,25 @@ const AGENT_POLICY_CORE = `
|
|
|
350
367
|
|
|
351
368
|
const AGENT_APP_POLICY_QUERY = `query ConstructAgentPolicy($appId: BigInt!) {
|
|
352
369
|
crowdyStudioAgentPolicy(appId: $appId) { ${AGENT_POLICY_CORE} }
|
|
353
|
-
crowdyStudioAgentEffectivePolicy(appId: $appId) {
|
|
370
|
+
crowdyStudioAgentEffectivePolicy(appId: $appId) {
|
|
371
|
+
${AGENT_POLICY_CORE} funding { billingMode payerKind walletDebitEnabled }
|
|
372
|
+
}
|
|
354
373
|
}`;
|
|
355
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Who pays for the agent's model use, as the effective policy has it. Metered, a
|
|
377
|
+
* request the payer's wallet cannot cover is refused, so say so up front.
|
|
378
|
+
*/
|
|
379
|
+
function fundingNote(effective) {
|
|
380
|
+
const funding = effective?.funding;
|
|
381
|
+
if (!funding) return 'funding not reported';
|
|
382
|
+
if (funding.billingMode !== 'METERED' || funding.walletDebitEnabled !== true) {
|
|
383
|
+
return 'platform-funded';
|
|
384
|
+
}
|
|
385
|
+
const payer = funding.payerKind === 'ORG' ? "the app's org wallet" : "each player's own wallet";
|
|
386
|
+
return `metered: model use is charged to ${payer}, which needs funds before the agent answers`;
|
|
387
|
+
}
|
|
388
|
+
|
|
356
389
|
const SET_AGENT_APP_POLICY = `mutation ConstructSetAgentPolicy($input: SetCrowdyStudioAgentAppPolicyInput!) {
|
|
357
390
|
setCrowdyStudioAgentPolicy(input: $input) { ${AGENT_POLICY_CORE} }
|
|
358
391
|
}`;
|
|
@@ -375,8 +408,8 @@ function catalogVisible(policy) {
|
|
|
375
408
|
* The dock stays fail-closed until the *app* policy is enabled and the
|
|
376
409
|
* player's Constructor tier holds `use_studio_agent`. The platform catalog is
|
|
377
410
|
* an operator concern — this starter never reads or writes `cp*` fields.
|
|
378
|
-
*
|
|
379
|
-
*
|
|
411
|
+
* This touches no wallet and no OpenRouter key in the game; who pays for model
|
|
412
|
+
* use is the effective policy's funding, which the log reports.
|
|
380
413
|
*/
|
|
381
414
|
export async function ensureAgentPolicy(identity, { appId }, log = noop) {
|
|
382
415
|
let app;
|
|
@@ -400,7 +433,7 @@ export async function ensureAgentPolicy(identity, { appId }, log = noop) {
|
|
|
400
433
|
'The dock stays closed until an operator publishes it. Studio → your app → Agent.',
|
|
401
434
|
);
|
|
402
435
|
} else {
|
|
403
|
-
log(
|
|
436
|
+
log(`Studio Agent app policy already enabled (${fundingNote(effective)})`);
|
|
404
437
|
}
|
|
405
438
|
return { enabled: true, skipped: true };
|
|
406
439
|
}
|
|
@@ -447,7 +480,7 @@ export async function ensureAgentPolicy(identity, { appId }, log = noop) {
|
|
|
447
480
|
'Studio → your app → Agent; dock stays closed.',
|
|
448
481
|
);
|
|
449
482
|
} else {
|
|
450
|
-
log(
|
|
483
|
+
log(`Studio Agent app policy enabled (${fundingNote(latestEffective)})`);
|
|
451
484
|
}
|
|
452
485
|
return { enabled: true, skipped: false };
|
|
453
486
|
} catch (error) {
|