@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,32 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
3
|
+
/** A module id without its query (`/a/b.ts?v=1` → `/a/b.ts`). */
|
|
4
|
+
export const fileOf = (id) => id.split('?')[0];
|
|
5
|
+
/** A platform path with `/` separators. */
|
|
6
|
+
export const posixPath = (path) => path.split(sep).join('/');
|
|
7
|
+
/** `file` relative to `dir`, `/`-separated, when it lies strictly inside `dir`; else null. */
|
|
8
|
+
export const relativeInside = (dir, file) => {
|
|
9
|
+
const rel = relative(dir, file);
|
|
10
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) ? posixPath(rel) : null;
|
|
11
|
+
};
|
|
12
|
+
export const isUnder = (file, dir) => dir !== null && relativeInside(dir, file) !== null;
|
|
13
|
+
const realPaths = new Map();
|
|
14
|
+
/**
|
|
15
|
+
* The real path of a module id (query dropped; the id itself when it is not a file), cached. The
|
|
16
|
+
* runtime's `src/` is a real path, and module ids are not always (`resolve.preserveSymlinks`, a
|
|
17
|
+
* linked workspace): comparing the two as written would silently strip nothing.
|
|
18
|
+
*/
|
|
19
|
+
export const realFileOf = (id) => {
|
|
20
|
+
const file = fileOf(id);
|
|
21
|
+
let real = realPaths.get(file);
|
|
22
|
+
if (real === undefined) {
|
|
23
|
+
try {
|
|
24
|
+
real = realpathSync(file);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
real = file;
|
|
28
|
+
}
|
|
29
|
+
realPaths.set(file, real);
|
|
30
|
+
}
|
|
31
|
+
return real;
|
|
32
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SceneRunner } from '@pix3/runtime';
|
|
2
|
+
import 'virtual:pix3/spine';
|
|
3
|
+
import 'virtual:pix3/postprocessing';
|
|
4
|
+
/**
|
|
5
|
+
* `@pix3/vite-plugin/player` (plan §B.5): the game's entry. A project's `src/main.ts` is
|
|
6
|
+
* `import { startGame } from '@pix3/vite-plugin/player'; startGame('#app');` — in dev the scenes
|
|
7
|
+
* and assets are fetched from the dev server, in a build they are embedded (html) or sit beside
|
|
8
|
+
* `index.html` (zip). Port of the 1.x `runtime/src/main.ts` + `register-project-scripts.ts`.
|
|
9
|
+
*
|
|
10
|
+
* `window.__PIX3_PLAYER__` is the gate's witness: `status`, `frames` (loop iterations), `errors`.
|
|
11
|
+
*/
|
|
12
|
+
export interface Pix3PlayerState {
|
|
13
|
+
status: 'booting' | 'running' | 'failed';
|
|
14
|
+
/** Frames the runner's loop has run. */
|
|
15
|
+
frames: number;
|
|
16
|
+
errors: string[];
|
|
17
|
+
/** The scene the player booted into. */
|
|
18
|
+
scene: string | null;
|
|
19
|
+
runner: SceneRunner | null;
|
|
20
|
+
}
|
|
21
|
+
declare global {
|
|
22
|
+
interface Window {
|
|
23
|
+
__PIX3_PLAYER__?: Pix3PlayerState;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export interface StartGameOptions {
|
|
27
|
+
/** `res://`-relative scene to boot instead of the manifest's entry scene. */
|
|
28
|
+
readonly scene?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Boot the game into `target` (a selector or an element; `#app` by default). */
|
|
31
|
+
export declare function startGame(target?: string | HTMLElement, options?: StartGameOptions): Promise<SceneRunner>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { AssetLoader, AudioService, registerBuiltInScripts, registerProjectScriptExports, ResourceManager, RuntimeRenderer, SceneLoader, SceneManager, SceneRunner, ScriptRegistry, } from '@pix3/runtime';
|
|
2
|
+
import { embeddedAssets } from 'virtual:pix3/embedded-assets';
|
|
3
|
+
import { installNetworkService } from 'virtual:pix3/network';
|
|
4
|
+
import { modules as projectScriptModules } from 'virtual:pix3/project-scripts';
|
|
5
|
+
import { activeScenePath, resourceBase, runtimeAutoloads, runtimeFonts, runtimeLocalization, runtimeQuality, runtimeViewportBaseSize, scenePaths, } from 'virtual:pix3/scene-manifest';
|
|
6
|
+
// Registers the optional Spine runtime (lazily in dev, statically in a build that uses it).
|
|
7
|
+
// Must run before the first scene load.
|
|
8
|
+
import 'virtual:pix3/spine';
|
|
9
|
+
// Same arrangement for `postprocessing`: registered statically when a single-file build places
|
|
10
|
+
// a PostProcess node, the runtime's own code-split import otherwise.
|
|
11
|
+
import 'virtual:pix3/postprocessing';
|
|
12
|
+
const describe = (error) => error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
13
|
+
const installState = () => {
|
|
14
|
+
const existing = window.__PIX3_PLAYER__;
|
|
15
|
+
if (existing)
|
|
16
|
+
return existing;
|
|
17
|
+
const state = {
|
|
18
|
+
status: 'booting',
|
|
19
|
+
frames: 0,
|
|
20
|
+
errors: [],
|
|
21
|
+
scene: null,
|
|
22
|
+
runner: null,
|
|
23
|
+
};
|
|
24
|
+
window.__PIX3_PLAYER__ = state;
|
|
25
|
+
window.addEventListener('error', event => {
|
|
26
|
+
state.errors.push(event.message || describe(event.error));
|
|
27
|
+
});
|
|
28
|
+
window.addEventListener('unhandledrejection', event => {
|
|
29
|
+
state.errors.push(`unhandledrejection: ${describe(event.reason)}`);
|
|
30
|
+
});
|
|
31
|
+
return state;
|
|
32
|
+
};
|
|
33
|
+
/** Boot the game into `target` (a selector or an element; `#app` by default). */
|
|
34
|
+
export async function startGame(target = '#app', options = {}) {
|
|
35
|
+
const state = installState();
|
|
36
|
+
try {
|
|
37
|
+
const app = typeof target === 'string' ? document.querySelector(target) : target;
|
|
38
|
+
if (!app)
|
|
39
|
+
throw new Error(`Missing ${typeof target === 'string' ? target : 'mount'} container`);
|
|
40
|
+
const resourceManager = new ResourceManager(resourceBase, embeddedAssets);
|
|
41
|
+
const audioService = new AudioService();
|
|
42
|
+
const scriptRegistry = new ScriptRegistry();
|
|
43
|
+
registerBuiltInScripts(scriptRegistry);
|
|
44
|
+
for (const [file, exports] of Object.entries(projectScriptModules)) {
|
|
45
|
+
registerProjectScriptExports(scriptRegistry, exports, file.replace(/^\/+/, ''));
|
|
46
|
+
}
|
|
47
|
+
const assetLoader = new AssetLoader(resourceManager, audioService);
|
|
48
|
+
const sceneLoader = new SceneLoader(assetLoader, scriptRegistry, resourceManager);
|
|
49
|
+
// No SceneSaver: a player never writes scenes back out, and one would pin every node class
|
|
50
|
+
// and `yaml.stringify` into the bundle (CLAUDE.md «Playable export size»).
|
|
51
|
+
const sceneManager = new SceneManager(sceneLoader);
|
|
52
|
+
const scenePath = options.scene || activeScenePath || scenePaths[0];
|
|
53
|
+
if (!scenePath)
|
|
54
|
+
throw new Error('No scenes found in this project.');
|
|
55
|
+
state.scene = scenePath;
|
|
56
|
+
const renderer = new RuntimeRenderer({
|
|
57
|
+
antialias: runtimeQuality.antialias,
|
|
58
|
+
shadows: runtimeQuality.shadows,
|
|
59
|
+
pixelRatio: Math.min(window.devicePixelRatio || 1, runtimeQuality.maxPixelRatio),
|
|
60
|
+
});
|
|
61
|
+
renderer.attach(app);
|
|
62
|
+
const runner = new SceneRunner(sceneManager, renderer, audioService, assetLoader, runtimeViewportBaseSize);
|
|
63
|
+
state.runner = runner;
|
|
64
|
+
// Project autoloads: built once when the first scene starts, kept across `changeScene` —
|
|
65
|
+
// the same runtime code the editor's play mode runs.
|
|
66
|
+
runner.setAutoloads(runtimeAutoloads, scriptRegistry);
|
|
67
|
+
// The loop runs on rAF; counting its iterations here is what `__PIX3_PLAYER__.frames` reports.
|
|
68
|
+
runner.setFrameScheduler({
|
|
69
|
+
request: callback => requestAnimationFrame(time => {
|
|
70
|
+
state.frames++;
|
|
71
|
+
callback(time);
|
|
72
|
+
}),
|
|
73
|
+
cancel: handle => cancelAnimationFrame(handle),
|
|
74
|
+
});
|
|
75
|
+
// Multiplayer (D5): owned by the runner, inert until a script connects; the generated module
|
|
76
|
+
// is a no-op when nothing in the project mentions the network.
|
|
77
|
+
installNetworkService(runner);
|
|
78
|
+
runner.setBatching2DEnabled(true);
|
|
79
|
+
runner.setProjectFonts(runtimeFonts.length > 0 ? runtimeFonts : null);
|
|
80
|
+
if (runtimeLocalization) {
|
|
81
|
+
runner.setLocalizationConfig({
|
|
82
|
+
defaultLocale: runtimeLocalization.defaultLocale,
|
|
83
|
+
fallbackLocale: runtimeLocalization.fallbackLocale,
|
|
84
|
+
locales: runtimeLocalization.locales,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// No pre-packed atlas: nothing in 2.x writes `assets/.atlas/atlas-manifest.json` (the editor
|
|
88
|
+
// packs into IndexedDB for its own play mode), so the player does not probe for one
|
|
89
|
+
// (.plans/player-build.md P26).
|
|
90
|
+
// `loadAndStartScene` reads, parses and runs the graph directly; `startScene` would clone it
|
|
91
|
+
// through the serializer, which a player does not need (and must not bundle).
|
|
92
|
+
await runner.loadAndStartScene(scenePath);
|
|
93
|
+
state.status = 'running';
|
|
94
|
+
return runner;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
state.status = 'failed';
|
|
98
|
+
state.errors.push(describe(error));
|
|
99
|
+
console.error('[pix3] Failed to start the game:', error);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import type { Duplex } from 'node:stream';
|
|
3
|
+
/**
|
|
4
|
+
* The editor's own WebSocket, `/__pix3/ws` (plan §B.2, variant B): the editor page carries no
|
|
5
|
+
* `/@vite/client`, so neither Vite's `full-reload` nor its reload-after-reconnect ever reaches
|
|
6
|
+
* it. Everything the plugin tells the page — file changes, "re-import your scripts", "flush" —
|
|
7
|
+
* goes over this socket.
|
|
8
|
+
*
|
|
9
|
+
* Client → plugin: `{type:'hello', tabId}` once, then `{type:'reply', id, ...}` answers.
|
|
10
|
+
* Plugin → client: `{type:'welcome', ...}`, broadcast frames (`pix3:fs`, `pix3:writer`,
|
|
11
|
+
* `pix3:scripts`), and requests `{type:'request', id, kind, ...}` that the tab answers by `id`.
|
|
12
|
+
*/
|
|
13
|
+
export interface EditorTab {
|
|
14
|
+
readonly tabId: string;
|
|
15
|
+
readonly connectedAt: number;
|
|
16
|
+
/** The page's `Origin` (the address the browser reached the server at), when it sent one. */
|
|
17
|
+
readonly origin?: string;
|
|
18
|
+
}
|
|
19
|
+
export type RequestReply = Record<string, unknown> & {
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
};
|
|
22
|
+
export interface EditorSocketOptions {
|
|
23
|
+
/** URL path of the socket (`<base>__pix3/ws`). */
|
|
24
|
+
readonly path: string;
|
|
25
|
+
/** Throws to refuse the upgrade (host, peer address, origin — `RequestGuard`). */
|
|
26
|
+
readonly accept: (req: IncomingMessage) => void;
|
|
27
|
+
/** First frame a tab gets after its `hello`. */
|
|
28
|
+
readonly welcome: (tab: EditorTab) => Record<string, unknown>;
|
|
29
|
+
readonly log?: (line: string) => void;
|
|
30
|
+
readonly onTabsChanged?: () => void;
|
|
31
|
+
}
|
|
32
|
+
export declare class EditorSocket {
|
|
33
|
+
private readonly options;
|
|
34
|
+
private readonly wss;
|
|
35
|
+
private readonly connections;
|
|
36
|
+
private readonly pending;
|
|
37
|
+
constructor(options: EditorSocketOptions);
|
|
38
|
+
/** `httpServer.on('upgrade')` handler; returns false for upgrades that are not ours. */
|
|
39
|
+
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): boolean;
|
|
40
|
+
/** Connected editor tabs, oldest first. */
|
|
41
|
+
tabs(): EditorTab[];
|
|
42
|
+
hasTab(tabId: string): boolean;
|
|
43
|
+
broadcast(frame: Record<string, unknown>): void;
|
|
44
|
+
/**
|
|
45
|
+
* Ask one tab to do something and wait for its answer. Never rejects: a missing tab, a closed
|
|
46
|
+
* socket or silence become `{ok:false, reason:'no_tab'|'tab_closed'|'timeout'}`.
|
|
47
|
+
*/
|
|
48
|
+
request(tabId: string, kind: string, payload: Record<string, unknown>, timeoutMs: number): Promise<RequestReply>;
|
|
49
|
+
close(): void;
|
|
50
|
+
private adopt;
|
|
51
|
+
private drop;
|
|
52
|
+
private onMessage;
|
|
53
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { isRecord } from "./http.js";
|
|
4
|
+
const TAB_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
5
|
+
export class EditorSocket {
|
|
6
|
+
options;
|
|
7
|
+
wss = new WebSocketServer({ noServer: true });
|
|
8
|
+
connections = new Set();
|
|
9
|
+
pending = new Map();
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
}
|
|
13
|
+
/** `httpServer.on('upgrade')` handler; returns false for upgrades that are not ours. */
|
|
14
|
+
handleUpgrade(req, socket, head) {
|
|
15
|
+
const path = (req.url ?? '').split('?')[0];
|
|
16
|
+
if (path !== this.options.path)
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
this.options.accept(req);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
23
|
+
socket.destroy();
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : null;
|
|
27
|
+
this.wss.handleUpgrade(req, socket, head, ws => this.adopt(ws, origin));
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
/** Connected editor tabs, oldest first. */
|
|
31
|
+
tabs() {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const connection of this.connections)
|
|
34
|
+
if (connection.tab)
|
|
35
|
+
out.push(connection.tab);
|
|
36
|
+
return out.sort((a, b) => a.connectedAt - b.connectedAt);
|
|
37
|
+
}
|
|
38
|
+
hasTab(tabId) {
|
|
39
|
+
return this.tabs().some(tab => tab.tabId === tabId);
|
|
40
|
+
}
|
|
41
|
+
broadcast(frame) {
|
|
42
|
+
const text = JSON.stringify(frame);
|
|
43
|
+
for (const connection of this.connections) {
|
|
44
|
+
if (connection.tab)
|
|
45
|
+
connection.socket.send(text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Ask one tab to do something and wait for its answer. Never rejects: a missing tab, a closed
|
|
50
|
+
* socket or silence become `{ok:false, reason:'no_tab'|'tab_closed'|'timeout'}`.
|
|
51
|
+
*/
|
|
52
|
+
request(tabId, kind, payload, timeoutMs) {
|
|
53
|
+
const connection = [...this.connections].find(c => c.tab?.tabId === tabId);
|
|
54
|
+
if (!connection)
|
|
55
|
+
return Promise.resolve({ ok: false, reason: 'no_tab', tabId });
|
|
56
|
+
const id = randomUUID();
|
|
57
|
+
return new Promise(resolve => {
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
this.pending.delete(id);
|
|
60
|
+
resolve({ ok: false, reason: 'timeout', tabId });
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
this.pending.set(id, { tabId, resolve, timer });
|
|
63
|
+
connection.socket.send(JSON.stringify({ type: 'request', id, kind, ...payload }));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
close() {
|
|
67
|
+
for (const [id, pending] of this.pending) {
|
|
68
|
+
clearTimeout(pending.timer);
|
|
69
|
+
pending.resolve({ ok: false, reason: 'tab_closed', tabId: pending.tabId });
|
|
70
|
+
this.pending.delete(id);
|
|
71
|
+
}
|
|
72
|
+
for (const connection of this.connections)
|
|
73
|
+
connection.socket.terminate();
|
|
74
|
+
this.connections.clear();
|
|
75
|
+
this.wss.close();
|
|
76
|
+
}
|
|
77
|
+
adopt(socket, origin) {
|
|
78
|
+
const connection = { socket, origin, tab: null };
|
|
79
|
+
this.connections.add(connection);
|
|
80
|
+
socket.on('message', (raw) => this.onMessage(connection, raw));
|
|
81
|
+
socket.on('close', () => this.drop(connection));
|
|
82
|
+
socket.on('error', () => this.drop(connection));
|
|
83
|
+
}
|
|
84
|
+
drop(connection) {
|
|
85
|
+
if (!this.connections.delete(connection))
|
|
86
|
+
return;
|
|
87
|
+
const tabId = connection.tab?.tabId;
|
|
88
|
+
if (!tabId)
|
|
89
|
+
return;
|
|
90
|
+
for (const [id, pending] of this.pending) {
|
|
91
|
+
if (pending.tabId !== tabId)
|
|
92
|
+
continue;
|
|
93
|
+
clearTimeout(pending.timer);
|
|
94
|
+
this.pending.delete(id);
|
|
95
|
+
pending.resolve({ ok: false, reason: 'tab_closed', tabId });
|
|
96
|
+
}
|
|
97
|
+
this.options.log?.(`editor tab ${tabId} disconnected`);
|
|
98
|
+
this.options.onTabsChanged?.();
|
|
99
|
+
}
|
|
100
|
+
onMessage(connection, raw) {
|
|
101
|
+
let message;
|
|
102
|
+
try {
|
|
103
|
+
message = JSON.parse(String(raw));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!isRecord(message))
|
|
109
|
+
return;
|
|
110
|
+
if (message.type === 'hello') {
|
|
111
|
+
const tabId = message.tabId;
|
|
112
|
+
if (connection.tab || typeof tabId !== 'string' || !TAB_ID_PATTERN.test(tabId))
|
|
113
|
+
return;
|
|
114
|
+
connection.tab = {
|
|
115
|
+
tabId,
|
|
116
|
+
connectedAt: Date.now(),
|
|
117
|
+
...(connection.origin ? { origin: connection.origin } : {}),
|
|
118
|
+
};
|
|
119
|
+
connection.socket.send(JSON.stringify({ type: 'welcome', ...this.options.welcome(connection.tab) }));
|
|
120
|
+
this.options.log?.(`editor tab ${tabId} connected`);
|
|
121
|
+
this.options.onTabsChanged?.();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (message.type === 'reply' && typeof message.id === 'string') {
|
|
125
|
+
const pending = this.pending.get(message.id);
|
|
126
|
+
if (!pending || pending.tabId !== connection.tab?.tabId)
|
|
127
|
+
return;
|
|
128
|
+
clearTimeout(pending.timer);
|
|
129
|
+
this.pending.delete(message.id);
|
|
130
|
+
const { type: _type, id: _id, ...rest } = message;
|
|
131
|
+
pending.resolve({ ...rest, ok: rest.ok === true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Who may talk to `/__pix3/*` (plan §B.1 «Защита записи»).
|
|
4
|
+
*
|
|
5
|
+
* The plugin's middleware runs before Vite's own host validation (it is a pre-middleware), so it
|
|
6
|
+
* checks the `Host` itself:
|
|
7
|
+
* - **Host** — `localhost`, a literal loopback address (127.0.0.0/8, ::1) or one of
|
|
8
|
+
* `server.allowedHosts` (DNS rebinding: a page on an attacker's domain that resolves to
|
|
9
|
+
* 127.0.0.1 still sends its own name — and `127.evil.com` is a name, not an address); under
|
|
10
|
+
* `allowRemote` any IP literal too, as Vite allows (a rebinding page never has one);
|
|
11
|
+
* - **peer address** — loopback unless `allowRemote` (a dev server started with `--host` must not
|
|
12
|
+
* hand the project's files to the LAN);
|
|
13
|
+
* - **mutations and the WebSocket** — `X-Pix3: 1` (mutations) and, when the request carries an
|
|
14
|
+
* `Origin`, the page's own origin. A cross-site page cannot add the header without a CORS
|
|
15
|
+
* preflight, which this server never answers; a WebSocket has no preflight at all, so its
|
|
16
|
+
* `Origin` is the only thing that keeps another site's tab from opening the event stream.
|
|
17
|
+
* Local processes (the CLI) send no `Origin`.
|
|
18
|
+
*/
|
|
19
|
+
export type AllowedHosts = readonly string[] | true;
|
|
20
|
+
export interface GuardOptions {
|
|
21
|
+
readonly allowRemote: boolean;
|
|
22
|
+
/** Vite's `server.allowedHosts` (`true` = any). */
|
|
23
|
+
readonly allowedHosts: () => AllowedHosts;
|
|
24
|
+
}
|
|
25
|
+
/** `Host` header → lower-cased name (`[::1]` keeps its brackets), or null when malformed. */
|
|
26
|
+
export declare const hostNameOf: (hostHeader: string | undefined) => string | null;
|
|
27
|
+
export declare const isAllowedHost: (hostHeader: string | undefined, allowed: AllowedHosts, options?: {
|
|
28
|
+
readonly allowRemote?: boolean;
|
|
29
|
+
}) => boolean;
|
|
30
|
+
export declare const isLoopbackAddress: (address: string | undefined) => boolean;
|
|
31
|
+
export declare class RequestGuard {
|
|
32
|
+
private readonly options;
|
|
33
|
+
constructor(options: GuardOptions);
|
|
34
|
+
/** Every `/__pix3/*` request, reads included. */
|
|
35
|
+
checkPeer(req: IncomingMessage): void;
|
|
36
|
+
/** The editor's WebSocket upgrade: no preflight exists, so its `Origin` is the only fence. */
|
|
37
|
+
checkSocket(req: IncomingMessage): void;
|
|
38
|
+
private checkSameOrigin;
|
|
39
|
+
/** Anything that is not GET/HEAD. */
|
|
40
|
+
checkMutation(req: IncomingMessage): void;
|
|
41
|
+
/**
|
|
42
|
+
* Routes only the editor page may use, reads included: the image-generation keys and proxy
|
|
43
|
+
* (plan §B.1). On top of {@link checkMutation}:
|
|
44
|
+
* - `Sec-Fetch-Site`, when the browser sends it, is `same-origin`;
|
|
45
|
+
* - `Referer`, when sent, is a page under `editorPrefix` (`<base>__pix3/`) — the game at `/` is
|
|
46
|
+
* the same origin, so the origin alone cannot tell the two apart;
|
|
47
|
+
* - `X-Pix3-Session` is the token the plugin hands only to a top-level navigation of the editor
|
|
48
|
+
* page ({@link editorSessionFor}). A `Referer` is the page's to choose within its origin
|
|
49
|
+
* (`fetch(url, {referrer})`), the token is not.
|
|
50
|
+
* Same-origin isolation has limits a header cannot fix (a service worker the game registers on
|
|
51
|
+
* `/` sees the editor's traffic); the key itself never reaches any page.
|
|
52
|
+
*/
|
|
53
|
+
checkEditorOnly(req: IncomingMessage, editorPrefix: string, sessionToken: string): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The session token for an editor-page request, or null when this request must not get it: the
|
|
57
|
+
* page fetched by script (`Sec-Fetch-Dest: empty`) or framed (`iframe`) — the game at `/` can do
|
|
58
|
+
* both. Browsers send `Sec-Fetch-*` only to secure origins (https, localhost); without them (a
|
|
59
|
+
* plain-http LAN address under `allowRemote`, a non-browser client) the token is handed out.
|
|
60
|
+
*/
|
|
61
|
+
export declare const editorSessionFor: (req: IncomingMessage, token: string) => string | null;
|
|
@@ -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
|
+
};
|