@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.
- package/BUNDLED_NOTICES +4 -4
- package/README.md +4 -1
- package/dist/asset-library.d.ts +98 -0
- package/dist/asset-library.js +7 -0
- package/dist/asset-library.js.map +7 -0
- package/dist/chunk-B2HUWFTT.js +25 -0
- package/dist/chunk-B2HUWFTT.js.map +7 -0
- package/dist/{chunk-7SZ3C4CC.js → chunk-IZJOOON2.js} +45 -8
- package/dist/chunk-IZJOOON2.js.map +7 -0
- package/dist/{chunk-Y5W77HYT.js → chunk-JDOIPALX.js} +38 -3
- package/dist/chunk-JDOIPALX.js.map +7 -0
- package/dist/{chunk-YPAOTPIF.js → chunk-JFAHP5CC.js} +19 -3
- package/dist/chunk-JFAHP5CC.js.map +7 -0
- package/dist/{chunk-SDIWRX5X.js → chunk-PC56TDHV.js} +5 -5
- package/dist/chunk-PC56TDHV.js.map +7 -0
- package/dist/{chunk-USXOIBVX.js → chunk-WIAH4XZE.js} +5 -4
- package/dist/chunk-WIAH4XZE.js.map +7 -0
- package/dist/client.d.ts +10 -4
- package/dist/client.js +2 -1
- package/dist/discovery.js +2 -2
- package/dist/editor.d.ts +11 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +18 -51
- package/dist/index.js.map +3 -3
- package/dist/product-locator.d.ts +2 -2
- package/dist/product-locator.js +2 -2
- package/dist/registry-format.d.ts +31 -3
- package/dist/registry-format.js +13 -3
- package/dist/session.d.ts +4 -15
- package/dist/volter-manifest.js +1 -1
- package/dist/workbench-locator.js +2 -2
- package/package.json +7 -3
- package/src/asset-library.ts +104 -0
- package/src/client.ts +48 -7
- package/src/discovery.ts +2 -2
- package/src/editor.ts +11 -3
- package/src/index.ts +2 -1
- package/src/product-locator.ts +2 -2
- package/src/registry-format.ts +68 -5
- package/src/session.ts +6 -86
- package/dist/chunk-7SZ3C4CC.js.map +0 -7
- package/dist/chunk-SDIWRX5X.js.map +0 -7
- package/dist/chunk-USXOIBVX.js.map +0 -7
- package/dist/chunk-Y5W77HYT.js.map +0 -7
- 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).
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
async setWorkspace(workspace: EditorWorkspaceName): Promise<
|
|
519
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
276
|
-
|
|
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
|
|
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 {
|
package/src/product-locator.ts
CHANGED
|
@@ -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
|
|
58
|
-
*
|
|
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
|
|
package/src/registry-format.ts
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
* route;
|
|
31
|
-
|
|
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['
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
6
|
-
import { dirname,
|
|
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
|
|
196
|
-
*
|
|
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.
|