@volter/editor-live 0.5.57

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/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ /** Attach to an existing editor session; this package never launches one. */
2
+ import { EditorClient } from '@volter/editor-sdk/client';
3
+ import { LiveEditor } from './editor.js';
4
+ import { LiveTools } from './tools.js';
5
+ import { lazyChainProxy } from './lazy-proxy.js';
6
+ import { resolveSession, type ResolvedSession, type SessionResolutionDeps } from './session.js';
7
+ import { createLazySession } from './singleton.js';
8
+
9
+ export { LiveEditor, inferAssetKind, type PanelName } from './editor.js';
10
+ export { LiveEditorDocument } from './editor-document.js';
11
+ export type { DocumentGestureOptions, DocumentKeyOptions, DocumentPasteOptions } from './editor-document.js';
12
+ export { LiveTools } from './tools.js';
13
+ export { resolveSession, findProjectRootFrom } from './session.js';
14
+ export type { ResolvedSession, SessionResolutionDeps, SessionListingTransport, ProjectSessionHint } from './session.js';
15
+ export type { ActiveDocumentCapture, EditorView, PresentedEditorView } from '@volter/editor-sdk';
16
+
17
+ export interface LiveBindings {
18
+ editor: LiveEditor;
19
+ tools: LiveTools;
20
+ }
21
+ export interface LiveSession extends LiveBindings {
22
+ session: ResolvedSession;
23
+ }
24
+ function bindTo(port: number): LiveBindings {
25
+ const client = new EditorClient({ url: `http://127.0.0.1:${port}` });
26
+ return { editor: new LiveEditor(client), tools: new LiveTools(client) };
27
+ }
28
+ /** Inspect the callable surface without connecting; calls fail on port zero. */
29
+ export function unconnectedBindings(): LiveBindings { return bindTo(0); }
30
+
31
+ /** Attach only to the session serving the requested project's canonical path. */
32
+ export async function connect(projectDir?: string, deps?: SessionResolutionDeps): Promise<LiveSession> {
33
+ const session = await resolveSession(projectDir, deps);
34
+ return { ...bindTo(session.port), session };
35
+ }
36
+ const lazySession = createLazySession(() => connect());
37
+ export const editor: LiveEditor = lazyChainProxy<LiveEditor>(() => lazySession.ensure().then(s => s.editor));
38
+ export const tools: LiveTools = lazyChainProxy<LiveTools>(() => lazySession.ensure().then(s => s.tools));
@@ -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
+ * `editor.document.query(...)`), 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 '@volter/editor-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(`@volter/editor-live: ${label} is not a function on the connected session.`);
63
+ }
64
+ return fn.apply(thisArg, args);
65
+ });
66
+ },
67
+ }) as T;
68
+ }
package/src/session.ts ADDED
@@ -0,0 +1,267 @@
1
+ /** Resolve an existing session for exactly one project.
2
+ * Prefer the project's hint, verify it against the served canonical path,
3
+ * then consult the shared registry. Never fall back to another project.
4
+ * Storage identifiers move with the server in the coordinated migration. */
5
+
6
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { resolveManifestPath } from '@volter/editor-project/manifest/locate';
9
+ import {
10
+ EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
11
+ type EditorSessionInfo,
12
+ type SessionListingTransport as SessionDiscoveryTransport,
13
+ HttpSessionDiscovery,
14
+ servedProjectAnswer,
15
+ withTimeout,
16
+ } from '@volter/editor-sdk/session/discovery';
17
+
18
+ /** Nearest ancestor containing the project's canonical manifest. */
19
+ export function findProjectRootFrom(dir: string): string | null {
20
+ let cur = resolve(dir);
21
+ for (;;) {
22
+ if (existsSync(resolveManifestPath(cur))) return cur;
23
+ const parent = dirname(cur);
24
+ if (parent === cur) return null;
25
+ cur = parent;
26
+ }
27
+ }
28
+
29
+ /** The minimal slice of `EditorTransport` `resolveSession` actually needs — narrower than the full interface so a test double only has to implement one method. */
30
+ export type SessionListingTransport = SessionDiscoveryTransport;
31
+
32
+ export interface ResolvedSession {
33
+ /** Editor dev-server port the resolved session is listening on. */
34
+ port: number;
35
+ /** Absolute project root — the nearest ancestor of the requested directory containing `vgai.project.json`. */
36
+ projectRoot: string;
37
+ }
38
+
39
+ export interface SessionResolutionDeps {
40
+ /** Session listing + liveness verification — defaults to a real `HttpSessionDiscovery`. Overridable for tests. */
41
+ transport?: SessionListingTransport;
42
+ /** Project-root discovery — defaults to the real fs walk (`findProjectRootFrom` above). Overridable for tests. */
43
+ findProjectRootFrom?: (dir: string) => string | null;
44
+ /** Project-local session hint reader. Defaults to `.vgai/session.json`. */
45
+ readProjectSession?: (projectRoot: string) => ProjectSessionHint | null;
46
+ /** Exact-session verifier. Defaults to GET /__editor/project + canonical project matching. */
47
+ verifyProjectSession?: (hint: ProjectSessionHint, projectRoot: string) => Promise<boolean>;
48
+ }
49
+
50
+ export interface ProjectSessionHint {
51
+ port: number;
52
+ pid: number;
53
+ url: string;
54
+ startedAt: string;
55
+ }
56
+
57
+ function canonicalPath(path: string): string {
58
+ try {
59
+ return realpathSync(path);
60
+ } catch {
61
+ return resolve(path);
62
+ }
63
+ }
64
+
65
+ function readProjectSession(projectRoot: string): ProjectSessionHint | null {
66
+ try {
67
+ const value: unknown = JSON.parse(
68
+ readFileSync(join(projectRoot, '.vgai', 'session.json'), 'utf8'),
69
+ );
70
+ if (typeof value !== 'object' || value === null) return null;
71
+ const hint = value as Record<string, unknown>;
72
+ if (
73
+ typeof hint['port'] !== 'number' ||
74
+ !Number.isInteger(hint['port']) ||
75
+ hint['port'] <= 0 ||
76
+ typeof hint['pid'] !== 'number' ||
77
+ typeof hint['url'] !== 'string' ||
78
+ typeof hint['startedAt'] !== 'string'
79
+ ) {
80
+ return null;
81
+ }
82
+ return hint as unknown as ProjectSessionHint;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * WHICH PROJECT a `/__editor/project` body says its server is serving, and the
90
+ * reason it cannot describe it.
91
+ *
92
+ * `serving` is the server's own statement of "I AM serving this project, I
93
+ * just cannot describe it" — its manifest is unparseable or fails strict
94
+ * validation. The route added it because a bare `{ project: null }` there is
95
+ * indistinguishable from "no project open"; reading only `project.path`
96
+ * reproduces that collapse on this side, and the cost is the worst kind of
97
+ * wrong answer: this door refused a session that WAS open on the caller's
98
+ * project with "no live editor session found … (N other live session(s) found,
99
+ * but none open this project)" — sending the operator to start an editor that
100
+ * was already running, with the real defect (their own manifest) never named.
101
+ */
102
+
103
+ /**
104
+ * What the server on `port` says it serves — `undefined` when it did not
105
+ * answer at all. One probe: the manifest failure arrives with the path, so
106
+ * neither the hint path nor the refusal path pays a second round trip.
107
+ */
108
+ async function probeServedProject(
109
+ port: number,
110
+ ): Promise<{ path: string | null; manifestError: string | null } | undefined> {
111
+ try {
112
+ const response = await fetch(`http://127.0.0.1:${port}/__editor/project`, {
113
+ signal: AbortSignal.timeout(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
114
+ });
115
+ if (!response.ok) return undefined;
116
+ return servedProjectAnswer(await response.json());
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The refusal for a session that IS this project's and cannot be driven,
124
+ * because the project's manifest does not load.
125
+ *
126
+ * A refusal, not an attach: with no readable manifest the editor page itself
127
+ * renders its startup-error screen, so there is no editor and no game behind
128
+ * this door to bind `{ editor, game, page, tools, session }` to. What changed
129
+ * is only that it now says the true thing — the failing file and key — instead
130
+ * of "no live editor session found", which sent operators to start an editor
131
+ * that was already running while their actual defect went unnamed.
132
+ */
133
+ function manifestRefusal(projectRoot: string, manifestError: string): Error {
134
+ return new Error(
135
+ `@volter/editor-live: the editor session for ${projectRoot} is live, but its vgai.project.json does ` +
136
+ 'not load, so there is no editor or game to drive — the editor page is showing this same ' +
137
+ `error. Fix the manifest and retry; the session recovers on save, no restart needed.\n${manifestError}`,
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Resolve `projectDir` (default `process.cwd()`) to the port of its already-
143
+ * running `vgai edit` session. Throws a descriptive error (never hangs
144
+ * indefinitely — bounded by `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`, and never
145
+ * silently attaches to an unrelated project's session — see the module doc
146
+ * above) when no vgai.project.json is found, or no live session covers it.
147
+ */
148
+ /**
149
+ * THE REFUSAL WHEN NOTHING MATCHED — and it says WHICH nothing.
150
+ *
151
+ * "No live editor session found … run `vgai edit`" used to be the answer to
152
+ * four different states, only one of which it described. The other three sent
153
+ * the operator to start an editor that was already running:
154
+ *
155
+ * - discovery FAILED (a probe timeout under load) — nothing was learned, so
156
+ * "no session is running" is not a fact anyone established;
157
+ * - the registry was read and is genuinely empty — the one case the old text
158
+ * was right about;
159
+ * - sessions exist, but every one resolves to a different canonical path. In
160
+ * a repo worked through git worktrees this is the ORDINARY miss: two
161
+ * checkouts of the same project differ only in a path prefix, and a
162
+ * symlinked worktree's `realpath` diverges from the path the caller typed.
163
+ * Naming both sides is what makes it a two-second diagnosis instead of a
164
+ * hunt.
165
+ *
166
+ * Each branch prescribes only what its own state supports.
167
+ */
168
+ function noMatchingSessionRefusal(
169
+ projectRoot: string,
170
+ canon: string,
171
+ sessions: readonly EditorSessionInfo[],
172
+ discoveryFailure: string | null,
173
+ ): Error {
174
+ if (discoveryFailure !== null) {
175
+ return new Error(
176
+ `@volter/editor-live: could not READ the editor session registry while looking for ${projectRoot} — ` +
177
+ `${discoveryFailure}. This is not the answer "no editor is running": the question went ` +
178
+ 'unanswered, so nothing is known about what is live. Retry (a probe can time out while ' +
179
+ 'the box is loaded); if it keeps failing, `vgai sessions` asks the same question directly.',
180
+ );
181
+ }
182
+ if (sessions.length === 0) {
183
+ return new Error(
184
+ `@volter/editor-live: the editor session registry is readable and lists NO live sessions, so none ` +
185
+ `covers ${projectRoot}. @volter/editor-live only attaches to an already-running session — it ` +
186
+ 'never starts one — so run `volter-editor edit` in that project first, then retry.',
187
+ );
188
+ }
189
+ const listed = sessions
190
+ .map((s) => ` port ${s.port} → ${s.project === null ? '(no project)' : s.project}`)
191
+ .join('\n');
192
+ return new Error(
193
+ `@volter/editor-live: ${sessions.length} live editor session(s) are running, but none of them opens ` +
194
+ `${projectRoot}. @volter/editor-live never silently attaches to a different project.\n` +
195
+ ` looking for (resolved): ${canon}\n` +
196
+ ` live sessions:\n${listed}\n` +
197
+ ' If one of those is meant to be this project, the two paths differ after resolution — ' +
198
+ 'the usual cause is a git worktree or a symlink, where the session was opened through a ' +
199
+ 'different path to the same files. Run `volter-editor edit` from THIS path, or use the path the ' +
200
+ 'session lists.',
201
+ );
202
+ }
203
+
204
+ export async function resolveSession(
205
+ projectDir: string = process.cwd(),
206
+ deps: SessionResolutionDeps = {},
207
+ ): Promise<ResolvedSession> {
208
+ const findRoot = deps.findProjectRootFrom ?? findProjectRootFrom;
209
+ const transport = deps.transport ?? new HttpSessionDiscovery();
210
+
211
+ const projectRoot = findRoot(projectDir);
212
+ if (projectRoot === null) {
213
+ throw new Error(
214
+ `@volter/editor-live: no vgai.project.json found in ${projectDir} or any parent directory — is this a vgai project?`,
215
+ );
216
+ }
217
+
218
+ // The editor writes this exact-project hint at boot and removes it on
219
+ // shutdown. Prefer it over a global all-session scan: it is both faster and
220
+ // immune to an unrelated slow/dead registry entry consuming the discovery
221
+ // deadline. The live probe remains authoritative, so a stale file cannot
222
+ // attach us to the wrong project or port.
223
+ const localHint = (deps.readProjectSession ?? readProjectSession)(projectRoot);
224
+ if (localHint) {
225
+ // `deps.verifyProjectSession` is the test-only override and keeps its
226
+ // boolean meaning: "this hint is this project's session, and healthy".
227
+ // With no override we read the probe ourselves, because the same response
228
+ // carries WHY a served project cannot be described.
229
+ if (deps.verifyProjectSession) {
230
+ if (await deps.verifyProjectSession(localHint, projectRoot)) {
231
+ return { port: localHint.port, projectRoot };
232
+ }
233
+ } else {
234
+ const served = await probeServedProject(localHint.port);
235
+ if (served?.path != null && canonicalPath(served.path) === canonicalPath(projectRoot)) {
236
+ if (served.manifestError !== null) throw manifestRefusal(projectRoot, served.manifestError);
237
+ return { port: localHint.port, projectRoot };
238
+ }
239
+ }
240
+ }
241
+
242
+ let sessions: EditorSessionInfo[];
243
+ // A FAILED discovery is not an empty one. Collapsing the two into `[]` is
244
+ // what made this door answer "no live editor session found" — and prescribe
245
+ // `vgai edit` — for a probe that merely timed out under load, sending the
246
+ // operator to start an editor that was already running while the real defect
247
+ // went unnamed. The same collapse the manifest refusal above was added for.
248
+ let discoveryFailure: string | null = null;
249
+ try {
250
+ sessions = await withTimeout(
251
+ transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
252
+ EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
253
+ 'editor session discovery',
254
+ );
255
+ } catch (err) {
256
+ sessions = [];
257
+ discoveryFailure = err instanceof Error ? err.message : String(err);
258
+ }
259
+
260
+ const canon = canonicalPath(projectRoot);
261
+ const match = sessions.find((s) => s.project !== null && canonicalPath(s.project) === canon);
262
+ if (!match) throw noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure);
263
+
264
+ if (match.manifestError != null) throw manifestRefusal(projectRoot, match.manifestError);
265
+
266
+ return { port: match.port, projectRoot };
267
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A tiny memoized-async-factory primitive — the machinery behind
3
+ * `index.ts`'s lazy top-level `editor`/`tools` 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
+
19
+ export function createLazySession<T>(factory: () => Promise<T>): LazySession<T> {
20
+ let promise: Promise<T> | null = null;
21
+ return {
22
+ ensure(): Promise<T> {
23
+ promise ??= factory();
24
+ return promise;
25
+ },
26
+ reset(): void {
27
+ promise = null;
28
+ },
29
+ };
30
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,45 @@
1
+ /** Registered project tools over the current shared editor session. */
2
+
3
+ import type {
4
+ EditorClient,
5
+ ProjectToolCatalog,
6
+ ProjectToolCatalogEntry,
7
+ ProjectToolOutcome,
8
+ } from '@volter/editor-sdk';
9
+
10
+ export class LiveTools {
11
+ /** `#`-private for the same reason `LiveEditor.#client` is. */
12
+ readonly #client: EditorClient;
13
+
14
+ constructor(client: EditorClient) {
15
+ this.#client = client;
16
+ }
17
+
18
+ /** Enumerate the exact `package.json#vgai.tools` catalog without executing it. */
19
+ async list(): Promise<ProjectToolCatalog> {
20
+ return this.#client.listProjectTools();
21
+ }
22
+
23
+ /** Return one tool's discoverable metadata, or `null` when it is not registered. */
24
+ async describe(name: string): Promise<ProjectToolCatalogEntry | null> {
25
+ const catalog = await this.list();
26
+ return catalog.tools.find((tool) => tool.name === name) ?? null;
27
+ }
28
+
29
+ /**
30
+ * Invoke the same validated callable used by the editor and CLI.
31
+ *
32
+ * `instance` names WHICH mounted instance the tool should drive when several
33
+ * are live (multiplayer authoring) — it reaches the tool as `ctx.instance`,
34
+ * and a tool that drives the game binds
35
+ * `game.instance(ctx.instance)` from it. Omitted is the single-instance case;
36
+ * the tool then targets the sole live instance, exactly as before.
37
+ */
38
+ async run(
39
+ name: string,
40
+ input: unknown = {},
41
+ options: { confirm?: boolean; instance?: string } = {},
42
+ ): Promise<ProjectToolOutcome> {
43
+ return this.#client.runProjectTool(name, input, options);
44
+ }
45
+ }