@volter/editor-core 0.5.57 → 0.5.59
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 +3 -3
- package/dist/server/frame-proxy.js +9 -0
- package/dist/server/frame-proxy.js.map +3 -3
- package/dist-server/packaged.mjs +19 -11
- package/package.json +4 -4
- package/server/blender-wasm-artifact.ts +3 -1
- package/server/frame-proxy.ts +4 -0
- package/src/action-registry.ts +2 -2
- package/src/authoring/consumer-actions.ts +11 -0
- package/src/command-listener.ts +9 -0
- package/src/editor-host-door.ts +10 -0
- package/src/editor-hotkeys.ts +16 -1
- package/src/frame/bridge.tsx +8 -2
- package/src/history/history-delegate.ts +28 -4
- package/src/history/history-service.ts +24 -8
- package/src/session-close.ts +17 -0
package/BUNDLED_NOTICES
CHANGED
|
@@ -31,7 +31,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
|
31
31
|
SOFTWARE.
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
@volter/editor-project@0.5.
|
|
34
|
+
@volter/editor-project@0.5.59
|
|
35
35
|
Declared license: Apache-2.0
|
|
36
36
|
|
|
37
37
|
--- packages/editor-project/LICENSE ---
|
|
@@ -240,7 +240,7 @@ Declared license: Apache-2.0
|
|
|
240
240
|
limitations under the License.
|
|
241
241
|
|
|
242
242
|
|
|
243
|
-
@volter/editor-sdk@0.5.
|
|
243
|
+
@volter/editor-sdk@0.5.59
|
|
244
244
|
Declared license: Apache-2.0
|
|
245
245
|
|
|
246
246
|
--- packages/editor-sdk/LICENSE ---
|
|
@@ -461,7 +461,7 @@ The project output-root policy is Apache-2.0, transferred from the original
|
|
|
461
461
|
SDK; its exact source is recorded in provenance/editor-server.json.
|
|
462
462
|
|
|
463
463
|
|
|
464
|
-
@volter/editor-threejs@0.5.
|
|
464
|
+
@volter/editor-threejs@0.5.59
|
|
465
465
|
Declared license: AGPL-3.0-only AND Apache-2.0
|
|
466
466
|
|
|
467
467
|
--- packages/editor-threejs/LICENSE ---
|
|
@@ -7,6 +7,12 @@ import { connect as netConnect } from "node:net";
|
|
|
7
7
|
import { basename } from "node:path";
|
|
8
8
|
import { pipeline } from "node:stream/promises";
|
|
9
9
|
import { createBrotliCompress, createGzip, constants as zlibConstants } from "node:zlib";
|
|
10
|
+
|
|
11
|
+
// server/js-profiling-policy.ts
|
|
12
|
+
var JS_PROFILING_POLICY_HEADER = "Document-Policy";
|
|
13
|
+
var JS_PROFILING_POLICY_VALUE = "js-profiling";
|
|
14
|
+
|
|
15
|
+
// server/frame-proxy.ts
|
|
10
16
|
var VSCODE_PATH_BASE = "^\\/$|^\\/(static|favicon|manifest\\.json|code-192|code-512|callback|oauth|out|extensions|builtin|remote|vscode-remote|_static|web-extension|version|delay-shutdown)";
|
|
11
17
|
var COMPRESSIBLE = /^(?:text\/|application\/(?:javascript|json|wasm|manifest\+json)|image\/svg\+xml)/;
|
|
12
18
|
var decodeAttr = (value) => value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
@@ -30,6 +36,9 @@ async function startFrameProxy(options) {
|
|
|
30
36
|
const webviewOriginPattern = `http://*.localhost:${port}`;
|
|
31
37
|
const projectId = basename(projectRoot);
|
|
32
38
|
const isolation = {
|
|
39
|
+
// The Code-OSS HTML comes from the upstream, not Express. Keep the
|
|
40
|
+
// editor's profiling permission on the actual top-level document too.
|
|
41
|
+
[JS_PROFILING_POLICY_HEADER]: JS_PROFILING_POLICY_VALUE,
|
|
33
42
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
34
43
|
"Cross-Origin-Embedder-Policy": "credentialless",
|
|
35
44
|
"Cross-Origin-Resource-Policy": "cross-origin"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../../server/frame-proxy.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * THE ONE ORIGIN in front of two servers: the Code-OSS workbench and this\n * session. It listens IN THE SESSION PROCESS \u2014 no spawn, no second program \u2014\n * exactly as the loopback Colyseus server does.\n *\n * /$product-segment/*, /static, /vscode-remote, \u2026 the workbench (the REH)\n * everything else this session\n *\n * WHY A PROXY AND NOT TWO ORIGINS (measured 2026-09-19 against the REH). The\n * REH stamps its own CSP on the workbench page\n * (`createWorkbenchContentSecurityPolicy`, src/vs/server/node/webClientServer.ts):\n *\n * script-src 'self' 'unsafe-eval' blob: 'nonce-\u2026' <sha> http://<the REH's own authority>\n * connect-src 'self' ws: wss: https:\n *\n * The ONLY http origin on either list is the server's own. The editor's own\n * entry module and every project module are imported as module scripts from\n * this session, and every `/__editor/*` call is a fetch to it \u2014 so a session on\n * a second port is refused by `script-src` before CORS is ever consulted, and\n * its fetches by `connect-src` (which carries no `http:` at all). Relaxing that\n * means editing a core service; the proxy costs none.\n *\n * CROSS-ORIGIN ISOLATION. Every response carries COOP `same-origin` and COEP\n * `credentialless`, because the Blender build is `-sPROXY_TO_PTHREAD` and needs\n * `SharedArrayBuffer`. The REH has no cross-origin-isolation flag or header\n * hook of its own \u2014 `--enable-coi` is an ELECTRON-MAIN argument \u2014 so these\n * headers are this proxy's. `credentialless` rather than `require-corp` so the\n * workbench's own cross-origin loads keep working, the same choice `dev.ts`\n * makes.\n *\n * THE WEBVIEW'S SECOND ORIGIN. VS Code loads every webview from\n * `webviewExternalEndpoint`, which defaults to `https://{{uuid}}.vscode-cdn.net/\u2026`\n * \u2014 a host that does not exist for this fork and that a cross-origin-isolated\n * page could not embed anyway. `IWorkbenchConstructionOptions.webviewEndpoint`\n * is the supported door and the REH leaves it unset, so this rewrites the page\n * config to `http://{{uuid}}.localhost:<port>`. It has to be a SUBDOMAIN and\n * not this origin itself: `pre/index.html` refuses to start unless\n * `location.hostname` IS the hash of `{parentOrigin, salt}` or begins with it,\n * and `*.localhost` is the one wildcard host a browser resolves to loopback\n * with no DNS. Two consequences handled here: the webview is genuinely\n * cross-origin, so its responses need CORP `cross-origin` (without it the frame\n * dies as `corp-not-same-origin-after-defaulted-to-same-origin-by-coep`, which\n * Chrome renders as \"refused to connect\" and which cost an hour); and the REH's\n * own `frame-src 'self' https://*.vscode-cdn.net` is widened on the way through.\n *\n * COMPRESSION IS THIS PROXY'S JOB, and it is the reason the frame's page weight\n * is not the workbench's disk size. U3 measured `transferSize ===\n * decodedBodySize` on every entry of a 41.75 MB page: the REH writes no\n * `content-encoding` at all, and neither did this proxy. It negotiates\n * `accept-encoding` now \u2014 brotli, then gzip \u2014 over text, JavaScript, CSS, JSON\n * and wasm only; everything else is byte-for-byte.\n */\n\nimport {\n createServer,\n request as httpRequest,\n type IncomingMessage,\n type Server,\n type ServerResponse,\n} from 'node:http';\nimport { connect as netConnect, type Socket } from 'node:net';\nimport { basename } from 'node:path';\nimport type { Transform } from 'node:stream';\nimport { pipeline } from 'node:stream/promises';\nimport { createBrotliCompress, createGzip, constants as zlibConstants } from 'node:zlib';\n\nexport interface FrameProxyOptions {\n /** The one port a person opens: this proxy's. */\n readonly port: number;\n /** The Code-OSS workbench (REH) behind it. */\n readonly upstreamPort: number;\n /** This session's own port. */\n readonly sessionPort: number;\n /** The project the workbench opens; its folder name is the `?project=` id. */\n readonly projectRoot: string;\n /**\n * Which way round the PRODUCT paints, from its own declaration\n * (`package.json#vgai.product.colorTheme`, `@volter/editor-sdk/session/\n * product-locator`), or `null` when this session could not resolve a product\n * \u2014 in which case the page keeps the workbench's own web default and the\n * `served-modules` door is what says why there is no product.\n */\n readonly colorTheme: 'dark' | 'light' | null;\n readonly log: (line: string) => void;\n}\n\nexport interface FrameProxy {\n /** The url to open: this origin, on this project. */\n readonly url: string;\n close(): Promise<void>;\n}\n\n/**\n * The workbench's own paths. `<product segment>` (`/oss-dev` out of a source\n * checkout, the quality segment out of a build \u2014 `getServerProductSegment`) is\n * DISCOVERED at boot rather than spelled here, because the page states it.\n */\nconst VSCODE_PATH_BASE =\n '^\\\\/$|^\\\\/(static|favicon|manifest\\\\.json|code-192|code-512|callback|oauth|out|extensions|builtin|remote|vscode-remote|_static|web-extension|version|delay-shutdown)';\n\n/** What is worth compressing. Everything else goes through byte for byte. */\nconst COMPRESSIBLE =\n /^(?:text\\/|application\\/(?:javascript|json|wasm|manifest\\+json)|image\\/svg\\+xml)/;\n\n/** `"`-encoded HTML attribute value \u2194 JSON, the way webClientServer writes it. */\nconst decodeAttr = (value: string): string =>\n value\n .replaceAll('"', '\"')\n .replaceAll(''', \"'\")\n .replaceAll('<', '<')\n .replaceAll('>', '>')\n .replaceAll('&', '&');\nconst encodeAttr = (value: string): string =>\n value\n .replaceAll('&', '&')\n .replaceAll('\"', '"')\n .replaceAll(\"'\", ''')\n .replaceAll('<', '<')\n .replaceAll('>', '>');\n\n/**\n * Which encoding the browser asked for, in this proxy's order of preference.\n * `null` means \"send it as it is\" \u2014 no `accept-encoding`, or none we write.\n */\nfunction negotiateEncoding(header: string | string[] | undefined): 'br' | 'gzip' | null {\n const accepted = (Array.isArray(header) ? header.join(',') : (header ?? '')).toLowerCase();\n if (accepted.includes('br')) return 'br';\n if (accepted.includes('gzip')) return 'gzip';\n return null;\n}\n\n/**\n * Quality 5 rather than brotli's default 11. The default spends seconds on a\n * multi-megabyte workbench chunk and this proxy compresses on the request path,\n * with the person waiting; 5 is the knee where the ratio is nearly the same and\n * the CPU is not.\n */\nfunction compressor(encoding: 'br' | 'gzip'): Transform {\n return encoding === 'br'\n ? createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } })\n : createGzip({ level: 6 });\n}\n\nexport async function startFrameProxy(options: FrameProxyOptions): Promise<FrameProxy> {\n const { port, upstreamPort, sessionPort, projectRoot, colorTheme, log } = options;\n const upstream = { host: '127.0.0.1', port: upstreamPort };\n const session = { host: '127.0.0.1', port: sessionPort };\n const upstreamOrigin = `http://${upstream.host}:${upstream.port}`;\n const selfOrigin = `http://127.0.0.1:${port}`;\n const webviewOriginTemplate = `http://{{uuid}}.localhost:${port}`;\n const webviewOriginPattern = `http://*.localhost:${port}`;\n const projectId = basename(projectRoot);\n\n const isolation: Record<string, string> = {\n 'Cross-Origin-Opener-Policy': 'same-origin',\n 'Cross-Origin-Embedder-Policy': 'credentialless',\n 'Cross-Origin-Resource-Policy': 'cross-origin',\n };\n\n let vscodePath = new RegExp(VSCODE_PATH_BASE);\n\n /**\n * THE DOCUMENT'S OWN COLOUR, before it has any styles at all.\n *\n * `initialColorTheme` below fixes the WORKBENCH's first paint, and that is not the page's\n * first paint: measured 2026-09-21 on the live release, `html` and `body` compute\n * `rgba(0, 0, 0, 0)` \u2014 upstream's `workbench.css` styles `.monaco-workbench` and nothing\n * above it \u2014 so from the document's first frame until `Workbench.renderWorkbench` builds that\n * element, the canvas is the BROWSER's default, which is white. One frame of it survived F1's\n * first reading (frame 0 at 0.0 s white, frame 1 at 0.5 s already the product's splash).\n *\n * `color-scheme` is the platform's own answer to exactly that question, and it is why this is\n * a meta tag and not a background rule: it hands the browser the product's `dark`/`light`\n * declaration UNCHANGED and lets it paint its own dark canvas, so nothing here becomes a\n * second author of a palette value. The workbench sets `color-scheme` itself once its theme\n * loads; this only fills the window before that.\n */\n const colorSchemeMeta = (html: string): string =>\n colorTheme === null\n ? html\n : html.replace('<head>', `<head>\\n\\t\\t<meta name=\"color-scheme\" content=\"${colorTheme}\">`);\n\n /** Rewrite the workbench page's own configuration for THIS origin.\n *\n * - `remoteAuthority` needs NOTHING here, and that is worth stating: the REH\n * derives it from the request's HOST header (measured \u2014 a direct fetch to\n * the REH reports its own authority, the same fetch through this proxy\n * reports this one), so forwarding the original Host is already what makes\n * the management websocket, the `vscode-remote://` file system, every\n * `vscode-remote-resource` url AND the page's own CSP address this origin.\n * - `webviewEndpoint` is set (see the header).\n * - `initialColorTheme` is set (see below). */\n const patchWorkbenchConfig = (html: string): string => {\n const webBaseUrl =\n html.match(/id=\"vscode-workbench-web-base-url\" data-settings=\"([^\"]*)\"/)?.[1] ?? '/static';\n return html.replace(\n /(id=\"vscode-workbench-web-configuration\" data-settings=\")([^\"]*)(\")/,\n (_all, head: string, value: string, tail: string) => {\n const config = JSON.parse(decodeAttr(value)) as Record<string, unknown>;\n config['webviewEndpoint'] =\n `${webviewOriginTemplate}${decodeAttr(webBaseUrl)}/out/vs/workbench/contrib/webview/browser/pre/`;\n // THE FIRST PAINT IS THE PRODUCT'S, NOT THE WEB DEFAULT'S. Measured\n // 2026-09-21 against both releases: with nothing here the page's first\n // frame is LIGHT \u2014 `workbenchThemeService` falls back to\n // `isWeb ? ColorScheme.LIGHT : ColorScheme.DARK` when it has no stored\n // theme \u2014 so the product's opening cover, every colour of which is a\n // `--vscode-*` variable by design, was WHITE from the first frame until\n // the real theme loaded seconds later, and then flipped.\n //\n // `IWorkbenchConstructionOptions.initialColorTheme` is the supported\n // door and the REH leaves it unset, the same shape as `webviewEndpoint`\n // above. It has to be HERE and not in a setting or an extension: the\n // theme service reads it in its constructor, before any extension is\n // registered, which is exactly the window the cover lives in. The\n // themeType strings ARE `ColorScheme`'s values (`dark`, `light`).\n //\n // The colour comes from the PRODUCT's own declaration and nothing else\n // (`package.json#vgai.product.colorTheme`), and it is a FALLBACK in the\n // workbench's own order: a person who has already picked a theme has it\n // in storage, and `ColorThemeData.fromStorageData` is preferred over\n // this on every open after the first.\n if (colorTheme !== null) config['initialColorTheme'] = { themeType: colorTheme };\n return head + encodeAttr(JSON.stringify(config)) + tail;\n },\n );\n };\n\n const sendText = (res: ServerResponse, status: number, body: string, type: string): void => {\n res.writeHead(status, { ...isolation, 'content-type': type, 'cache-control': 'no-store' });\n res.end(body);\n };\n\n /** An unknown `?project=` says so by name rather than opening an empty workbench. */\n const refuseProject = (res: ServerResponse, id: string): void => {\n sendText(\n res,\n 404,\n `<!doctype html><meta charset=\"utf-8\"><title>no such project</title>` +\n `<body style=\"font:14px system-ui;padding:2rem\"><h1>No project \"${encodeAttr(id)}\" is open here.</h1>` +\n `<p>This workbench is the session for <code>${encodeAttr(projectRoot)}</code>, whose id is ` +\n `<code>${encodeAttr(projectId)}</code>.</p>`,\n 'text/html; charset=utf-8',\n );\n };\n\n const handleRequest = (req: IncomingMessage, res: ServerResponse): void => {\n const url = new URL(req.url ?? '/', selfOrigin);\n const pathname = url.pathname;\n // THE ONE HOSTED BOOT PARAM, ONTO THE WORKBENCH'S OWN. `?project=<id>` is\n // what a link to the vgai editor carries; the workbench's own boot contract\n // is `?folder=<path>` (`WorkspaceProvider.create`, which reads a bare path\n // against `remoteAuthority`). This is a REDIRECT and not an injected\n // `folderUri`, and the reason was measured (2026-09-19): under this shape\n // the workbench page is ALSO the vgai editor's page, and the editor's boot\n // refuses `?project=` on a session-backed surface by name \u2014 \"project\n // identity does NOT live in the URL on a local editor\"\n // (`assertNoRemovedBootParams`, packages/editor/src/boot-routing.ts). The\n // redirect hands the page a url both halves accept.\n if (pathname === '/') {\n const id = url.searchParams.get('project');\n if (id && !url.searchParams.has('folder') && !url.searchParams.has('workspace')) {\n if (id !== projectId) {\n refuseProject(res, id);\n return;\n }\n const next = new URL(url);\n next.searchParams.delete('project');\n next.searchParams.set('folder', projectRoot);\n res.writeHead(302, {\n ...isolation,\n location: `${next.pathname}${next.search}`,\n 'cache-control': 'no-store',\n });\n res.end();\n return;\n }\n }\n\n const target = vscodePath.test(pathname) ? upstream : session;\n const encoding = negotiateEncoding(req.headers['accept-encoding']);\n const forward = httpRequest(\n { ...target, method: req.method, path: req.url, headers: req.headers },\n (answer) => {\n const headers: Record<string, string | string[]> = {};\n for (const [key, value] of Object.entries(answer.headers)) {\n if (value !== undefined) headers[key] = value;\n }\n delete headers['cross-origin-opener-policy'];\n delete headers['cross-origin-embedder-policy'];\n delete headers['cross-origin-resource-policy'];\n // The webview's second origin, onto the REH's own `frame-src`.\n const csp = headers['content-security-policy'];\n if (typeof csp === 'string' && csp.includes('frame-src')) {\n // GLTFLoader fetches local blob textures; img-src alone cannot\n // authorize that fetch. Media previews use local blobs as well.\n headers['content-security-policy'] = csp\n .replace(/frame-src([^;]*)/, `frame-src$1 ${webviewOriginPattern}`)\n .replace(/connect-src([^;]*)/, 'connect-src$1 blob:')\n .replace(/media-src([^;]*)/, 'media-src$1 blob:');\n }\n const type = String(headers['content-type'] ?? '');\n const alreadyEncoded = headers['content-encoding'] !== undefined;\n // The workbench HTML and its config spell the upstream's OWN origin\n // into absolute script and endpoint URLs; through this origin those are\n // cross-origin and CORS refuses them. Text bodies are re-spelled here,\n // which needs the whole body \u2014 so this branch buffers and the streaming\n // one below does not.\n if (/text\\/html|javascript|json/.test(type) && !alreadyEncoded) {\n const chunks: Buffer[] = [];\n answer.on('data', (chunk: Buffer) => chunks.push(chunk));\n answer.on('end', () => {\n let body = Buffer.concat(chunks)\n .toString('utf8')\n .replaceAll(upstreamOrigin, selfOrigin);\n if (pathname === '/' && target === upstream && /text\\/html/.test(type)) {\n body = colorSchemeMeta(patchWorkbenchConfig(body));\n }\n delete headers['content-length'];\n const raw = Buffer.from(body, 'utf8');\n if (encoding === null) {\n res.writeHead(answer.statusCode ?? 502, {\n ...headers,\n ...isolation,\n 'content-length': raw.byteLength,\n });\n res.end(raw);\n return;\n }\n res.writeHead(answer.statusCode ?? 502, {\n ...headers,\n ...isolation,\n 'content-encoding': encoding,\n vary: 'accept-encoding',\n });\n const stream = compressor(encoding);\n stream.pipe(res);\n stream.end(raw);\n });\n return;\n }\n if (encoding !== null && !alreadyEncoded && COMPRESSIBLE.test(type)) {\n delete headers['content-length'];\n res.writeHead(answer.statusCode ?? 502, {\n ...headers,\n ...isolation,\n 'content-encoding': encoding,\n vary: 'accept-encoding',\n });\n void pipeline(answer, compressor(encoding), res).catch(() => res.destroy());\n return;\n }\n res.writeHead(answer.statusCode ?? 502, { ...headers, ...isolation });\n answer.pipe(res);\n },\n );\n forward.on('error', (error) => {\n if (!res.headersSent) {\n sendText(\n res,\n 502,\n `upstream ${target.port}: ${error.message}`,\n 'text/plain; charset=utf-8',\n );\n } else res.destroy();\n });\n // A browser that goes away takes its upstream request with it \u2014 otherwise a\n // dead tab's event stream stays open, this session keeps that tab blessed,\n // and EVERY later tab lands on `/__editor/tab-yielded` (measured\n // 2026-09-19 in the session journal).\n res.on('close', () => forward.destroy());\n req.pipe(forward);\n };\n\n const handleUpgrade = (req: IncomingMessage, socket: Socket, head: Buffer): void => {\n const target = vscodePath.test(new URL(req.url ?? '/', selfOrigin).pathname)\n ? upstream\n : session;\n const proxied = netConnect(target.port, target.host, () => {\n proxied.write(\n `${req.method} ${req.url} HTTP/${req.httpVersion}\\r\\n` +\n Object.entries(req.headers)\n .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}`)\n .join('\\r\\n') +\n '\\r\\n\\r\\n',\n );\n if (head.length) proxied.write(head);\n socket.pipe(proxied).pipe(socket);\n });\n proxied.on('error', () => socket.destroy());\n socket.on('error', () => proxied.destroy());\n };\n\n /**\n * Wait for the upstream to report its shape before accepting requests. The REH serves its static\n * assets under a PRODUCT SEGMENT (`/oss-dev/static` out of a source checkout,\n * `/<quality>/static` out of a build), and this cannot route without knowing\n * it. The page itself states it in `vscode-workbench-web-base-url`, so it is\n * read rather than spelled.\n */\n const discoverUpstream = (): Promise<string | null> =>\n new Promise((done) => {\n const probe = httpRequest({ ...upstream, path: '/' }, (answer) => {\n const chunks: Buffer[] = [];\n answer.on('data', (chunk: Buffer) => chunks.push(chunk));\n answer.on('end', () => {\n const html = Buffer.concat(chunks).toString('utf8');\n const base = html.match(\n /id=\"vscode-workbench-web-base-url\" data-settings=\"([^\"]*)\"/,\n )?.[1];\n done(answer.statusCode === 200 && base ? decodeAttr(base).replace(/\\/static\\/?$/, '') : null);\n });\n });\n probe.on('error', () => done(null));\n probe.setTimeout(3_000, () => {\n probe.destroy();\n done(null);\n });\n probe.end();\n });\n\n // A listening socket is not proof that Code-OSS can serve its HTML yet.\n // Never expose a half-configured proxy: unknown prefixes route native JS to\n // the session server and produce a blank workbench with script 404s.\n const discoveryDeadline = Date.now() + 30_000;\n let segment: string | null = null;\n do {\n segment = await discoverUpstream();\n if (segment !== null) break;\n await new Promise(resolve => setTimeout(resolve, 100));\n } while (Date.now() < discoveryDeadline);\n if (segment === null) throw new Error(\n `Code-OSS on port ${upstream.port} did not report its workbench asset base within 30s; refusing to start an incorrectly routed proxy.`,\n );\n if (segment) {\n vscodePath = new RegExp(\n `${VSCODE_PATH_BASE}|^${segment.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(\\\\/|$)`,\n );\n log(`frame: workbench product segment ${segment}`);\n }\n\n // ONE handler, TWO loopback listeners \u2014 127.0.0.1 and ::1. The webview's\n // origin is a `*.localhost` subdomain (see the header), and macOS answers\n // `<anything>.localhost` with the IPv6 `::1` as well as 127.0.0.1\n // (`dscacheutil -q host -a name x.localhost`); a browser that picks the IPv6\n // address finds nothing listening and the webview iframe fails as\n // `chrome-error://chromewebdata` with no message at all \u2014 measured\n // 2026-09-19 against a proxy bound to 127.0.0.1 alone.\n const servers: Server[] = [];\n await new Promise<void>((ready, failed) => {\n let settled = false;\n for (const host of ['127.0.0.1', '::1']) {\n const one = createServer(handleRequest);\n one.on('upgrade', handleUpgrade);\n one.on('error', (error) => {\n if (host === '::1') {\n log(\n `frame: no IPv6 loopback listener (${error.message}); webviews need one if the browser resolves *.localhost to ::1`,\n );\n return;\n }\n if (!settled) {\n settled = true;\n failed(error);\n }\n });\n servers.push(one);\n one.listen(port, host, () => {\n if (!settled) {\n settled = true;\n ready();\n }\n });\n }\n });\n\n return {\n url: `${selfOrigin}/?project=${encodeURIComponent(projectId)}`,\n close: async () => {\n await Promise.all(\n servers.map(\n (one) =>\n new Promise<void>((done) => {\n one.close(() => done());\n one.closeAllConnections?.();\n }),\n ),\n );\n },\n };\n}\n"],
|
|
5
|
-
"mappings": ";AAqDA;AAAA,EACE;AAAA,EACA,WAAW;AAAA,OAIN;AACP,SAAS,WAAW,kBAA+B;AACnD,SAAS,gBAAgB;AAEzB,SAAS,gBAAgB;AACzB,SAAS,sBAAsB,YAAY,aAAa,qBAAqB;
|
|
3
|
+
"sources": ["../../server/frame-proxy.ts", "../../server/js-profiling-policy.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * THE ONE ORIGIN in front of two servers: the Code-OSS workbench and this\n * session. It listens IN THE SESSION PROCESS \u2014 no spawn, no second program \u2014\n * exactly as the loopback Colyseus server does.\n *\n * /$product-segment/*, /static, /vscode-remote, \u2026 the workbench (the REH)\n * everything else this session\n *\n * WHY A PROXY AND NOT TWO ORIGINS (measured 2026-09-19 against the REH). The\n * REH stamps its own CSP on the workbench page\n * (`createWorkbenchContentSecurityPolicy`, src/vs/server/node/webClientServer.ts):\n *\n * script-src 'self' 'unsafe-eval' blob: 'nonce-\u2026' <sha> http://<the REH's own authority>\n * connect-src 'self' ws: wss: https:\n *\n * The ONLY http origin on either list is the server's own. The editor's own\n * entry module and every project module are imported as module scripts from\n * this session, and every `/__editor/*` call is a fetch to it \u2014 so a session on\n * a second port is refused by `script-src` before CORS is ever consulted, and\n * its fetches by `connect-src` (which carries no `http:` at all). Relaxing that\n * means editing a core service; the proxy costs none.\n *\n * CROSS-ORIGIN ISOLATION. Every response carries COOP `same-origin` and COEP\n * `credentialless`, because the Blender build is `-sPROXY_TO_PTHREAD` and needs\n * `SharedArrayBuffer`. The REH has no cross-origin-isolation flag or header\n * hook of its own \u2014 `--enable-coi` is an ELECTRON-MAIN argument \u2014 so these\n * headers are this proxy's. `credentialless` rather than `require-corp` so the\n * workbench's own cross-origin loads keep working, the same choice `dev.ts`\n * makes.\n *\n * THE WEBVIEW'S SECOND ORIGIN. VS Code loads every webview from\n * `webviewExternalEndpoint`, which defaults to `https://{{uuid}}.vscode-cdn.net/\u2026`\n * \u2014 a host that does not exist for this fork and that a cross-origin-isolated\n * page could not embed anyway. `IWorkbenchConstructionOptions.webviewEndpoint`\n * is the supported door and the REH leaves it unset, so this rewrites the page\n * config to `http://{{uuid}}.localhost:<port>`. It has to be a SUBDOMAIN and\n * not this origin itself: `pre/index.html` refuses to start unless\n * `location.hostname` IS the hash of `{parentOrigin, salt}` or begins with it,\n * and `*.localhost` is the one wildcard host a browser resolves to loopback\n * with no DNS. Two consequences handled here: the webview is genuinely\n * cross-origin, so its responses need CORP `cross-origin` (without it the frame\n * dies as `corp-not-same-origin-after-defaulted-to-same-origin-by-coep`, which\n * Chrome renders as \"refused to connect\" and which cost an hour); and the REH's\n * own `frame-src 'self' https://*.vscode-cdn.net` is widened on the way through.\n *\n * COMPRESSION IS THIS PROXY'S JOB, and it is the reason the frame's page weight\n * is not the workbench's disk size. U3 measured `transferSize ===\n * decodedBodySize` on every entry of a 41.75 MB page: the REH writes no\n * `content-encoding` at all, and neither did this proxy. It negotiates\n * `accept-encoding` now \u2014 brotli, then gzip \u2014 over text, JavaScript, CSS, JSON\n * and wasm only; everything else is byte-for-byte.\n */\n\nimport {\n createServer,\n request as httpRequest,\n type IncomingMessage,\n type Server,\n type ServerResponse,\n} from 'node:http';\nimport { connect as netConnect, type Socket } from 'node:net';\nimport { basename } from 'node:path';\nimport type { Transform } from 'node:stream';\nimport { pipeline } from 'node:stream/promises';\nimport { createBrotliCompress, createGzip, constants as zlibConstants } from 'node:zlib';\nimport { JS_PROFILING_POLICY_HEADER, JS_PROFILING_POLICY_VALUE } from './js-profiling-policy';\n\nexport interface FrameProxyOptions {\n /** The one port a person opens: this proxy's. */\n readonly port: number;\n /** The Code-OSS workbench (REH) behind it. */\n readonly upstreamPort: number;\n /** This session's own port. */\n readonly sessionPort: number;\n /** The project the workbench opens; its folder name is the `?project=` id. */\n readonly projectRoot: string;\n /**\n * Which way round the PRODUCT paints, from its own declaration\n * (`package.json#vgai.product.colorTheme`, `@volter/editor-sdk/session/\n * product-locator`), or `null` when this session could not resolve a product\n * \u2014 in which case the page keeps the workbench's own web default and the\n * `served-modules` door is what says why there is no product.\n */\n readonly colorTheme: 'dark' | 'light' | null;\n readonly log: (line: string) => void;\n}\n\nexport interface FrameProxy {\n /** The url to open: this origin, on this project. */\n readonly url: string;\n close(): Promise<void>;\n}\n\n/**\n * The workbench's own paths. `<product segment>` (`/oss-dev` out of a source\n * checkout, the quality segment out of a build \u2014 `getServerProductSegment`) is\n * DISCOVERED at boot rather than spelled here, because the page states it.\n */\nconst VSCODE_PATH_BASE =\n '^\\\\/$|^\\\\/(static|favicon|manifest\\\\.json|code-192|code-512|callback|oauth|out|extensions|builtin|remote|vscode-remote|_static|web-extension|version|delay-shutdown)';\n\n/** What is worth compressing. Everything else goes through byte for byte. */\nconst COMPRESSIBLE =\n /^(?:text\\/|application\\/(?:javascript|json|wasm|manifest\\+json)|image\\/svg\\+xml)/;\n\n/** `"`-encoded HTML attribute value \u2194 JSON, the way webClientServer writes it. */\nconst decodeAttr = (value: string): string =>\n value\n .replaceAll('"', '\"')\n .replaceAll(''', \"'\")\n .replaceAll('<', '<')\n .replaceAll('>', '>')\n .replaceAll('&', '&');\nconst encodeAttr = (value: string): string =>\n value\n .replaceAll('&', '&')\n .replaceAll('\"', '"')\n .replaceAll(\"'\", ''')\n .replaceAll('<', '<')\n .replaceAll('>', '>');\n\n/**\n * Which encoding the browser asked for, in this proxy's order of preference.\n * `null` means \"send it as it is\" \u2014 no `accept-encoding`, or none we write.\n */\nfunction negotiateEncoding(header: string | string[] | undefined): 'br' | 'gzip' | null {\n const accepted = (Array.isArray(header) ? header.join(',') : (header ?? '')).toLowerCase();\n if (accepted.includes('br')) return 'br';\n if (accepted.includes('gzip')) return 'gzip';\n return null;\n}\n\n/**\n * Quality 5 rather than brotli's default 11. The default spends seconds on a\n * multi-megabyte workbench chunk and this proxy compresses on the request path,\n * with the person waiting; 5 is the knee where the ratio is nearly the same and\n * the CPU is not.\n */\nfunction compressor(encoding: 'br' | 'gzip'): Transform {\n return encoding === 'br'\n ? createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } })\n : createGzip({ level: 6 });\n}\n\nexport async function startFrameProxy(options: FrameProxyOptions): Promise<FrameProxy> {\n const { port, upstreamPort, sessionPort, projectRoot, colorTheme, log } = options;\n const upstream = { host: '127.0.0.1', port: upstreamPort };\n const session = { host: '127.0.0.1', port: sessionPort };\n const upstreamOrigin = `http://${upstream.host}:${upstream.port}`;\n const selfOrigin = `http://127.0.0.1:${port}`;\n const webviewOriginTemplate = `http://{{uuid}}.localhost:${port}`;\n const webviewOriginPattern = `http://*.localhost:${port}`;\n const projectId = basename(projectRoot);\n\n const isolation: Record<string, string> = {\n // The Code-OSS HTML comes from the upstream, not Express. Keep the\n // editor's profiling permission on the actual top-level document too.\n [JS_PROFILING_POLICY_HEADER]: JS_PROFILING_POLICY_VALUE,\n 'Cross-Origin-Opener-Policy': 'same-origin',\n 'Cross-Origin-Embedder-Policy': 'credentialless',\n 'Cross-Origin-Resource-Policy': 'cross-origin',\n };\n\n let vscodePath = new RegExp(VSCODE_PATH_BASE);\n\n /**\n * THE DOCUMENT'S OWN COLOUR, before it has any styles at all.\n *\n * `initialColorTheme` below fixes the WORKBENCH's first paint, and that is not the page's\n * first paint: measured 2026-09-21 on the live release, `html` and `body` compute\n * `rgba(0, 0, 0, 0)` \u2014 upstream's `workbench.css` styles `.monaco-workbench` and nothing\n * above it \u2014 so from the document's first frame until `Workbench.renderWorkbench` builds that\n * element, the canvas is the BROWSER's default, which is white. One frame of it survived F1's\n * first reading (frame 0 at 0.0 s white, frame 1 at 0.5 s already the product's splash).\n *\n * `color-scheme` is the platform's own answer to exactly that question, and it is why this is\n * a meta tag and not a background rule: it hands the browser the product's `dark`/`light`\n * declaration UNCHANGED and lets it paint its own dark canvas, so nothing here becomes a\n * second author of a palette value. The workbench sets `color-scheme` itself once its theme\n * loads; this only fills the window before that.\n */\n const colorSchemeMeta = (html: string): string =>\n colorTheme === null\n ? html\n : html.replace('<head>', `<head>\\n\\t\\t<meta name=\"color-scheme\" content=\"${colorTheme}\">`);\n\n /** Rewrite the workbench page's own configuration for THIS origin.\n *\n * - `remoteAuthority` needs NOTHING here, and that is worth stating: the REH\n * derives it from the request's HOST header (measured \u2014 a direct fetch to\n * the REH reports its own authority, the same fetch through this proxy\n * reports this one), so forwarding the original Host is already what makes\n * the management websocket, the `vscode-remote://` file system, every\n * `vscode-remote-resource` url AND the page's own CSP address this origin.\n * - `webviewEndpoint` is set (see the header).\n * - `initialColorTheme` is set (see below). */\n const patchWorkbenchConfig = (html: string): string => {\n const webBaseUrl =\n html.match(/id=\"vscode-workbench-web-base-url\" data-settings=\"([^\"]*)\"/)?.[1] ?? '/static';\n return html.replace(\n /(id=\"vscode-workbench-web-configuration\" data-settings=\")([^\"]*)(\")/,\n (_all, head: string, value: string, tail: string) => {\n const config = JSON.parse(decodeAttr(value)) as Record<string, unknown>;\n config['webviewEndpoint'] =\n `${webviewOriginTemplate}${decodeAttr(webBaseUrl)}/out/vs/workbench/contrib/webview/browser/pre/`;\n // THE FIRST PAINT IS THE PRODUCT'S, NOT THE WEB DEFAULT'S. Measured\n // 2026-09-21 against both releases: with nothing here the page's first\n // frame is LIGHT \u2014 `workbenchThemeService` falls back to\n // `isWeb ? ColorScheme.LIGHT : ColorScheme.DARK` when it has no stored\n // theme \u2014 so the product's opening cover, every colour of which is a\n // `--vscode-*` variable by design, was WHITE from the first frame until\n // the real theme loaded seconds later, and then flipped.\n //\n // `IWorkbenchConstructionOptions.initialColorTheme` is the supported\n // door and the REH leaves it unset, the same shape as `webviewEndpoint`\n // above. It has to be HERE and not in a setting or an extension: the\n // theme service reads it in its constructor, before any extension is\n // registered, which is exactly the window the cover lives in. The\n // themeType strings ARE `ColorScheme`'s values (`dark`, `light`).\n //\n // The colour comes from the PRODUCT's own declaration and nothing else\n // (`package.json#vgai.product.colorTheme`), and it is a FALLBACK in the\n // workbench's own order: a person who has already picked a theme has it\n // in storage, and `ColorThemeData.fromStorageData` is preferred over\n // this on every open after the first.\n if (colorTheme !== null) config['initialColorTheme'] = { themeType: colorTheme };\n return head + encodeAttr(JSON.stringify(config)) + tail;\n },\n );\n };\n\n const sendText = (res: ServerResponse, status: number, body: string, type: string): void => {\n res.writeHead(status, { ...isolation, 'content-type': type, 'cache-control': 'no-store' });\n res.end(body);\n };\n\n /** An unknown `?project=` says so by name rather than opening an empty workbench. */\n const refuseProject = (res: ServerResponse, id: string): void => {\n sendText(\n res,\n 404,\n `<!doctype html><meta charset=\"utf-8\"><title>no such project</title>` +\n `<body style=\"font:14px system-ui;padding:2rem\"><h1>No project \"${encodeAttr(id)}\" is open here.</h1>` +\n `<p>This workbench is the session for <code>${encodeAttr(projectRoot)}</code>, whose id is ` +\n `<code>${encodeAttr(projectId)}</code>.</p>`,\n 'text/html; charset=utf-8',\n );\n };\n\n const handleRequest = (req: IncomingMessage, res: ServerResponse): void => {\n const url = new URL(req.url ?? '/', selfOrigin);\n const pathname = url.pathname;\n // THE ONE HOSTED BOOT PARAM, ONTO THE WORKBENCH'S OWN. `?project=<id>` is\n // what a link to the vgai editor carries; the workbench's own boot contract\n // is `?folder=<path>` (`WorkspaceProvider.create`, which reads a bare path\n // against `remoteAuthority`). This is a REDIRECT and not an injected\n // `folderUri`, and the reason was measured (2026-09-19): under this shape\n // the workbench page is ALSO the vgai editor's page, and the editor's boot\n // refuses `?project=` on a session-backed surface by name \u2014 \"project\n // identity does NOT live in the URL on a local editor\"\n // (`assertNoRemovedBootParams`, packages/editor/src/boot-routing.ts). The\n // redirect hands the page a url both halves accept.\n if (pathname === '/') {\n const id = url.searchParams.get('project');\n if (id && !url.searchParams.has('folder') && !url.searchParams.has('workspace')) {\n if (id !== projectId) {\n refuseProject(res, id);\n return;\n }\n const next = new URL(url);\n next.searchParams.delete('project');\n next.searchParams.set('folder', projectRoot);\n res.writeHead(302, {\n ...isolation,\n location: `${next.pathname}${next.search}`,\n 'cache-control': 'no-store',\n });\n res.end();\n return;\n }\n }\n\n const target = vscodePath.test(pathname) ? upstream : session;\n const encoding = negotiateEncoding(req.headers['accept-encoding']);\n const forward = httpRequest(\n { ...target, method: req.method, path: req.url, headers: req.headers },\n (answer) => {\n const headers: Record<string, string | string[]> = {};\n for (const [key, value] of Object.entries(answer.headers)) {\n if (value !== undefined) headers[key] = value;\n }\n delete headers['cross-origin-opener-policy'];\n delete headers['cross-origin-embedder-policy'];\n delete headers['cross-origin-resource-policy'];\n // The webview's second origin, onto the REH's own `frame-src`.\n const csp = headers['content-security-policy'];\n if (typeof csp === 'string' && csp.includes('frame-src')) {\n // GLTFLoader fetches local blob textures; img-src alone cannot\n // authorize that fetch. Media previews use local blobs as well.\n headers['content-security-policy'] = csp\n .replace(/frame-src([^;]*)/, `frame-src$1 ${webviewOriginPattern}`)\n .replace(/connect-src([^;]*)/, 'connect-src$1 blob:')\n .replace(/media-src([^;]*)/, 'media-src$1 blob:');\n }\n const type = String(headers['content-type'] ?? '');\n const alreadyEncoded = headers['content-encoding'] !== undefined;\n // The workbench HTML and its config spell the upstream's OWN origin\n // into absolute script and endpoint URLs; through this origin those are\n // cross-origin and CORS refuses them. Text bodies are re-spelled here,\n // which needs the whole body \u2014 so this branch buffers and the streaming\n // one below does not.\n if (/text\\/html|javascript|json/.test(type) && !alreadyEncoded) {\n const chunks: Buffer[] = [];\n answer.on('data', (chunk: Buffer) => chunks.push(chunk));\n answer.on('end', () => {\n let body = Buffer.concat(chunks)\n .toString('utf8')\n .replaceAll(upstreamOrigin, selfOrigin);\n if (pathname === '/' && target === upstream && /text\\/html/.test(type)) {\n body = colorSchemeMeta(patchWorkbenchConfig(body));\n }\n delete headers['content-length'];\n const raw = Buffer.from(body, 'utf8');\n if (encoding === null) {\n res.writeHead(answer.statusCode ?? 502, {\n ...headers,\n ...isolation,\n 'content-length': raw.byteLength,\n });\n res.end(raw);\n return;\n }\n res.writeHead(answer.statusCode ?? 502, {\n ...headers,\n ...isolation,\n 'content-encoding': encoding,\n vary: 'accept-encoding',\n });\n const stream = compressor(encoding);\n stream.pipe(res);\n stream.end(raw);\n });\n return;\n }\n if (encoding !== null && !alreadyEncoded && COMPRESSIBLE.test(type)) {\n delete headers['content-length'];\n res.writeHead(answer.statusCode ?? 502, {\n ...headers,\n ...isolation,\n 'content-encoding': encoding,\n vary: 'accept-encoding',\n });\n void pipeline(answer, compressor(encoding), res).catch(() => res.destroy());\n return;\n }\n res.writeHead(answer.statusCode ?? 502, { ...headers, ...isolation });\n answer.pipe(res);\n },\n );\n forward.on('error', (error) => {\n if (!res.headersSent) {\n sendText(\n res,\n 502,\n `upstream ${target.port}: ${error.message}`,\n 'text/plain; charset=utf-8',\n );\n } else res.destroy();\n });\n // A browser that goes away takes its upstream request with it \u2014 otherwise a\n // dead tab's event stream stays open, this session keeps that tab blessed,\n // and EVERY later tab lands on `/__editor/tab-yielded` (measured\n // 2026-09-19 in the session journal).\n res.on('close', () => forward.destroy());\n req.pipe(forward);\n };\n\n const handleUpgrade = (req: IncomingMessage, socket: Socket, head: Buffer): void => {\n const target = vscodePath.test(new URL(req.url ?? '/', selfOrigin).pathname)\n ? upstream\n : session;\n const proxied = netConnect(target.port, target.host, () => {\n proxied.write(\n `${req.method} ${req.url} HTTP/${req.httpVersion}\\r\\n` +\n Object.entries(req.headers)\n .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}`)\n .join('\\r\\n') +\n '\\r\\n\\r\\n',\n );\n if (head.length) proxied.write(head);\n socket.pipe(proxied).pipe(socket);\n });\n proxied.on('error', () => socket.destroy());\n socket.on('error', () => proxied.destroy());\n };\n\n /**\n * Wait for the upstream to report its shape before accepting requests. The REH serves its static\n * assets under a PRODUCT SEGMENT (`/oss-dev/static` out of a source checkout,\n * `/<quality>/static` out of a build), and this cannot route without knowing\n * it. The page itself states it in `vscode-workbench-web-base-url`, so it is\n * read rather than spelled.\n */\n const discoverUpstream = (): Promise<string | null> =>\n new Promise((done) => {\n const probe = httpRequest({ ...upstream, path: '/' }, (answer) => {\n const chunks: Buffer[] = [];\n answer.on('data', (chunk: Buffer) => chunks.push(chunk));\n answer.on('end', () => {\n const html = Buffer.concat(chunks).toString('utf8');\n const base = html.match(\n /id=\"vscode-workbench-web-base-url\" data-settings=\"([^\"]*)\"/,\n )?.[1];\n done(answer.statusCode === 200 && base ? decodeAttr(base).replace(/\\/static\\/?$/, '') : null);\n });\n });\n probe.on('error', () => done(null));\n probe.setTimeout(3_000, () => {\n probe.destroy();\n done(null);\n });\n probe.end();\n });\n\n // A listening socket is not proof that Code-OSS can serve its HTML yet.\n // Never expose a half-configured proxy: unknown prefixes route native JS to\n // the session server and produce a blank workbench with script 404s.\n const discoveryDeadline = Date.now() + 30_000;\n let segment: string | null = null;\n do {\n segment = await discoverUpstream();\n if (segment !== null) break;\n await new Promise(resolve => setTimeout(resolve, 100));\n } while (Date.now() < discoveryDeadline);\n if (segment === null) throw new Error(\n `Code-OSS on port ${upstream.port} did not report its workbench asset base within 30s; refusing to start an incorrectly routed proxy.`,\n );\n if (segment) {\n vscodePath = new RegExp(\n `${VSCODE_PATH_BASE}|^${segment.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(\\\\/|$)`,\n );\n log(`frame: workbench product segment ${segment}`);\n }\n\n // ONE handler, TWO loopback listeners \u2014 127.0.0.1 and ::1. The webview's\n // origin is a `*.localhost` subdomain (see the header), and macOS answers\n // `<anything>.localhost` with the IPv6 `::1` as well as 127.0.0.1\n // (`dscacheutil -q host -a name x.localhost`); a browser that picks the IPv6\n // address finds nothing listening and the webview iframe fails as\n // `chrome-error://chromewebdata` with no message at all \u2014 measured\n // 2026-09-19 against a proxy bound to 127.0.0.1 alone.\n const servers: Server[] = [];\n await new Promise<void>((ready, failed) => {\n let settled = false;\n for (const host of ['127.0.0.1', '::1']) {\n const one = createServer(handleRequest);\n one.on('upgrade', handleUpgrade);\n one.on('error', (error) => {\n if (host === '::1') {\n log(\n `frame: no IPv6 loopback listener (${error.message}); webviews need one if the browser resolves *.localhost to ::1`,\n );\n return;\n }\n if (!settled) {\n settled = true;\n failed(error);\n }\n });\n servers.push(one);\n one.listen(port, host, () => {\n if (!settled) {\n settled = true;\n ready();\n }\n });\n }\n });\n\n return {\n url: `${selfOrigin}/?project=${encodeURIComponent(projectId)}`,\n close: async () => {\n await Promise.all(\n servers.map(\n (one) =>\n new Promise<void>((done) => {\n one.close(() => done());\n one.closeAllConnections?.();\n }),\n ),\n );\n },\n };\n}\n", "/**\n * `Document-Policy: js-profiling` \u2014 the permission the editor's own diagnostics\n * assume it already has.\n *\n * The `visible-tab-fps-floor` invariant tells the reader, by name, to \"find the\n * cost with the Profiler's Overview (long-animation-frame attribution)\"\n * (`coverage/ontology-invariants.ts`). But `new Profiler(...)` in the editor\n * page throws `NotAllowedError: JS profiling is disabled by Document Policy`:\n * the JS Self-Profiling API is gated on a policy the DOCUMENT's own response\n * has to grant, and no editor server was sending it. The product printed an\n * instruction its own page forbade.\n *\n * A document policy is granted by the response that created the document, so\n * this is a response header or it is nothing \u2014 there is no client-side way to\n * turn it on. It is set on EVERY response rather than on a sniffed subset:\n * the header is meaningless on a subresource (browsers read it only when\n * creating a document), the editor serves its index through three different\n * paths (`express.static`, the SPA `sendFile` fallback, and Vite's own HTML\n * middleware in dev), and a heuristic that tried to name \"the document one\"\n * would silently miss whichever path a future change routed through. Blanket\n * is the version that cannot be quietly wrong.\n *\n * It also reaches the game's own frame this way, which is the point: a game\n * mounted in a child document is exactly what the reader is profiling.\n */\n\n/** The header name and value, together, so a test names them once. */\nexport const JS_PROFILING_POLICY_HEADER = 'Document-Policy';\nexport const JS_PROFILING_POLICY_VALUE = 'js-profiling';\n\ninterface HeaderSink {\n setHeader(name: string, value: string): unknown;\n}\n\n/**\n * Express middleware granting {@link JS_PROFILING_POLICY_VALUE} to every\n * document this server creates.\n *\n * Typed against the narrowest shape it uses (`setHeader`) so the unit test can\n * drive it without an HTTP server, and so both editor hosts can mount it\n * without agreeing on an express version.\n */\nexport function jsProfilingPolicy(): (\n request: unknown,\n response: HeaderSink,\n next: () => void,\n) => void {\n return (_request, response, next) => {\n response.setHeader(JS_PROFILING_POLICY_HEADER, JS_PROFILING_POLICY_VALUE);\n next();\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist-server/packaged.mjs
CHANGED
|
@@ -8390,6 +8390,7 @@ var alwaysRefresh = (timeoutMs = DEFAULT_RELAY_COMMAND_TIMEOUT_MS) => ({
|
|
|
8390
8390
|
derivedRefresh: "always"
|
|
8391
8391
|
});
|
|
8392
8392
|
var RELAY_COMMANDS = {
|
|
8393
|
+
"session-prepare-close": noDerivedRefresh(12e4),
|
|
8393
8394
|
// ---- Selection and framing ---------------------------------------------
|
|
8394
8395
|
select: noDerivedRefresh(3e4),
|
|
8395
8396
|
"select-multiple": noDerivedRefresh(),
|
|
@@ -80769,7 +80770,9 @@ import { brotliDecompressSync } from "node:zlib";
|
|
|
80769
80770
|
var BLENDER_WASM_FILES = [
|
|
80770
80771
|
"blender_browser.js",
|
|
80771
80772
|
"blender_browser.wasm",
|
|
80772
|
-
"blender_browser.data"
|
|
80773
|
+
"blender_browser.data",
|
|
80774
|
+
"essentials.json",
|
|
80775
|
+
"essentials.bin"
|
|
80773
80776
|
];
|
|
80774
80777
|
var BLENDER_WALI_ARTIFACT = "blender.wasm";
|
|
80775
80778
|
var BLENDER_WALI_RUNTIME = "runtime";
|
|
@@ -86495,6 +86498,18 @@ import { connect as netConnect } from "node:net";
|
|
|
86495
86498
|
import { basename as basename13 } from "node:path";
|
|
86496
86499
|
import { pipeline as pipeline2 } from "node:stream/promises";
|
|
86497
86500
|
import { createBrotliCompress, createGzip, constants as zlibConstants } from "node:zlib";
|
|
86501
|
+
|
|
86502
|
+
// server/js-profiling-policy.ts
|
|
86503
|
+
var JS_PROFILING_POLICY_HEADER = "Document-Policy";
|
|
86504
|
+
var JS_PROFILING_POLICY_VALUE = "js-profiling";
|
|
86505
|
+
function jsProfilingPolicy() {
|
|
86506
|
+
return (_request, response, next) => {
|
|
86507
|
+
response.setHeader(JS_PROFILING_POLICY_HEADER, JS_PROFILING_POLICY_VALUE);
|
|
86508
|
+
next();
|
|
86509
|
+
};
|
|
86510
|
+
}
|
|
86511
|
+
|
|
86512
|
+
// server/frame-proxy.ts
|
|
86498
86513
|
var VSCODE_PATH_BASE = "^\\/$|^\\/(static|favicon|manifest\\.json|code-192|code-512|callback|oauth|out|extensions|builtin|remote|vscode-remote|_static|web-extension|version|delay-shutdown)";
|
|
86499
86514
|
var COMPRESSIBLE = /^(?:text\/|application\/(?:javascript|json|wasm|manifest\+json)|image\/svg\+xml)/;
|
|
86500
86515
|
var decodeAttr = (value) => value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
@@ -86518,6 +86533,9 @@ async function startFrameProxy(options) {
|
|
|
86518
86533
|
const webviewOriginPattern = `http://*.localhost:${port}`;
|
|
86519
86534
|
const projectId = basename13(projectRoot);
|
|
86520
86535
|
const isolation = {
|
|
86536
|
+
// The Code-OSS HTML comes from the upstream, not Express. Keep the
|
|
86537
|
+
// editor's profiling permission on the actual top-level document too.
|
|
86538
|
+
[JS_PROFILING_POLICY_HEADER]: JS_PROFILING_POLICY_VALUE,
|
|
86521
86539
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
86522
86540
|
"Cross-Origin-Embedder-Policy": "credentialless",
|
|
86523
86541
|
"Cross-Origin-Resource-Policy": "cross-origin"
|
|
@@ -87043,16 +87061,6 @@ function createIdleShutdown(options) {
|
|
|
87043
87061
|
};
|
|
87044
87062
|
}
|
|
87045
87063
|
|
|
87046
|
-
// server/js-profiling-policy.ts
|
|
87047
|
-
var JS_PROFILING_POLICY_HEADER = "Document-Policy";
|
|
87048
|
-
var JS_PROFILING_POLICY_VALUE = "js-profiling";
|
|
87049
|
-
function jsProfilingPolicy() {
|
|
87050
|
-
return (_request, response, next) => {
|
|
87051
|
-
response.setHeader(JS_PROFILING_POLICY_HEADER, JS_PROFILING_POLICY_VALUE);
|
|
87052
|
-
next();
|
|
87053
|
-
};
|
|
87054
|
-
}
|
|
87055
|
-
|
|
87056
87064
|
// server/managed-account-defaults.ts
|
|
87057
87065
|
var DEFAULT_VGAI_ACCOUNT_URL = "https://auth.videogame.ai";
|
|
87058
87066
|
function configureManagedAccountDefaults() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/editor-core",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.59",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -82,9 +82,9 @@
|
|
|
82
82
|
"three.quarks": "0.16.0",
|
|
83
83
|
"typescript": "5.9.3",
|
|
84
84
|
"zod": "4.3.6",
|
|
85
|
-
"@volter/editor-project": "0.5.
|
|
86
|
-
"@volter/editor-sdk": "0.5.
|
|
87
|
-
"@volter/editor-threejs": "0.5.
|
|
85
|
+
"@volter/editor-project": "0.5.59",
|
|
86
|
+
"@volter/editor-sdk": "0.5.59",
|
|
87
|
+
"@volter/editor-threejs": "0.5.59",
|
|
88
88
|
"vite": "^6.4.3",
|
|
89
89
|
"express": "^5.2.1",
|
|
90
90
|
"chokidar": "^5.0.0",
|
|
@@ -61,11 +61,13 @@ import { brotliDecompressSync } from 'node:zlib';
|
|
|
61
61
|
|
|
62
62
|
export type BlenderSkew = 'emscripten' | 'wali';
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/** Engine artifacts plus the separately packaged Essentials asset payload. */
|
|
65
65
|
export const BLENDER_WASM_FILES = [
|
|
66
66
|
'blender_browser.js',
|
|
67
67
|
'blender_browser.wasm',
|
|
68
68
|
'blender_browser.data',
|
|
69
|
+
'essentials.json',
|
|
70
|
+
'essentials.bin',
|
|
69
71
|
] as const;
|
|
70
72
|
|
|
71
73
|
export type BlenderWasmFile = (typeof BLENDER_WASM_FILES)[number];
|
package/server/frame-proxy.ts
CHANGED
|
@@ -63,6 +63,7 @@ import { basename } from 'node:path';
|
|
|
63
63
|
import type { Transform } from 'node:stream';
|
|
64
64
|
import { pipeline } from 'node:stream/promises';
|
|
65
65
|
import { createBrotliCompress, createGzip, constants as zlibConstants } from 'node:zlib';
|
|
66
|
+
import { JS_PROFILING_POLICY_HEADER, JS_PROFILING_POLICY_VALUE } from './js-profiling-policy';
|
|
66
67
|
|
|
67
68
|
export interface FrameProxyOptions {
|
|
68
69
|
/** The one port a person opens: this proxy's. */
|
|
@@ -152,6 +153,9 @@ export async function startFrameProxy(options: FrameProxyOptions): Promise<Frame
|
|
|
152
153
|
const projectId = basename(projectRoot);
|
|
153
154
|
|
|
154
155
|
const isolation: Record<string, string> = {
|
|
156
|
+
// The Code-OSS HTML comes from the upstream, not Express. Keep the
|
|
157
|
+
// editor's profiling permission on the actual top-level document too.
|
|
158
|
+
[JS_PROFILING_POLICY_HEADER]: JS_PROFILING_POLICY_VALUE,
|
|
155
159
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
156
160
|
'Cross-Origin-Embedder-Policy': 'credentialless',
|
|
157
161
|
'Cross-Origin-Resource-Policy': 'cross-origin',
|
package/src/action-registry.ts
CHANGED
|
@@ -211,14 +211,14 @@ export function buildStaticActions(
|
|
|
211
211
|
label: 'Duplicate',
|
|
212
212
|
category: 'action',
|
|
213
213
|
shortcut: shortcutFor('edit.duplicate'),
|
|
214
|
-
execute: () =>
|
|
214
|
+
execute: () => duplicateSelection(store),
|
|
215
215
|
},
|
|
216
216
|
{
|
|
217
217
|
id: 'editor.delete',
|
|
218
218
|
label: 'Delete Selected',
|
|
219
219
|
category: 'action',
|
|
220
220
|
shortcut: shortcutFor('edit.delete'),
|
|
221
|
-
execute: () =>
|
|
221
|
+
execute: () => deleteSelection(store),
|
|
222
222
|
},
|
|
223
223
|
{
|
|
224
224
|
id: 'editor.select-all',
|
|
@@ -320,6 +320,17 @@ export function duplicateAuthoringNode(
|
|
|
320
320
|
);
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
export function duplicateManyAuthoringNodes(
|
|
324
|
+
adapter: AuthoringAdapter,
|
|
325
|
+
nodeIds: readonly string[],
|
|
326
|
+
): StructuralWriteOutcome {
|
|
327
|
+
const provider = adapter.structure;
|
|
328
|
+
if (!provider?.duplicateMany) return;
|
|
329
|
+
return structureWrite(adapter, 'duplicateMany',
|
|
330
|
+
`the editor duplicated ${nodeIds.length} selected node(s) as one operation`,
|
|
331
|
+
() => provider.duplicateMany?.(nodeIds));
|
|
332
|
+
}
|
|
333
|
+
|
|
323
334
|
export function reparentAuthoringNode(
|
|
324
335
|
adapter: AuthoringAdapter,
|
|
325
336
|
nodeId: string,
|
package/src/command-listener.ts
CHANGED
|
@@ -99,6 +99,7 @@ import { setPlayBootPhaseReporter } from './play-boot-phase';
|
|
|
99
99
|
import { projectAdapterFacet, subscribeProjectAdapter } from './project-adapter';
|
|
100
100
|
import { getProjectModuleSplitReports } from './project-module-split';
|
|
101
101
|
import { onSessionEndedChange, sessionEndedRefusal, sessionEndedState } from './session-tombstone';
|
|
102
|
+
import { prepareSessionClose } from './session-close';
|
|
102
103
|
import { focusedStageStore } from './stage-context';
|
|
103
104
|
import { scheduleDeferredFullReport } from './state-report-deferral';
|
|
104
105
|
import { interactiveViewportRendererCounts } from './three-viewport/interactive-renderer';
|
|
@@ -139,6 +140,7 @@ import {
|
|
|
139
140
|
createAuthoringNode,
|
|
140
141
|
cutAuthoringNodes,
|
|
141
142
|
duplicateAuthoringNode,
|
|
143
|
+
duplicateManyAuthoringNodes,
|
|
142
144
|
groupAuthoringNodes,
|
|
143
145
|
pasteAuthoringNodes,
|
|
144
146
|
removeAuthoringNode,
|
|
@@ -1259,6 +1261,9 @@ export async function handleCommand(
|
|
|
1259
1261
|
}
|
|
1260
1262
|
const commandType: RelayCommandType = cmd['type'];
|
|
1261
1263
|
switch (commandType) {
|
|
1264
|
+
case 'session-prepare-close':
|
|
1265
|
+
await prepareSessionClose();
|
|
1266
|
+
return { ok: true };
|
|
1262
1267
|
// Selection
|
|
1263
1268
|
case 'select': {
|
|
1264
1269
|
const id = (cmd['id'] as string | null) ?? null;
|
|
@@ -1943,6 +1948,10 @@ export async function handleCommand(
|
|
|
1943
1948
|
return { ok: true, data: { write } };
|
|
1944
1949
|
}
|
|
1945
1950
|
case 'duplicate': {
|
|
1951
|
+
if (ids.length > 1 && structure.duplicateMany) {
|
|
1952
|
+
const write = await duplicateManyAuthoringNodes(adapter, ids);
|
|
1953
|
+
return { ok: true, data: { write } };
|
|
1954
|
+
}
|
|
1946
1955
|
const copy = duplicateAuthoringNode(adapter, needsId());
|
|
1947
1956
|
return { ok: true, data: { id: copy.id, write: await copy.ack } };
|
|
1948
1957
|
}
|
package/src/editor-host-door.ts
CHANGED
|
@@ -38,6 +38,10 @@ import { setFilesProvider } from './files/file-provider';
|
|
|
38
38
|
import { projectFiles } from './files/project-files';
|
|
39
39
|
import {
|
|
40
40
|
onHistoryElement,
|
|
41
|
+
invalidateHistoryResources,
|
|
42
|
+
onHistoryInvalidated,
|
|
43
|
+
notifyHistoryDelegateChanged,
|
|
44
|
+
emitHistoryElement,
|
|
41
45
|
recordedHistoryElements,
|
|
42
46
|
setHistoryDelegate,
|
|
43
47
|
setHistoryDocumentResolver,
|
|
@@ -98,6 +102,7 @@ import { getCurrentProject } from './project-manager';
|
|
|
98
102
|
import { onProjectReady } from './project-ready';
|
|
99
103
|
import { projectMounts } from './project-shape';
|
|
100
104
|
import { onSessionEndedChange } from './session-tombstone';
|
|
105
|
+
import { onBeforeSessionClose } from './session-close';
|
|
101
106
|
import { setSettingsProvider, subscribeSettingsProvider } from './settings/settings-provider';
|
|
102
107
|
import { getSetting, inspectSetting, setSetting, subscribeSettings } from './settings-store';
|
|
103
108
|
import { onShellStoreChange, shellStoreForHost } from './shell-store-door';
|
|
@@ -250,6 +255,7 @@ export function installEditorHostDoor(): void {
|
|
|
250
255
|
onSessionEndedChange((state) => {
|
|
251
256
|
if (state !== null) fn();
|
|
252
257
|
}),
|
|
258
|
+
onBeforeClose: onBeforeSessionClose,
|
|
253
259
|
reportWorkerCallMeter: setBlenderCallMeter,
|
|
254
260
|
},
|
|
255
261
|
project: {
|
|
@@ -332,8 +338,12 @@ export function installEditorHostDoor(): void {
|
|
|
332
338
|
// keyed by the document's own file; `history-service.ts` stays the
|
|
333
339
|
// RECORDER and stops deciding which entry is next.
|
|
334
340
|
history: {
|
|
341
|
+
record: emitHistoryElement,
|
|
335
342
|
setDelegate: setHistoryDelegate,
|
|
336
343
|
onElement: onHistoryElement,
|
|
344
|
+
invalidate: invalidateHistoryResources,
|
|
345
|
+
onInvalidated: onHistoryInvalidated,
|
|
346
|
+
changed: notifyHistoryDelegateChanged,
|
|
337
347
|
elements: recordedHistoryElements,
|
|
338
348
|
focusedResource: () => {
|
|
339
349
|
const document = activeWorkspaceDocument();
|
package/src/editor-hotkeys.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
copyAuthoringNodes,
|
|
4
4
|
cutAuthoringNodes,
|
|
5
5
|
duplicateAuthoringNode,
|
|
6
|
+
duplicateManyAuthoringNodes,
|
|
6
7
|
groupAuthoringNodes,
|
|
7
8
|
pasteAuthoringNodes,
|
|
8
9
|
removeAuthoringNode,
|
|
@@ -216,7 +217,21 @@ async function runDuplicateSelection(store: EditorShellStore): Promise<void> {
|
|
|
216
217
|
? (adapter.hierarchy.node(selection[0] as string)?.label ?? 'selection')
|
|
217
218
|
: `${selection.length} objects`;
|
|
218
219
|
showTransientHint(`Duplicating ${label}…`);
|
|
219
|
-
|
|
220
|
+
if (structure.duplicateMany) {
|
|
221
|
+
const ack = await duplicateManyAuthoringNodes(adapter, selection);
|
|
222
|
+
if (ack && !ack.persisted) {
|
|
223
|
+
showTransientHint(ack.destination);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
for (const id of selection) {
|
|
228
|
+
const ack = await duplicateAuthoringNode(adapter, id).ack;
|
|
229
|
+
if (ack && !ack.persisted) {
|
|
230
|
+
showTransientHint(ack.destination);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
220
235
|
showTransientHint(`Duplicated ${label}`);
|
|
221
236
|
}
|
|
222
237
|
|
package/src/frame/bridge.tsx
CHANGED
|
@@ -200,15 +200,19 @@ export interface VgaiKeyboardHandle {
|
|
|
200
200
|
export interface VgaiHistoryHandle {
|
|
201
201
|
elements(): readonly VgaiHistoryElementHandle[];
|
|
202
202
|
onElement(listener: (element: VgaiHistoryElementHandle) => void): () => void;
|
|
203
|
+
onInvalidated(listener: (resources: readonly string[]) => void): () => void;
|
|
204
|
+
changed(): void;
|
|
203
205
|
focusedResource(): string | null;
|
|
204
206
|
focusedDocument(): { id: string; label: string } | null;
|
|
205
207
|
/** Hand the frame's OWN undo to the editor, so its Edit menu, palette and
|
|
206
208
|
* `vgai eval` reach the one stack instead of a cursor nobody drives. */
|
|
207
209
|
setDelegate(delegate: {
|
|
208
|
-
undo(): void
|
|
209
|
-
redo(): void
|
|
210
|
+
undo(): void | boolean | Promise<void | boolean>;
|
|
211
|
+
redo(): void | boolean | Promise<void | boolean>;
|
|
210
212
|
canUndo(): boolean;
|
|
211
213
|
canRedo(): boolean;
|
|
214
|
+
undoLabel?(): string | null;
|
|
215
|
+
redoLabel?(): string | null;
|
|
212
216
|
}): void;
|
|
213
217
|
report(level: 'warn' | 'error', message: string): void;
|
|
214
218
|
}
|
|
@@ -1181,6 +1185,8 @@ export async function mountEditor(next: VscodeParts): Promise<{
|
|
|
1181
1185
|
const history: VgaiHistoryHandle = {
|
|
1182
1186
|
elements: () => editorHost().history.elements(),
|
|
1183
1187
|
onElement: (listener) => editorHost().history.onElement(listener),
|
|
1188
|
+
onInvalidated: (listener) => editorHost().history.onInvalidated(listener),
|
|
1189
|
+
changed: () => editorHost().history.changed(),
|
|
1184
1190
|
focusedResource: () => editorHost().history.focusedResource(),
|
|
1185
1191
|
// The document's TITLE is what a refusal should say ("Nothing to undo in
|
|
1186
1192
|
// MainScene"), and the id is what scopes the stack; both come from the
|
|
@@ -80,10 +80,12 @@ export interface HistoryElement {
|
|
|
80
80
|
* is worse than one that is absent.
|
|
81
81
|
*/
|
|
82
82
|
export interface HistoryDelegate {
|
|
83
|
-
undo(): void
|
|
84
|
-
redo(): void
|
|
83
|
+
undo(): void | boolean | Promise<void | boolean>;
|
|
84
|
+
redo(): void | boolean | Promise<void | boolean>;
|
|
85
85
|
canUndo(): boolean;
|
|
86
86
|
canRedo(): boolean;
|
|
87
|
+
undoLabel?(): string | null;
|
|
88
|
+
redoLabel?(): string | null;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
let delegate: HistoryDelegate | null = null;
|
|
@@ -93,6 +95,7 @@ let delegate: HistoryDelegate | null = null;
|
|
|
93
95
|
let documentResolver: (() => string | null) | null = null;
|
|
94
96
|
const ownerListeners = new Set<() => void>();
|
|
95
97
|
const elementListeners = new Set<(element: HistoryElement) => void>();
|
|
98
|
+
const invalidationListeners = new Set<(resources: readonly string[]) => void>();
|
|
96
99
|
/** Everything recorded while a delegate was installed, so a LATE subscriber
|
|
97
100
|
* (the bridge mounts after the editor's first edits are possible) can catch
|
|
98
101
|
* up rather than start with a stack that is missing its beginning. */
|
|
@@ -131,16 +134,24 @@ export function subscribeHistoryDelegate(listener: () => void): () => void {
|
|
|
131
134
|
return () => ownerListeners.delete(listener);
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
export function notifyHistoryDelegateChanged(): void {
|
|
138
|
+
for (const listener of ownerListeners) listener();
|
|
139
|
+
}
|
|
140
|
+
|
|
134
141
|
/**
|
|
135
142
|
* Offer one committed transaction to the frame. Recorded even before the
|
|
136
143
|
* delegate arrives — that buffer is what lets a frame that mounts after the
|
|
137
144
|
* editor's first edits push what it missed, in order, rather than start with a
|
|
138
145
|
* stack missing its beginning.
|
|
139
146
|
*/
|
|
140
|
-
export function emitHistoryElement(element: Omit<HistoryElement, 'document'>): void {
|
|
141
|
-
const stamped: HistoryElement = {
|
|
147
|
+
export function emitHistoryElement(element: Omit<HistoryElement, 'document'> & { document?: string | null }): void {
|
|
148
|
+
const stamped: HistoryElement = {
|
|
149
|
+
...element,
|
|
150
|
+
document: element.document === undefined ? documentResolver?.() ?? null : element.document,
|
|
151
|
+
};
|
|
142
152
|
recorded.push(stamped);
|
|
143
153
|
for (const listener of elementListeners) listener(stamped);
|
|
154
|
+
for (const listener of ownerListeners) listener();
|
|
144
155
|
}
|
|
145
156
|
|
|
146
157
|
/** Install the reader of "which document is active". Called once by the host
|
|
@@ -161,3 +172,16 @@ export function onHistoryElement(listener: (element: HistoryElement) => void): (
|
|
|
161
172
|
elementListeners.add(listener);
|
|
162
173
|
return () => elementListeners.delete(listener);
|
|
163
174
|
}
|
|
175
|
+
|
|
176
|
+
/** A native document was replaced or its snapshot owner was closed. */
|
|
177
|
+
export function invalidateHistoryResources(resources: readonly string[]): void {
|
|
178
|
+
const expired = new Set(resources);
|
|
179
|
+
recorded = recorded.filter(element => !element.resources.some(path => expired.has(path)));
|
|
180
|
+
for (const listener of invalidationListeners) listener(resources);
|
|
181
|
+
for (const listener of ownerListeners) listener();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function onHistoryInvalidated(listener: (resources: readonly string[]) => void): () => void {
|
|
185
|
+
invalidationListeners.add(listener);
|
|
186
|
+
return () => invalidationListeners.delete(listener);
|
|
187
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { emitHistoryElement, historyDelegate, historyDelegateInstalled } from './history-delegate';
|
|
1
|
+
import { emitHistoryElement, historyDelegate, historyDelegateInstalled, subscribeHistoryDelegate } from './history-delegate';
|
|
2
2
|
import { PersistenceCoordinator } from './persistence-coordinator';
|
|
3
3
|
import { ResourceRegistry } from './resource-registry';
|
|
4
4
|
import { SnapshotStore } from './snapshot-store';
|
|
@@ -236,6 +236,7 @@ export class HistoryService {
|
|
|
236
236
|
private droppedTotal = 0;
|
|
237
237
|
private executionTail: Promise<void> = Promise.resolve();
|
|
238
238
|
private publicSnapshot: HistorySnapshot;
|
|
239
|
+
private readonly unsubscribeOwner: () => void;
|
|
239
240
|
|
|
240
241
|
constructor(options: HistoryServiceOptions = {}) {
|
|
241
242
|
this.registry = options.registry ?? new ResourceRegistry();
|
|
@@ -257,6 +258,7 @@ export class HistoryService {
|
|
|
257
258
|
throw new Error('History limits must be positive.');
|
|
258
259
|
}
|
|
259
260
|
this.publicSnapshot = this.buildSnapshot();
|
|
261
|
+
this.unsubscribeOwner = subscribeHistoryDelegate(() => this.notify());
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
subscribe = (listener: () => void): (() => void) => {
|
|
@@ -265,7 +267,17 @@ export class HistoryService {
|
|
|
265
267
|
return () => this.listeners.delete(listener);
|
|
266
268
|
};
|
|
267
269
|
|
|
268
|
-
getSnapshot = (): HistorySnapshot =>
|
|
270
|
+
getSnapshot = (): HistorySnapshot => {
|
|
271
|
+
// Focus may move between documents without changing any history entry.
|
|
272
|
+
// Keep the external-store snapshot stable unless the native answer changes.
|
|
273
|
+
if (historyDelegateInstalled()) {
|
|
274
|
+
const next = this.buildSnapshot();
|
|
275
|
+
if (next.canUndo !== this.publicSnapshot.canUndo || next.canRedo !== this.publicSnapshot.canRedo ||
|
|
276
|
+
next.undoLabel !== this.publicSnapshot.undoLabel || next.redoLabel !== this.publicSnapshot.redoLabel)
|
|
277
|
+
this.publicSnapshot = next;
|
|
278
|
+
}
|
|
279
|
+
return this.publicSnapshot;
|
|
280
|
+
};
|
|
269
281
|
|
|
270
282
|
subscribeCommits(listener: (transaction: HistoryTransaction) => void): () => void {
|
|
271
283
|
this.assertNotDisposed();
|
|
@@ -447,15 +459,18 @@ export class HistoryService {
|
|
|
447
459
|
* ONE stack instead of walking a cursor nobody is driving. A frame that has
|
|
448
460
|
* not installed its undo yet gets a named refusal rather than silence.
|
|
449
461
|
*/
|
|
450
|
-
private delegateOrRefuse(direction: 'undo' | 'redo'): boolean {
|
|
462
|
+
private async delegateOrRefuse(direction: 'undo' | 'redo'): Promise<boolean> {
|
|
451
463
|
const delegate = historyDelegate();
|
|
452
464
|
if (delegate) {
|
|
453
465
|
// A handled keyboard command is not evidence that history moved. In
|
|
454
466
|
// particular, a native document may have no entry on Code-OSS's stack.
|
|
455
467
|
if (!(direction === 'undo' ? delegate.canUndo() : delegate.canRedo())) return false;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
468
|
+
try {
|
|
469
|
+
const moved = direction === 'undo' ? await delegate.undo() : await delegate.redo();
|
|
470
|
+
return moved !== false;
|
|
471
|
+
} finally {
|
|
472
|
+
this.notify();
|
|
473
|
+
}
|
|
459
474
|
}
|
|
460
475
|
this.lastErrorValue = historyError(
|
|
461
476
|
'busy',
|
|
@@ -634,6 +649,7 @@ export class HistoryService {
|
|
|
634
649
|
dispose(): void {
|
|
635
650
|
if (this.disposeRequested) return;
|
|
636
651
|
this.disposeRequested = true;
|
|
652
|
+
this.unsubscribeOwner();
|
|
637
653
|
this.listeners.clear();
|
|
638
654
|
this.commitListeners.clear();
|
|
639
655
|
this.finishDisposeIfIdle();
|
|
@@ -1162,8 +1178,8 @@ export class HistoryService {
|
|
|
1162
1178
|
canRedo: historyDelegateInstalled()
|
|
1163
1179
|
? (historyDelegate()?.canRedo() ?? false)
|
|
1164
1180
|
: idle && !!redo && redo.status !== 'expired' && !this.blockedValue,
|
|
1165
|
-
undoLabel: undo?.label ?? null,
|
|
1166
|
-
redoLabel: redo?.label ?? null,
|
|
1181
|
+
undoLabel: historyDelegateInstalled() ? historyDelegate()?.undoLabel?.() ?? null : undo?.label ?? null,
|
|
1182
|
+
redoLabel: historyDelegateInstalled() ? historyDelegate()?.redoLabel?.() ?? null : redo?.label ?? null,
|
|
1167
1183
|
uniqueSnapshotBytes: this.snapshots.uniqueByteLength,
|
|
1168
1184
|
lastError: this.lastErrorValue,
|
|
1169
1185
|
limitWarning: this.limitWarningValue,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Save barriers run while the server and command relay are still live.
|
|
2
|
+
* Never put them in onEnded: that signal is forced resource teardown. */
|
|
3
|
+
const handlers = new Set<() => Promise<void>>();
|
|
4
|
+
let preparing: Promise<void> | null = null;
|
|
5
|
+
|
|
6
|
+
export function onBeforeSessionClose(handler: () => Promise<void>): () => void {
|
|
7
|
+
handlers.add(handler);
|
|
8
|
+
return () => { handlers.delete(handler); };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function prepareSessionClose(): Promise<void> {
|
|
12
|
+
if (preparing) return preparing;
|
|
13
|
+
preparing = Promise.all([...handlers].map(handler => Promise.resolve().then(handler)))
|
|
14
|
+
.then(() => undefined)
|
|
15
|
+
.finally(() => { preparing = null; });
|
|
16
|
+
return preparing;
|
|
17
|
+
}
|