@volter/live 0.5.212 → 0.5.213

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 (45) hide show
  1. package/BUNDLED_NOTICES +4 -4
  2. package/README.md +4 -1
  3. package/dist/asset-library.d.ts +98 -0
  4. package/dist/asset-library.js +7 -0
  5. package/dist/asset-library.js.map +7 -0
  6. package/dist/chunk-B2HUWFTT.js +25 -0
  7. package/dist/chunk-B2HUWFTT.js.map +7 -0
  8. package/dist/{chunk-7SZ3C4CC.js → chunk-IZJOOON2.js} +45 -8
  9. package/dist/chunk-IZJOOON2.js.map +7 -0
  10. package/dist/{chunk-Y5W77HYT.js → chunk-JDOIPALX.js} +38 -3
  11. package/dist/chunk-JDOIPALX.js.map +7 -0
  12. package/dist/{chunk-YPAOTPIF.js → chunk-JFAHP5CC.js} +19 -3
  13. package/dist/chunk-JFAHP5CC.js.map +7 -0
  14. package/dist/{chunk-SDIWRX5X.js → chunk-PC56TDHV.js} +5 -5
  15. package/dist/chunk-PC56TDHV.js.map +7 -0
  16. package/dist/{chunk-USXOIBVX.js → chunk-WIAH4XZE.js} +5 -4
  17. package/dist/chunk-WIAH4XZE.js.map +7 -0
  18. package/dist/client.d.ts +10 -4
  19. package/dist/client.js +2 -1
  20. package/dist/discovery.js +2 -2
  21. package/dist/editor.d.ts +11 -2
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +18 -51
  24. package/dist/index.js.map +3 -3
  25. package/dist/product-locator.d.ts +2 -2
  26. package/dist/product-locator.js +2 -2
  27. package/dist/registry-format.d.ts +31 -3
  28. package/dist/registry-format.js +13 -3
  29. package/dist/session.d.ts +4 -15
  30. package/dist/volter-manifest.js +1 -1
  31. package/dist/workbench-locator.js +2 -2
  32. package/package.json +7 -3
  33. package/src/asset-library.ts +104 -0
  34. package/src/client.ts +48 -7
  35. package/src/discovery.ts +2 -2
  36. package/src/editor.ts +11 -3
  37. package/src/index.ts +2 -1
  38. package/src/product-locator.ts +2 -2
  39. package/src/registry-format.ts +68 -5
  40. package/src/session.ts +6 -86
  41. package/dist/chunk-7SZ3C4CC.js.map +0 -7
  42. package/dist/chunk-SDIWRX5X.js.map +0 -7
  43. package/dist/chunk-USXOIBVX.js.map +0 -7
  44. package/dist/chunk-Y5W77HYT.js.map +0 -7
  45. package/dist/chunk-YPAOTPIF.js.map +0 -7
@@ -0,0 +1,104 @@
1
+ /**
2
+ * THE ASSET LIBRARY, as the session's `/__editor/asset-library/*` routes answer it (editor-core's
3
+ * `server/asset-library-routes.ts`, which takes these shapes from here): search a source, list an asset's files, and
4
+ * download one into the project. `editor.assetLibrary` reaches it through `EditorClient`, so a request carries the
5
+ * session's connection token as every other one does.
6
+ */
7
+ import type { EditorClient } from './client.js';
8
+
9
+ /** An asset as a search answers it. */
10
+ export interface OnlineAsset {
11
+ id: string;
12
+ source: 'polyhaven' | 'ambientcg' | 'local';
13
+ name: string;
14
+ type: 'model' | 'animation' | 'source' | 'hdri' | 'texture' | 'material';
15
+ thumbnailUrl: string;
16
+ categories: string[];
17
+ tags: string[];
18
+ downloadCount?: number;
19
+ license?: string;
20
+ author?: string;
21
+ sourceUrl?: string;
22
+ cloudHosted?: boolean;
23
+ variantId?: string;
24
+ variantCount?: number;
25
+ sizeBytes?: number;
26
+ animationCount?: number;
27
+ deliveryKinds?: ('local-ssd' | 'cloud' | 'remote')[];
28
+ addedAt?: string;
29
+ capabilities?: {
30
+ previewable: boolean;
31
+ importable: boolean;
32
+ runtimeReady: boolean;
33
+ animated: boolean;
34
+ deliveryAvailable: boolean;
35
+ health: 'healthy' | 'source-only' | 'unsupported';
36
+ };
37
+ }
38
+
39
+ /** One file of an asset that can be downloaded (`files`); `download` takes it with the asset. */
40
+ export interface AssetFileOption {
41
+ label: string;
42
+ format: string;
43
+ url: string;
44
+ sizeBytes?: number;
45
+ /** Additional files that must be downloaded alongside the main URL (GLTF includes). */
46
+ includes?: { relativePath: string; url: string; size: number }[];
47
+ }
48
+
49
+ /** `GET /search`'s query. `type` is the kind the source names (`model`, `hdri`, …; `all` in the local catalog). */
50
+ export interface AssetSearchQuery {
51
+ source: OnlineAsset['source'];
52
+ type: string;
53
+ q?: string;
54
+ category?: string;
55
+ offset?: number;
56
+ limit?: number;
57
+ }
58
+
59
+ /** `POST /download`'s body: the asset and the file `files` offered for it. `license` is the search result's, which the
60
+ * route records for a remote source; the local catalog's own license is recorded whatever is passed. */
61
+ export interface AssetDownloadRequest {
62
+ source: OnlineAsset['source'];
63
+ id: string;
64
+ name: string;
65
+ url: string;
66
+ format: string;
67
+ includes?: AssetFileOption['includes'];
68
+ license?: string;
69
+ }
70
+
71
+ /** What a download answers: the file's path under the project's `public/`, the provenance operation that wrote it, and
72
+ * `ledgerError` when the asset and its license could not be recorded in `.volter/assets.json`. */
73
+ export interface AssetDownloadResult {
74
+ ok: true;
75
+ path: string;
76
+ provenanceOperationId?: string;
77
+ ledgerError?: string;
78
+ alreadyExists?: boolean;
79
+ }
80
+
81
+ /** `editor.assetLibrary`: the routes, one call each. */
82
+ export class LiveAssetLibrary {
83
+ readonly #client: EditorClient;
84
+
85
+ constructor(client: EditorClient) {
86
+ this.#client = client;
87
+ }
88
+
89
+ /** Search one source: `{ assets, total }`, each asset with its tags, license, author and source address. */
90
+ async search(query: AssetSearchQuery): Promise<{ assets: OnlineAsset[]; total: number }> {
91
+ return this.#client.assetLibraryRequest('GET', 'search', query);
92
+ }
93
+
94
+ /** The files an asset can be downloaded as. */
95
+ async files(asset: { source: OnlineAsset['source']; id: string }): Promise<{ files: AssetFileOption[] }> {
96
+ return this.#client.assetLibraryRequest('GET', 'files', asset);
97
+ }
98
+
99
+ /** Download one file of an asset into the project's `public/asset-library/`, recording the asset and its license in
100
+ * the project's `.volter/assets.json`. */
101
+ async download(request: AssetDownloadRequest): Promise<AssetDownloadResult> {
102
+ return this.#client.assetLibraryRequest('POST', 'download', request);
103
+ }
104
+ }
package/src/client.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Dispatcher } from 'undici';
2
2
  import { createDispatcher, dispatchFetch } from './http-transport.js';
3
+ import { refuseWithoutRegisteredToken, withSessionToken } from './registry-format.js';
3
4
  import { verbCommandId } from '@volter/sdk/commands';
4
5
  import type { DocumentProbeResult, DocumentProbeStep } from '@volter/sdk/document-probe';
5
6
  import type {
@@ -232,7 +233,7 @@ export class EditorClient {
232
233
  commandDispatcher = deadlineMs > UNDICI_DEFAULT_HEADERS_TIMEOUT_MS
233
234
  ? createDispatcher(deadlineMs + 30_000)
234
235
  : undefined;
235
- res = this.fetchOverride ? await this.fetchOverride(url, init) : await dispatchFetch(url, init, commandDispatcher);
236
+ res = this.fetchOverride ? await this.fetchOverride(url, init) : await dispatchFetch(url, withSessionToken(url, init), commandDispatcher);
236
237
  break;
237
238
  } catch (error) {
238
239
  await commandDispatcher?.destroy();
@@ -270,6 +271,14 @@ export class EditorClient {
270
271
  );
271
272
  }
272
273
  }
274
+ if (!this.fetchOverride) {
275
+ try {
276
+ refuseWithoutRegisteredToken(url, res.status);
277
+ } catch (error) {
278
+ await commandDispatcher?.destroy();
279
+ throw error;
280
+ }
281
+ }
273
282
  let data: { ok: boolean; error?: string; code?: string } & T;
274
283
  try {
275
284
  data = (await this.readJson(res)) as typeof data;
@@ -512,11 +521,11 @@ export class EditorClient {
512
521
  }
513
522
 
514
523
  /** Switch the editor's NAMED WORKSPACE — the task-named layout memory
515
- * (`design`/`animate`/`look`, or one a package contributes). Resolves once the
516
- * dock has finished rebuilding, so a following capture photographs the
517
- * arrangement that was asked for. */
518
- async setWorkspace(workspace: EditorWorkspaceName): Promise<void> {
519
- await this.command({ type: 'set-workspace', workspace });
524
+ * (`design`/`animate`/`look`, or one a package contributes). Answers the workspace now active and whether its dock
525
+ * had rebuilt within the command's wait (`applied`): when it had, a following capture photographs the arrangement
526
+ * that was asked for. */
527
+ async setWorkspace(workspace: EditorWorkspaceName): Promise<{ readonly workspace: EditorWorkspaceName; readonly applied: boolean }> {
528
+ return this.command<{ workspace: EditorWorkspaceName; applied: boolean }>({ type: 'set-workspace', workspace });
520
529
  }
521
530
 
522
531
  /** Choose a STYLE (`volter.style`) — its material, icon set and type, and the colour theme it
@@ -873,6 +882,34 @@ export class EditorClient {
873
882
  return data.project;
874
883
  }
875
884
 
885
+ /** One `/__editor/asset-library/<route>` request, for `editor.assetLibrary` (`asset-library.ts`): a GET's fields as
886
+ * its query, a POST's as its JSON body. A refusal throws the route's own error. */
887
+ async assetLibraryRequest<T>(method: 'GET' | 'POST', route: string, fields: object): Promise<T> {
888
+ const url = new URL(`${this.baseUrl}/__editor/asset-library/${route}`);
889
+ if (method === 'GET') {
890
+ for (const [name, value] of Object.entries(fields)) if (value !== undefined) url.searchParams.set(name, String(value));
891
+ }
892
+ // A DOWNLOAD WAITS AS LONG AS THE ROUTE DOES. It answers once the whole file is in the project, and it cancels the
893
+ // import when its caller goes (`res.once('close')` in the route), so undici's default 300 s headers deadline would
894
+ // cancel a large asset on a slow link. Like a command whose budget passes that default (`command`), it gets its
895
+ // own dispatcher, here with no deadline at all (undici sets no timer for 0, `client-h1.js` `setTimeout`): the
896
+ // route sets none. A search or a file listing is one upstream lookup whose route cancels nothing, and keeps the
897
+ // default as its ceiling.
898
+ const dispatcher = route === 'download' ? createDispatcher(0) : undefined;
899
+ try {
900
+ const res = await this.httpFetch(
901
+ url.toString(),
902
+ method === 'GET' ? undefined : { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields) },
903
+ dispatcher,
904
+ );
905
+ const data = (await this.readJson(res)) as T & { error?: string };
906
+ if (!res.ok) throw new Error(data.error ?? `${method} /__editor/asset-library/${route} failed: ${res.status}`);
907
+ return data;
908
+ } finally {
909
+ await dispatcher?.destroy();
910
+ }
911
+ }
912
+
876
913
  async listRecentProjects(): Promise<RecentProject[]> {
877
914
  const res = await this.httpFetch(`${this.baseUrl}/__editor/recent-projects`);
878
915
  const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
@@ -987,9 +1024,11 @@ export class EditorClient {
987
1024
  private async httpFetch(
988
1025
  url: string,
989
1026
  init?: RequestInit,
1027
+ dispatcher?: Dispatcher,
990
1028
  ): Promise<Response> {
1029
+ let res: Response;
991
1030
  try {
992
- return this.fetchOverride ? await this.fetchOverride(url, init) : await dispatchFetch(url, init);
1031
+ res = this.fetchOverride ? await this.fetchOverride(url, init) : await dispatchFetch(url, withSessionToken(url, init), dispatcher);
993
1032
  } catch (error) {
994
1033
  if ((error as { name?: string } | null)?.name === 'TimeoutError') throw error;
995
1034
  const { code, detail } = describeFetchFailure(error);
@@ -1000,6 +1039,8 @@ export class EditorClient {
1000
1039
  code,
1001
1040
  );
1002
1041
  }
1042
+ if (!this.fetchOverride) refuseWithoutRegisteredToken(url, res.status);
1043
+ return res;
1003
1044
  }
1004
1045
 
1005
1046
  /** Require a JSON response from the editor server. */
package/src/discovery.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Registered-session discovery; never selects another project's session. */
2
- import { readLiveRegisteredSessions, servedProjectAnswer } from './registry-format.js';
2
+ import { readLiveRegisteredSessions, servedProjectAnswer, sessionFetch } from './registry-format.js';
3
3
  export { servedProjectAnswer } from './registry-format.js';
4
4
 
5
5
  export const EDITOR_SESSION_DISCOVERY_TIMEOUT_MS = 2000;
@@ -50,7 +50,7 @@ export interface EditorSessionInfo {
50
50
  }
51
51
 
52
52
  async function fetchJson(url: string, timeoutMs: number): Promise<unknown> {
53
- const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
53
+ const res = await sessionFetch(url, { signal: AbortSignal.timeout(timeoutMs) });
54
54
  if (!res.ok) throw new Error(`GET /__editor/project returned HTTP ${res.status}`);
55
55
  return await res.json();
56
56
  }
package/src/editor.ts CHANGED
@@ -29,6 +29,7 @@ import type {
29
29
  ViewportCapture,
30
30
  } from '@volter/sdk';
31
31
  import type { EditorClient, HistoryStep, OpenedDocument } from './client.js';
32
+ import { LiveAssetLibrary } from './asset-library.js';
32
33
  import { LiveEditorDocument } from './editor-document.js';
33
34
 
34
35
  /** A panel `showPanel` can focus: the console, the build surface, or any key
@@ -97,9 +98,14 @@ export class LiveEditor {
97
98
  * See `@volter/sdk`'s editor-document-probe for scope and gesture contracts. */
98
99
  readonly document: LiveEditorDocument;
99
100
 
101
+ /** The asset library: search a source, list an asset's files, download one into the project with its license
102
+ * recorded (`asset-library.ts`). */
103
+ readonly assetLibrary: LiveAssetLibrary;
104
+
100
105
  constructor(client: EditorClient) {
101
106
  this.#client = client;
102
107
  this.document = new LiveEditorDocument(client);
108
+ this.assetLibrary = new LiveAssetLibrary(client);
103
109
  }
104
110
 
105
111
  /** A verb a composed package contributes (`<package>-<verb>`), waited on for its own row's budget. */
@@ -272,8 +278,8 @@ export class LiveEditor {
272
278
  }
273
279
 
274
280
 
275
- async workspace(id: EditorWorkspaceName): Promise<void> {
276
- await this.#client.setWorkspace(id);
281
+ async workspace(id: EditorWorkspaceName): Promise<{ readonly workspace: EditorWorkspaceName; readonly applied: boolean }> {
282
+ return this.#client.setWorkspace(id);
277
283
  }
278
284
 
279
285
  /**
@@ -521,7 +527,9 @@ export class LiveEditor {
521
527
  return this.#client.removeInspectionField(path);
522
528
  }
523
529
 
524
- /** Open a document by its adapter-declared id, through its registered owner. */
530
+ /** Open a document by its adapter-declared id, through its registered owner. An id the adapter would list for a
531
+ * file not written yet (a new model, `model:prefabs/coin/coin.blend`) opens as that new document, written by its
532
+ * first save. */
525
533
  async open(id: string): Promise<OpenedDocument> {
526
534
  return this.#client.open(id);
527
535
  }
package/src/index.ts CHANGED
@@ -8,10 +8,11 @@ import { editorMemberHint } from './member-hints.js';
8
8
 
9
9
  export { LiveEditor, inferAssetKind, type PanelName } from './editor.js';
10
10
  export { LiveEditorDocument } from './editor-document.js';
11
+ export type { AssetDownloadRequest, AssetDownloadResult, AssetFileOption, AssetSearchQuery, OnlineAsset } from './asset-library.js';
11
12
  export type { DocumentGestureOptions, DocumentKeyOptions, DocumentPasteOptions } from './editor-document.js';
12
13
  export { editorMemberHint, withEditorMemberHints } from './member-hints.js';
13
14
  export { resolveSession, findProjectRootFrom, NO_SESSION_FOR_PROJECT, SESSION_MANIFEST_UNREADABLE } from './session.js';
14
- export type { ResolvedSession, SessionResolutionDeps, SessionListingTransport, ProjectSessionHint } from './session.js';
15
+ export type { ResolvedSession, SessionResolutionDeps, SessionListingTransport } from './session.js';
15
16
  export type { ActiveDocumentCapture, EditorView, PresentedEditorView } from '@volter/sdk';
16
17
 
17
18
  export interface LiveBindings {
@@ -54,8 +54,8 @@ export interface ProductIdentity {
54
54
  readonly entry: string;
55
55
  /**
56
56
  * The packages this product mounts, in order — `contributes.volter.product.composes`. The ONE statement of
57
- * the composition: the build serves it to the entry as `volter:composition`, and the session
58
- * reads it here for the catalog and the serving doors.
57
+ * the composition: the session reads it here for the packages it installs and serves, the catalog and the
58
+ * serving doors.
59
59
  */
60
60
  readonly composes: readonly string[];
61
61
 
@@ -2,6 +2,11 @@
2
2
  * Shared editor-session registry format: entry shape, guards, path,
3
3
  * liveness-filtered reads, served-project parsing and pending launches.
4
4
  * The server’s session-registry module owns writes to this format.
5
+ *
6
+ * The registry is also the CREDENTIAL FILE for the owner's own processes: each entry carries its session's
7
+ * connection token, and `sessionFetch` is how a process on this machine reaches a session with it, as a Code-OSS
8
+ * client reads its server's `--connection-token-file` (`src/vs/server/node/serverConnectionToken.ts:62-79` in the
9
+ * `volter-ai/code-oss` fork). The file is the user's alone (0600, written by the session).
5
10
  */
6
11
 
7
12
  import {
@@ -26,9 +31,9 @@ export interface EditorSessionEntry {
26
31
  /** ISO timestamp of server start. */
27
32
  startedAt: string;
28
33
  sessionId: string | null;
29
- /** Private loopback control credential — never returned by an editor HTTP
30
- * route; exists only in this user-private registry. */
31
- controlSecret?: string | null;
34
+ /** The session's connection token, which every request to it carries (`sessionFetch`). Never returned by a
35
+ * session route; it is in this user-private file and in the page's cookie. */
36
+ connectionToken?: string | null;
32
37
  repositoryId: string | null;
33
38
  worktreeId: string | null;
34
39
  worktreeRoot: string | null;
@@ -51,7 +56,7 @@ export function isEditorSessionEntry(v: unknown): v is EditorSessionEntry {
51
56
  typeof s['pid'] === 'number' &&
52
57
  typeof s['startedAt'] === 'string' &&
53
58
  optionalIdentity(s['sessionId']) &&
54
- optionalIdentity(s['controlSecret']) &&
59
+ optionalIdentity(s['connectionToken']) &&
55
60
  optionalIdentity(s['repositoryId']) &&
56
61
  optionalIdentity(s['worktreeId']) &&
57
62
  optionalIdentity(s['worktreeRoot']) &&
@@ -66,7 +71,7 @@ export function normalizeEditorSessionEntry(session: EditorSessionEntry): Editor
66
71
  return {
67
72
  ...session,
68
73
  sessionId: session.sessionId ?? null,
69
- controlSecret: session.controlSecret ?? null,
74
+ connectionToken: session.connectionToken ?? null,
70
75
  repositoryId: session.repositoryId ?? null,
71
76
  worktreeId: session.worktreeId ?? null,
72
77
  worktreeRoot: session.worktreeRoot ?? null,
@@ -102,6 +107,64 @@ export function readLiveRegisteredSessions(): EditorSessionEntry[] {
102
107
  }
103
108
  }
104
109
 
110
+ /**
111
+ * The cookie the session reads its connection token from: Code-OSS's own (`connectionTokenCookieName`,
112
+ * `src/vs/base/common/network.ts:187`). The page's Code-OSS server sets it on the page's host from the address's
113
+ * `?tkn=` (`src/vs/server/node/webClientServer.ts:313-336`), and the session's routes share that host through its
114
+ * proxy and the token through the project's token file, so one cookie carries both.
115
+ */
116
+ export const CONNECTION_TOKEN_COOKIE = 'vscode-tkn';
117
+
118
+ /** The connection token of the live session listening on `port` of this machine, from its entry; null when no live
119
+ * session there registered one. */
120
+ export function registeredConnectionToken(port: number): string | null {
121
+ return readLiveRegisteredSessions().find((session) => session.port === port)?.connectionToken ?? null;
122
+ }
123
+
124
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
125
+
126
+ /** The port of a session `url` names on this machine, or null for any other address. */
127
+ function loopbackSessionPort(url: string | URL): number | null {
128
+ const target = new URL(url);
129
+ return target.protocol === 'http:' && LOOPBACK_HOSTS.has(target.hostname) && target.port !== '' ? Number(target.port) : null;
130
+ }
131
+
132
+ /** `init` with the connection token of the session `url` names, as the cookie the session reads. Only a loopback
133
+ * address gets one, and only a registered session's: the token never leaves this machine. */
134
+ export function withSessionToken(url: string | URL, init: RequestInit = {}): RequestInit {
135
+ const port = loopbackSessionPort(url);
136
+ const token = port === null ? null : registeredConnectionToken(port);
137
+ if (token === null) return init;
138
+ const headers = new Headers(init.headers);
139
+ headers.set('cookie', `${CONNECTION_TOKEN_COOKIE}=${token}`);
140
+ return { ...init, headers };
141
+ }
142
+
143
+ /**
144
+ * Say why a session on this machine refused (403) a request this process could send it no token with: its registry
145
+ * entry, the one place the owner's processes read the token from, is missing or carries none. Throws that; returns
146
+ * when the refusal is something else (a token was sent, or the address is not a local session's).
147
+ */
148
+ export function refuseWithoutRegisteredToken(url: string | URL, status: number): void {
149
+ if (status !== 403) return;
150
+ const port = loopbackSessionPort(url);
151
+ if (port === null || registeredConnectionToken(port) !== null) return;
152
+ throw new Error(
153
+ `The editor session on port ${port} refused this request: its connection token could not be read, because the ` +
154
+ `session registry ${EDITOR_SESSIONS_REGISTRY_FILE} has no live entry for port ${port} carrying one. The session ` +
155
+ "says why its entry could not be written in its terminal and on its console, and writes it again every minute.",
156
+ );
157
+ }
158
+
159
+ /** `fetch` to a session on this machine, carrying its connection token (`withSessionToken`). Every request a process
160
+ * of the owner's makes to a session goes through here or through `EditorClient`, which does the same, and a refusal
161
+ * for want of the token says so (`refuseWithoutRegisteredToken`). */
162
+ export async function sessionFetch(url: string | URL, init?: RequestInit): Promise<Response> {
163
+ const response = await fetch(url, withSessionToken(url, init));
164
+ refuseWithoutRegisteredToken(url, response.status);
165
+ return response;
166
+ }
167
+
105
168
  /** Announce a launch before the server can register. Each launcher owns its file. */
106
169
  export function announceEditorLaunch(project: string): () => void {
107
170
  const directory = join(project, '.volter', 'editor-launches');
package/src/session.ts CHANGED
@@ -1,9 +1,8 @@
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. */
1
+ /** Resolve an existing session for exactly one project, from the shared registry.
2
+ * Never fall back to another project. */
4
3
 
5
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
6
- import { dirname, join, resolve } from 'node:path';
4
+ import { existsSync, realpathSync, statSync } from 'node:fs';
5
+ import { dirname, resolve } from 'node:path';
7
6
  import { resolveManifestPath } from '@volter/project/manifest/locate';
8
7
  import { resolveProductForProject } from './product-locator.js';
9
8
  import {
@@ -11,7 +10,6 @@ import {
11
10
  type EditorSessionInfo,
12
11
  type SessionListingTransport as SessionDiscoveryTransport,
13
12
  HttpSessionDiscovery,
14
- servedProjectAnswer,
15
13
  withTimeout,
16
14
  } from './discovery.js';
17
15
 
@@ -41,17 +39,6 @@ export interface SessionResolutionDeps {
41
39
  transport?: SessionListingTransport;
42
40
  /** Project-root discovery — defaults to the real fs walk (`findProjectRootFrom` above). Overridable for tests. */
43
41
  findProjectRootFrom?: (dir: string) => string | null;
44
- /** Project-local session hint reader. Defaults to `.volter/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
42
  }
56
43
 
57
44
  function canonicalPath(path: string): string {
@@ -80,49 +67,6 @@ export const NO_SESSION_FOR_PROJECT = 'VOLTER_NO_SESSION_FOR_PROJECT';
80
67
  /** The `code` of the refusal for this project's session whose manifest does not load; it carries `session`. */
81
68
  export const SESSION_MANIFEST_UNREADABLE = 'VOLTER_SESSION_MANIFEST_UNREADABLE';
82
69
 
83
- function readProjectSession(projectRoot: string): ProjectSessionHint | null {
84
- try {
85
- const value: unknown = JSON.parse(
86
- readFileSync(join(projectRoot, '.volter', 'session.json'), 'utf8'),
87
- );
88
- if (typeof value !== 'object' || value === null) return null;
89
- const hint = value as Record<string, unknown>;
90
- if (
91
- typeof hint['port'] !== 'number' ||
92
- !Number.isInteger(hint['port']) ||
93
- hint['port'] <= 0 ||
94
- typeof hint['pid'] !== 'number' ||
95
- typeof hint['url'] !== 'string' ||
96
- typeof hint['startedAt'] !== 'string'
97
- ) {
98
- return null;
99
- }
100
- return hint as unknown as ProjectSessionHint;
101
- } catch {
102
- return null;
103
- }
104
- }
105
-
106
- /**
107
- * Read project identity and manifest failure in one `/__editor/project` probe.
108
- * A serving path identifies the project even when its manifest is unreadable;
109
- * `project: null` without that path denotes no described project. Return
110
- * `undefined` when the server does not answer successfully.
111
- */
112
- async function probeServedProject(
113
- port: number,
114
- ): Promise<{ path: string | null; manifestError: string | null } | undefined> {
115
- try {
116
- const response = await fetch(`http://127.0.0.1:${port}/__editor/project`, {
117
- signal: AbortSignal.timeout(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
118
- });
119
- if (!response.ok) return undefined;
120
- return servedProjectAnswer(await response.json());
121
- } catch {
122
- return undefined;
123
- }
124
- }
125
-
126
70
  /**
127
71
  * The refusal for a session that IS this project's and cannot be driven,
128
72
  * because the project's manifest does not load.
@@ -192,8 +136,8 @@ function noMatchingSessionRefusal(
192
136
 
193
137
  /**
194
138
  * Resolve `projectDir` (default `process.cwd()`) to its already-running editor
195
- * session. Verify the project hint, then consult the shared registry, with
196
- * each discovery probe bounded by `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`.
139
+ * session from the shared registry, with each discovery probe bounded by
140
+ * `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`.
197
141
  * Throw when no project manifest or matching live session is found.
198
142
  */
199
143
  export async function resolveSession(
@@ -210,30 +154,6 @@ export async function resolveSession(
210
154
  );
211
155
  }
212
156
 
213
- // The editor writes this exact-project hint at boot and removes it on
214
- // shutdown. Prefer it over a global all-session scan: it is both faster and
215
- // immune to an unrelated slow/dead registry entry consuming the discovery
216
- // deadline. The live probe remains authoritative, so a stale file cannot
217
- // attach us to the wrong project or port.
218
- const localHint = (deps.readProjectSession ?? readProjectSession)(projectRoot);
219
- if (localHint) {
220
- // `deps.verifyProjectSession` is the test-only override and keeps its
221
- // boolean meaning: "this hint is this project's session, and healthy".
222
- // With no override we read the probe ourselves, because the same response
223
- // carries WHY a served project cannot be described.
224
- if (deps.verifyProjectSession) {
225
- if (await deps.verifyProjectSession(localHint, projectRoot)) {
226
- return { port: localHint.port, projectRoot };
227
- }
228
- } else {
229
- const served = await probeServedProject(localHint.port);
230
- if (served?.path != null && canonicalPath(served.path) === canonicalPath(projectRoot)) {
231
- if (served.manifestError !== null) throw manifestRefusal(projectRoot, served.manifestError, localHint.port);
232
- return { port: localHint.port, projectRoot };
233
- }
234
- }
235
- }
236
-
237
157
  let sessions: EditorSessionInfo[];
238
158
  // Retain discovery failures separately from an empty session list so the
239
159
  // refusal names an unanswered probe rather than claiming no session exists.