@pix3/vite-plugin 0.0.0-stage → 2.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +38 -3
  3. package/dist/build/bundle-hooks.d.ts +23 -0
  4. package/dist/build/bundle-hooks.js +79 -0
  5. package/dist/build/classic-script.d.ts +11 -0
  6. package/dist/build/classic-script.js +46 -0
  7. package/dist/build/compress.d.ts +29 -0
  8. package/dist/build/compress.js +63 -0
  9. package/dist/build/dependency-imports.d.ts +53 -0
  10. package/dist/build/dependency-imports.js +239 -0
  11. package/dist/build/dev-manifest.d.ts +10 -0
  12. package/dist/build/dev-manifest.js +25 -0
  13. package/dist/build/editor-flush.d.ts +42 -0
  14. package/dist/build/editor-flush.js +91 -0
  15. package/dist/build/finish.d.ts +11 -0
  16. package/dist/build/finish.js +126 -0
  17. package/dist/build/output-plugins.d.ts +26 -0
  18. package/dist/build/output-plugins.js +88 -0
  19. package/dist/build/plan.d.ts +75 -0
  20. package/dist/build/plan.js +162 -0
  21. package/dist/build/player-modules.d.ts +99 -0
  22. package/dist/build/player-modules.js +204 -0
  23. package/dist/build/project-manifest.d.ts +57 -0
  24. package/dist/build/project-manifest.js +169 -0
  25. package/dist/build/record.d.ts +25 -0
  26. package/dist/build/record.js +14 -0
  27. package/dist/build/report.d.ts +114 -0
  28. package/dist/build/report.js +102 -0
  29. package/dist/build/scan.d.ts +84 -0
  30. package/dist/build/scan.js +375 -0
  31. package/dist/build/strip-decision.d.ts +27 -0
  32. package/dist/build/strip-decision.js +82 -0
  33. package/dist/build/strippable-runtime-modules.d.ts +85 -0
  34. package/dist/build/strippable-runtime-modules.js +309 -0
  35. package/dist/build/zip.d.ts +6 -0
  36. package/dist/build/zip.js +28 -0
  37. package/dist/client/host-files.d.ts +96 -0
  38. package/dist/client/host-files.js +169 -0
  39. package/dist/client/index.d.ts +218 -0
  40. package/dist/client/index.js +381 -0
  41. package/dist/dev-info.d.ts +55 -0
  42. package/dist/dev-info.js +116 -0
  43. package/dist/dev-server.d.ts +28 -0
  44. package/dist/dev-server.js +139 -0
  45. package/dist/editor-page.d.ts +31 -0
  46. package/dist/editor-page.js +74 -0
  47. package/dist/files/changeset.d.ts +84 -0
  48. package/dist/files/changeset.js +321 -0
  49. package/dist/files/content-type.d.ts +1 -0
  50. package/dist/files/content-type.js +56 -0
  51. package/dist/files/history.d.ts +67 -0
  52. package/dist/files/history.js +162 -0
  53. package/dist/files/paths.d.ts +41 -0
  54. package/dist/files/paths.js +144 -0
  55. package/dist/files/project-files.d.ts +208 -0
  56. package/dist/files/project-files.js +1062 -0
  57. package/dist/files/scan.d.ts +71 -0
  58. package/dist/files/scan.js +252 -0
  59. package/dist/index.d.ts +56 -0
  60. package/dist/index.js +278 -0
  61. package/dist/module-paths.d.ts +13 -0
  62. package/dist/module-paths.js +32 -0
  63. package/dist/player/index.d.ts +31 -0
  64. package/dist/player/index.js +102 -0
  65. package/dist/server/editor-socket.d.ts +53 -0
  66. package/dist/server/editor-socket.js +134 -0
  67. package/dist/server/guard.d.ts +61 -0
  68. package/dist/server/guard.js +165 -0
  69. package/dist/server/http.d.ts +24 -0
  70. package/dist/server/http.js +70 -0
  71. package/dist/server/image-keys.d.ts +46 -0
  72. package/dist/server/image-keys.js +118 -0
  73. package/dist/server/image-proxy.d.ts +32 -0
  74. package/dist/server/image-proxy.js +124 -0
  75. package/dist/server/router.d.ts +54 -0
  76. package/dist/server/router.js +147 -0
  77. package/dist/sync/barrier.d.ts +69 -0
  78. package/dist/sync/barrier.js +240 -0
  79. package/dist/sync/hot-update.d.ts +16 -0
  80. package/dist/sync/hot-update.js +58 -0
  81. package/dist/sync/script-graph.d.ts +79 -0
  82. package/dist/sync/script-graph.js +214 -0
  83. package/dist/vite-config.d.ts +12 -0
  84. package/dist/vite-config.js +62 -0
  85. package/package.json +60 -6
@@ -0,0 +1,116 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const LOCAL_NAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
5
+ /**
6
+ * The public base URL for `dev.json`: `PIX3_PUBLIC_URL` (an origin or an origin with the base
7
+ * path) wins; otherwise a tab's `Origin` that is not this server's own loopback address. Null
8
+ * when neither says anything new. The result always ends with `base`.
9
+ */
10
+ export const publicUrlOf = (options) => {
11
+ const withBase = (origin) => `${origin}${options.base}`;
12
+ if (options.env) {
13
+ try {
14
+ const url = new URL(options.env);
15
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
16
+ return null;
17
+ const path = url.pathname.replace(/\/+$/, '');
18
+ // `http://localhost:5174` or `http://localhost:5174/game/` (the base spelled out).
19
+ return path && path !== options.base.replace(/\/+$/, '')
20
+ ? `${url.origin}${path}/`
21
+ : withBase(url.origin);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ if (!options.tabOrigin)
28
+ return null;
29
+ let origin;
30
+ try {
31
+ origin = new URL(options.tabOrigin);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ const port = Number(origin.port || (origin.protocol === 'https:' ? 443 : 80));
37
+ if (LOCAL_NAMES.has(origin.hostname) && port === options.port)
38
+ return null;
39
+ return withBase(origin.origin);
40
+ };
41
+ export const devJsonPath = (root) => join(root, '.pix3', 'dev.json');
42
+ export const writeDevInfo = (root, info) => {
43
+ const path = devJsonPath(root);
44
+ mkdirSync(dirname(path), { recursive: true });
45
+ writeFileSync(path, `${JSON.stringify(info, null, 2)}\n`);
46
+ };
47
+ export const clearDevInfo = (root, pid = process.pid) => {
48
+ const path = devJsonPath(root);
49
+ try {
50
+ const current = JSON.parse(readFileSync(path, 'utf8'));
51
+ if (current.pid !== pid)
52
+ return;
53
+ }
54
+ catch {
55
+ return;
56
+ }
57
+ rmSync(path, { force: true });
58
+ };
59
+ const readVersion = (packageJson) => {
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(packageJson, 'utf8'));
62
+ return typeof parsed.version === 'string' ? parsed.version : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ };
68
+ /** The plugin's own version (its `package.json`, one level above `src/` and `dist/`). */
69
+ export const pluginVersion = () => readVersion(fileURLToPath(new URL('../package.json', import.meta.url))) ?? '0.0.0';
70
+ /**
71
+ * Directory of an installed package as the project sees it: `node_modules/<name>` in the root or
72
+ * any ancestor (hoisting). Not `require.resolve` — `@pix3/runtime` does not export its
73
+ * `package.json`.
74
+ */
75
+ export const findPackageDir = (root, name) => {
76
+ for (let dir = root;; dir = dirname(dir)) {
77
+ const candidate = join(dir, 'node_modules', ...name.split('/'));
78
+ if (existsSync(join(candidate, 'package.json')))
79
+ return candidate;
80
+ if (dirname(dir) === dir)
81
+ return null;
82
+ }
83
+ };
84
+ export const installedVersion = (root, name) => {
85
+ const dir = findPackageDir(root, name);
86
+ return dir ? readVersion(join(dir, 'package.json')) : null;
87
+ };
88
+ const parse = (version) => {
89
+ const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+.*)?$/.exec(version);
90
+ if (!match)
91
+ return null;
92
+ return { major: Number(match[1]), minor: Number(match[2]), prerelease: match[4] !== undefined };
93
+ };
94
+ /**
95
+ * Plan §A.3 gate: the project's `@pix3/runtime` must equal `@pix3/editor-core` exactly for
96
+ * prereleases and share major.minor for releases. Returns the problem, or null.
97
+ */
98
+ export const versionMismatch = (runtime, editorCore) => {
99
+ if (runtime === null)
100
+ return 'The project has no @pix3/runtime installed. Run: npm i @pix3/runtime';
101
+ if (editorCore === null)
102
+ return null;
103
+ const a = parse(runtime);
104
+ const b = parse(editorCore);
105
+ const exact = runtime === editorCore;
106
+ const compatible = a !== null &&
107
+ b !== null &&
108
+ !a.prerelease &&
109
+ !b.prerelease &&
110
+ a.major === b.major &&
111
+ a.minor === b.minor;
112
+ if (exact || compatible)
113
+ return null;
114
+ return (`This editor (@pix3/editor-core ${editorCore}) needs @pix3/runtime ${editorCore}; the project ` +
115
+ `has ${runtime}. Run: npm i @pix3/runtime@${editorCore}`);
116
+ };
@@ -0,0 +1,28 @@
1
+ import type { ViteDevServer } from 'vite';
2
+ import { type Versions } from './dev-info.ts';
3
+ import { ProjectFiles } from './files/project-files.ts';
4
+ import { EditorSocket } from './server/editor-socket.ts';
5
+ import { SyncBarrier } from './sync/barrier.ts';
6
+ import type { ScriptGraph } from './sync/script-graph.ts';
7
+ import type { Pix3Options } from './index.ts';
8
+ export interface EditorServer {
9
+ readonly files: ProjectFiles;
10
+ readonly socket: EditorSocket;
11
+ readonly barrier: SyncBarrier;
12
+ }
13
+ /**
14
+ * `configureServer` with the editor on: the file API, the editor's socket, the sync barrier, the
15
+ * image-gen proxy and `/__pix3/*`, and `.pix3/dev.json` for as long as the server listens.
16
+ */
17
+ export declare const startEditorServer: (devServer: ViteDevServer, options: {
18
+ readonly root: string;
19
+ /** Vite's `base`, ending in `/`. */
20
+ readonly base: string;
21
+ readonly resRoot: string;
22
+ readonly build: Pix3Options["build"];
23
+ readonly allowRemote: boolean;
24
+ readonly imageGen: Pix3Options["imageGen"];
25
+ readonly versions: Versions;
26
+ readonly scripts: ScriptGraph;
27
+ readonly log: (line: string) => void;
28
+ }) => Promise<EditorServer>;
@@ -0,0 +1,139 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { clearDevInfo, findPackageDir, publicUrlOf, versionMismatch, writeDevInfo, } from "./dev-info.js";
5
+ import { editorPageHtml, versionGatePageHtml } from "./editor-page.js";
6
+ import { ProjectFiles } from "./files/project-files.js";
7
+ import { EditorSocket } from "./server/editor-socket.js";
8
+ import { RequestGuard } from "./server/guard.js";
9
+ import { defaultPix3Home, IMAGE_PROVIDERS, ImageKeyStore } from "./server/image-keys.js";
10
+ import { DEFAULT_UPSTREAMS, ImageProxy, UPSTREAM_ENV } from "./server/image-proxy.js";
11
+ import { createRouter } from "./server/router.js";
12
+ import { SyncBarrier } from "./sync/barrier.js";
13
+ /**
14
+ * `configureServer` with the editor on: the file API, the editor's socket, the sync barrier, the
15
+ * image-gen proxy and `/__pix3/*`, and `.pix3/dev.json` for as long as the server listens.
16
+ */
17
+ export const startEditorServer = async (devServer, options) => {
18
+ const { root, base, resRoot, versions, log } = options;
19
+ const logger = devServer.config.logger;
20
+ const guard = new RequestGuard({
21
+ allowRemote: options.allowRemote,
22
+ allowedHosts: () => devServer.config.server.allowedHosts ?? [],
23
+ });
24
+ const imageProxy = new ImageProxy({
25
+ keys: new ImageKeyStore({
26
+ home: options.imageGen?.home ?? defaultPix3Home(),
27
+ projectRoot: root,
28
+ }),
29
+ upstreams: Object.fromEntries(IMAGE_PROVIDERS.map(provider => [
30
+ provider,
31
+ options.imageGen?.upstreams?.[provider] ||
32
+ process.env[UPSTREAM_ENV[provider]] ||
33
+ DEFAULT_UPSTREAMS[provider],
34
+ ])),
35
+ log,
36
+ });
37
+ const files = new ProjectFiles({
38
+ root,
39
+ log,
40
+ onChange: frame => socket.broadcast({ ...frame }),
41
+ });
42
+ // What both a tab's `welcome` and `GET /__pix3/api/hello` start with.
43
+ const projectState = () => ({
44
+ seq: files.currentSeq,
45
+ revision: files.revision(),
46
+ writerId: files.writerId,
47
+ root,
48
+ resRoot,
49
+ versions,
50
+ });
51
+ const socket = new EditorSocket({
52
+ path: `${base}__pix3/ws`,
53
+ accept: req => guard.checkSocket(req),
54
+ onTabsChanged: () => notePublicUrl(),
55
+ welcome: tab => ({ tabId: tab.tabId, ...projectState(), projectName: basename(root) }),
56
+ log,
57
+ });
58
+ // `dev.json`'s `publicUrl` (Remote SSH, plan §E.3): `PIX3_PUBLIC_URL`, else the newest tab's
59
+ // `Origin` when the browser reached this server through another address (a VS Code port
60
+ // forward that is not 1:1). The agent finds its tab by that prefix.
61
+ let devInfo = null;
62
+ const notePublicUrl = () => {
63
+ if (!devInfo)
64
+ return;
65
+ const publicUrl = publicUrlOf({
66
+ env: process.env.PIX3_PUBLIC_URL,
67
+ tabOrigin: socket.tabs().at(-1)?.origin ?? null,
68
+ port: devInfo.port,
69
+ base,
70
+ });
71
+ if (!publicUrl || publicUrl === devInfo.publicUrl)
72
+ return;
73
+ devInfo = { ...devInfo, publicUrl, publicEditorUrl: `${publicUrl}__pix3/` };
74
+ writeDevInfo(root, devInfo);
75
+ log(`the browser reaches this server at ${publicUrl} (dev.json publicUrl)`);
76
+ };
77
+ const barrier = new SyncBarrier({ files, scripts: options.scripts, socket, log });
78
+ await files.start();
79
+ devServer.httpServer?.on('upgrade', (req, sock, head) => {
80
+ socket.handleUpgrade(req, sock, head);
81
+ });
82
+ devServer.watcher.on('all', (_event, path) => files.noteFsEvent(path));
83
+ const gate = versionMismatch(versions.runtime, versions.editorCore);
84
+ const editorCoreDir = findPackageDir(root, '@pix3/editor-core');
85
+ const editorCss = editorCoreDir ? join(editorCoreDir, 'dist', 'editor.css') : null;
86
+ devServer.middlewares.use(createRouter({
87
+ base,
88
+ guard,
89
+ files,
90
+ socket,
91
+ barrier,
92
+ log,
93
+ hello: () => ({
94
+ ...projectState(),
95
+ tabs: socket.tabs().map(tab => tab.tabId),
96
+ build: options.build,
97
+ }),
98
+ editorPage: session => gate
99
+ ? { status: 409, html: versionGatePageHtml(gate) }
100
+ : {
101
+ status: 200,
102
+ html: editorPageHtml(base, {
103
+ css: editorCss !== null && existsSync(editorCss),
104
+ session,
105
+ }),
106
+ },
107
+ // Handed to the editor page on a top-level navigation only (`editorSessionFor`).
108
+ sessionToken: randomBytes(32).toString('base64url'),
109
+ imageProxy,
110
+ editorCss: () => (editorCss && existsSync(editorCss) ? readFileSync(editorCss) : null),
111
+ }));
112
+ const httpServer = devServer.httpServer;
113
+ httpServer?.on('listening', () => {
114
+ const address = httpServer.address();
115
+ const port = typeof address === 'object' && address ? address.port : 0;
116
+ const protocol = devServer.config.server.https ? 'https' : 'http';
117
+ const url = `${protocol}://localhost:${port}${base}`;
118
+ const editorUrl = `${url}__pix3/`;
119
+ devInfo = {
120
+ url,
121
+ editorUrl,
122
+ port,
123
+ pid: process.pid,
124
+ versions,
125
+ startedAt: new Date().toISOString(),
126
+ };
127
+ writeDevInfo(root, devInfo);
128
+ notePublicUrl();
129
+ logger.info(`\n Pix3 editor: ${editorUrl}\n`);
130
+ if (gate)
131
+ logger.warn(`[pix3] ${gate}`);
132
+ });
133
+ httpServer?.on('close', () => {
134
+ clearDevInfo(root);
135
+ socket.close();
136
+ void files.close();
137
+ });
138
+ return { files, socket, barrier };
139
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The editor page and `virtual:pix3/editor-host` (plan §B.1, §B.2 variant B).
3
+ *
4
+ * The page is raw HTML: `transformIndexHtml` would inject `/@vite/client`, and with it Vite's
5
+ * `full-reload` and reload-after-reconnect. Its one module is the host, requested from Vite
6
+ * directly by its `/@id/` URL (S1: no inline module and no html-proxy needed).
7
+ */
8
+ export declare const EDITOR_HOST_ID = "virtual:pix3/editor-host";
9
+ export declare const SPINE_LOADER_ID = "virtual:pix3/spine-loader";
10
+ /** The `<meta>` the page client reads (and removes) the editor session token from. */
11
+ export declare const SESSION_META = "pix3-session";
12
+ export declare const editorPageHtml: (base: string, options?: {
13
+ css?: boolean;
14
+ session?: string | null;
15
+ }) => string;
16
+ /** What `/__pix3/` shows instead of the editor when the version gate fails (plan §A.3). */
17
+ export declare const versionGatePageHtml: (message: string) => string;
18
+ export interface EditorHostSource {
19
+ readonly base: string;
20
+ /** `/@fs/…` URL of the plugin's page client (`src/client/index`). */
21
+ readonly clientUrl: string;
22
+ /** Whether `@pix3/editor-core` resolves from the project. */
23
+ readonly editorCore: boolean;
24
+ }
25
+ /**
26
+ * Source of `virtual:pix3/editor-host`. The script roots are imported statically, so they are in
27
+ * the module graph from the first load and every sync only re-imports them with a fresh `?t=`.
28
+ * Without `@pix3/editor-core` (P1, until the port lands) the page shows the host's state instead
29
+ * of the editor — enough to run the barrier against a real browser.
30
+ */
31
+ export declare const editorHostSource: ({ base, clientUrl, editorCore }: EditorHostSource) => string;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The editor page and `virtual:pix3/editor-host` (plan §B.1, §B.2 variant B).
3
+ *
4
+ * The page is raw HTML: `transformIndexHtml` would inject `/@vite/client`, and with it Vite's
5
+ * `full-reload` and reload-after-reconnect. Its one module is the host, requested from Vite
6
+ * directly by its `/@id/` URL (S1: no inline module and no html-proxy needed).
7
+ */
8
+ export const EDITOR_HOST_ID = 'virtual:pix3/editor-host';
9
+ export const SPINE_LOADER_ID = 'virtual:pix3/spine-loader';
10
+ /**
11
+ * Contract B's page check (plan §B.2), installed before any module loads: a resource observer sets
12
+ * `window.__PIX3_VITE_CLIENT__` when Vite's client is fetched, at load or later (a script added
13
+ * with a CSS import reaches the page on a sync). The resource-timing buffer is raised first — the
14
+ * default 250 entries fill up with the editor's own modules and later entries would be dropped.
15
+ * The regex keeps the literal client path out of the page text.
16
+ */
17
+ const VITE_CLIENT_PROBE = 'window.__PIX3_VITE_CLIENT__=false;try{performance.setResourceTimingBufferSize(1e5);' +
18
+ 'new PerformanceObserver(function(list){list.getEntries().forEach(function(e){' +
19
+ 'if(/\\/@vite\\/client$/.test(new URL(e.name,location.href).pathname))window.__PIX3_VITE_CLIENT__=true;' +
20
+ "})}).observe({type:'resource',buffered:true})}catch(e){}";
21
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, ch => `&#${ch.charCodeAt(0)};`);
22
+ /** The `<meta>` the page client reads (and removes) the editor session token from. */
23
+ export const SESSION_META = 'pix3-session';
24
+ export const editorPageHtml = (base, options = {}) => `<!doctype html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="utf-8" />
28
+ <meta name="viewport" content="width=device-width, initial-scale=1" />${options.session
29
+ ? `\n <meta name="${SESSION_META}" content="${escapeHtml(options.session)}" />`
30
+ : ''}
31
+ <title>Pix3</title>
32
+ <style>html,body,#pix3-editor{margin:0;height:100%}</style>
33
+ <script>${VITE_CLIENT_PROBE}</script>${options.css ? `\n <link rel="stylesheet" href="${base}__pix3/editor.css" />` : ''}
34
+ </head>
35
+ <body>
36
+ <div id="pix3-editor"></div>
37
+ <script type="module" src="${base}@id/__x00__${EDITOR_HOST_ID}"></script>
38
+ </body>
39
+ </html>
40
+ `;
41
+ /** What `/__pix3/` shows instead of the editor when the version gate fails (plan §A.3). */
42
+ export const versionGatePageHtml = (message) => `<!doctype html>
43
+ <html lang="en">
44
+ <head><meta charset="utf-8" /><title>Pix3 — version mismatch</title></head>
45
+ <body style="font:15px/1.5 system-ui,sans-serif;margin:3rem;max-width:44rem">
46
+ <h1 style="font-size:1.4rem">Pix3 cannot open this project yet</h1>
47
+ <p>${escapeHtml(message)}</p>
48
+ <p>Then restart the dev server.</p>
49
+ </body>
50
+ </html>
51
+ `;
52
+ /**
53
+ * Source of `virtual:pix3/editor-host`. The script roots are imported statically, so they are in
54
+ * the module graph from the first load and every sync only re-imports them with a fresh `?t=`.
55
+ * Without `@pix3/editor-core` (P1, until the port lands) the page shows the host's state instead
56
+ * of the editor — enough to run the barrier against a real browser.
57
+ */
58
+ export const editorHostSource = ({ base, clientUrl, editorCore }) => {
59
+ const lines = [
60
+ `import * as editorScripts from 'virtual:pix3/editor-scripts';`,
61
+ `import * as botPolicies from 'virtual:pix3/bot-policies';`,
62
+ `import { EditorHostConnection, viteClientLoaded } from ${JSON.stringify(clientUrl)};`,
63
+ ];
64
+ if (editorCore)
65
+ lines.push(`import { mountEditor } from '@pix3/editor-core';`);
66
+ lines.push(`const host = new EditorHostConnection({ base: ${JSON.stringify(base)}, roots: { editorScripts, botPolicies } });`, `window.__PIX3_HOST__ = host;`, `host.connect();`, `if (viteClientLoaded()) console.error('[pix3] /@vite/client is loaded on the editor page: a non-literal import(), import.meta.hot or a CSS import reached the editor chain (plan §B.2, contract B; \`pix3 check\` names the file).');`, `const root = document.getElementById('pix3-editor');`);
67
+ if (editorCore) {
68
+ lines.push(`void host.ready().then(() => mountEditor(root, host));`);
69
+ }
70
+ else {
71
+ lines.push(`root.textContent = 'Pix3 editor host is running; @pix3/editor-core is not installed.';`, `void host.ready().then(hello => { root.dataset.tabId = hello.tabId; root.dataset.seq = String(hello.seq); });`);
72
+ }
73
+ return `${lines.join('\n')}\n`;
74
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Multi-file transactions of the write path (plan §C.4 "Changeset: staging и восстановление").
3
+ * `temp+rename` is atomic for one file only; a flush that touches a scene and a prefab must land
4
+ * both or neither, including across a crash of the dev server between the two renames.
5
+ *
6
+ * On-disk layout of one transaction `<id>`:
7
+ *
8
+ * .pix3/tx/<id>/new/<n> the new bytes of entry n (fsynced before the intent)
9
+ * .pix3/tx/<id>/old/<n> a copy of what was at entry n's path (absent when nothing was)
10
+ * .pix3/tx/<id>/intent.json {id, files: [{path, sha256, old: 'absent' | 'old/<n>'}],
11
+ * state: 'prepared' | 'committed'} — replaced atomically
12
+ *
13
+ * Renames start only once `intent.json` is durable, so a transaction without a readable intent
14
+ * never touched a project file. A rename moves `new/<n>` away, so "staged file still present"
15
+ * means "not renamed yet"; a missing staged file whose target has the intended sha was renamed.
16
+ */
17
+ export interface ChangesetEntry {
18
+ readonly path: string;
19
+ readonly data: Buffer;
20
+ readonly sha256: string;
21
+ /** Entity tags the file must match (`*` = must exist), or null for unconditional. */
22
+ readonly ifMatch: readonly string[] | null;
23
+ readonly createOnly: boolean;
24
+ }
25
+ export interface TxIntentFile {
26
+ readonly path: string;
27
+ readonly sha256: string;
28
+ readonly old: 'absent' | `old/${number}`;
29
+ }
30
+ export interface TxIntent {
31
+ readonly id: string;
32
+ readonly files: readonly TxIntentFile[];
33
+ readonly state: 'prepared' | 'committed';
34
+ }
35
+ /** What recovery did to one path; the caller journals it. */
36
+ export interface RecoveredFile {
37
+ readonly path: string;
38
+ readonly author: 'editor' | 'restore';
39
+ }
40
+ export declare const MAX_CHANGESET_BYTES: number;
41
+ export declare const txRootOf: (root: string) => string;
42
+ /** Validate `POST /__pix3/api/changeset` `{files: [{path, text? | base64?, ifMatch?, createOnly?}]}`. */
43
+ export declare const parseChangeset: (body: Record<string, unknown>) => ChangesetEntry[];
44
+ /** A request fingerprint for `X-Mutation-Id` replay: same paths, conditions and bytes. */
45
+ export declare const changesetFingerprint: (entries: readonly ChangesetEntry[]) => string;
46
+ /** fsync a directory, so a rename or a create inside it is durable. A no-op where unsupported. */
47
+ export declare const fsyncDir: (dir: string) => Promise<void>;
48
+ /** Replace `intent.json` atomically and durably (temp + fsync + rename + fsync of the dir). */
49
+ export declare const writeIntent: (txDir: string, intent: TxIntent) => Promise<void>;
50
+ /**
51
+ * Rename `temp` over `target`. When `.pix3/` is on another device than the target, the bytes are
52
+ * copied to a sibling first and renamed from there, so the replace itself stays atomic.
53
+ */
54
+ export declare const renameIntoPlace: (temp: string, target: string) => Promise<void>;
55
+ export interface StagedFile {
56
+ readonly entry: ChangesetEntry;
57
+ readonly absolute: string;
58
+ /** The file's mode bits to keep, or null when nothing is there yet. */
59
+ readonly mode: number | null;
60
+ }
61
+ /**
62
+ * Steps 3 of plan §C.4: stage the new bytes and copies of the old ones, then write the intent
63
+ * (`prepared`). Returns the transaction directory and its intent; nothing in the project changed.
64
+ */
65
+ export declare const stageTransaction: (root: string, files: readonly StagedFile[]) => Promise<{
66
+ txDir: string;
67
+ intent: TxIntent;
68
+ }>;
69
+ /** Put entry `n`'s old bytes back (or remove the file it created). */
70
+ export declare const rollBackFile: (root: string, txDir: string, file: TxIntentFile) => Promise<void>;
71
+ /**
72
+ * Recovery at plugin start (plan §C.4), before the first scan. For every `.pix3/tx/<id>/`:
73
+ *
74
+ * - no readable intent → nothing was renamed yet (renames start after the intent is durable):
75
+ * the directory is removed;
76
+ * - `committed` → every file is in place: clean up (the versions are journaled as `editor`);
77
+ * - `prepared` and consistent — every entry is either still staged with the intended bytes, or
78
+ * gone from `new/` with the target at the intended sha → roll forward;
79
+ * - `prepared` and anything else (a staged file lost, a renamed target with other bytes) → roll
80
+ * back every renamed entry from `old/` (a target whose old was `absent` is deleted).
81
+ *
82
+ * Either way both files of a two-file changeset end up all old or all new (gate row N6).
83
+ */
84
+ export declare const recoverTransactions: (root: string, log: (line: string) => void) => Promise<RecoveredFile[]>;