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

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 (101) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +39 -2
  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 +123 -0
  22. package/dist/build/player-modules.js +254 -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 +290 -0
  61. package/dist/module-paths.d.ts +13 -0
  62. package/dist/module-paths.js +32 -0
  63. package/dist/player/debug/context.d.ts +9 -0
  64. package/dist/player/debug/context.js +1 -0
  65. package/dist/player/debug/device-info.d.ts +15 -0
  66. package/dist/player/debug/device-info.js +36 -0
  67. package/dist/player/debug/frame-stats.d.ts +79 -0
  68. package/dist/player/debug/frame-stats.js +159 -0
  69. package/dist/player/debug/index.d.ts +10 -0
  70. package/dist/player/debug/index.js +20 -0
  71. package/dist/player/debug/long-frames.d.ts +46 -0
  72. package/dist/player/debug/long-frames.js +79 -0
  73. package/dist/player/debug/overlay.d.ts +25 -0
  74. package/dist/player/debug/overlay.js +320 -0
  75. package/dist/player/debug/session.d.ts +92 -0
  76. package/dist/player/debug/session.js +156 -0
  77. package/dist/player/dev/editor-link.d.ts +16 -0
  78. package/dist/player/dev/editor-link.js +125 -0
  79. package/dist/player/index.d.ts +38 -0
  80. package/dist/player/index.js +119 -0
  81. package/dist/server/editor-socket.d.ts +53 -0
  82. package/dist/server/editor-socket.js +134 -0
  83. package/dist/server/guard.d.ts +61 -0
  84. package/dist/server/guard.js +165 -0
  85. package/dist/server/http.d.ts +24 -0
  86. package/dist/server/http.js +70 -0
  87. package/dist/server/image-keys.d.ts +46 -0
  88. package/dist/server/image-keys.js +118 -0
  89. package/dist/server/image-proxy.d.ts +32 -0
  90. package/dist/server/image-proxy.js +124 -0
  91. package/dist/server/router.d.ts +54 -0
  92. package/dist/server/router.js +147 -0
  93. package/dist/sync/barrier.d.ts +69 -0
  94. package/dist/sync/barrier.js +240 -0
  95. package/dist/sync/hot-update.d.ts +16 -0
  96. package/dist/sync/hot-update.js +58 -0
  97. package/dist/sync/script-graph.d.ts +79 -0
  98. package/dist/sync/script-graph.js +214 -0
  99. package/dist/vite-config.d.ts +12 -0
  100. package/dist/vite-config.js +65 -0
  101. package/package.json +58 -4
@@ -0,0 +1,165 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { BlockList, isIP } from 'node:net';
3
+ import { headerValue, HttpError } from "./http.js";
4
+ const LOOPBACK = new BlockList();
5
+ LOOPBACK.addSubnet('127.0.0.0', 8, 'ipv4');
6
+ LOOPBACK.addAddress('::1', 'ipv6');
7
+ /** A literal IP address (`[…]` brackets dropped), or null for a name. */
8
+ const ipLiteral = (name) => {
9
+ const bare = name.startsWith('[') && name.endsWith(']') ? name.slice(1, -1) : name;
10
+ const family = isIP(bare);
11
+ return family === 4
12
+ ? { address: bare, family: 'ipv4' }
13
+ : family === 6
14
+ ? { address: bare, family: 'ipv6' }
15
+ : null;
16
+ };
17
+ /** In 127.0.0.0/8 or ::1 (IPv4-mapped included) — by address, never by how a name begins. */
18
+ const isLoopbackIp = (address) => {
19
+ const ip = ipLiteral(address);
20
+ return ip !== null && LOOPBACK.check(ip.address, ip.family);
21
+ };
22
+ /** `Host` header → lower-cased name (`[::1]` keeps its brackets), or null when malformed. */
23
+ export const hostNameOf = (hostHeader) => {
24
+ const host = (hostHeader ?? '').trim().toLowerCase();
25
+ if (!host)
26
+ return null;
27
+ let name;
28
+ let port;
29
+ if (host.startsWith('[')) {
30
+ const close = host.indexOf(']');
31
+ if (close < 0)
32
+ return null;
33
+ name = host.slice(0, close + 1);
34
+ const rest = host.slice(close + 1);
35
+ if (rest && !rest.startsWith(':'))
36
+ return null;
37
+ port = rest.slice(1);
38
+ }
39
+ else {
40
+ const colon = host.indexOf(':');
41
+ name = colon < 0 ? host : host.slice(0, colon);
42
+ port = colon < 0 ? '' : host.slice(colon + 1);
43
+ if (colon >= 0 && !port)
44
+ return null;
45
+ }
46
+ if (port && !/^\d{1,5}$/.test(port))
47
+ return null;
48
+ return name || null;
49
+ };
50
+ /** Vite's rule for one `allowedHosts` entry: exact name, or `.example.com` for it and subdomains. */
51
+ const matchesAllowed = (name, entry) => {
52
+ const allowed = entry.toLowerCase();
53
+ if (allowed.startsWith('.'))
54
+ return name === allowed.slice(1) || name.endsWith(allowed);
55
+ return name === allowed;
56
+ };
57
+ export const isAllowedHost = (hostHeader, allowed, options = {}) => {
58
+ const name = hostNameOf(hostHeader);
59
+ if (name === null)
60
+ return false;
61
+ if (name === 'localhost' || isLoopbackIp(name))
62
+ return true;
63
+ // An IP literal cannot be a rebinding page's name; the peer check still applies.
64
+ if (options.allowRemote && ipLiteral(name) !== null)
65
+ return true;
66
+ if (allowed === true)
67
+ return true;
68
+ return allowed.some(entry => matchesAllowed(name, entry));
69
+ };
70
+ export const isLoopbackAddress = (address) => Boolean(address) && isLoopbackIp(address ?? '');
71
+ /** `Origin` header, or null when absent (a non-browser client). */
72
+ const originOf = (req) => headerValue(req, 'origin');
73
+ /** The origin a page served by this request's `Host` has. */
74
+ const ownOrigins = (req) => {
75
+ const host = headerValue(req, 'host') ?? '';
76
+ return [`http://${host}`, `https://${host}`].map(origin => origin.toLowerCase());
77
+ };
78
+ export class RequestGuard {
79
+ options;
80
+ constructor(options) {
81
+ this.options = options;
82
+ }
83
+ /** Every `/__pix3/*` request, reads included. */
84
+ checkPeer(req) {
85
+ if (!isAllowedHost(req.headers.host, this.options.allowedHosts(), {
86
+ allowRemote: this.options.allowRemote,
87
+ })) {
88
+ throw new HttpError(403, 'forbidden_host', 'Forbidden host.');
89
+ }
90
+ if (!this.options.allowRemote && !isLoopbackAddress(req.socket.remoteAddress)) {
91
+ throw new HttpError(403, 'forbidden_remote', 'The Pix3 editor only answers this machine. Pass pix3({ allowRemote: true }) to serve it ' +
92
+ 'to other hosts.');
93
+ }
94
+ }
95
+ /** The editor's WebSocket upgrade: no preflight exists, so its `Origin` is the only fence. */
96
+ checkSocket(req) {
97
+ this.checkPeer(req);
98
+ this.checkSameOrigin(req);
99
+ }
100
+ checkSameOrigin(req) {
101
+ const origin = originOf(req);
102
+ if (origin !== null && !ownOrigins(req).includes(origin.toLowerCase())) {
103
+ throw new HttpError(403, 'forbidden_origin', 'Origin not allowed.');
104
+ }
105
+ }
106
+ /** Anything that is not GET/HEAD. */
107
+ checkMutation(req) {
108
+ if (headerValue(req, 'x-pix3') !== '1') {
109
+ throw new HttpError(403, 'missing_x_pix3', 'Mutations need the header `X-Pix3: 1`.');
110
+ }
111
+ this.checkSameOrigin(req);
112
+ }
113
+ /**
114
+ * Routes only the editor page may use, reads included: the image-generation keys and proxy
115
+ * (plan §B.1). On top of {@link checkMutation}:
116
+ * - `Sec-Fetch-Site`, when the browser sends it, is `same-origin`;
117
+ * - `Referer`, when sent, is a page under `editorPrefix` (`<base>__pix3/`) — the game at `/` is
118
+ * the same origin, so the origin alone cannot tell the two apart;
119
+ * - `X-Pix3-Session` is the token the plugin hands only to a top-level navigation of the editor
120
+ * page ({@link editorSessionFor}). A `Referer` is the page's to choose within its origin
121
+ * (`fetch(url, {referrer})`), the token is not.
122
+ * Same-origin isolation has limits a header cannot fix (a service worker the game registers on
123
+ * `/` sees the editor's traffic); the key itself never reaches any page.
124
+ */
125
+ checkEditorOnly(req, editorPrefix, sessionToken) {
126
+ this.checkMutation(req);
127
+ const site = headerValue(req, 'sec-fetch-site');
128
+ if (site !== null && site !== 'same-origin') {
129
+ throw new HttpError(403, 'forbidden_site', 'Only the editor page may call this route.');
130
+ }
131
+ const referer = headerValue(req, 'referer');
132
+ if (referer !== null) {
133
+ let path;
134
+ try {
135
+ path = new URL(referer).pathname;
136
+ }
137
+ catch {
138
+ path = '';
139
+ }
140
+ if (!path.startsWith(editorPrefix)) {
141
+ throw new HttpError(403, 'forbidden_page', 'Only the editor page may call this route.');
142
+ }
143
+ }
144
+ if (!sameSecret(headerValue(req, 'x-pix3-session') ?? '', sessionToken)) {
145
+ throw new HttpError(403, 'bad_session', 'Missing or stale editor session: reload the editor tab.');
146
+ }
147
+ }
148
+ }
149
+ const sameSecret = (given, expected) => {
150
+ const a = Buffer.from(given);
151
+ const b = Buffer.from(expected);
152
+ return a.length === b.length && timingSafeEqual(a, b);
153
+ };
154
+ /**
155
+ * The session token for an editor-page request, or null when this request must not get it: the
156
+ * page fetched by script (`Sec-Fetch-Dest: empty`) or framed (`iframe`) — the game at `/` can do
157
+ * both. Browsers send `Sec-Fetch-*` only to secure origins (https, localhost); without them (a
158
+ * plain-http LAN address under `allowRemote`, a non-browser client) the token is handed out.
159
+ */
160
+ export const editorSessionFor = (req, token) => {
161
+ const dest = headerValue(req, 'sec-fetch-dest');
162
+ if (dest === null)
163
+ return token;
164
+ return dest === 'document' ? token : null;
165
+ };
@@ -0,0 +1,24 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /**
3
+ * HTTP primitives of `/__pix3/api/*` (port of `packages/cli/src/server/http.ts`). Errors are always
4
+ * `{ error: <code>, message, ...extra }` JSON, the shape the editor's `WorkspaceClient` parses.
5
+ */
6
+ export declare class HttpError extends Error {
7
+ readonly status: number;
8
+ readonly code: string;
9
+ readonly extra: Record<string, unknown>;
10
+ constructor(status: number, code: string, message: string, extra?: Record<string, unknown>);
11
+ }
12
+ export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
13
+ export declare const DEFAULT_MAX_JSON_BYTES: number;
14
+ /** The request body as bytes, refused past `maxBytes`. */
15
+ export declare const readBytes: (req: IncomingMessage, maxBytes?: number) => Promise<Buffer>;
16
+ export declare const readBody: (req: IncomingMessage, maxBytes?: number) => Promise<string>;
17
+ export declare const readJson: (req: IncomingMessage, maxBytes?: number) => Promise<Record<string, unknown>>;
18
+ export declare const headerValue: (req: IncomingMessage, name: string) => string | null;
19
+ export declare const sendJson: (res: ServerResponse, status: number, body: Record<string, unknown>, extraHeaders?: Record<string, string>) => void;
20
+ export declare const errnoCode: (error: unknown) => string | undefined;
21
+ export declare const errorBody: (error: unknown) => {
22
+ status: number;
23
+ body: Record<string, unknown>;
24
+ };
@@ -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;