@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.
- package/LICENSE +201 -0
- package/README.md +38 -3
- package/dist/build/bundle-hooks.d.ts +23 -0
- package/dist/build/bundle-hooks.js +79 -0
- package/dist/build/classic-script.d.ts +11 -0
- package/dist/build/classic-script.js +46 -0
- package/dist/build/compress.d.ts +29 -0
- package/dist/build/compress.js +63 -0
- package/dist/build/dependency-imports.d.ts +53 -0
- package/dist/build/dependency-imports.js +239 -0
- package/dist/build/dev-manifest.d.ts +10 -0
- package/dist/build/dev-manifest.js +25 -0
- package/dist/build/editor-flush.d.ts +42 -0
- package/dist/build/editor-flush.js +91 -0
- package/dist/build/finish.d.ts +11 -0
- package/dist/build/finish.js +126 -0
- package/dist/build/output-plugins.d.ts +26 -0
- package/dist/build/output-plugins.js +88 -0
- package/dist/build/plan.d.ts +75 -0
- package/dist/build/plan.js +162 -0
- package/dist/build/player-modules.d.ts +99 -0
- package/dist/build/player-modules.js +204 -0
- package/dist/build/project-manifest.d.ts +57 -0
- package/dist/build/project-manifest.js +169 -0
- package/dist/build/record.d.ts +25 -0
- package/dist/build/record.js +14 -0
- package/dist/build/report.d.ts +114 -0
- package/dist/build/report.js +102 -0
- package/dist/build/scan.d.ts +84 -0
- package/dist/build/scan.js +375 -0
- package/dist/build/strip-decision.d.ts +27 -0
- package/dist/build/strip-decision.js +82 -0
- package/dist/build/strippable-runtime-modules.d.ts +85 -0
- package/dist/build/strippable-runtime-modules.js +309 -0
- package/dist/build/zip.d.ts +6 -0
- package/dist/build/zip.js +28 -0
- package/dist/client/host-files.d.ts +96 -0
- package/dist/client/host-files.js +169 -0
- package/dist/client/index.d.ts +218 -0
- package/dist/client/index.js +381 -0
- package/dist/dev-info.d.ts +55 -0
- package/dist/dev-info.js +116 -0
- package/dist/dev-server.d.ts +28 -0
- package/dist/dev-server.js +139 -0
- package/dist/editor-page.d.ts +31 -0
- package/dist/editor-page.js +74 -0
- package/dist/files/changeset.d.ts +84 -0
- package/dist/files/changeset.js +321 -0
- package/dist/files/content-type.d.ts +1 -0
- package/dist/files/content-type.js +56 -0
- package/dist/files/history.d.ts +67 -0
- package/dist/files/history.js +162 -0
- package/dist/files/paths.d.ts +41 -0
- package/dist/files/paths.js +144 -0
- package/dist/files/project-files.d.ts +208 -0
- package/dist/files/project-files.js +1062 -0
- package/dist/files/scan.d.ts +71 -0
- package/dist/files/scan.js +252 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +278 -0
- package/dist/module-paths.d.ts +13 -0
- package/dist/module-paths.js +32 -0
- package/dist/player/index.d.ts +31 -0
- package/dist/player/index.js +102 -0
- package/dist/server/editor-socket.d.ts +53 -0
- package/dist/server/editor-socket.js +134 -0
- package/dist/server/guard.d.ts +61 -0
- package/dist/server/guard.js +165 -0
- package/dist/server/http.d.ts +24 -0
- package/dist/server/http.js +70 -0
- package/dist/server/image-keys.d.ts +46 -0
- package/dist/server/image-keys.js +118 -0
- package/dist/server/image-proxy.d.ts +32 -0
- package/dist/server/image-proxy.js +124 -0
- package/dist/server/router.d.ts +54 -0
- package/dist/server/router.js +147 -0
- package/dist/sync/barrier.d.ts +69 -0
- package/dist/sync/barrier.js +240 -0
- package/dist/sync/hot-update.d.ts +16 -0
- package/dist/sync/hot-update.js +58 -0
- package/dist/sync/script-graph.d.ts +79 -0
- package/dist/sync/script-graph.js +214 -0
- package/dist/vite-config.d.ts +12 -0
- package/dist/vite-config.js +62 -0
- package/package.json +60 -6
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP primitives of `/__pix3/api/*` (port of `packages/cli/src/server/http.ts`). Errors are always
|
|
3
|
+
* `{ error: <code>, message, ...extra }` JSON, the shape the editor's `WorkspaceClient` parses.
|
|
4
|
+
*/
|
|
5
|
+
export class HttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
extra;
|
|
9
|
+
constructor(status, code, message, extra = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.extra = extra;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
export const DEFAULT_MAX_JSON_BYTES = 1024 * 1024;
|
|
18
|
+
/** The request body as bytes, refused past `maxBytes`. */
|
|
19
|
+
export const readBytes = (req, maxBytes = DEFAULT_MAX_JSON_BYTES) => new Promise((resolve, reject) => {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let size = 0;
|
|
22
|
+
req.on('data', (chunk) => {
|
|
23
|
+
size += chunk.length;
|
|
24
|
+
if (size > maxBytes) {
|
|
25
|
+
reject(new HttpError(413, 'too_large', 'Request body too large.'));
|
|
26
|
+
req.destroy();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
});
|
|
31
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
32
|
+
req.on('error', reject);
|
|
33
|
+
});
|
|
34
|
+
export const readBody = async (req, maxBytes = DEFAULT_MAX_JSON_BYTES) => (await readBytes(req, maxBytes)).toString('utf8');
|
|
35
|
+
export const readJson = async (req, maxBytes = DEFAULT_MAX_JSON_BYTES) => {
|
|
36
|
+
const raw = await readBody(req, maxBytes);
|
|
37
|
+
if (!raw.trim())
|
|
38
|
+
return {};
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (isRecord(parsed))
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// fall through
|
|
46
|
+
}
|
|
47
|
+
throw new HttpError(400, 'bad_json', 'Body must be a JSON object.');
|
|
48
|
+
};
|
|
49
|
+
export const headerValue = (req, name) => {
|
|
50
|
+
const value = req.headers[name];
|
|
51
|
+
if (typeof value === 'string')
|
|
52
|
+
return value;
|
|
53
|
+
if (Array.isArray(value))
|
|
54
|
+
return value.join(', ');
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
57
|
+
export const sendJson = (res, status, body, extraHeaders = {}) => {
|
|
58
|
+
if (res.writableEnded || res.destroyed)
|
|
59
|
+
return;
|
|
60
|
+
res.writeHead(status, {
|
|
61
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
62
|
+
'Cache-Control': 'no-store',
|
|
63
|
+
...extraHeaders,
|
|
64
|
+
});
|
|
65
|
+
res.end(JSON.stringify(body));
|
|
66
|
+
};
|
|
67
|
+
export const errnoCode = (error) => error && typeof error === 'object' && 'code' in error ? String(error.code) : undefined;
|
|
68
|
+
export const errorBody = (error) => error instanceof HttpError
|
|
69
|
+
? { status: error.status, body: { error: error.code, message: error.message, ...error.extra } }
|
|
70
|
+
: { status: 500, body: { error: 'internal', message: 'Internal error.' } };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image-generation API keys (plan §B.1 «Ключи генерации картинок»). They live on this machine,
|
|
3
|
+
* never in the browser: the origin is the dev server's port, which every project and the game's
|
|
4
|
+
* own code share, so localStorage / IndexedDB is no place for a secret. The plugin's proxy
|
|
5
|
+
* (`image-proxy.ts`) reads the key per request and adds it to the upstream call.
|
|
6
|
+
*
|
|
7
|
+
* Where: `~/.pix3/keys.json` (`PIX3_HOME` overrides `~/.pix3`, as for the CLI), mode 0600 in a
|
|
8
|
+
* 0700 directory. When `~` cannot be written (a sandbox), `.pix3/local/keys.json` in the project —
|
|
9
|
+
* `.pix3/` is gitignored by the starter, and the file API refuses `.pix3/local` (`paths.ts`).
|
|
10
|
+
* The file is read on every request, so an edit by hand applies at once; unknown entries survive
|
|
11
|
+
* a write.
|
|
12
|
+
*/
|
|
13
|
+
export declare const IMAGE_PROVIDERS: readonly ["gemini", "openai"];
|
|
14
|
+
export type ImageProvider = (typeof IMAGE_PROVIDERS)[number];
|
|
15
|
+
export declare const isImageProvider: (value: unknown) => value is ImageProvider;
|
|
16
|
+
/** What the editor may learn about a key: whether it is there, and its last four characters. */
|
|
17
|
+
export interface KeyStatus {
|
|
18
|
+
readonly set: boolean;
|
|
19
|
+
readonly last4?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface KeyStoreOptions {
|
|
22
|
+
/** `~/.pix3` (or `PIX3_HOME`). */
|
|
23
|
+
readonly home: string;
|
|
24
|
+
readonly projectRoot: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const defaultPix3Home: (env?: NodeJS.ProcessEnv) => string;
|
|
27
|
+
export declare const KEYS_FILE = "keys.json";
|
|
28
|
+
export declare const LOCAL_KEYS_PATH = ".pix3/local/keys.json";
|
|
29
|
+
export declare class ImageKeyStore {
|
|
30
|
+
private readonly homeFile;
|
|
31
|
+
private readonly localFile;
|
|
32
|
+
constructor(options: KeyStoreOptions);
|
|
33
|
+
/** The key for `provider`, or null. The home file wins over the project fallback. */
|
|
34
|
+
key(provider: ImageProvider): Promise<string | null>;
|
|
35
|
+
status(): Promise<Record<ImageProvider, KeyStatus>>;
|
|
36
|
+
/**
|
|
37
|
+
* Set (or with `null` remove) one provider's key. Written to `~/.pix3/keys.json`, or to the
|
|
38
|
+
* project's `.pix3/local/keys.json` when the home directory refuses the write. A removal clears
|
|
39
|
+
* the key from both files, so the fallback cannot bring it back.
|
|
40
|
+
*/
|
|
41
|
+
set(provider: ImageProvider, key: string | null): Promise<{
|
|
42
|
+
readonly status: KeyStatus;
|
|
43
|
+
readonly where: 'home' | 'project';
|
|
44
|
+
}>;
|
|
45
|
+
private update;
|
|
46
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { errnoCode } from "./http.js";
|
|
5
|
+
/**
|
|
6
|
+
* Image-generation API keys (plan §B.1 «Ключи генерации картинок»). They live on this machine,
|
|
7
|
+
* never in the browser: the origin is the dev server's port, which every project and the game's
|
|
8
|
+
* own code share, so localStorage / IndexedDB is no place for a secret. The plugin's proxy
|
|
9
|
+
* (`image-proxy.ts`) reads the key per request and adds it to the upstream call.
|
|
10
|
+
*
|
|
11
|
+
* Where: `~/.pix3/keys.json` (`PIX3_HOME` overrides `~/.pix3`, as for the CLI), mode 0600 in a
|
|
12
|
+
* 0700 directory. When `~` cannot be written (a sandbox), `.pix3/local/keys.json` in the project —
|
|
13
|
+
* `.pix3/` is gitignored by the starter, and the file API refuses `.pix3/local` (`paths.ts`).
|
|
14
|
+
* The file is read on every request, so an edit by hand applies at once; unknown entries survive
|
|
15
|
+
* a write.
|
|
16
|
+
*/
|
|
17
|
+
export const IMAGE_PROVIDERS = ['gemini', 'openai'];
|
|
18
|
+
export const isImageProvider = (value) => typeof value === 'string' && IMAGE_PROVIDERS.includes(value);
|
|
19
|
+
export const defaultPix3Home = (env = process.env) => env.PIX3_HOME || join(homedir(), '.pix3');
|
|
20
|
+
export const KEYS_FILE = 'keys.json';
|
|
21
|
+
export const LOCAL_KEYS_PATH = '.pix3/local/keys.json';
|
|
22
|
+
/** Errors that mean "this directory is not ours to write" — the cue for the project fallback. */
|
|
23
|
+
const UNWRITABLE = new Set(['EACCES', 'EPERM', 'EROFS', 'ENOTDIR']);
|
|
24
|
+
const statusOf = (key) => key ? { set: true, last4: key.length >= 12 ? key.slice(-4) : undefined } : { set: false };
|
|
25
|
+
export class ImageKeyStore {
|
|
26
|
+
homeFile;
|
|
27
|
+
localFile;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.homeFile = join(options.home, KEYS_FILE);
|
|
30
|
+
this.localFile = join(options.projectRoot, ...LOCAL_KEYS_PATH.split('/'));
|
|
31
|
+
}
|
|
32
|
+
/** The key for `provider`, or null. The home file wins over the project fallback. */
|
|
33
|
+
async key(provider) {
|
|
34
|
+
const home = await readKeys(this.homeFile);
|
|
35
|
+
const value = home?.[provider] ?? (await readKeys(this.localFile))?.[provider];
|
|
36
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
37
|
+
}
|
|
38
|
+
async status() {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const provider of IMAGE_PROVIDERS) {
|
|
41
|
+
out[provider] = statusOf((await this.key(provider)) ?? undefined);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Set (or with `null` remove) one provider's key. Written to `~/.pix3/keys.json`, or to the
|
|
47
|
+
* project's `.pix3/local/keys.json` when the home directory refuses the write. A removal clears
|
|
48
|
+
* the key from both files, so the fallback cannot bring it back.
|
|
49
|
+
*/
|
|
50
|
+
async set(provider, key) {
|
|
51
|
+
const value = key?.trim() || null;
|
|
52
|
+
if (value === null) {
|
|
53
|
+
await this.update(this.homeFile, provider, null).catch(ignoreUnwritable);
|
|
54
|
+
await this.update(this.localFile, provider, null).catch(ignoreUnwritable);
|
|
55
|
+
return { status: { set: false }, where: 'home' };
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await this.update(this.homeFile, provider, value);
|
|
59
|
+
// A key moved home must not leave a stale copy behind in the project.
|
|
60
|
+
await this.update(this.localFile, provider, null).catch(ignoreUnwritable);
|
|
61
|
+
return { status: statusOf(value), where: 'home' };
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (!UNWRITABLE.has(errnoCode(error) ?? ''))
|
|
65
|
+
throw error;
|
|
66
|
+
await this.update(this.localFile, provider, value);
|
|
67
|
+
return { status: statusOf(value), where: 'project' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async update(file, provider, value) {
|
|
71
|
+
const current = await readKeys(file);
|
|
72
|
+
if (value === null && !current?.[provider])
|
|
73
|
+
return;
|
|
74
|
+
const next = { ...(current ?? {}) };
|
|
75
|
+
if (value === null)
|
|
76
|
+
delete next[provider];
|
|
77
|
+
else
|
|
78
|
+
next[provider] = value;
|
|
79
|
+
await writeSecretFile(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const ignoreUnwritable = (error) => {
|
|
83
|
+
const code = errnoCode(error) ?? '';
|
|
84
|
+
if (!UNWRITABLE.has(code) && code !== 'ENOENT')
|
|
85
|
+
throw error;
|
|
86
|
+
};
|
|
87
|
+
const readKeys = async (file) => {
|
|
88
|
+
let text;
|
|
89
|
+
try {
|
|
90
|
+
text = await readFile(file, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
// An inaccessible home store must not hide the project fallback. In particular, macOS
|
|
94
|
+
// reports ENOTDIR for a file in a parent path, where other systems may report ENOENT.
|
|
95
|
+
const code = errnoCode(error) ?? '';
|
|
96
|
+
if (code === 'ENOENT' || UNWRITABLE.has(code))
|
|
97
|
+
return null;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(text);
|
|
102
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
103
|
+
? parsed
|
|
104
|
+
: null;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A hand edit that broke the JSON: no key rather than a crash (the message never quotes it).
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/** 0600 file in a 0700 directory, replaced atomically (never a moment with looser bits). */
|
|
112
|
+
const writeSecretFile = async (file, text) => {
|
|
113
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
114
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
115
|
+
await writeFile(tmp, text, { mode: 0o600 });
|
|
116
|
+
await chmod(tmp, 0o600);
|
|
117
|
+
await rename(tmp, file);
|
|
118
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type ImageKeyStore, type ImageProvider } from './image-keys.ts';
|
|
3
|
+
/**
|
|
4
|
+
* `/__pix3/api/keys` and `/__pix3/api/proxy/{gemini,openai}/…` (plan §B.1 «Ключи генерации
|
|
5
|
+
* картинок»). The editor's Generate panel calls the providers through here; the plugin adds the
|
|
6
|
+
* key from {@link ImageKeyStore}, so the page never holds it. Only the two generation endpoints
|
|
7
|
+
* the panel uses are forwarded — the key is not a general-purpose credential for the page.
|
|
8
|
+
* Who may call is the router's business (`RequestGuard.checkEditorOnly`).
|
|
9
|
+
*
|
|
10
|
+
* The key never leaves this module: responses and logs carry the provider, the path, the status
|
|
11
|
+
* and the size; an upstream answer that happens to contain the key gets it replaced by `[key]`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_UPSTREAMS: Readonly<Record<ImageProvider, string>>;
|
|
14
|
+
/** Env overrides of the upstream origins (a local mock in an e2e run). */
|
|
15
|
+
export declare const UPSTREAM_ENV: Readonly<Record<ImageProvider, string>>;
|
|
16
|
+
export interface ImageProxyDeps {
|
|
17
|
+
readonly keys: ImageKeyStore;
|
|
18
|
+
readonly upstreams: Readonly<Record<ImageProvider, string>>;
|
|
19
|
+
readonly log: (line: string) => void;
|
|
20
|
+
/** For specs; the global `fetch` otherwise. */
|
|
21
|
+
readonly fetchImpl?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
export declare class ImageProxy {
|
|
24
|
+
private readonly deps;
|
|
25
|
+
constructor(deps: ImageProxyDeps);
|
|
26
|
+
/** `GET /__pix3/api/keys` → `{keys: {gemini: {set, last4?}, openai: …}}`. */
|
|
27
|
+
keyStatus(res: ServerResponse): Promise<void>;
|
|
28
|
+
/** `PUT /__pix3/api/keys` `{provider, key}` (`key: null` or `''` removes) → the new status only. */
|
|
29
|
+
setKey(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
30
|
+
/** `POST /__pix3/api/proxy/<provider>/<path>` → the upstream's answer, the key added. */
|
|
31
|
+
forward(req: IncomingMessage, res: ServerResponse, rest: string): Promise<void>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { HttpError, readBytes, readJson, sendJson } from "./http.js";
|
|
2
|
+
import { isImageProvider } from "./image-keys.js";
|
|
3
|
+
/**
|
|
4
|
+
* `/__pix3/api/keys` and `/__pix3/api/proxy/{gemini,openai}/…` (plan §B.1 «Ключи генерации
|
|
5
|
+
* картинок»). The editor's Generate panel calls the providers through here; the plugin adds the
|
|
6
|
+
* key from {@link ImageKeyStore}, so the page never holds it. Only the two generation endpoints
|
|
7
|
+
* the panel uses are forwarded — the key is not a general-purpose credential for the page.
|
|
8
|
+
* Who may call is the router's business (`RequestGuard.checkEditorOnly`).
|
|
9
|
+
*
|
|
10
|
+
* The key never leaves this module: responses and logs carry the provider, the path, the status
|
|
11
|
+
* and the size; an upstream answer that happens to contain the key gets it replaced by `[key]`.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_UPSTREAMS = {
|
|
14
|
+
gemini: 'https://generativelanguage.googleapis.com',
|
|
15
|
+
openai: 'https://api.openai.com',
|
|
16
|
+
};
|
|
17
|
+
/** Env overrides of the upstream origins (a local mock in an e2e run). */
|
|
18
|
+
export const UPSTREAM_ENV = {
|
|
19
|
+
gemini: 'PIX3_PROXY_GEMINI_URL',
|
|
20
|
+
openai: 'PIX3_PROXY_OPENAI_URL',
|
|
21
|
+
};
|
|
22
|
+
/** The upstream paths a provider may be called on (relative to its origin, no leading `/`). */
|
|
23
|
+
const ALLOWED_PATHS = {
|
|
24
|
+
gemini: /^v1(beta)?\/models\/[A-Za-z0-9._-]+:generateContent$/,
|
|
25
|
+
openai: /^v1\/images\/(generations|edits)$/,
|
|
26
|
+
};
|
|
27
|
+
/** Reference images ride in the body as base64 / multipart. */
|
|
28
|
+
const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
29
|
+
const UPSTREAM_TIMEOUT_MS = 5 * 60_000;
|
|
30
|
+
const authHeaders = (provider, key) => provider === 'gemini' ? { 'x-goog-api-key': key } : { Authorization: `Bearer ${key}` };
|
|
31
|
+
const KEY_HINT = 'Set it in the Generate panel (the key icon) or in Editor Settings → AI Images.';
|
|
32
|
+
export class ImageProxy {
|
|
33
|
+
deps;
|
|
34
|
+
constructor(deps) {
|
|
35
|
+
this.deps = deps;
|
|
36
|
+
}
|
|
37
|
+
/** `GET /__pix3/api/keys` → `{keys: {gemini: {set, last4?}, openai: …}}`. */
|
|
38
|
+
async keyStatus(res) {
|
|
39
|
+
sendJson(res, 200, { keys: await this.deps.keys.status() });
|
|
40
|
+
}
|
|
41
|
+
/** `PUT /__pix3/api/keys` `{provider, key}` (`key: null` or `''` removes) → the new status only. */
|
|
42
|
+
async setKey(req, res) {
|
|
43
|
+
const body = await readJson(req, 16 * 1024);
|
|
44
|
+
if (!isImageProvider(body.provider)) {
|
|
45
|
+
throw new HttpError(400, 'bad_provider', '`provider` must be "gemini" or "openai".');
|
|
46
|
+
}
|
|
47
|
+
if (body.key !== null && typeof body.key !== 'string') {
|
|
48
|
+
throw new HttpError(400, 'bad_key', '`key` must be a string, or null to remove it.');
|
|
49
|
+
}
|
|
50
|
+
if (typeof body.key === 'string' && /[\s\0]/.test(body.key.trim())) {
|
|
51
|
+
throw new HttpError(400, 'bad_key', 'An API key has no spaces or control characters.');
|
|
52
|
+
}
|
|
53
|
+
const { status, where } = await this.deps.keys.set(body.provider, body.key);
|
|
54
|
+
this.deps.log(`image-gen: ${body.provider} key ${status.set ? 'set' : 'removed'} (${where})`);
|
|
55
|
+
sendJson(res, 200, { provider: body.provider, ...status, where });
|
|
56
|
+
}
|
|
57
|
+
/** `POST /__pix3/api/proxy/<provider>/<path>` → the upstream's answer, the key added. */
|
|
58
|
+
async forward(req, res, rest) {
|
|
59
|
+
const slash = rest.indexOf('/');
|
|
60
|
+
const provider = slash < 0 ? rest : rest.slice(0, slash);
|
|
61
|
+
const path = slash < 0 ? '' : rest.slice(slash + 1);
|
|
62
|
+
if (!isImageProvider(provider)) {
|
|
63
|
+
throw new HttpError(404, 'not_found', 'Unknown image provider.');
|
|
64
|
+
}
|
|
65
|
+
if (req.method !== 'POST')
|
|
66
|
+
throw new HttpError(405, 'method', 'POST only.');
|
|
67
|
+
if (!ALLOWED_PATHS[provider].test(path)) {
|
|
68
|
+
throw new HttpError(403, 'path_not_allowed', `The ${provider} proxy forwards image generation only.`);
|
|
69
|
+
}
|
|
70
|
+
const key = await this.deps.keys.key(provider);
|
|
71
|
+
if (!key) {
|
|
72
|
+
req.resume();
|
|
73
|
+
throw new HttpError(409, 'no_key', `No ${provider} API key on this machine. ${KEY_HINT}`);
|
|
74
|
+
}
|
|
75
|
+
const body = await readBytes(req, MAX_REQUEST_BYTES);
|
|
76
|
+
const headers = authHeaders(provider, key);
|
|
77
|
+
const contentType = req.headers['content-type'];
|
|
78
|
+
if (typeof contentType === 'string')
|
|
79
|
+
headers['Content-Type'] = contentType;
|
|
80
|
+
const target = `${this.deps.upstreams[provider].replace(/\/+$/, '')}/${path}`;
|
|
81
|
+
const started = Date.now();
|
|
82
|
+
const abort = new AbortController();
|
|
83
|
+
const timer = setTimeout(() => abort.abort(), UPSTREAM_TIMEOUT_MS);
|
|
84
|
+
// The editor's Cancel (or a closed tab) aborts the upstream call too.
|
|
85
|
+
res.on('close', () => {
|
|
86
|
+
if (!res.writableFinished)
|
|
87
|
+
abort.abort();
|
|
88
|
+
});
|
|
89
|
+
let upstream;
|
|
90
|
+
let bytes;
|
|
91
|
+
try {
|
|
92
|
+
upstream = await (this.deps.fetchImpl ?? fetch)(target, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers,
|
|
95
|
+
body,
|
|
96
|
+
signal: abort.signal,
|
|
97
|
+
});
|
|
98
|
+
bytes = Buffer.from(await upstream.arrayBuffer());
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const reason = abort.signal.aborted ? 'aborted' : 'unreachable';
|
|
102
|
+
this.deps.log(`image-gen: ${provider} ${path} → ${reason} (${Date.now() - started} ms)`);
|
|
103
|
+
throw new HttpError(502, 'upstream_unreachable', `The ${provider} API did not answer (${reason}): ${scrub(describe(error), key)}`);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
const scrubbed = scrubBuffer(bytes, key);
|
|
109
|
+
this.deps.log(`image-gen: ${provider} ${path} → ${upstream.status} (${scrubbed.length} bytes, ` +
|
|
110
|
+
`${Date.now() - started} ms)`);
|
|
111
|
+
if (res.writableEnded || res.destroyed)
|
|
112
|
+
return;
|
|
113
|
+
res.writeHead(upstream.status, {
|
|
114
|
+
'Content-Type': upstream.headers.get('content-type') ?? 'application/octet-stream',
|
|
115
|
+
'Cache-Control': 'no-store',
|
|
116
|
+
});
|
|
117
|
+
res.end(scrubbed);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const describe = (error) => error instanceof Error
|
|
121
|
+
? `${error.message}${error.cause instanceof Error ? ` (${error.cause.message})` : ''}`
|
|
122
|
+
: String(error);
|
|
123
|
+
const scrub = (text, key) => text.split(key).join('[key]');
|
|
124
|
+
const scrubBuffer = (bytes, key) => bytes.includes(key) ? Buffer.from(scrub(bytes.toString('utf8'), key), 'utf8') : bytes;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { ProjectFiles } from '../files/project-files.ts';
|
|
3
|
+
import { type SyncBarrier } from '../sync/barrier.ts';
|
|
4
|
+
import type { EditorSocket } from './editor-socket.ts';
|
|
5
|
+
import { type RequestGuard } from './guard.ts';
|
|
6
|
+
import type { ImageProxy } from './image-proxy.ts';
|
|
7
|
+
/**
|
|
8
|
+
* `/__pix3/*` (plan §B.1): the editor page and the file API. Mounted as a pre-middleware, so it
|
|
9
|
+
* sees `req.url` with Vite's `base` still on it.
|
|
10
|
+
*
|
|
11
|
+
* | Route | |
|
|
12
|
+
* |---|---|
|
|
13
|
+
* | `GET /__pix3/` | the editor page (raw HTML: no `transformIndexHtml`, no `/@vite/client`) |
|
|
14
|
+
* | `GET /__pix3/editor.css` | the prebuilt editor's stylesheet |
|
|
15
|
+
* | `GET /__pix3/api/hello` | project, versions, `seq`, `revision`, current writer |
|
|
16
|
+
* | `GET\|HEAD\|PUT /__pix3/api/file?path=` | bytes with sha256 ETag; conditional atomic write |
|
|
17
|
+
* | `GET /__pix3/api/manifest` | full rescan + every file with its hash |
|
|
18
|
+
* | `POST /__pix3/api/hash` | `{paths}` → `{hashes}` |
|
|
19
|
+
* | `POST /__pix3/api/{mkdir,delete,move}` | |
|
|
20
|
+
* | `POST /__pix3/api/changeset` | `{files: [{path, text\|base64, ifMatch?, createOnly?}]}` as one transaction (§C.4) |
|
|
21
|
+
* | `GET /__pix3/api/history?path=` | `{entries}` of the version journal, newest first |
|
|
22
|
+
* | `GET\|HEAD /__pix3/api/history/version?path=&id=` | one journaled version's raw bytes |
|
|
23
|
+
* | `POST /__pix3/api/history/record` | `{path, text, author: 'rejected-draft', note?}` → the entry (any tab) |
|
|
24
|
+
* | `POST /__pix3/api/history/restore` | `{path, id}` (+ `If-Match`) → written back, seen as `external` |
|
|
25
|
+
* | `POST /__pix3/api/handover/claim` | `{writerId}` → the disk the new writer starts from |
|
|
26
|
+
* | `POST /__pix3/api/sync` | the barrier (§B.3) |
|
|
27
|
+
* | `POST /__pix3/api/flush` | step 0 of the barrier on its own (`vite build`, `pix3 check`/`smoke` before they read the disk) |
|
|
28
|
+
* | `GET\|PUT /__pix3/api/keys` | image-gen key status `{set, last4?}` per provider / set or remove one (never echoed) |
|
|
29
|
+
* | `POST /__pix3/api/proxy/{gemini,openai}/…` | the provider's generation endpoint, the key added server-side |
|
|
30
|
+
*
|
|
31
|
+
* `keys` and `proxy` answer the editor page only (`RequestGuard.checkEditorOnly`: the page's
|
|
32
|
+
* session token, its `Referer`, `Sec-Fetch-Site`). No URL with a `.pix3` segment is served at all —
|
|
33
|
+
* the project's `.pix3/` holds `local/keys.json` when `~` is not writable.
|
|
34
|
+
*/
|
|
35
|
+
export interface RouterDeps {
|
|
36
|
+
readonly base: string;
|
|
37
|
+
readonly guard: RequestGuard;
|
|
38
|
+
readonly files: ProjectFiles;
|
|
39
|
+
readonly socket: EditorSocket;
|
|
40
|
+
readonly barrier: SyncBarrier;
|
|
41
|
+
readonly hello: () => Record<string, unknown>;
|
|
42
|
+
/** `session`: the token for this request's page, or null (`editorSessionFor`). */
|
|
43
|
+
readonly editorPage: (session: string | null) => {
|
|
44
|
+
status: number;
|
|
45
|
+
html: string;
|
|
46
|
+
};
|
|
47
|
+
/** Handed to the editor page on a top-level navigation; required by `keys` and `proxy`. */
|
|
48
|
+
readonly sessionToken: string;
|
|
49
|
+
readonly imageProxy: ImageProxy;
|
|
50
|
+
/** The prebuilt editor's stylesheet (`@pix3/editor-core/dist/editor.css`), or null. */
|
|
51
|
+
readonly editorCss: () => Buffer | null;
|
|
52
|
+
readonly log: (line: string) => void;
|
|
53
|
+
}
|
|
54
|
+
export declare const createRouter: (deps: RouterDeps) => (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { DEFAULT_SYNC_TIMEOUT_MS, parseSyncRequest, parseTimeout, } from "../sync/barrier.js";
|
|
2
|
+
import { editorSessionFor } from "./guard.js";
|
|
3
|
+
import { errorBody, HttpError, readJson, sendJson } from "./http.js";
|
|
4
|
+
export const createRouter = (deps) => {
|
|
5
|
+
const prefix = `${deps.base}__pix3`;
|
|
6
|
+
const apiPrefix = `${prefix}/api/`;
|
|
7
|
+
/** A handler whose answer is `200` + JSON. */
|
|
8
|
+
const json = (answer) => async (req, res, url) => sendJson(res, 200, await answer(req, url));
|
|
9
|
+
const { files } = deps;
|
|
10
|
+
const readFile = (req, res, url, head) => files.readFile(req, res, url, head);
|
|
11
|
+
const historyVersion = (_req, res, url, head) => files.historyVersion(res, url, head);
|
|
12
|
+
const jsonMutation = (op) => async (req, res) => files.jsonMutation(req, res, op, await readJson(req));
|
|
13
|
+
/** `api path → method → handler`; an unknown pair is a 404. */
|
|
14
|
+
const apiRoutes = {
|
|
15
|
+
hello: { GET: json(async () => deps.hello()) },
|
|
16
|
+
file: { GET: readFile, HEAD: readFile, PUT: (req, res, url) => files.writeFile(req, res, url) },
|
|
17
|
+
manifest: { GET: json(() => files.manifest()) },
|
|
18
|
+
hash: { POST: json(async (req) => files.hashPaths(await readJson(req))) },
|
|
19
|
+
mkdir: { POST: jsonMutation('mkdir') },
|
|
20
|
+
delete: { POST: jsonMutation('delete') },
|
|
21
|
+
move: { POST: jsonMutation('move') },
|
|
22
|
+
changeset: { POST: (req, res) => files.writeChangeset(req, res) },
|
|
23
|
+
history: { GET: json((_req, url) => files.historyList(url)) },
|
|
24
|
+
'history/version': { GET: historyVersion, HEAD: historyVersion },
|
|
25
|
+
'history/record': {
|
|
26
|
+
POST: json(async (req) => ({ ...(await files.historyRecord(await readJson(req))) })),
|
|
27
|
+
},
|
|
28
|
+
'history/restore': { POST: (req, res) => files.historyRestore(req, res) },
|
|
29
|
+
'handover/claim': {
|
|
30
|
+
POST: json(async (req) => {
|
|
31
|
+
const claimed = await files.claim((await readJson(req)).writerId);
|
|
32
|
+
deps.socket.broadcast({ type: 'pix3:writer', writerId: claimed.writerId });
|
|
33
|
+
return claimed;
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
sync: { POST: json(async (req) => deps.barrier.sync(parseSyncRequest(await readJson(req)))) },
|
|
37
|
+
flush: {
|
|
38
|
+
POST: json(async (req) => deps.barrier.flush(parseTimeout((await readJson(req)).timeoutMs, DEFAULT_SYNC_TIMEOUT_MS))),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const route = async (req, res, url) => {
|
|
42
|
+
const method = req.method ?? 'GET';
|
|
43
|
+
const path = url.pathname;
|
|
44
|
+
deps.guard.checkPeer(req);
|
|
45
|
+
if (method !== 'GET' && method !== 'HEAD')
|
|
46
|
+
deps.guard.checkMutation(req);
|
|
47
|
+
if (path === prefix) {
|
|
48
|
+
res.writeHead(302, { Location: `${prefix}/${url.search}` });
|
|
49
|
+
res.end();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (path === `${prefix}/` || path === `${prefix}/index.html`) {
|
|
53
|
+
if (method !== 'GET' && method !== 'HEAD')
|
|
54
|
+
throw new HttpError(405, 'method', 'GET only.');
|
|
55
|
+
const page = deps.editorPage(editorSessionFor(req, deps.sessionToken));
|
|
56
|
+
res.writeHead(page.status, {
|
|
57
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
58
|
+
'Cache-Control': 'no-store',
|
|
59
|
+
// A window the game opens on the editor gets no handle into it (the session token, the
|
|
60
|
+
// host object): another page's popup with a different policy lands in its own group.
|
|
61
|
+
// `-allow-popups` keeps the editor's own game pop-out scriptable.
|
|
62
|
+
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
|
|
63
|
+
});
|
|
64
|
+
res.end(method === 'HEAD' ? undefined : page.html);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (path === `${prefix}/editor.css` && (method === 'GET' || method === 'HEAD')) {
|
|
68
|
+
// Served statically, past the project's PostCSS (plan §B.0): no CSS import may reach the
|
|
69
|
+
// editor chain (contract B).
|
|
70
|
+
const css = deps.editorCss();
|
|
71
|
+
if (!css)
|
|
72
|
+
throw new HttpError(404, 'not_found', '@pix3/editor-core is not installed.');
|
|
73
|
+
res.writeHead(200, {
|
|
74
|
+
'Content-Type': 'text/css; charset=utf-8',
|
|
75
|
+
'Cache-Control': 'no-cache',
|
|
76
|
+
});
|
|
77
|
+
res.end(method === 'HEAD' ? undefined : css);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!path.startsWith(apiPrefix))
|
|
81
|
+
throw new HttpError(404, 'not_found', 'Not found.');
|
|
82
|
+
const api = path.slice(apiPrefix.length);
|
|
83
|
+
if (api === 'keys' || api.startsWith('proxy/')) {
|
|
84
|
+
deps.guard.checkEditorOnly(req, `${prefix}/`, deps.sessionToken);
|
|
85
|
+
if (method === 'GET' && api === 'keys') {
|
|
86
|
+
await deps.imageProxy.keyStatus(res);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (method === 'PUT' && api === 'keys') {
|
|
90
|
+
await deps.imageProxy.setKey(req, res);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (api.startsWith('proxy/')) {
|
|
94
|
+
await deps.imageProxy.forward(req, res, api.slice('proxy/'.length));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
throw new HttpError(405, 'method', 'GET or PUT.');
|
|
98
|
+
}
|
|
99
|
+
const handler = Object.hasOwn(apiRoutes, api) ? apiRoutes[api][method] : undefined;
|
|
100
|
+
if (handler) {
|
|
101
|
+
await handler(req, res, url, method === 'HEAD');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw new HttpError(404, 'not_found', 'Not found.');
|
|
105
|
+
};
|
|
106
|
+
return (req, res, next) => {
|
|
107
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
108
|
+
if (isProjectStatePath(url.pathname)) {
|
|
109
|
+
// `.pix3/` is the plugin's and the CLI's state (dev.json, the journal, local keys), not a
|
|
110
|
+
// static asset of the game.
|
|
111
|
+
req.resume();
|
|
112
|
+
sendJson(res, 404, { error: 'not_found', message: 'Not found.' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) {
|
|
116
|
+
next();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
route(req, res, url).catch((error) => {
|
|
120
|
+
if (!(error instanceof HttpError)) {
|
|
121
|
+
deps.log(`500 ${req.method} ${req.url}: ${error instanceof Error ? error.stack : String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
if (res.headersSent) {
|
|
124
|
+
res.destroy();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const { status, body } = errorBody(error);
|
|
128
|
+
// Drain what the client is still sending, so it reads our answer instead of EPIPE.
|
|
129
|
+
req.resume();
|
|
130
|
+
sendJson(res, status, body);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Any `.pix3` segment, however the browser spelled it (`%2e`, case): the project's own `.pix3/`
|
|
136
|
+
* at the root, by `/@fs/<abs>/.pix3/…`, and `~/.pix3` should `fs.allow` ever reach the home.
|
|
137
|
+
*/
|
|
138
|
+
const isProjectStatePath = (pathname) => {
|
|
139
|
+
let decoded;
|
|
140
|
+
try {
|
|
141
|
+
decoded = decodeURIComponent(pathname);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return decoded.split('/').some(segment => segment.toLowerCase() === '.pix3');
|
|
147
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ProjectFiles } from '../files/project-files.ts';
|
|
2
|
+
import type { EditorSocket } from '../server/editor-socket.ts';
|
|
3
|
+
import { type ScriptGraph } from './script-graph.ts';
|
|
4
|
+
/**
|
|
5
|
+
* The sync barrier (plan §B.3), as S1 found it works:
|
|
6
|
+
*
|
|
7
|
+
* 0. **flush** — the editor writes its dirty scenes. The page's `pix3_sync` flushes itself before
|
|
8
|
+
* calling `POST /sync`; a caller without a tab (the CLI), or a tab that is not the writer (a
|
|
9
|
+
* read-only second tab's `pix3_sync`), gets the flush done here, through the writer tab
|
|
10
|
+
* (`POST /flush` is the same step on its own);
|
|
11
|
+
* 1. **rescan** — stat + sha256 of the revision set, changes broadcast as `pix3:fs`;
|
|
12
|
+
* 2. **hard-invalidate the roots**, so their `load()` runs again with the new revision;
|
|
13
|
+
* 3. **propagate** every changed file through the client graph (`environment.reloadModule`), and
|
|
14
|
+
* the roots themselves when a globbed script was added or removed;
|
|
15
|
+
* 4. **page confirmation** — the tab re-imports the roots with `?t=<13 digits>` and answers with the
|
|
16
|
+
* revision it executed and `globalThis.__pix3Executed`. Sync is ok only when that revision is
|
|
17
|
+
* current AND every changed module the editor runs carries a stamp equal to the sha on disk.
|
|
18
|
+
* A page behind on either is asked again (roots re-invalidated, bounded: {@link
|
|
19
|
+
* MAX_CONFIRM_ATTEMPTS}, within `timeoutMs`); still behind → `{ok:false, reason:'stale_modules',
|
|
20
|
+
* paths, ackRev}`.
|
|
21
|
+
*
|
|
22
|
+
* A non-ok answer is not a barrier: on `gesture_in_progress` and `stale_modules` the agent
|
|
23
|
+
* retries; on `expect_mismatch` it re-reads; on `stale` (play is running) it follows `playing`.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_SYNC_TIMEOUT_MS = 10000;
|
|
26
|
+
export declare const VITE_CLIENT_WARNING: string;
|
|
27
|
+
export interface SyncRequest {
|
|
28
|
+
readonly expect?: Record<string, string>;
|
|
29
|
+
readonly timeoutMs: number;
|
|
30
|
+
/** The tab that asked (the page's `pix3_sync`); it has flushed already. */
|
|
31
|
+
readonly tabId: string | null;
|
|
32
|
+
}
|
|
33
|
+
export type SyncResult = Record<string, unknown> & {
|
|
34
|
+
readonly ok: boolean;
|
|
35
|
+
};
|
|
36
|
+
export declare const parseTimeout: (raw: unknown, fallback: number) => number;
|
|
37
|
+
export declare const parseSyncRequest: (body: Record<string, unknown>) => SyncRequest;
|
|
38
|
+
export interface BarrierDeps {
|
|
39
|
+
readonly files: ProjectFiles;
|
|
40
|
+
readonly scripts: ScriptGraph;
|
|
41
|
+
readonly socket: EditorSocket;
|
|
42
|
+
readonly log?: (line: string) => void;
|
|
43
|
+
}
|
|
44
|
+
export declare class SyncBarrier {
|
|
45
|
+
private readonly deps;
|
|
46
|
+
/** sha already propagated by a sync, per wire path: the watcher's late echo is suppressed. */
|
|
47
|
+
private readonly propagated;
|
|
48
|
+
/** One barrier at a time; a second sync waits for the first. */
|
|
49
|
+
private tail;
|
|
50
|
+
constructor(deps: BarrierDeps);
|
|
51
|
+
/** The tab that answers for the editor: the asker, else the writer, else the oldest tab. */
|
|
52
|
+
private targetTab;
|
|
53
|
+
/**
|
|
54
|
+
* Step 0 on its own (`POST /__pix3/api/flush`, and `npm run build` / `pix3 check` before they
|
|
55
|
+
* read the disk): the writer tab writes its dirty scenes. No writer tab = nothing unsaved here.
|
|
56
|
+
*/
|
|
57
|
+
flush(timeoutMs: number): Promise<SyncResult>;
|
|
58
|
+
sync(request: SyncRequest): Promise<SyncResult>;
|
|
59
|
+
private run;
|
|
60
|
+
/** Changed files the editor executes, with the sha their stamp must carry. */
|
|
61
|
+
private requiredStamps;
|
|
62
|
+
private judge;
|
|
63
|
+
/**
|
|
64
|
+
* `handleHotUpdate` for a file a sync already propagated with the same bytes: the watcher's
|
|
65
|
+
* late echo would otherwise propagate a second time (S1: chokidar repeats `change`).
|
|
66
|
+
*/
|
|
67
|
+
alreadyPropagated(wirePath: string, sha: string | null): boolean;
|
|
68
|
+
notePropagated(wirePath: string, sha: string | null): void;
|
|
69
|
+
}
|