@vgai/live 0.4.0-canary.20260715.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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Session resolution for `connect()` (`index.ts`) — turns a project
3
+ * directory into `{ port, projectRoot }` for an ALREADY-RUNNING `vgai edit`
4
+ * session. `@vgai/live` never starts or stops a session (see the module doc
5
+ * on `index.ts`); this only finds one.
6
+ *
7
+ * Two pieces, from two different sources:
8
+ *
9
+ * 1. Live-session DISCOVERY + PROBING (`HttpEditorTransport.listSessions`,
10
+ * `withTimeout`, `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`) — reused
11
+ * directly from `@vgai/sdk`'s `editor/transport.ts` (B3,
12
+ * `resolveEditorSession`'s own sibling machinery). That module is a
13
+ * real, tested, PUBLICLY EXPORTED part of `@vgai/sdk` — reading
14
+ * `~/.vgai/editor-sessions.json`, PID-liveness-filtering it, and
15
+ * verifying each survivor with a live `GET /__editor/project` probe is
16
+ * exactly the hard, easy-to-get-subtly-wrong part worth reusing rather
17
+ * than re-deriving.
18
+ *
19
+ * 2. Project-root discovery (`findProjectRootFrom` below) — LIFTED (not
20
+ * imported) from `packages/vgai-cli/src/project-root.ts`. `@vgai/sdk`'s
21
+ * `resolveEditorSession` takes an already-known `ctx.projectRoot`; it has
22
+ * no "walk up from an arbitrary cwd to find the nearest vgai.game.json"
23
+ * step of its own, which `connect(projectDir?)` needs. `@vgai/cli` is
24
+ * `"private": true` with no `exports` field to import through (and
25
+ * depending on it from here would invert the intended CLI -> SDK ->
26
+ * engine direction anyway — see `@vgai/sdk`'s own architecture note),
27
+ * so this ~10-line pure function is duplicated verbatim instead. Keep in
28
+ * sync if that logic ever changes.
29
+ *
30
+ * DELIBERATE DIVERGENCE from `resolveEditorSession` itself: that function's
31
+ * documented precedence (see its own doc comment) falls back to "the
32
+ * lowest-numbered port among ALL live sessions" when `ctx.projectRoot` is
33
+ * given but matches none of them — a reasonable default for a single-agent,
34
+ * usually-one-session tool context. `@vgai/live` explicitly does NOT reuse
35
+ * that fallback: CLAUDE.md's editor-first workflow is emphatic that a
36
+ * project must never be silently retargeted to a DIFFERENT project's editor
37
+ * session (a real hazard in a multi-project/fleet setup — several unrelated
38
+ * `vgai edit` sessions can easily be running at once). `resolveSession`
39
+ * below matches the CLI's own stricter `getClient()` behavior instead: only
40
+ * a session whose OWN project matches `projectRoot` is ever returned: no
41
+ * match means a clear, actionable error, never a guess.
42
+ */
43
+ import { existsSync } from 'node:fs';
44
+ import { dirname, join, resolve } from 'node:path';
45
+ import { EDITOR_SESSION_DISCOVERY_TIMEOUT_MS, HttpEditorTransport, withTimeout, } from '@vgai/sdk';
46
+ /**
47
+ * Lifted verbatim from `packages/vgai-cli/src/project-root.ts` (see this
48
+ * module's doc comment above for why it's duplicated rather than imported).
49
+ * Nearest ancestor of `dir` (inclusive) containing a `vgai.game.json`, or
50
+ * `null` if none.
51
+ */
52
+ export function findProjectRootFrom(dir) {
53
+ let cur = resolve(dir);
54
+ for (;;) {
55
+ if (existsSync(join(cur, 'vgai.game.json')))
56
+ return cur;
57
+ const parent = dirname(cur);
58
+ if (parent === cur)
59
+ return null;
60
+ cur = parent;
61
+ }
62
+ }
63
+ /**
64
+ * Resolve `projectDir` (default `process.cwd()`) to the port of its already-
65
+ * running `vgai edit` session. Throws a descriptive error (never hangs
66
+ * indefinitely — bounded by `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`, and never
67
+ * silently attaches to an unrelated project's session — see the module doc
68
+ * above) when no vgai.game.json is found, or no live session covers it.
69
+ */
70
+ export async function resolveSession(projectDir = process.cwd(), deps = {}) {
71
+ const findRoot = deps.findProjectRootFrom ?? findProjectRootFrom;
72
+ const transport = deps.transport ?? new HttpEditorTransport();
73
+ const projectRoot = findRoot(projectDir);
74
+ if (projectRoot === null) {
75
+ throw new Error(`@vgai/live: no vgai.game.json found in ${projectDir} or any parent directory — is this a vgai project?`);
76
+ }
77
+ let sessions;
78
+ try {
79
+ sessions = await withTimeout(transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS), EDITOR_SESSION_DISCOVERY_TIMEOUT_MS, 'editor session discovery');
80
+ }
81
+ catch {
82
+ sessions = [];
83
+ }
84
+ const canon = resolve(projectRoot);
85
+ const match = sessions.find((s) => s.project !== null && resolve(s.project) === canon);
86
+ if (!match) {
87
+ const otherCount = sessions.length;
88
+ throw new Error(`@vgai/live: no live editor session found for ${projectRoot}. @vgai/live only attaches to ` +
89
+ 'an already-running session — it never starts one — so run `vgai edit` in that project ' +
90
+ 'first, then retry.' +
91
+ (otherCount > 0
92
+ ? ` (${otherCount} other live session(s) found, but none open this project — @vgai/live ` +
93
+ 'never silently attaches to a different project.)'
94
+ : ''));
95
+ }
96
+ return { port: match.port, projectRoot };
97
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A tiny memoized-async-factory primitive — the machinery behind
3
+ * `index.ts`'s lazy top-level `editor`/`game`/`page` singletons. Pulled into
4
+ * its own module (rather than inlined) so it's independently unit-testable
5
+ * with a fake factory, with no need to exercise a real `connect()` (session
6
+ * discovery, network) just to prove "two accesses, one connect".
7
+ *
8
+ * Caches the in-flight PROMISE, not just the resolved value — two callers
9
+ * racing `ensure()` before the first resolves still share the SAME
10
+ * connection attempt, not two independent ones.
11
+ */
12
+ export interface LazySession<T> {
13
+ /** Returns the memoized promise, creating it via `factory()` on first call. */
14
+ ensure(): Promise<T>;
15
+ /** Clears the memo — the NEXT `ensure()` calls `factory()` again. Exposed for tests (and for a caller that deliberately wants to reconnect); not needed in ordinary use. */
16
+ reset(): void;
17
+ }
18
+ export declare function createLazySession<T>(factory: () => Promise<T>): LazySession<T>;
@@ -0,0 +1,12 @@
1
+ export function createLazySession(factory) {
2
+ let promise = null;
3
+ return {
4
+ ensure() {
5
+ promise ??= factory();
6
+ return promise;
7
+ },
8
+ reset() {
9
+ promise = null;
10
+ },
11
+ };
12
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@vgai/live",
3
+ "author": "Volter AI, Inc.",
4
+ "license": "Apache-2.0",
5
+ "version": "0.4.0-canary.20260715.0",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/volter-ai/vgai-engine.git",
10
+ "directory": "packages/vgai-live"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "engines": {
27
+ "node": ">=22.0.0"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.build.json"
31
+ },
32
+ "dependencies": {
33
+ "@vgai/editor-sdk": "0.4.0-canary.20260715.0",
34
+ "@vgai/probe": "0.4.0-canary.20260715.0",
35
+ "@vgai/sdk": "0.4.0-canary.20260715.0"
36
+ },
37
+ "peerDependencies": {
38
+ "@playwright/test": ">=1.58.2 <2"
39
+ },
40
+ "devDependencies": {
41
+ "@playwright/test": "^1.58.2",
42
+ "@types/node": "^25.3.0",
43
+ "typescript": "^5.6.0"
44
+ }
45
+ }
package/src/editor.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * `LiveEditor` — the editor-control half of `@vgai/live`'s `{ editor, game,
3
+ * page }` (SHARED-SESSION-SPEC.md, Wave 2). Methods are named after the
4
+ * ACTION, not the CLI flag spelling (e.g. `select('all')` rather than a
5
+ * separate `selectAll`, `showPanel('viewport-game')` rather than
6
+ * `vgai show viewport game`'s two-token shape) — see each method's own doc
7
+ * comment for the exact CLI verb / `EditorClient` call it mirrors.
8
+ *
9
+ * Every method reuses `@vgai/editor-sdk`'s `EditorClient` — this module
10
+ * never hand-rolls a `fetch` to `/__editor/command` itself. The ONE
11
+ * exception is `applyDiff`, which is FILE mode (writes the scene file
12
+ * directly), not a live wire command — see its own doc comment.
13
+ */
14
+
15
+ import type {
16
+ AssetKind,
17
+ AssetPreviewCapture,
18
+ AssetPreviewOptions,
19
+ EditorClient,
20
+ EditorState,
21
+ ShadingMode,
22
+ ViewPreset,
23
+ ViewportCapture,
24
+ } from '@vgai/editor-sdk';
25
+ import { operations } from '@vgai/sdk';
26
+
27
+ /** `vgai show <...>`'s four sub-verbs folded into one action name — see `showPanel`. */
28
+ export type PanelName = 'viewport-scene' | 'viewport-game' | 'inspector' | 'console' | 'build';
29
+
30
+ /** Mirrors `@vgai/sdk`'s `project.scene.apply` operation input — the same shape `vgai apply-diff` builds from its `<scene> <patch>` file arguments. `patch` is a `SceneDiff` JSON object (validated by the operation's own Zod schema — a malformed patch throws, never silently no-ops). */
31
+ export interface ApplyDiffInput {
32
+ /** Project-relative path to the `.vscn.json` scene file to modify. */
33
+ scenePath: string;
34
+ /** A `SceneDiff`-shaped patch object. */
35
+ patch: unknown;
36
+ /** Validate + report only — never write to disk. Default `false`. */
37
+ dryRun?: boolean;
38
+ /** Refuse a stale write when the file's content hash no longer matches (optimistic concurrency) — omit to skip the check. */
39
+ baseHash?: string;
40
+ }
41
+
42
+ /** The `project.scene.apply` operation's result shape. */
43
+ export interface ApplyDiffResult {
44
+ dryRun: boolean;
45
+ written: boolean;
46
+ filesChanged: string[];
47
+ before: { scene: unknown };
48
+ after: { scene: unknown };
49
+ }
50
+
51
+ const EXTENSION_KIND: Record<string, AssetKind> = {
52
+ '.glb': 'model',
53
+ '.gltf': 'model',
54
+ '.png': 'image',
55
+ '.jpg': 'image',
56
+ '.jpeg': 'image',
57
+ '.webp': 'image',
58
+ '.gif': 'image',
59
+ '.mp3': 'audio',
60
+ '.ogg': 'audio',
61
+ '.wav': 'audio',
62
+ '.flac': 'audio',
63
+ };
64
+
65
+ /**
66
+ * Lightweight extension-based `AssetKind` guess for `openAsset`'s optional
67
+ * `kind` argument. Deliberately independent of (not shared with) the
68
+ * editor's own `AssetBrowser.tsx#getAssetKind` — that function is a private,
69
+ * React-component-local helper of a package `@vgai/live` has no dependency
70
+ * on. Callers with an unusual extension can always pass `kind` explicitly.
71
+ */
72
+ export function inferAssetKind(path: string): AssetKind {
73
+ if (path.endsWith('.vscn.json')) return 'scene';
74
+ if (path.endsWith('.prefab.json')) return 'prefab';
75
+ if (path.endsWith('.mat.json')) return 'material';
76
+ const dot = path.lastIndexOf('.');
77
+ const ext = dot >= 0 ? path.slice(dot).toLowerCase() : '';
78
+ return EXTENSION_KIND[ext] ?? 'json';
79
+ }
80
+
81
+ export class LiveEditor {
82
+ constructor(
83
+ private readonly client: EditorClient,
84
+ /** Absolute project root this session is bound to — used only by `applyDiff` (FILE mode; every other method goes over the wire and never needs it). */
85
+ private readonly projectRoot: string,
86
+ ) {}
87
+
88
+ /**
89
+ * No `path` -> the currently open scene's save path (`status().savePath`).
90
+ * `path` given -> opens it (`EditorClient.openScene` — the same wire call
91
+ * `vgai scene <path>` sends), and the given path is returned back.
92
+ */
93
+ async scene(path?: string): Promise<string | null> {
94
+ if (path !== undefined) {
95
+ await this.client.openScene(path);
96
+ return path;
97
+ }
98
+ const state = await this.client.getState();
99
+ return state.savePath;
100
+ }
101
+
102
+ /**
103
+ * FILE mode — writes the scene file directly. NOT a live wire command:
104
+ * there is no live-editor wire op for this yet (`vgai apply-diff`'s own
105
+ * module doc names the identical gap: "LIVE MODE — NOT BUILT ... route the
106
+ * patch through a RUNNING editor"). Reuses the exact same engine
107
+ * machinery `vgai apply-diff` uses, through `@vgai/sdk`'s
108
+ * `project.scene.apply` operation (the operation-registry's own
109
+ * projection of that logic) — never hand-rolled here.
110
+ */
111
+ async applyDiff(input: ApplyDiffInput): Promise<ApplyDiffResult> {
112
+ const outcome = await operations.dispatch('project.scene.apply', input, {
113
+ projectRoot: this.projectRoot,
114
+ });
115
+ if (!outcome.ok) {
116
+ throw new Error(
117
+ `@vgai/live: applyDiff failed [${outcome.error.code}]: ${outcome.error.message}`,
118
+ );
119
+ }
120
+ return outcome.data as ApplyDiffResult;
121
+ }
122
+
123
+ async play(opts?: { seed?: number }): Promise<void> {
124
+ await this.client.play(opts);
125
+ }
126
+
127
+ async stop(): Promise<void> {
128
+ await this.client.stop();
129
+ }
130
+
131
+ async pause(): Promise<void> {
132
+ await this.client.pause();
133
+ }
134
+
135
+ async resume(): Promise<void> {
136
+ await this.client.resume();
137
+ }
138
+
139
+ /** Advance `n` frames (default 1) — `EditorClient.step()` sent `n` times, mirroring `vgai step` run repeatedly. */
140
+ async step(n = 1): Promise<void> {
141
+ for (let i = 0; i < n; i++) {
142
+ await this.client.step();
143
+ }
144
+ }
145
+
146
+ /** `'all'` -> `EditorClient.selectAll()` (mirrors `vgai select --all`); otherwise `EditorClient.select(id)` (mirrors `vgai select <entityId>`). */
147
+ async select(id: string | 'all'): Promise<void> {
148
+ if (id === 'all') {
149
+ await this.client.selectAll();
150
+ return;
151
+ }
152
+ await this.client.select(id);
153
+ }
154
+
155
+ /** Mirrors `vgai deselect`. */
156
+ async deselect(): Promise<void> {
157
+ await this.client.select(null);
158
+ }
159
+
160
+ /** No `id` -> focus the current selection (mirrors bare `vgai focus`); `id` given -> focus that entity. */
161
+ async focus(id?: string): Promise<void> {
162
+ if (id !== undefined) {
163
+ await this.client.focusEntity(id);
164
+ return;
165
+ }
166
+ await this.client.focusSelection();
167
+ }
168
+
169
+ async view(preset: ViewPreset): Promise<void> {
170
+ await this.client.viewPreset(preset);
171
+ }
172
+
173
+ /** `vgai show <viewport <scene|game>|inspector|console|build>`'s four sub-verbs, folded into one action name. */
174
+ async showPanel(name: PanelName): Promise<void> {
175
+ switch (name) {
176
+ case 'viewport-scene':
177
+ await this.client.showViewport('scene');
178
+ return;
179
+ case 'viewport-game':
180
+ await this.client.showViewport('game');
181
+ return;
182
+ case 'inspector':
183
+ await this.client.showInspector();
184
+ return;
185
+ case 'console':
186
+ await this.client.toggleConsole();
187
+ return;
188
+ case 'build':
189
+ await this.client.showBuild();
190
+ return;
191
+ }
192
+ }
193
+
194
+ /** `kind` inferred from `path`'s extension when omitted (`inferAssetKind`) — pass it explicitly to override. */
195
+ async openAsset(path: string, kind?: AssetKind): Promise<void> {
196
+ await this.client.openAsset(path, kind ?? inferAssetKind(path));
197
+ }
198
+
199
+ /** Captures the editor's native Asset Lab four-view preview for `path` (a project-relative asset path). */
200
+ async assetPreview(path: string, options?: AssetPreviewOptions): Promise<AssetPreviewCapture> {
201
+ return this.client.captureAssetPreview({ assetPath: path }, options);
202
+ }
203
+
204
+ async grid(on: boolean): Promise<void> {
205
+ await this.client.setGrid(on);
206
+ }
207
+
208
+ async helpers(on: boolean): Promise<void> {
209
+ await this.client.setHelpers(on);
210
+ }
211
+
212
+ async stats(on: boolean): Promise<void> {
213
+ await this.client.setStats(on);
214
+ }
215
+
216
+ async shading(mode: ShadingMode): Promise<void> {
217
+ await this.client.setShadingMode(mode);
218
+ }
219
+
220
+ /** Mirrors `vgai status` — the full live editor state as JSON. */
221
+ async status(): Promise<EditorState> {
222
+ return this.client.getState();
223
+ }
224
+
225
+ /** A live viewport PNG (`EditorClient.captureViewport`) — no direct CLI verb exists; this is the closest wire read. */
226
+ async screenshot(size?: number): Promise<ViewportCapture> {
227
+ return this.client.captureViewport(size);
228
+ }
229
+ }
package/src/game.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `game` — the game-control half of `@vgai/live`'s `{ editor, game, page }`.
3
+ * A `GameClient` (`@vgai/probe`) bound to a `RelayTransport` on the resolved
4
+ * session's port — the SAME session-wire relay (`POST /__editor/command`,
5
+ * `bridge-call`/`bridge-screenshot`/`page-script`) every `EditorClient`
6
+ * method already uses. Never forked: this module only WIRES `GameClient` up,
7
+ * it does not reimplement any of its methods (`state`/`waitFor`/`events`/
8
+ * `input.hold`/`input.tap`/`screenshot`/`command`/`page` — whatever
9
+ * `GameClient` exposes is exposed here, unchanged).
10
+ */
11
+
12
+ import { GameClient, RelayTransport } from '@vgai/probe';
13
+
14
+ /**
15
+ * Builds a `GameClient` over a `RelayTransport({ port })`.
16
+ *
17
+ * Fence values (`fenceTick`/`fenceSeq`/`fenceSimSeconds`/`fenceWallMs`) are
18
+ * placeholders — tick 0, seq 0, sim-seconds 0, "now" — rather than a real
19
+ * play-mode-start snapshot. `@vgai/probe`'s own relay fixture
20
+ * (`relay-fixture.ts`'s `waitForRelayBridge`) waits for a FRESH play-mode
21
+ * boot before constructing its `game` client, because a test always starts
22
+ * from a known instant. A `connect()`ed live session has no such instant —
23
+ * it may attach to an ALREADY-RUNNING game session — so there is no single
24
+ * "test start" to fence from. Practical effect: `game.events.expect(...)`
25
+ * sees the WHOLE event history since play mode started (not a per-call
26
+ * window); every other method (`state`/`waitFor`/`input`/`command`/
27
+ * `screenshot`) is unaffected by the fence. `pageErrors`/`consoleErrors` are
28
+ * always empty — relay mode has no separate page handle to listen on (the
29
+ * same documented gap `relay-fixture.ts` carries).
30
+ */
31
+ export function createGameClient(port: number): GameClient {
32
+ return new GameClient({
33
+ transport: new RelayTransport({ port }),
34
+ pageErrors: [],
35
+ consoleErrors: [],
36
+ fenceTick: 0,
37
+ fenceSeq: 0,
38
+ fenceSimSeconds: 0,
39
+ fenceWallMs: Date.now(),
40
+ warmSession: false,
41
+ });
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `@vgai/live` — `{ editor, game, page }` over the session wire
3
+ * (docs/SHARED-SESSION-SPEC.md, Wave 2). Editor control consolidates HERE
4
+ * from the ~19 `vgai` CLI verbs: a plain node/tsx script gets
5
+ * `import { editor, game, page } from '@vgai/live'` instead of shelling out
6
+ * to the CLI — semantics are discoverable from THIS module's types, not from
7
+ * CLI usage text.
8
+ *
9
+ * `@vgai/live` never starts or stops a session — the CLI keeps that
10
+ * (`create`/`edit`/`sessions`/`close`). `connect()` (and the lazy
11
+ * `editor`/`game`/`page` singletons below) only ATTACH to a session already
12
+ * started by `vgai edit`; if none is running for the resolved project, every
13
+ * one of them rejects with a clear "run `vgai edit` first" error (see
14
+ * `session.ts`'s `resolveSession`) rather than launching anything.
15
+ *
16
+ * Two ways to use this:
17
+ *
18
+ * // A: explicit connect() — resolve once, reuse the bound session object.
19
+ * import { connect } from '@vgai/live';
20
+ * const { editor, game, page } = await connect(); // cwd's project
21
+ * const { editor, game, page } = await connect('../other-game');
22
+ * await editor.play();
23
+ * await game.waitFor((s) => s('score') >= 10, { simSeconds: 5 });
24
+ *
25
+ * // B: lazy top-level singletons — auto-connect (against cwd) on first
26
+ * // use, memoized after that. No explicit connect() call needed.
27
+ * import { editor, game, page } from '@vgai/live';
28
+ * await editor.grid(true);
29
+ * await game.input.tap('jump');
30
+ *
31
+ * `page(step)` = `GameClient.page(step)` (Wave-2's playwright-shim surface,
32
+ * PR #166) — write `step` as a literal `async (page) => {...}` and inline
33
+ * every value it needs. KNOWN WIRE LIMITATION: under the relay transport
34
+ * this binds to (the same one `vgai probe --in-editor` uses), `step` is
35
+ * shipped to the editor dev server as `step.toString()` and reconstructed
36
+ * there — closures over outer variables do NOT survive that trip. See
37
+ * `@vgai/probe`'s `bridge-transport.ts` (`runPageScript`'s doc comment) and
38
+ * `relay-transport.ts` for the full honesty-boundary contract this wraps.
39
+ */
40
+
41
+ import { EditorClient } from '@vgai/editor-sdk';
42
+ import type { GameClient } from '@vgai/probe';
43
+ import { LiveEditor } from './editor.js';
44
+ import { createGameClient } from './game.js';
45
+ import { lazyChainProxy } from './lazy-proxy.js';
46
+ import { type ResolvedSession, resolveSession, type SessionResolutionDeps } from './session.js';
47
+ import { createLazySession } from './singleton.js';
48
+
49
+ export type { EditorClient } from '@vgai/editor-sdk';
50
+ export type { GameClient } from '@vgai/probe';
51
+ export type { ApplyDiffInput, ApplyDiffResult, PanelName } from './editor.js';
52
+ export { inferAssetKind, LiveEditor } from './editor.js';
53
+ export { createGameClient } from './game.js';
54
+ export type {
55
+ ResolvedSession,
56
+ SessionListingTransport,
57
+ SessionResolutionDeps,
58
+ } from './session.js';
59
+ export { findProjectRootFrom, resolveSession } from './session.js';
60
+ export type { LazySession } from './singleton.js';
61
+ export { createLazySession } from './singleton.js';
62
+
63
+ /** A `game.page(step)`-shaped call — see this module's doc comment for the closure-capture limitation. */
64
+ export type PageStep = GameClient['page'];
65
+
66
+ export interface LiveSession {
67
+ editor: LiveEditor;
68
+ game: GameClient;
69
+ /** `GameClient.page` bound to `game` — see this module's own doc comment for the wire limitation. */
70
+ page: PageStep;
71
+ /** The resolved session this is bound to — useful for logging/debugging which port/project a script attached to. */
72
+ session: ResolvedSession;
73
+ }
74
+
75
+ /**
76
+ * Resolve `projectDir` (default `process.cwd()`) to its live `vgai edit`
77
+ * session and bind `{ editor, game, page }` to it. `deps` is an advanced/
78
+ * test-only escape hatch (see `session.ts`'s `SessionResolutionDeps`) — real
79
+ * callers never need it.
80
+ */
81
+ export async function connect(
82
+ projectDir?: string,
83
+ deps?: SessionResolutionDeps,
84
+ ): Promise<LiveSession> {
85
+ const resolved = await resolveSession(projectDir, deps);
86
+ const client = new EditorClient({ url: `http://localhost:${resolved.port}` });
87
+ const editor = new LiveEditor(client, resolved.projectRoot);
88
+ const game = createGameClient(resolved.port);
89
+ const page: PageStep = (step) => game.page(step);
90
+ return { editor, game, page, session: resolved };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Lazy top-level singletons (usage form B above) — thin proxies over an
95
+ // internal memoized connect() against cwd. First access triggers connect();
96
+ // every access after that (across editor/game/page, and across however many
97
+ // calls) reuses the SAME resolved session.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const lazySession = createLazySession(() => connect());
101
+
102
+ /** Test-only reset of the lazy top-level singletons' memo — NOT part of the documented public surface (real callers never need to reconnect mid-process). */
103
+ export function __resetLiveSingletonForTests(): void {
104
+ lazySession.reset();
105
+ }
106
+
107
+ export const editor: LiveEditor = lazyChainProxy<LiveEditor>(() =>
108
+ lazySession.ensure().then((s) => s.editor),
109
+ );
110
+ export const game: GameClient = lazyChainProxy<GameClient>(() =>
111
+ lazySession.ensure().then((s) => s.game),
112
+ );
113
+ export const page: PageStep = lazyChainProxy<PageStep>(() =>
114
+ lazySession.ensure().then((s) => s.page),
115
+ );
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `lazyChainProxy` — turns an async "resolve the real object" function into
3
+ * a synchronously-importable stand-in that supports the SAME call shape as
4
+ * the real thing, including nested member access (`game.input.hold(...)`,
5
+ * `game.events.expect(...)`), by recording the property-access PATH and only
6
+ * resolving + walking it down at the point of an actual function CALL.
7
+ *
8
+ * This is what makes `index.ts`'s top-level `export const editor = ...` /
9
+ * `game` / `page` work as plain values a script can `import { editor, game,
10
+ * page } from '@vgai/live'` and call immediately — each call transparently
11
+ * awaits the memoized `connect()` first.
12
+ *
13
+ * Limitation (by design, documented on `index.ts`'s exports too): only
14
+ * FUNCTION-shaped access resolves through this proxy — `game.input.hold(x)`
15
+ * works, but reading a plain data property (e.g. `game.fenceTick`) would
16
+ * return another inert proxy, not the real number, since there is no
17
+ * function CALL to trigger resolution. Every documented `editor`/`game`/
18
+ * `page` member is a method, so this never bites the documented surface.
19
+ */
20
+
21
+ type AnyFn = (...args: unknown[]) => unknown;
22
+
23
+ function isFunction(value: unknown): value is AnyFn {
24
+ return typeof value === 'function';
25
+ }
26
+
27
+ function walk(root: unknown, path: PropertyKey[]): { thisArg: unknown; fn: unknown } {
28
+ if (path.length === 0) return { thisArg: undefined, fn: root };
29
+ let obj: Record<PropertyKey, unknown> = root as Record<PropertyKey, unknown>;
30
+ for (let i = 0; i < path.length - 1; i++) {
31
+ const key = path[i] as PropertyKey;
32
+ obj = obj[key] as Record<PropertyKey, unknown>;
33
+ }
34
+ const lastKey = path[path.length - 1] as PropertyKey;
35
+ return { thisArg: obj, fn: obj[lastKey] };
36
+ }
37
+
38
+ /**
39
+ * `resolveRoot` is called (and its result awaited) on every terminal
40
+ * function call reached through the returned proxy — callers typically wire
41
+ * it to a memoized `connect()` (see `index.ts`), so repeated calls across
42
+ * many proxy invocations still resolve only once.
43
+ */
44
+ export function lazyChainProxy<T>(
45
+ resolveRoot: () => Promise<unknown>,
46
+ path: PropertyKey[] = [],
47
+ ): T {
48
+ const callableTarget = (() => {}) as unknown as object;
49
+ return new Proxy(callableTarget, {
50
+ get(_target, prop) {
51
+ // Never look thenable — `await`ing a proxy (accidentally, or via a
52
+ // generic helper checking `typeof x.then`) must not trigger resolution
53
+ // or hang; there is no promise here, only a call-shaped stand-in.
54
+ if (prop === 'then' || prop === 'catch' || prop === 'finally') return undefined;
55
+ return lazyChainProxy(resolveRoot, [...path, prop]);
56
+ },
57
+ apply(_target, _thisArg, args) {
58
+ return resolveRoot().then((root) => {
59
+ const { thisArg, fn } = walk(root, path);
60
+ if (!isFunction(fn)) {
61
+ const label = path.length > 0 ? path.map(String).join('.') : '(the connected value)';
62
+ throw new TypeError(`@vgai/live: ${label} is not a function on the connected session.`);
63
+ }
64
+ return fn.apply(thisArg, args);
65
+ });
66
+ },
67
+ }) as T;
68
+ }