@crowdedkingdoms/crowdyjs 9.0.0 → 10.0.0

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.
Files changed (72) hide show
  1. package/MIGRATION.md +56 -34
  2. package/README.md +83 -50
  3. package/dist/crowdy-client.d.ts +3 -0
  4. package/dist/crowdy-client.d.ts.map +1 -1
  5. package/dist/crowdy-client.js +2 -0
  6. package/dist/domains/playerCodeProjects.d.ts +25 -0
  7. package/dist/domains/playerCodeProjects.d.ts.map +1 -0
  8. package/dist/domains/playerCodeProjects.js +299 -0
  9. package/dist/domains/playerCompute.d.ts +1 -1
  10. package/dist/domains/playerCompute.js +1 -1
  11. package/dist/generated/graphql.d.ts +792 -7
  12. package/dist/generated/graphql.d.ts.map +1 -1
  13. package/dist/generated/graphql.js +57 -0
  14. package/dist/index.d.ts +3 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -5
  17. package/dist/kit/npcs.d.ts.map +1 -1
  18. package/dist/kit/social.d.ts.map +1 -1
  19. package/dist/live-coding/rust-analysis.d.ts.map +1 -1
  20. package/dist/live-coding/rust-analysis.js +64 -1
  21. package/dist/live-coding/worker-transport.d.ts +3 -0
  22. package/dist/live-coding/worker-transport.d.ts.map +1 -1
  23. package/dist/live-coding/worker-transport.js +13 -0
  24. package/dist/mod-studio/controller.d.ts +188 -0
  25. package/dist/mod-studio/controller.d.ts.map +1 -0
  26. package/dist/mod-studio/controller.js +915 -0
  27. package/dist/mod-studio/diagnostics.d.ts +22 -0
  28. package/dist/mod-studio/diagnostics.d.ts.map +1 -0
  29. package/dist/mod-studio/diagnostics.js +141 -0
  30. package/dist/mod-studio/dom-shell.d.ts +55 -0
  31. package/dist/mod-studio/dom-shell.d.ts.map +1 -0
  32. package/dist/mod-studio/dom-shell.js +527 -0
  33. package/dist/mod-studio/editor.d.ts +17 -0
  34. package/dist/mod-studio/editor.d.ts.map +1 -0
  35. package/dist/mod-studio/editor.js +1 -0
  36. package/dist/mod-studio/index.d.ts +10 -0
  37. package/dist/mod-studio/index.d.ts.map +1 -0
  38. package/dist/mod-studio/index.js +9 -0
  39. package/dist/mod-studio/models.d.ts +149 -0
  40. package/dist/mod-studio/models.d.ts.map +1 -0
  41. package/dist/mod-studio/models.js +62 -0
  42. package/dist/mod-studio/monaco-editor.d.ts +16 -0
  43. package/dist/mod-studio/monaco-editor.d.ts.map +1 -0
  44. package/dist/mod-studio/monaco-editor.js +548 -0
  45. package/dist/mod-studio/mount.d.ts +17 -0
  46. package/dist/mod-studio/mount.d.ts.map +1 -0
  47. package/dist/mod-studio/mount.js +86 -0
  48. package/dist/mod-studio/starter-projects.d.ts +11 -0
  49. package/dist/mod-studio/starter-projects.d.ts.map +1 -0
  50. package/dist/mod-studio/starter-projects.js +102 -0
  51. package/dist/mod-studio/styles.d.ts +2 -0
  52. package/dist/mod-studio/styles.d.ts.map +1 -0
  53. package/dist/mod-studio/styles.js +14 -0
  54. package/dist/mod-studio/textarea-editor.d.ts +8 -0
  55. package/dist/mod-studio/textarea-editor.d.ts.map +1 -0
  56. package/dist/mod-studio/textarea-editor.js +71 -0
  57. package/package.json +4 -4
  58. package/dist/live-coding/ide.d.ts +0 -23
  59. package/dist/live-coding/ide.d.ts.map +0 -1
  60. package/dist/live-coding/ide.js +0 -514
  61. package/dist/live-coding/index.d.ts +0 -8
  62. package/dist/live-coding/index.d.ts.map +0 -1
  63. package/dist/live-coding/index.js +0 -7
  64. package/dist/live-coding/live-coding-controller.d.ts +0 -75
  65. package/dist/live-coding/live-coding-controller.d.ts.map +0 -1
  66. package/dist/live-coding/live-coding-controller.js +0 -141
  67. package/dist/live-coding/mount.d.ts +0 -26
  68. package/dist/live-coding/mount.d.ts.map +0 -1
  69. package/dist/live-coding/mount.js +0 -108
  70. package/dist/live-coding/templates.d.ts +0 -17
  71. package/dist/live-coding/templates.d.ts.map +0 -1
  72. package/dist/live-coding/templates.js +0 -59
@@ -0,0 +1,915 @@
1
+ import { PlayerCodeBroker, } from '../player-runtime/player-code-broker.js';
2
+ import { parseRustcDiagnostics } from './diagnostics.js';
3
+ import { cloneModStudioProject, modStudioFileKey, normalizeModStudioPath, projectTargets, ModStudioOfflineError, ModStudioRevisionConflictError, } from './models.js';
4
+ import { createModStudioStarterProject, } from './starter-projects.js';
5
+ class OperationCancelledError extends Error {
6
+ }
7
+ /**
8
+ * Headless project-first Mod Studio driver. It owns optimistic project saves,
9
+ * file CRUD, deployment ordering, runtime polling, and client hot swaps; the
10
+ * DOM mount is only a view over this state.
11
+ */
12
+ export class ModStudioController {
13
+ constructor(options) {
14
+ this.options = options;
15
+ this.state = {
16
+ projects: [],
17
+ project: null,
18
+ personalLibraryFiles: [],
19
+ commonFiles: [],
20
+ openFiles: [],
21
+ activeFile: null,
22
+ saveState: 'SAVED',
23
+ runtime: { phase: 'IDLE' },
24
+ buildOutput: '',
25
+ authoritativeDiagnostics: [],
26
+ localDiagnostics: [],
27
+ runs: [],
28
+ logs: [],
29
+ usage: null,
30
+ wallet: null,
31
+ invokeResult: null,
32
+ };
33
+ this.listeners = new Set();
34
+ this.autosaveTimer = null;
35
+ this.retryTimer = null;
36
+ this.savePromise = null;
37
+ this.editGeneration = 0;
38
+ this.persistedGeneration = 0;
39
+ this.conflictRemote = null;
40
+ this.broker = null;
41
+ this.operationGeneration = 0;
42
+ this.visibleSurfaces = new Set();
43
+ this.surfaceTimers = new Map();
44
+ this.pageVisible = true;
45
+ this.destroyed = false;
46
+ if (options.onStateChange)
47
+ this.listeners.add(options.onStateChange);
48
+ }
49
+ getState() {
50
+ return this.state;
51
+ }
52
+ subscribe(listener) {
53
+ this.listeners.add(listener);
54
+ listener(this.state);
55
+ return () => this.listeners.delete(listener);
56
+ }
57
+ canTarget(target, action) {
58
+ const permission = this.options.targetPermissions?.[target];
59
+ return permission
60
+ ? action === 'write'
61
+ ? permission.canWrite
62
+ : permission.canRun
63
+ : true;
64
+ }
65
+ async initialize() {
66
+ this.ensureAlive();
67
+ const scope = this.scope();
68
+ let loaded;
69
+ try {
70
+ loaded = await Promise.all([
71
+ this.options.projectProvider.listProjects(scope),
72
+ this.options.projectProvider.listPersonalLibraryFiles(scope),
73
+ this.options.projectProvider.listCommonFiles(scope),
74
+ ]);
75
+ }
76
+ catch (error) {
77
+ if (error instanceof ModStudioOfflineError ||
78
+ this.options.isOnline?.() === false) {
79
+ this.update({
80
+ saveState: 'OFFLINE',
81
+ saveMessage: errorMessage(error),
82
+ });
83
+ return;
84
+ }
85
+ throw error;
86
+ }
87
+ const [projects, personalLibraryFiles, commonFiles] = loaded;
88
+ this.update({
89
+ projects,
90
+ personalLibraryFiles,
91
+ commonFiles,
92
+ saveState: 'SAVED',
93
+ saveMessage: undefined,
94
+ });
95
+ const first = projects.find((project) => project.projectId === this.options.initialProjectId) ?? projects[0];
96
+ if (first)
97
+ await this.loadProject(first.projectId);
98
+ }
99
+ async createProject(options) {
100
+ this.ensureAlive();
101
+ if (this.state.project && !(await this.saveNow())) {
102
+ throw new Error('Resolve or retry the current project save before creating another');
103
+ }
104
+ const input = createModStudioStarterProject({
105
+ ...options,
106
+ ...this.scope(),
107
+ });
108
+ const project = await this.options.projectProvider.createProject(input);
109
+ this.installProject(project);
110
+ this.update({
111
+ projects: upsertSummary(this.state.projects, summaryOf(project)),
112
+ saveState: 'SAVED',
113
+ saveMessage: undefined,
114
+ });
115
+ return project;
116
+ }
117
+ async switchProject(projectId) {
118
+ if (this.state.project?.projectId === projectId)
119
+ return;
120
+ if (this.state.project && !(await this.saveNow())) {
121
+ throw new Error('Resolve or retry the current project save before switching');
122
+ }
123
+ await this.loadProject(projectId);
124
+ }
125
+ async loadProject(projectId) {
126
+ const project = await this.options.projectProvider.getProject({
127
+ ...this.scope(),
128
+ projectId,
129
+ });
130
+ this.installProject(project);
131
+ }
132
+ installProject(project) {
133
+ ++this.operationGeneration;
134
+ this.stopSurfacePolling();
135
+ this.broker?.stop();
136
+ this.broker = null;
137
+ this.clearSaveTimers();
138
+ this.editGeneration = 0;
139
+ this.persistedGeneration = 0;
140
+ this.conflictRemote = null;
141
+ const clone = cloneModStudioProject(project);
142
+ const preferred = clone.files.find((file) => file.path === 'src/lib.rs') ?? clone.files[0];
143
+ const activeFile = preferred ? projectFileRef(preferred) : null;
144
+ this.update({
145
+ project: clone,
146
+ openFiles: activeFile ? [activeFile] : [],
147
+ activeFile,
148
+ saveState: 'SAVED',
149
+ saveMessage: undefined,
150
+ runtime: { phase: 'IDLE' },
151
+ buildOutput: '',
152
+ authoritativeDiagnostics: [],
153
+ localDiagnostics: [],
154
+ runs: [],
155
+ logs: [],
156
+ invokeResult: null,
157
+ });
158
+ this.restartVisibleSurfacePolling();
159
+ }
160
+ openFile(ref) {
161
+ this.requireFile(ref);
162
+ const exists = this.state.openFiles.some((entry) => sameFileRef(entry, ref));
163
+ this.update({
164
+ openFiles: exists ? this.state.openFiles : [...this.state.openFiles, ref],
165
+ activeFile: ref,
166
+ });
167
+ }
168
+ closeFile(ref) {
169
+ const openFiles = this.state.openFiles.filter((entry) => !sameFileRef(entry, ref));
170
+ const activeFile = this.state.activeFile && sameFileRef(this.state.activeFile, ref)
171
+ ? openFiles.at(-1) ?? null
172
+ : this.state.activeFile;
173
+ this.update({ openFiles, activeFile });
174
+ }
175
+ fileContent(ref) {
176
+ return this.requireFile(ref).content;
177
+ }
178
+ addFile(target, path, content = '') {
179
+ const project = this.requireProject();
180
+ this.assertProjectTarget(project, target);
181
+ const normalized = normalizeModStudioPath(path);
182
+ if (project.files.some((file) => file.target === target && file.path === normalized)) {
183
+ throw new Error(`${target}:${normalized} already exists`);
184
+ }
185
+ project.files.push({ target, path: normalized, content });
186
+ project.files.sort(compareProjectFile);
187
+ const ref = {
188
+ source: 'PROJECT',
189
+ target,
190
+ path: normalized,
191
+ };
192
+ this.markEdited();
193
+ this.openFile(ref);
194
+ }
195
+ renameFile(target, path, nextPath) {
196
+ const project = this.requireProject();
197
+ const normalized = normalizeModStudioPath(path);
198
+ const renamed = normalizeModStudioPath(nextPath);
199
+ const file = project.files.find((entry) => entry.target === target && entry.path === normalized);
200
+ if (!file)
201
+ throw new Error(`${target}:${normalized} does not exist`);
202
+ if (project.files.some((entry) => entry.target === target && entry.path === renamed)) {
203
+ throw new Error(`${target}:${renamed} already exists`);
204
+ }
205
+ file.path = renamed;
206
+ project.files.sort(compareProjectFile);
207
+ const replaceRef = (ref) => ref.source === 'PROJECT' &&
208
+ ref.target === target &&
209
+ ref.path === normalized
210
+ ? { ...ref, path: renamed }
211
+ : ref;
212
+ this.update({
213
+ openFiles: this.state.openFiles.map(replaceRef),
214
+ activeFile: this.state.activeFile
215
+ ? replaceRef(this.state.activeFile)
216
+ : null,
217
+ });
218
+ this.markEdited();
219
+ }
220
+ deleteFile(target, path) {
221
+ const project = this.requireProject();
222
+ const normalized = normalizeModStudioPath(path);
223
+ const index = project.files.findIndex((entry) => entry.target === target && entry.path === normalized);
224
+ if (index < 0)
225
+ throw new Error(`${target}:${normalized} does not exist`);
226
+ project.files.splice(index, 1);
227
+ this.closeFile({ source: 'PROJECT', target, path: normalized });
228
+ this.markEdited();
229
+ }
230
+ async importReferenceFile(reference, destinationPath = reference.path) {
231
+ this.requireProject();
232
+ if (!(await this.saveNow())) {
233
+ throw new Error('Resolve the current project save before importing a file');
234
+ }
235
+ const project = this.requireProject();
236
+ const saved = await this.options.projectProvider.importReferenceFile({
237
+ ...this.scope(),
238
+ projectId: project.projectId,
239
+ expectedRevisionId: project.revision.id,
240
+ source: reference.source,
241
+ referenceId: reference.id,
242
+ destinationPath,
243
+ });
244
+ this.installProject(saved);
245
+ this.update({
246
+ projects: upsertSummary(this.state.projects, summaryOf(saved)),
247
+ });
248
+ const imported = saved.files.find((file) => file.target === reference.target &&
249
+ file.path === normalizeModStudioPath(destinationPath));
250
+ if (imported)
251
+ this.openFile(projectFileRef(imported));
252
+ }
253
+ async saveProjectFileToLibrary(target, path, title) {
254
+ const file = this.requireProject().files.find((entry) => entry.target === target &&
255
+ entry.path === normalizeModStudioPath(path));
256
+ if (!file)
257
+ throw new Error(`${target}:${path} does not exist`);
258
+ const saved = await this.options.projectProvider.savePersonalLibraryFile({
259
+ ...this.scope(),
260
+ title: title?.trim() || file.path.split('/').at(-1) || file.path,
261
+ target,
262
+ path: file.path,
263
+ content: file.content,
264
+ });
265
+ this.update({
266
+ personalLibraryFiles: upsertReference(this.state.personalLibraryFiles, saved),
267
+ });
268
+ return saved;
269
+ }
270
+ updateFile(target, path, content) {
271
+ const project = this.requireProject();
272
+ const normalized = normalizeModStudioPath(path);
273
+ const file = project.files.find((entry) => entry.target === target && entry.path === normalized);
274
+ if (!file)
275
+ throw new Error(`${target}:${normalized} does not exist`);
276
+ if (file.content === content)
277
+ return;
278
+ file.content = content;
279
+ this.markEdited();
280
+ }
281
+ updateSettings(patch) {
282
+ const project = this.requireProject();
283
+ project.metadata = {
284
+ ...project.metadata,
285
+ ...patch,
286
+ pairingPreference: patch.pairingPreference ?? project.metadata.pairingPreference,
287
+ };
288
+ this.markEdited();
289
+ }
290
+ setPairingPreference(preference) {
291
+ this.updateSettings({ pairingPreference: preference });
292
+ }
293
+ setLocalDiagnostics(diagnostics) {
294
+ this.update({ localDiagnostics: [...diagnostics] });
295
+ }
296
+ /**
297
+ * Flush all edits in one optimistic-concurrency save. If edits arrive while
298
+ * the request is in flight, a second atomic save follows with the new
299
+ * revision instead of overwriting local content with the earlier response.
300
+ */
301
+ async saveNow() {
302
+ this.ensureAlive();
303
+ this.clearTimer('autosave');
304
+ if (!this.state.project)
305
+ return true;
306
+ if (this.savePromise) {
307
+ await this.savePromise;
308
+ if (this.persistedGeneration === this.editGeneration)
309
+ return true;
310
+ }
311
+ this.savePromise = this.performSaveLoop();
312
+ try {
313
+ return await this.savePromise;
314
+ }
315
+ finally {
316
+ this.savePromise = null;
317
+ }
318
+ }
319
+ async retrySave() {
320
+ this.clearTimer('retry');
321
+ if (!this.state.project) {
322
+ this.update({ saveState: 'SAVING', saveMessage: undefined });
323
+ await this.initialize();
324
+ return this.state.saveState !== 'OFFLINE';
325
+ }
326
+ if (this.state.saveState === 'CONFLICT')
327
+ return false;
328
+ this.update({ saveState: 'SAVING', saveMessage: undefined });
329
+ return this.saveNow();
330
+ }
331
+ async acceptRemoteConflict() {
332
+ if (this.state.saveState !== 'CONFLICT')
333
+ return;
334
+ const remote = this.conflictRemote ??
335
+ (await this.options.projectProvider.getProject({
336
+ ...this.scope(),
337
+ projectId: this.requireProject().projectId,
338
+ }));
339
+ this.installProject(remote);
340
+ }
341
+ async overwriteConflict() {
342
+ if (this.state.saveState !== 'CONFLICT')
343
+ return this.saveNow();
344
+ const project = this.requireProject();
345
+ const remote = this.conflictRemote ??
346
+ (await this.options.projectProvider.getProject({
347
+ ...this.scope(),
348
+ projectId: project.projectId,
349
+ }));
350
+ project.revision = { ...remote.revision };
351
+ this.conflictRemote = null;
352
+ this.update({ saveState: 'SAVING', saveMessage: undefined });
353
+ return this.saveNow();
354
+ }
355
+ async testDraft() {
356
+ await this.deployProject(true);
357
+ }
358
+ async deployLive() {
359
+ await this.deployProject(false);
360
+ }
361
+ async deployProject(draft) {
362
+ this.requireProject();
363
+ if (!(await this.saveNow())) {
364
+ this.setRuntime('ERROR', 'Project must be saved before it can be built');
365
+ return;
366
+ }
367
+ const project = this.requireProject();
368
+ const operation = ++this.operationGeneration;
369
+ this.stopSurfacePolling();
370
+ this.update({
371
+ runtime: { phase: draft ? 'TESTING_DRAFT' : 'DEPLOYING_LIVE' },
372
+ buildOutput: '',
373
+ authoritativeDiagnostics: [],
374
+ });
375
+ try {
376
+ const targets = projectTargets(project.kind);
377
+ if (targets.length === 1) {
378
+ const target = targets[0];
379
+ const compiled = await this.compileTarget(project, target, draft, operation);
380
+ if (!compiled)
381
+ return;
382
+ if (target === 'SERVER') {
383
+ await this.enableServer(compiled.name, operation);
384
+ }
385
+ else {
386
+ await this.runClient(compiled, operation);
387
+ }
388
+ }
389
+ else {
390
+ // Compile the client first so a client failure never publishes a new
391
+ // server version or alters the currently live pairing requirement.
392
+ const client = await this.compileTarget(project, 'CLIENT', draft, operation);
393
+ if (!client)
394
+ return;
395
+ const server = await this.compileTarget(project, 'SERVER', draft, operation);
396
+ if (!server)
397
+ return;
398
+ this.checkOperation(operation);
399
+ const requiredClientName = project.metadata.pairingPreference === 'REQUIRED'
400
+ ? client.name
401
+ : null;
402
+ await this.options.playerCompute.setRequires({
403
+ ...this.scope(),
404
+ serverName: server.name,
405
+ requiredClientName,
406
+ });
407
+ this.checkOperation(operation);
408
+ await this.enableServer(server.name, operation);
409
+ await this.runClient(client, operation);
410
+ }
411
+ this.update({
412
+ runtime: {
413
+ phase: 'RUNNING',
414
+ message: draft ? 'Draft test is running' : 'Project is live',
415
+ },
416
+ });
417
+ await this.refreshSurface('usage').catch(() => { });
418
+ }
419
+ catch (error) {
420
+ if (error instanceof OperationCancelledError)
421
+ return;
422
+ this.setRuntime('ERROR', errorMessage(error));
423
+ }
424
+ finally {
425
+ if (operation === this.operationGeneration) {
426
+ this.restartVisibleSurfacePolling();
427
+ }
428
+ }
429
+ }
430
+ async compileTarget(project, target, draft, operation) {
431
+ if (!this.canTarget(target, 'write')) {
432
+ throw new Error(`${target} authoring is unavailable on this grid`);
433
+ }
434
+ const name = moduleNameFor(project, target);
435
+ const files = project.files.filter((file) => file.target === target);
436
+ if (files.length === 0)
437
+ throw new Error(`${target} has no project files`);
438
+ this.update({
439
+ runtime: {
440
+ phase: 'COMPILING',
441
+ target,
442
+ message: `Submitting ${name}`,
443
+ },
444
+ });
445
+ // This is the sole project→legacy wire conversion. Project state, provider
446
+ // contracts, editors, and templates all use typed files.
447
+ const sourceFilesJson = JSON.stringify(Object.fromEntries(files.map((file) => [file.path, file.content])));
448
+ const deployed = await this.options.playerCompute.deploy({
449
+ ...this.scope(),
450
+ name,
451
+ target: target,
452
+ sourceFilesJson,
453
+ sdkVersion: project.sdkVersion,
454
+ abiVersion: project.abiVersion,
455
+ tickHz: target === 'SERVER' ? 1 : undefined,
456
+ draft,
457
+ });
458
+ this.checkOperation(operation);
459
+ const limit = this.options.compilePollLimit ?? 60;
460
+ const pollMs = this.options.compilePollMs ?? 1500;
461
+ for (let attempt = 0; attempt < limit; attempt++) {
462
+ const versions = await this.options.playerCompute.versions({
463
+ ...this.scope(),
464
+ name,
465
+ });
466
+ this.checkOperation(operation);
467
+ const version = versions.find((candidate) => candidate.versionId === deployed.versionId);
468
+ const status = version?.compileStatus.toLowerCase();
469
+ if (status === 'succeeded' || status === 'success') {
470
+ this.recordBuild(target, version?.compileLog ?? '');
471
+ return { target, name, versionId: deployed.versionId };
472
+ }
473
+ if (status === 'failed' || status === 'error') {
474
+ const log = version?.compileLog ?? 'Compilation failed without output';
475
+ this.recordBuild(target, log);
476
+ this.update({
477
+ runtime: {
478
+ phase: 'COMPILE_FAILED',
479
+ target,
480
+ message: `${name} failed to compile`,
481
+ },
482
+ });
483
+ return null;
484
+ }
485
+ await this.sleep(pollMs);
486
+ this.checkOperation(operation);
487
+ }
488
+ const timeout = `Compilation timed out after ${limit} polls`;
489
+ this.recordBuild(target, timeout);
490
+ this.update({
491
+ runtime: { phase: 'COMPILE_FAILED', target, message: timeout },
492
+ });
493
+ return null;
494
+ }
495
+ recordBuild(target, log) {
496
+ const section = `## ${target}\n${log || 'Compiled successfully.'}`;
497
+ const authoritativeDiagnostics = [
498
+ ...this.state.authoritativeDiagnostics.filter((diagnostic) => diagnostic.target !== target),
499
+ ...parseRustcDiagnostics(log, target),
500
+ ];
501
+ this.update({
502
+ buildOutput: [this.state.buildOutput, section].filter(Boolean).join('\n\n'),
503
+ authoritativeDiagnostics,
504
+ });
505
+ }
506
+ async enableServer(name, operation) {
507
+ if (!this.canTarget('SERVER', 'run')) {
508
+ throw new Error(`${name} compiled successfully, but run_server_code is unavailable on this grid`);
509
+ }
510
+ this.update({
511
+ runtime: { phase: 'ENABLING', target: 'SERVER', message: `Enabling ${name}` },
512
+ });
513
+ await this.options.playerCompute.setEnabled({
514
+ ...this.scope(),
515
+ name,
516
+ enabled: true,
517
+ });
518
+ this.checkOperation(operation);
519
+ }
520
+ async runClient(compiled, operation) {
521
+ if (!this.canTarget('CLIENT', 'run')) {
522
+ throw new Error(`${compiled.name} compiled successfully, but run_client_code is unavailable on this grid`);
523
+ }
524
+ const runtime = this.clientRuntimeOptions();
525
+ const artifact = await this.options.playerCompute.artifactBytes({
526
+ ...this.scope(),
527
+ name: compiled.name,
528
+ versionId: compiled.versionId,
529
+ });
530
+ this.checkOperation(operation);
531
+ if (artifact.versionId !== compiled.versionId ||
532
+ !artifact.artifactHash ||
533
+ artifact.bytes.byteLength === 0) {
534
+ throw new Error('Client artifact did not match the compiled project version');
535
+ }
536
+ const brokerOptions = {
537
+ ...runtime,
538
+ artifactHash: artifact.artifactHash,
539
+ fuelPerDispatch: artifact.fuelPerDispatch,
540
+ onPresentation: this.options.onPresentation,
541
+ tickIntervalMs: this.options.clientTickIntervalMs ?? 1000,
542
+ };
543
+ const broker = this.options.brokerFactory?.(brokerOptions) ??
544
+ new PlayerCodeBroker(brokerOptions);
545
+ await broker.start(artifact.bytes);
546
+ this.checkOperation(operation);
547
+ const previous = this.broker;
548
+ this.broker = broker;
549
+ previous?.stop();
550
+ }
551
+ async stopProject() {
552
+ const project = this.requireProject();
553
+ ++this.operationGeneration;
554
+ this.stopSurfacePolling();
555
+ this.update({ runtime: { phase: 'STOPPING' } });
556
+ const failures = [];
557
+ let serverStopped = null;
558
+ let clientStopped = null;
559
+ if (projectTargets(project.kind).includes('CLIENT')) {
560
+ clientStopped = true;
561
+ try {
562
+ this.broker?.stop();
563
+ }
564
+ catch (error) {
565
+ clientStopped = false;
566
+ failures.push(`Client: ${errorMessage(error)}`);
567
+ }
568
+ finally {
569
+ this.broker = null;
570
+ }
571
+ }
572
+ if (projectTargets(project.kind).includes('SERVER')) {
573
+ serverStopped = false;
574
+ try {
575
+ await this.options.playerCompute.setEnabled({
576
+ ...this.scope(),
577
+ name: moduleNameFor(project, 'SERVER'),
578
+ enabled: false,
579
+ });
580
+ serverStopped = true;
581
+ }
582
+ catch (error) {
583
+ failures.push(`Server: ${errorMessage(error)}`);
584
+ }
585
+ }
586
+ const result = { serverStopped, clientStopped, failures };
587
+ this.update({
588
+ runtime: failures.length === 0
589
+ ? { phase: 'STOPPED', message: 'Project stopped' }
590
+ : {
591
+ phase: 'PARTIAL_FAILURE',
592
+ message: failures.join(' · '),
593
+ },
594
+ });
595
+ return result;
596
+ }
597
+ async invoke(exportName, paramsJson) {
598
+ const project = this.requireProject();
599
+ if (!projectTargets(project.kind).includes('SERVER')) {
600
+ throw new Error('Invoke requires a SERVER target');
601
+ }
602
+ const result = await this.options.playerCompute.invoke({
603
+ ...this.scope(),
604
+ moduleName: moduleNameFor(project, 'SERVER'),
605
+ exportName: exportName.trim() || 'invoke',
606
+ paramsJson: paramsJson?.trim() || null,
607
+ });
608
+ this.update({ invokeResult: result });
609
+ return result;
610
+ }
611
+ setSurfaceVisible(surface, visible) {
612
+ if (visible) {
613
+ this.visibleSurfaces.add(surface);
614
+ if (this.pageVisible) {
615
+ void this.refreshSurface(surface).catch(() => { });
616
+ this.scheduleSurfacePoll(surface);
617
+ }
618
+ }
619
+ else {
620
+ this.visibleSurfaces.delete(surface);
621
+ this.clearSurfaceTimer(surface);
622
+ }
623
+ }
624
+ setPageVisible(visible) {
625
+ if (this.pageVisible === visible)
626
+ return;
627
+ this.pageVisible = visible;
628
+ if (visible)
629
+ this.restartVisibleSurfacePolling();
630
+ else
631
+ this.stopSurfacePolling();
632
+ }
633
+ async refreshSurface(surface) {
634
+ if (!this.state.project)
635
+ return;
636
+ const serverName = this.state.project.metadata.serverModuleName;
637
+ if (surface === 'runs') {
638
+ this.update({
639
+ runs: await this.options.playerCompute.runs({
640
+ ...this.scope(),
641
+ ...(serverName ? { moduleName: serverName } : {}),
642
+ limit: 50,
643
+ offset: 0,
644
+ }),
645
+ });
646
+ return;
647
+ }
648
+ if (surface === 'logs') {
649
+ this.update({
650
+ logs: await this.options.playerCompute.logs({
651
+ ...this.scope(),
652
+ ...(serverName ? { moduleName: serverName } : {}),
653
+ limit: 50,
654
+ }),
655
+ });
656
+ return;
657
+ }
658
+ const [usage, wallet] = await Promise.all([
659
+ this.options.playerCompute.usage({ appId: this.options.appId }),
660
+ this.options.playerWallet?.balance() ?? Promise.resolve(null),
661
+ ]);
662
+ this.update({ usage: normalizeUsage(usage), wallet });
663
+ }
664
+ destroy() {
665
+ if (this.destroyed)
666
+ return;
667
+ this.destroyed = true;
668
+ ++this.operationGeneration;
669
+ this.clearSaveTimers();
670
+ this.stopSurfacePolling();
671
+ this.broker?.stop();
672
+ this.broker = null;
673
+ this.listeners.clear();
674
+ }
675
+ async performSaveLoop() {
676
+ while (this.persistedGeneration !== this.editGeneration) {
677
+ const project = this.requireProject();
678
+ const savingGeneration = this.editGeneration;
679
+ const snapshot = cloneModStudioProject(project);
680
+ this.update({ saveState: 'SAVING', saveMessage: undefined });
681
+ try {
682
+ const saved = await this.options.projectProvider.saveProject({
683
+ ...this.scope(),
684
+ projectId: snapshot.projectId,
685
+ expectedRevisionId: snapshot.revision.id,
686
+ metadata: snapshot.metadata,
687
+ files: snapshot.files,
688
+ });
689
+ this.persistedGeneration = savingGeneration;
690
+ if (this.state.project?.projectId !== saved.projectId)
691
+ return true;
692
+ if (this.editGeneration === savingGeneration) {
693
+ this.state.project = cloneModStudioProject(saved);
694
+ }
695
+ else {
696
+ // Preserve newer local edits while advancing the revision precondition.
697
+ this.state.project.revision = { ...saved.revision };
698
+ this.state.project.updatedAt = saved.updatedAt;
699
+ }
700
+ this.update({
701
+ projects: upsertSummary(this.state.projects, summaryOf(saved)),
702
+ saveState: this.persistedGeneration === this.editGeneration ? 'SAVED' : 'SAVING',
703
+ saveMessage: undefined,
704
+ });
705
+ }
706
+ catch (error) {
707
+ if (error instanceof ModStudioRevisionConflictError) {
708
+ this.conflictRemote = error.remoteProject ?? null;
709
+ this.update({ saveState: 'CONFLICT', saveMessage: error.message });
710
+ return false;
711
+ }
712
+ if (error instanceof ModStudioOfflineError ||
713
+ this.options.isOnline?.() === false) {
714
+ this.update({ saveState: 'OFFLINE', saveMessage: errorMessage(error) });
715
+ this.scheduleRetry();
716
+ return false;
717
+ }
718
+ this.update({ saveState: 'OFFLINE', saveMessage: errorMessage(error) });
719
+ throw error;
720
+ }
721
+ }
722
+ this.update({ saveState: 'SAVED', saveMessage: undefined });
723
+ return true;
724
+ }
725
+ markEdited() {
726
+ this.editGeneration++;
727
+ this.update({ saveState: 'SAVING', saveMessage: undefined });
728
+ this.clearTimer('autosave');
729
+ this.autosaveTimer = setTimeout(() => {
730
+ this.autosaveTimer = null;
731
+ void this.saveNow().catch(() => { });
732
+ }, this.options.autosaveMs ?? 700);
733
+ }
734
+ scheduleRetry() {
735
+ this.clearTimer('retry');
736
+ if (this.destroyed)
737
+ return;
738
+ this.retryTimer = setTimeout(() => {
739
+ this.retryTimer = null;
740
+ void this.retrySave().catch(() => { });
741
+ }, this.options.retryMs ?? 3000);
742
+ }
743
+ scheduleSurfacePoll(surface) {
744
+ this.clearSurfaceTimer(surface);
745
+ if (!this.pageVisible ||
746
+ !this.visibleSurfaces.has(surface) ||
747
+ this.destroyed) {
748
+ return;
749
+ }
750
+ const timer = setTimeout(() => {
751
+ this.surfaceTimers.delete(surface);
752
+ void this.refreshSurface(surface)
753
+ .catch(() => { })
754
+ .finally(() => this.scheduleSurfacePoll(surface));
755
+ }, this.options.monitorPollMs ?? 5000);
756
+ this.surfaceTimers.set(surface, timer);
757
+ }
758
+ restartVisibleSurfacePolling() {
759
+ if (!this.pageVisible || this.destroyed)
760
+ return;
761
+ for (const surface of this.visibleSurfaces) {
762
+ void this.refreshSurface(surface).catch(() => { });
763
+ this.scheduleSurfacePoll(surface);
764
+ }
765
+ }
766
+ stopSurfacePolling() {
767
+ for (const timer of this.surfaceTimers.values())
768
+ clearTimeout(timer);
769
+ this.surfaceTimers.clear();
770
+ }
771
+ clearSurfaceTimer(surface) {
772
+ const timer = this.surfaceTimers.get(surface);
773
+ if (timer)
774
+ clearTimeout(timer);
775
+ this.surfaceTimers.delete(surface);
776
+ }
777
+ clearSaveTimers() {
778
+ this.clearTimer('autosave');
779
+ this.clearTimer('retry');
780
+ }
781
+ clearTimer(kind) {
782
+ const timer = kind === 'autosave' ? this.autosaveTimer : this.retryTimer;
783
+ if (timer)
784
+ clearTimeout(timer);
785
+ if (kind === 'autosave')
786
+ this.autosaveTimer = null;
787
+ else
788
+ this.retryTimer = null;
789
+ }
790
+ clientRuntimeOptions() {
791
+ if (!this.options.workerUrl || !this.options.grid || !this.options.onHostCall) {
792
+ throw new Error('CLIENT projects require workerUrl, grid, and an allow-listed onHostCall router');
793
+ }
794
+ return {
795
+ workerUrl: this.options.workerUrl,
796
+ grid: this.options.grid,
797
+ onHostCall: this.options.onHostCall,
798
+ };
799
+ }
800
+ checkOperation(generation) {
801
+ if (generation !== this.operationGeneration || this.destroyed) {
802
+ throw new OperationCancelledError();
803
+ }
804
+ }
805
+ sleep(ms) {
806
+ return (this.options.sleep ??
807
+ ((delay) => new Promise((resolve) => setTimeout(resolve, delay))))(ms);
808
+ }
809
+ requireProject() {
810
+ if (!this.state.project)
811
+ throw new Error('No Mod Studio project is open');
812
+ return this.state.project;
813
+ }
814
+ requireFile(ref) {
815
+ if (ref.source === 'PROJECT') {
816
+ const file = this.requireProject().files.find((entry) => entry.target === ref.target &&
817
+ entry.path === normalizeModStudioPath(ref.path));
818
+ if (file)
819
+ return file;
820
+ }
821
+ else {
822
+ const files = ref.source === 'PERSONAL_LIBRARY'
823
+ ? this.state.personalLibraryFiles
824
+ : this.state.commonFiles;
825
+ const file = files.find((entry) => ref.referenceId
826
+ ? entry.id === ref.referenceId
827
+ : entry.path === ref.path && entry.target === ref.target);
828
+ if (file)
829
+ return file;
830
+ }
831
+ throw new Error(`File is not loaded: ${ref.source}:${ref.path}`);
832
+ }
833
+ assertProjectTarget(project, target) {
834
+ if (!projectTargets(project.kind).includes(target)) {
835
+ throw new Error(`${project.kind} projects do not have a ${target} target`);
836
+ }
837
+ }
838
+ scope() {
839
+ return { appId: this.options.appId, gridId: this.options.gridId };
840
+ }
841
+ setRuntime(phase, message) {
842
+ this.update({ runtime: { phase, message } });
843
+ }
844
+ update(patch) {
845
+ this.state = { ...this.state, ...patch };
846
+ for (const listener of this.listeners)
847
+ listener(this.state);
848
+ }
849
+ ensureAlive() {
850
+ if (this.destroyed)
851
+ throw new Error('ModStudioController is destroyed');
852
+ }
853
+ }
854
+ function projectFileRef(file) {
855
+ return { source: 'PROJECT', target: file.target, path: file.path };
856
+ }
857
+ function sameFileRef(a, b) {
858
+ return (a.source === b.source &&
859
+ a.target === b.target &&
860
+ a.path === b.path &&
861
+ a.referenceId === b.referenceId);
862
+ }
863
+ function compareProjectFile(a, b) {
864
+ return modStudioFileKey(a.target, a.path).localeCompare(modStudioFileKey(b.target, b.path));
865
+ }
866
+ function moduleNameFor(project, target) {
867
+ const name = target === 'SERVER'
868
+ ? project.metadata.serverModuleName
869
+ : project.metadata.clientModuleName;
870
+ if (!name?.trim()) {
871
+ throw new Error(`${target} module name is required in Project settings`);
872
+ }
873
+ return name.trim();
874
+ }
875
+ function summaryOf(project) {
876
+ return {
877
+ projectId: project.projectId,
878
+ name: project.metadata.name,
879
+ kind: project.kind,
880
+ revisionId: project.revision.id,
881
+ ...(project.metadata.serverModuleName
882
+ ? { serverModuleName: project.metadata.serverModuleName }
883
+ : {}),
884
+ ...(project.metadata.clientModuleName
885
+ ? { clientModuleName: project.metadata.clientModuleName }
886
+ : {}),
887
+ updatedAt: project.updatedAt,
888
+ };
889
+ }
890
+ function upsertSummary(projects, next) {
891
+ const result = projects.filter((project) => project.projectId !== next.projectId);
892
+ result.push(next);
893
+ return result.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
894
+ }
895
+ function upsertReference(files, next) {
896
+ return [
897
+ next,
898
+ ...files.filter((file) => file.source !== next.source || file.id !== next.id),
899
+ ];
900
+ }
901
+ function normalizeUsage(value) {
902
+ return {
903
+ hourUnitsUsed: String(value.hourUnitsUsed),
904
+ dayUnitsUsed: String(value.dayUnitsUsed),
905
+ unitsPerHour: value.unitsPerHour == null ? null : String(value.unitsPerHour),
906
+ unitsPerDay: value.unitsPerDay == null ? null : String(value.unitsPerDay),
907
+ compilesThisHour: value.compilesThisHour,
908
+ maxCompilesPerHour: value.maxCompilesPerHour,
909
+ gateStatus: value.gateStatus,
910
+ gateReason: value.gateReason ?? null,
911
+ };
912
+ }
913
+ function errorMessage(error) {
914
+ return error instanceof Error ? error.message : String(error);
915
+ }