@crowdedkingdoms/construct 0.0.0 → 0.2.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.
Files changed (69) hide show
  1. package/README.md +76 -1
  2. package/node/copy-dsh-web.mjs +71 -0
  3. package/node/security-headers.d.mts +23 -0
  4. package/node/security-headers.mjs +243 -0
  5. package/node/vite-plugins.d.mts +6 -0
  6. package/node/vite-plugins.mjs +102 -0
  7. package/package.json +46 -3
  8. package/sandbox/boot.ts +74 -0
  9. package/src/engine/GameLoop.ts +106 -0
  10. package/src/engine/GameScene.ts +74 -0
  11. package/src/engine/Input.test.ts +129 -0
  12. package/src/engine/Input.ts +257 -0
  13. package/src/engine/SceneRouter.test.ts +80 -0
  14. package/src/engine/SceneRouter.ts +113 -0
  15. package/src/engine/controls.test.ts +26 -0
  16. package/src/engine/controls.ts +63 -0
  17. package/src/grid/GridProgramRunner.ts +205 -0
  18. package/src/grid/sandboxProtocol.ts +9 -0
  19. package/src/index.ts +88 -0
  20. package/src/platform/GameSession.ts +239 -0
  21. package/src/platform/auth/AuthService.ts +45 -0
  22. package/src/platform/config.test.ts +110 -0
  23. package/src/platform/config.ts +212 -0
  24. package/src/platform/envScope.test.ts +42 -0
  25. package/src/platform/envScope.ts +76 -0
  26. package/src/platform/exec/worldHub.test.ts +61 -0
  27. package/src/platform/exec/worldHub.ts +86 -0
  28. package/src/platform/media/VoiceService.ts +423 -0
  29. package/src/platform/media/WebcamService.ts +346 -0
  30. package/src/platform/media/mulaw.test.ts +20 -0
  31. package/src/platform/media/mulaw.ts +56 -0
  32. package/src/platform/media/videoFrames.test.ts +197 -0
  33. package/src/platform/media/voiceFrames.test.ts +82 -0
  34. package/src/platform/model/ModelService.test.ts +59 -0
  35. package/src/platform/model/ModelService.ts +124 -0
  36. package/src/platform/network/NetworkManager.ts +519 -0
  37. package/src/platform/onboarding/steps.d.mts +154 -0
  38. package/src/platform/onboarding/steps.mjs +616 -0
  39. package/src/platform/onboarding/steps.test.ts +486 -0
  40. package/src/platform/realtime/WorldStores.ts +153 -0
  41. package/src/platform/realtime/actorCodec.test.ts +62 -0
  42. package/src/platform/realtime/actorCodec.ts +142 -0
  43. package/src/platform/realtime/space.test.ts +33 -0
  44. package/src/platform/realtime/space.ts +63 -0
  45. package/src/platform/social/ChatService.ts +92 -0
  46. package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
  47. package/src/platform/studio/ClientModLifecycle.ts +102 -0
  48. package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
  49. package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
  50. package/src/platform/studio/GridService.test.ts +131 -0
  51. package/src/platform/studio/GridService.ts +295 -0
  52. package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
  53. package/src/platform/studio/HumanInputMonitor.ts +48 -0
  54. package/src/platform/studio/StudioService.ts +686 -0
  55. package/src/platform/studio/agentLocomotion.test.ts +31 -0
  56. package/src/platform/studio/agentLocomotion.ts +97 -0
  57. package/src/platform/studio/clientModHost.test.ts +199 -0
  58. package/src/platform/studio/clientModHost.ts +369 -0
  59. package/src/platform/studio/instanceSchema.test.ts +280 -0
  60. package/src/platform/studio/instanceSchema.ts +746 -0
  61. package/src/platform/studio/instanceStore.test.ts +97 -0
  62. package/src/platform/studio/instanceStore.ts +120 -0
  63. package/src/platform/studio/modOverlay.test.ts +56 -0
  64. package/src/platform/studio/modOverlay.ts +51 -0
  65. package/src/platform/studio/permissions.test.ts +51 -0
  66. package/src/platform/studio/permissions.ts +134 -0
  67. package/src/platform/studio/pointerClicks.test.ts +23 -0
  68. package/src/platform/studio/pointerClicks.ts +132 -0
  69. package/src/platform/util/Emitter.ts +32 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Loads one scene at a time into the game root and keeps the platform session
3
+ * informed of which one. Scenes are constructed lazily from factories so an
4
+ * unused renderer never initialises (or, with code-splitting, never loads).
5
+ */
6
+ import type { GameScene, SceneContext, SceneFactory, SceneSize } from './GameScene';
7
+ import { Emitter } from '../platform/util/Emitter';
8
+
9
+ export interface SceneRouterEvents {
10
+ /** Fired after a scene has mounted. */
11
+ scene: GameScene;
12
+ /** Fired when a load fails; the previous scene stays mounted if it can. */
13
+ error: { sceneId: string; error: unknown };
14
+ }
15
+
16
+ export class SceneRouter {
17
+ readonly events = new Emitter<SceneRouterEvents>();
18
+ private readonly factories = new Map<string, SceneFactory>();
19
+ private readonly instances = new Map<string, GameScene>();
20
+ private active: GameScene | null = null;
21
+ private loading: Promise<GameScene> | null = null;
22
+ private context: SceneContext | null = null;
23
+ private size: SceneSize = { width: 1, height: 1, rightInset: 0 };
24
+
25
+ register(id: string, factory: SceneFactory): void {
26
+ this.factories.set(id, factory);
27
+ }
28
+
29
+ ids(): string[] {
30
+ return [...this.factories.keys()];
31
+ }
32
+
33
+ /** Called once by the loop before the first `load`. */
34
+ bind(context: SceneContext, size: SceneSize): void {
35
+ this.context = context;
36
+ this.size = size;
37
+ }
38
+
39
+ get current(): GameScene | null {
40
+ return this.active;
41
+ }
42
+
43
+ get isLoading(): boolean {
44
+ return this.loading !== null;
45
+ }
46
+
47
+ /**
48
+ * Switch scenes. The new scene mounts with the local player at `spawn` (or
49
+ * where the scene decides when omitted). Concurrent loads are serialised.
50
+ */
51
+ async load(
52
+ id: string,
53
+ spawn?: { x: number; y: number; z: number; yaw?: number },
54
+ ): Promise<GameScene> {
55
+ if (this.loading) await this.loading.catch(() => undefined);
56
+ if (this.active?.id === id) return this.active;
57
+ const context = this.context;
58
+ if (!context) throw new Error('SceneRouter.bind() before load()');
59
+ const factory = this.factories.get(id);
60
+ if (!factory) throw new Error(`No scene registered as "${id}"`);
61
+
62
+ const run = (async () => {
63
+ const previous = this.active;
64
+ let scene = this.instances.get(id);
65
+ if (!scene) {
66
+ scene = factory();
67
+ this.instances.set(id, scene);
68
+ }
69
+ const previousPose = previous?.localPose();
70
+ try {
71
+ previous?.unmount();
72
+ this.active = null;
73
+ await scene.mount(context, this.size);
74
+ if (spawn) scene.setLocalPosition(spawn);
75
+ this.active = scene;
76
+ this.events.emit('scene', scene);
77
+ return scene;
78
+ } catch (error) {
79
+ // Never leave the player staring at nothing: put the previous scene
80
+ // back where it was, then report.
81
+ try {
82
+ scene.unmount();
83
+ } catch {
84
+ // best effort
85
+ }
86
+ if (previous) {
87
+ await previous.mount(context, this.size);
88
+ if (previousPose) previous.setLocalPosition(previousPose);
89
+ this.active = previous;
90
+ }
91
+ this.events.emit('error', { sceneId: id, error });
92
+ throw error;
93
+ }
94
+ })();
95
+ this.loading = run;
96
+ try {
97
+ return await run;
98
+ } finally {
99
+ if (this.loading === run) this.loading = null;
100
+ }
101
+ }
102
+
103
+ resize(size: SceneSize): void {
104
+ this.size = size;
105
+ this.active?.resize(size);
106
+ }
107
+
108
+ dispose(): void {
109
+ this.active?.unmount();
110
+ this.active = null;
111
+ this.instances.clear();
112
+ }
113
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { Controls, helpLines, isControl } from './controls';
4
+
5
+ describe('controls', () => {
6
+ it('maps chat to T and both Enters, and activate to E only', () => {
7
+ expect(isControl('chat', 'KeyT')).toBe(true);
8
+ expect(isControl('chat', 'Enter')).toBe(true);
9
+ expect(isControl('chat', 'NumpadEnter')).toBe(true);
10
+ expect(isControl('activate', 'KeyE')).toBe(true);
11
+ expect(isControl('activate', 'Enter')).toBe(false);
12
+ expect(Controls.voice).toContain('KeyV');
13
+ expect(Controls.webcam).toContain('KeyB');
14
+ });
15
+
16
+ it('lists holodeck look/zoom and paint pan without mixing them', () => {
17
+ const holodeck = helpLines('holodeck').join('\n');
18
+ const paint = helpLines('paint').join('\n');
19
+ expect(holodeck).toMatch(/right mouse/);
20
+ expect(holodeck).toMatch(/Scroll/);
21
+ expect(holodeck).toMatch(/E — activate/);
22
+ expect(holodeck).toMatch(/claimed chunk/);
23
+ expect(paint).toMatch(/middle-drag/);
24
+ expect(paint).not.toMatch(/activate a pad/);
25
+ });
26
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The one place key and mouse bindings are named. Scenes and the HUD read
3
+ * this map; they do not invent codes. `KeyboardEvent.code` (physical keys)
4
+ * so WASD stays WASD on an AZERTY layout.
5
+ */
6
+ export const Controls = {
7
+ moveForward: ['KeyW', 'ArrowUp'],
8
+ moveBack: ['KeyS', 'ArrowDown'],
9
+ moveLeft: ['KeyA', 'ArrowLeft'],
10
+ moveRight: ['KeyD', 'ArrowRight'],
11
+ run: ['ShiftLeft', 'ShiftRight'],
12
+ activate: ['KeyE'],
13
+ chat: ['KeyT', 'Enter', 'NumpadEnter'],
14
+ webcam: ['KeyB'],
15
+ voice: ['KeyV'],
16
+ studio: ['KeyM'],
17
+ holodeck: ['KeyH'],
18
+ help: ['F1', 'Slash'],
19
+ escape: ['Escape'],
20
+ } as const;
21
+
22
+ export type ControlAction = keyof typeof Controls;
23
+
24
+ export function codesFor(action: ControlAction): readonly string[] {
25
+ return Controls[action];
26
+ }
27
+
28
+ export function isControl(action: ControlAction, code: string): boolean {
29
+ return (Controls[action] as readonly string[]).includes(code);
30
+ }
31
+
32
+ export type HelpScene = 'holodeck' | 'paint';
33
+
34
+ /** Lines shown in the F1 / ? overlay. */
35
+ export function helpLines(scene: HelpScene): string[] {
36
+ const shared = [
37
+ 'WASD / arrows — move',
38
+ 'Shift — run (holodeck)',
39
+ 'T or Enter — chat',
40
+ 'B — webcam',
41
+ 'V — voice',
42
+ 'M — Crowdy Studio (Ask / Build / Play agent in the dock)',
43
+ 'F1 or ? — this help',
44
+ 'Escape — close help, unlock look',
45
+ ];
46
+ if (scene === 'paint') {
47
+ return [
48
+ 'Click / drag — paint · right-drag — erase',
49
+ '1–8 — colours · 0 — eraser',
50
+ 'Scroll — zoom · middle-drag or Alt+click — pan',
51
+ 'H — back to holodeck',
52
+ ...shared,
53
+ ];
54
+ }
55
+ return [
56
+ 'Hold right mouse — look',
57
+ 'Left click — grid mods (click-to-charge)',
58
+ 'Amber box — your claimed chunk',
59
+ 'Scroll — zoom camera',
60
+ 'E — activate a pad',
61
+ ...shared,
62
+ ];
63
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Page side of JS grid programs (DN-10 §5): player-authored JavaScript that
3
+ * uses the full CrowdyJS SDK inside one grid.
4
+ *
5
+ * Each program runs in its own hidden `sandbox="allow-scripts"` iframe (an
6
+ * opaque origin with no network, see `buildGridProgramCsp`). Its CrowdyJS
7
+ * talks over a MessagePort that `hostGridProgram` relays with a grid-scoped
8
+ * token, so the server confines everything it does to the grid. The runner
9
+ * also answers the Crowdy Studio agent's grid requests (crowdy-dsh bridge v4).
10
+ */
11
+ import type { CrowdyClient } from '@crowdedkingdoms/crowdyjs';
12
+ import type {
13
+ CrowdyStudioDshGridHost,
14
+ DshGridContext,
15
+ DshGridProgramStatus,
16
+ } from '@crowdedkingdoms/crowdyjs/crowdy-dsh';
17
+ import { hostGridProgram, type GridProgramHost } from '@crowdedkingdoms/crowdyjs/grid-program';
18
+
19
+ import { GRID_PROGRAM_SOURCE, type GridProgramSandboxMessage } from './sandboxProtocol';
20
+
21
+ export interface GridProgramRunnerOptions {
22
+ /** The page's client (holds the player's app token); resolved per call. */
23
+ client: () => CrowdyClient;
24
+ appId: () => string;
25
+ /** ck-api GraphQL endpoints the relay dials (a function follows endpoint changes). */
26
+ graphqlUrl: string | (() => string);
27
+ graphqlWsUrl: string | (() => string);
28
+ /** URL of the sandbox page, usually `${import.meta.env.BASE_URL}grid-program.html`. */
29
+ sandboxUrl: string;
30
+ /** Where the hidden iframes are attached (default document.body). */
31
+ container?: () => HTMLElement;
32
+ /** Whether the signed-in player owns a grid (for `grid.context`). */
33
+ owns?: (gridId: string) => boolean;
34
+ /** Called when a program's state changes (for a HUD line). */
35
+ onChange?: (status: DshGridProgramStatus) => void;
36
+ }
37
+
38
+ interface RunningProgram {
39
+ key: string;
40
+ gridId: string;
41
+ status: DshGridProgramStatus;
42
+ iframe: HTMLIFrameElement;
43
+ host: GridProgramHost | null;
44
+ listener: (event: MessageEvent) => void;
45
+ }
46
+
47
+ const LOG_LINES = 200;
48
+ const READY_TIMEOUT_MS = 15_000;
49
+
50
+ export class GridProgramRunner implements CrowdyStudioDshGridHost {
51
+ private readonly running = new Map<string, RunningProgram>();
52
+
53
+ constructor(private readonly options: GridProgramRunnerOptions) {}
54
+
55
+ async context(gridId: string): Promise<DshGridContext | null> {
56
+ const scope = this.options.client().grid(this.options.appId(), gridId);
57
+ const token = await scope.mintToken(60).catch(() => null);
58
+ if (!token) return null;
59
+ const [channels, sessions] = await Promise.all([
60
+ scope.channels.list().catch(() => []),
61
+ scope.sessions.list({ status: 'active' }).catch(() => []),
62
+ ]);
63
+ return {
64
+ appId: this.options.appId(),
65
+ gridId,
66
+ low: token.lowChunk,
67
+ high: token.highChunk,
68
+ owned: this.options.owns?.(gridId) ?? false,
69
+ channels: channels.map((c) => ({ groupId: String(c.groupId), name: c.name })),
70
+ sessions: sessions.map((s) => ({
71
+ sessionId: s.sessionId,
72
+ name: s.name ?? null,
73
+ status: s.status,
74
+ })),
75
+ };
76
+ }
77
+
78
+ async runProgram(input: {
79
+ gridId: string;
80
+ path: string;
81
+ source: string;
82
+ stop?: boolean;
83
+ }): Promise<DshGridProgramStatus> {
84
+ const key = `${input.gridId}:${input.path}`;
85
+ this.stop(key);
86
+ if (input.stop) return { path: input.path, running: false, log: [] };
87
+ return this.start(key, input.gridId, input.path, input.source);
88
+ }
89
+
90
+ programs(): DshGridProgramStatus[] {
91
+ return [...this.running.values()].map((p) => ({ ...p.status, log: [...p.status.log] }));
92
+ }
93
+
94
+ /** Stop every program (leaving a grid, signing out). */
95
+ stopAll(gridId?: string): void {
96
+ for (const program of [...this.running.values()]) {
97
+ if (gridId === undefined || program.gridId === gridId) this.stop(program.key);
98
+ }
99
+ }
100
+
101
+ private stop(key: string): void {
102
+ const program = this.running.get(key);
103
+ if (!program) return;
104
+ program.host?.stop();
105
+ window.removeEventListener('message', program.listener);
106
+ program.iframe.remove();
107
+ program.status = { ...program.status, running: false };
108
+ this.running.delete(key);
109
+ this.options.onChange?.(program.status);
110
+ }
111
+
112
+ private async start(
113
+ key: string,
114
+ gridId: string,
115
+ path: string,
116
+ source: string,
117
+ ): Promise<DshGridProgramStatus> {
118
+ const iframe = document.createElement('iframe');
119
+ iframe.setAttribute('sandbox', 'allow-scripts');
120
+ iframe.setAttribute('aria-hidden', 'true');
121
+ iframe.title = `grid program ${path}`;
122
+ iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden';
123
+ iframe.src = this.options.sandboxUrl;
124
+ const status: DshGridProgramStatus = {
125
+ path,
126
+ running: false,
127
+ startedAt: new Date().toISOString(),
128
+ log: [],
129
+ };
130
+ let resolveReady!: () => void;
131
+ const ready = new Promise<void>((resolve) => (resolveReady = resolve));
132
+ const program: RunningProgram = {
133
+ key,
134
+ gridId,
135
+ status,
136
+ iframe,
137
+ host: null,
138
+ listener: (event: MessageEvent) => {
139
+ if (event.source !== iframe.contentWindow) return;
140
+ const data = event.data as ({ source?: string } & GridProgramSandboxMessage) | null;
141
+ if (!data || data.source !== GRID_PROGRAM_SOURCE) return;
142
+ switch (data.type) {
143
+ case 'ready':
144
+ resolveReady();
145
+ return;
146
+ case 'started':
147
+ program.status = { ...program.status, running: true };
148
+ break;
149
+ case 'log':
150
+ program.status = {
151
+ ...program.status,
152
+ log: [...program.status.log, `[${data.level}] ${data.line}`].slice(-LOG_LINES),
153
+ };
154
+ break;
155
+ case 'error':
156
+ program.status = {
157
+ ...program.status,
158
+ lastError: data.message,
159
+ log: [...program.status.log, `[error] ${data.message}`].slice(-LOG_LINES),
160
+ };
161
+ break;
162
+ }
163
+ this.options.onChange?.(program.status);
164
+ },
165
+ };
166
+ this.running.set(key, program);
167
+ window.addEventListener('message', program.listener);
168
+ (this.options.container?.() ?? document.body).appendChild(iframe);
169
+
170
+ const timedOut = await Promise.race([
171
+ ready.then(() => false),
172
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(true), READY_TIMEOUT_MS)),
173
+ ]);
174
+ if (timedOut) {
175
+ program.status = { ...program.status, lastError: 'the sandbox page did not load' };
176
+ this.stop(key);
177
+ return program.status;
178
+ }
179
+ const channel = new MessageChannel();
180
+ program.host = await hostGridProgram({
181
+ port: channel.port1,
182
+ scope: this.options.client().grid(this.options.appId(), gridId),
183
+ graphqlUrl: endpoint(this.options.graphqlUrl),
184
+ graphqlWsUrl: endpoint(this.options.graphqlWsUrl),
185
+ onRefused: (reason) => {
186
+ program.status = {
187
+ ...program.status,
188
+ log: [...program.status.log, `[relay] refused: ${reason}`].slice(-LOG_LINES),
189
+ };
190
+ },
191
+ });
192
+ iframe.contentWindow?.postMessage(
193
+ { source: GRID_PROGRAM_SOURCE, type: 'init', program: source, path },
194
+ '*',
195
+ [channel.port2],
196
+ );
197
+ // Give the program a moment to report `started` or an import error.
198
+ await new Promise((resolve) => setTimeout(resolve, 750));
199
+ return { ...program.status, log: [...program.status.log] };
200
+ }
201
+ }
202
+
203
+ function endpoint(value: string | (() => string)): string {
204
+ return typeof value === 'function' ? value() : value;
205
+ }
@@ -0,0 +1,9 @@
1
+ /** window.postMessage traffic between the game page and a grid program sandbox. */
2
+ export const GRID_PROGRAM_SOURCE = 'construct-grid-program';
3
+
4
+ /** Sandbox -> page (the CrowdyJS traffic itself rides the MessagePort, not this). */
5
+ export type GridProgramSandboxMessage =
6
+ | { type: 'ready' }
7
+ | { type: 'started' }
8
+ | { type: 'log'; level: 'log' | 'info' | 'warn' | 'error'; line: string }
9
+ | { type: 'error'; message: string };
package/src/index.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `@crowdedkingdoms/construct` — the Construct framework.
3
+ *
4
+ * The platform layer every Crowded Kingdoms browser game needs (hosted
5
+ * sign-in, app entry, World Stores presence, voice / webcam / chat, the
6
+ * Crowdy Studio dock with app and grid mods, JS grid programs) and the engine
7
+ * loop around it. Deep imports (`@crowdedkingdoms/construct/platform/...`,
8
+ * `/engine/...`, `/grid/...`) reach every module; this barrel re-exports the
9
+ * pieces a game wires at boot.
10
+ */
11
+
12
+ // Engine
13
+ export { GameLoop } from './engine/GameLoop';
14
+ export { SceneRouter, type SceneRouterEvents } from './engine/SceneRouter';
15
+ export {
16
+ Input,
17
+ isTextEntry,
18
+ isPointerLockOn,
19
+ type KeyHandler,
20
+ type PointerState,
21
+ } from './engine/Input';
22
+ export { Controls, helpLines, type ControlAction } from './engine/controls';
23
+ export type {
24
+ GameScene,
25
+ SceneContext,
26
+ SceneFactory,
27
+ SceneHud,
28
+ SceneSize,
29
+ } from './engine/GameScene';
30
+
31
+ // Session, network, presence
32
+ export { GameSession, type GameSessionEvents } from './platform/GameSession';
33
+ export {
34
+ NetworkManager,
35
+ messageOf,
36
+ type AppRoute,
37
+ type BootstrapInfo,
38
+ type SessionUser,
39
+ } from './platform/network/NetworkManager';
40
+ export { AuthService } from './platform/auth/AuthService';
41
+ export {
42
+ worldSession,
43
+ hasWorldSession,
44
+ disposeWorldSession,
45
+ instanceStore,
46
+ type RemotePlayer,
47
+ type SaveState,
48
+ } from './platform/realtime/WorldStores';
49
+ export { NEUTRAL_POSE, POSE_BYTES, poseCodec, type Pose } from './platform/realtime/actorCodec';
50
+ export {
51
+ chunkKey,
52
+ worldToChunk,
53
+ worldToVoxel,
54
+ type ChunkCoord,
55
+ type Vec3,
56
+ } from './platform/realtime/space';
57
+ export { ensureEnvScope, readScoped, writeScoped } from './platform/envScope';
58
+
59
+ // Social and media
60
+ export { ChatService, type ChatMessage } from './platform/social/ChatService';
61
+ export { VoiceService } from './platform/media/VoiceService';
62
+ export { WebcamService } from './platform/media/WebcamService';
63
+
64
+ // The world hub (ck-exec)
65
+ export {
66
+ WorldHub,
67
+ configureWorldHub,
68
+ worldHubAddress,
69
+ type WorldHubAddress,
70
+ } from './platform/exec/worldHub';
71
+
72
+ // Crowdy Studio, mods, grids
73
+ export { StudioService, type StudioHooks, type StudioState } from './platform/studio/StudioService';
74
+ export { GridService, type GridSnapshot } from './platform/studio/GridService';
75
+ export {
76
+ toBrokerBounds,
77
+ hasAnyStudioPermission,
78
+ type GridBounds,
79
+ type StudioPermissions,
80
+ } from './platform/studio/permissions';
81
+ export {
82
+ routeClientHostCall,
83
+ routeWithFallback,
84
+ runConsentedGridMod,
85
+ type ClientModHostReads,
86
+ type ClientModHostWrites,
87
+ } from './platform/studio/clientModHost';
88
+ export { GridProgramRunner, type GridProgramRunnerOptions } from './grid/GridProgramRunner';