@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.
- package/LICENSE +201 -0
- package/README.md +39 -2
- 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 +123 -0
- package/dist/build/player-modules.js +254 -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 +290 -0
- package/dist/module-paths.d.ts +13 -0
- package/dist/module-paths.js +32 -0
- package/dist/player/debug/context.d.ts +9 -0
- package/dist/player/debug/context.js +1 -0
- package/dist/player/debug/device-info.d.ts +15 -0
- package/dist/player/debug/device-info.js +36 -0
- package/dist/player/debug/frame-stats.d.ts +79 -0
- package/dist/player/debug/frame-stats.js +159 -0
- package/dist/player/debug/index.d.ts +10 -0
- package/dist/player/debug/index.js +20 -0
- package/dist/player/debug/long-frames.d.ts +46 -0
- package/dist/player/debug/long-frames.js +79 -0
- package/dist/player/debug/overlay.d.ts +25 -0
- package/dist/player/debug/overlay.js +320 -0
- package/dist/player/debug/session.d.ts +92 -0
- package/dist/player/debug/session.js +156 -0
- package/dist/player/dev/editor-link.d.ts +16 -0
- package/dist/player/dev/editor-link.js +125 -0
- package/dist/player/index.d.ts +38 -0
- package/dist/player/index.js +119 -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 +65 -0
- package/package.json +58 -4
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Pix3 editor" link over the game in dev (`virtual:pix3/dev`; not on `?clean`, not in a
|
|
3
|
+
* frame, not in a build). A second editor tab would open read-only (the writer lock), so the link
|
|
4
|
+
* first asks over a `BroadcastChannel` whether an editor of this dev server is open: if one
|
|
5
|
+
* answers, the page says so and tries to bring its opener forward; if none does, this tab becomes
|
|
6
|
+
* the editor. The editor answers in `packages/editor-core/src/host/editor-presence.ts`.
|
|
7
|
+
*/
|
|
8
|
+
/** Keep in step with `editor-presence.ts` in editor-core. */
|
|
9
|
+
export const EDITOR_PRESENCE_CHANNEL = 'pix3-editor-presence';
|
|
10
|
+
const ANSWER_MS = 300;
|
|
11
|
+
const HIDDEN_KEY = 'pix3:editor-link:hidden';
|
|
12
|
+
/** True when an editor tab of this origin answers within {@link ANSWER_MS}. */
|
|
13
|
+
export const isEditorOpen = (timeoutMs = ANSWER_MS) => new Promise(resolve => {
|
|
14
|
+
if (typeof BroadcastChannel === 'undefined') {
|
|
15
|
+
resolve(false);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const channel = new BroadcastChannel(EDITOR_PRESENCE_CHANNEL);
|
|
19
|
+
const id = Math.random().toString(36).slice(2);
|
|
20
|
+
const done = (open) => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
channel.close();
|
|
23
|
+
resolve(open);
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => done(false), timeoutMs);
|
|
26
|
+
channel.onmessage = event => {
|
|
27
|
+
const data = event.data;
|
|
28
|
+
if (data?.type === 'pong' && data.id === id)
|
|
29
|
+
done(true);
|
|
30
|
+
};
|
|
31
|
+
channel.postMessage({ type: 'ping', id });
|
|
32
|
+
});
|
|
33
|
+
/** Go to the editor: this tab becomes it, unless one is already open. */
|
|
34
|
+
export const openEditor = async (editorUrl) => {
|
|
35
|
+
if (await isEditorOpen()) {
|
|
36
|
+
try {
|
|
37
|
+
// The editor opened this page (Play in Browser Tab / with Profiler): it is the opener.
|
|
38
|
+
window.opener?.focus();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// A cross-origin or severed opener: nothing to focus.
|
|
42
|
+
}
|
|
43
|
+
return 'already-open';
|
|
44
|
+
}
|
|
45
|
+
window.location.href = editorUrl;
|
|
46
|
+
return 'navigated';
|
|
47
|
+
};
|
|
48
|
+
const STYLE = `
|
|
49
|
+
:host { all: initial; }
|
|
50
|
+
.link {
|
|
51
|
+
position: fixed; left: 8px; bottom: 8px; z-index: 2147483646;
|
|
52
|
+
display: flex; align-items: center; gap: 2px;
|
|
53
|
+
font: 600 11px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
54
|
+
color: #e8e8e8; background: rgba(18, 18, 20, 0.72); border: 1px solid rgba(255,255,255,0.14);
|
|
55
|
+
border-radius: 999px; padding: 2px; opacity: 0.55; transition: opacity 120ms ease;
|
|
56
|
+
}
|
|
57
|
+
.link:hover, .link:focus-within { opacity: 1; }
|
|
58
|
+
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer;
|
|
59
|
+
border-radius: 999px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; }
|
|
60
|
+
button:hover { background: rgba(255,255,255,0.1); }
|
|
61
|
+
button:focus-visible { outline: 2px solid rgba(255,207,51,0.6); }
|
|
62
|
+
.close { padding: 4px; }
|
|
63
|
+
svg { display: block; width: 12px; height: 12px; }
|
|
64
|
+
.note { padding: 4px 8px 4px 4px; color: #ffcf33; font-weight: 500; }
|
|
65
|
+
`;
|
|
66
|
+
// Feather `edit-3` and `x`, inline: the player has no IconService.
|
|
67
|
+
const EDIT_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>';
|
|
68
|
+
const CLOSE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
69
|
+
const isHidden = () => {
|
|
70
|
+
try {
|
|
71
|
+
return sessionStorage.getItem(HIDDEN_KEY) === '1';
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/** Mount the link; returns the teardown. Hidden for the rest of the tab's session by its close button. */
|
|
78
|
+
export function mountEditorLink(editorUrl) {
|
|
79
|
+
if (isHidden())
|
|
80
|
+
return () => undefined;
|
|
81
|
+
const host = document.createElement('div');
|
|
82
|
+
host.className = 'pix3-editor-link';
|
|
83
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
84
|
+
root.innerHTML = `<style>${STYLE}</style>
|
|
85
|
+
<div class="link" part="link">
|
|
86
|
+
<button type="button" data-action="open" title="Open this project in the Pix3 editor">${EDIT_ICON}<span>Pix3 editor</span></button>
|
|
87
|
+
<span class="note" hidden>Already open in another tab</span>
|
|
88
|
+
<button type="button" class="close" data-action="hide" title="Hide" aria-label="Hide the editor link">${CLOSE_ICON}</button>
|
|
89
|
+
</div>`;
|
|
90
|
+
const link = root.querySelector('.link');
|
|
91
|
+
const note = root.querySelector('.note');
|
|
92
|
+
// Keep the game's own listeners out of the link's clicks.
|
|
93
|
+
for (const type of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'keydown']) {
|
|
94
|
+
link.addEventListener(type, event => event.stopPropagation());
|
|
95
|
+
}
|
|
96
|
+
let noteTimer;
|
|
97
|
+
root.addEventListener('click', event => {
|
|
98
|
+
const action = event.target
|
|
99
|
+
?.closest('[data-action]')
|
|
100
|
+
?.getAttribute('data-action');
|
|
101
|
+
if (action === 'hide') {
|
|
102
|
+
try {
|
|
103
|
+
sessionStorage.setItem(HIDDEN_KEY, '1');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Hidden for this page only.
|
|
107
|
+
}
|
|
108
|
+
host.remove();
|
|
109
|
+
}
|
|
110
|
+
else if (action === 'open') {
|
|
111
|
+
void openEditor(editorUrl).then(outcome => {
|
|
112
|
+
if (outcome !== 'already-open')
|
|
113
|
+
return;
|
|
114
|
+
note.hidden = false;
|
|
115
|
+
clearTimeout(noteTimer);
|
|
116
|
+
noteTimer = setTimeout(() => (note.hidden = true), 3000);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
document.body.appendChild(host);
|
|
121
|
+
return () => {
|
|
122
|
+
clearTimeout(noteTimer);
|
|
123
|
+
host.remove();
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
* In dev (`virtual:pix3/dev`), `/?debug` adds the debug overlay (`player/debug/`) and
|
|
12
|
+
* `__PIX3_PLAYER__.debug.snapshot()`, a plain page gets the "Pix3 editor" link
|
|
13
|
+
* (`player/dev/`), and `?scene=<res path>` boots that scene.
|
|
14
|
+
*/
|
|
15
|
+
export interface Pix3PlayerState {
|
|
16
|
+
status: 'booting' | 'running' | 'failed';
|
|
17
|
+
/** Frames the runner's loop has run. */
|
|
18
|
+
frames: number;
|
|
19
|
+
errors: string[];
|
|
20
|
+
/** The scene the player booted into. */
|
|
21
|
+
scene: string | null;
|
|
22
|
+
runner: SceneRunner | null;
|
|
23
|
+
/** Debug mode only (`/?debug` in dev): the overlay's numbers, for an agent over CDP. */
|
|
24
|
+
debug?: {
|
|
25
|
+
snapshot(): unknown;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
declare global {
|
|
29
|
+
interface Window {
|
|
30
|
+
__PIX3_PLAYER__?: Pix3PlayerState;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export interface StartGameOptions {
|
|
34
|
+
/** `res://`-relative scene to boot instead of the manifest's entry scene. */
|
|
35
|
+
readonly scene?: string;
|
|
36
|
+
}
|
|
37
|
+
/** Boot the game into `target` (a selector or an element; `#app` by default). */
|
|
38
|
+
export declare function startGame(target?: string | HTMLElement, options?: StartGameOptions): Promise<SceneRunner>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { AssetLoader, AudioService, registerBuiltInScripts, registerProjectScriptExports, ResourceManager, RuntimeRenderer, SceneLoader, SceneManager, SceneRunner, ScriptRegistry, } from '@pix3/runtime';
|
|
2
|
+
import { devSceneOverride, installDevTools } from 'virtual:pix3/dev';
|
|
3
|
+
import { embeddedAssets } from 'virtual:pix3/embedded-assets';
|
|
4
|
+
import { installNetworkService } from 'virtual:pix3/network';
|
|
5
|
+
import { modules as projectScriptModules } from 'virtual:pix3/project-scripts';
|
|
6
|
+
import { activeScenePath, resourceBase, runtimeAutoloads, runtimeFonts, runtimeLocalization, runtimeQuality, runtimeViewportBaseSize, scenePaths, } from 'virtual:pix3/scene-manifest';
|
|
7
|
+
// Registers the optional Spine runtime (lazily in dev, statically in a build that uses it).
|
|
8
|
+
// Must run before the first scene load.
|
|
9
|
+
import 'virtual:pix3/spine';
|
|
10
|
+
// Same arrangement for `postprocessing`: registered statically when a single-file build places
|
|
11
|
+
// a PostProcess node, the runtime's own code-split import otherwise.
|
|
12
|
+
import 'virtual:pix3/postprocessing';
|
|
13
|
+
const describe = (error) => error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
14
|
+
const installState = () => {
|
|
15
|
+
const existing = window.__PIX3_PLAYER__;
|
|
16
|
+
if (existing)
|
|
17
|
+
return existing;
|
|
18
|
+
const state = {
|
|
19
|
+
status: 'booting',
|
|
20
|
+
frames: 0,
|
|
21
|
+
errors: [],
|
|
22
|
+
scene: null,
|
|
23
|
+
runner: null,
|
|
24
|
+
};
|
|
25
|
+
window.__PIX3_PLAYER__ = state;
|
|
26
|
+
window.addEventListener('error', event => {
|
|
27
|
+
state.errors.push(event.message || describe(event.error));
|
|
28
|
+
});
|
|
29
|
+
window.addEventListener('unhandledrejection', event => {
|
|
30
|
+
state.errors.push(`unhandledrejection: ${describe(event.reason)}`);
|
|
31
|
+
});
|
|
32
|
+
return state;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Dev only: the `?scene=` the editor's Play in Browser Tab / with Profiler asks for, when it is one
|
|
36
|
+
* of the project's scenes (a prefab or a typo falls back to the entry scene, with a warning).
|
|
37
|
+
*/
|
|
38
|
+
const requestedScene = () => {
|
|
39
|
+
const scene = devSceneOverride();
|
|
40
|
+
if (!scene)
|
|
41
|
+
return null;
|
|
42
|
+
if (scenePaths.includes(scene))
|
|
43
|
+
return scene;
|
|
44
|
+
console.warn(`[pix3] ?scene=${scene} is not one of the project's scenes; starting the entry scene.`);
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
/** Boot the game into `target` (a selector or an element; `#app` by default). */
|
|
48
|
+
export async function startGame(target = '#app', options = {}) {
|
|
49
|
+
const state = installState();
|
|
50
|
+
try {
|
|
51
|
+
const app = typeof target === 'string' ? document.querySelector(target) : target;
|
|
52
|
+
if (!app)
|
|
53
|
+
throw new Error(`Missing ${typeof target === 'string' ? target : 'mount'} container`);
|
|
54
|
+
const resourceManager = new ResourceManager(resourceBase, embeddedAssets);
|
|
55
|
+
const audioService = new AudioService();
|
|
56
|
+
const scriptRegistry = new ScriptRegistry();
|
|
57
|
+
registerBuiltInScripts(scriptRegistry);
|
|
58
|
+
for (const [file, exports] of Object.entries(projectScriptModules)) {
|
|
59
|
+
registerProjectScriptExports(scriptRegistry, exports, file.replace(/^\/+/, ''));
|
|
60
|
+
}
|
|
61
|
+
const assetLoader = new AssetLoader(resourceManager, audioService);
|
|
62
|
+
const sceneLoader = new SceneLoader(assetLoader, scriptRegistry, resourceManager);
|
|
63
|
+
// No SceneSaver: a player never writes scenes back out, and one would pin every node class
|
|
64
|
+
// and `yaml.stringify` into the bundle (CLAUDE.md «Playable export size»).
|
|
65
|
+
const sceneManager = new SceneManager(sceneLoader);
|
|
66
|
+
const scenePath = options.scene || requestedScene() || activeScenePath || scenePaths[0];
|
|
67
|
+
if (!scenePath)
|
|
68
|
+
throw new Error('No scenes found in this project.');
|
|
69
|
+
state.scene = scenePath;
|
|
70
|
+
const renderer = new RuntimeRenderer({
|
|
71
|
+
antialias: runtimeQuality.antialias,
|
|
72
|
+
shadows: runtimeQuality.shadows,
|
|
73
|
+
pixelRatio: Math.min(window.devicePixelRatio || 1, runtimeQuality.maxPixelRatio),
|
|
74
|
+
});
|
|
75
|
+
renderer.attach(app);
|
|
76
|
+
const runner = new SceneRunner(sceneManager, renderer, audioService, assetLoader, runtimeViewportBaseSize);
|
|
77
|
+
state.runner = runner;
|
|
78
|
+
// Project autoloads: built once when the first scene starts, kept across `changeScene` —
|
|
79
|
+
// the same runtime code the editor's play mode runs.
|
|
80
|
+
runner.setAutoloads(runtimeAutoloads, scriptRegistry);
|
|
81
|
+
// The loop runs on rAF; counting its iterations here is what `__PIX3_PLAYER__.frames` reports.
|
|
82
|
+
runner.setFrameScheduler({
|
|
83
|
+
request: callback => requestAnimationFrame(time => {
|
|
84
|
+
state.frames++;
|
|
85
|
+
callback(time);
|
|
86
|
+
}),
|
|
87
|
+
cancel: handle => cancelAnimationFrame(handle),
|
|
88
|
+
});
|
|
89
|
+
// Multiplayer (D5): owned by the runner, inert until a script connects; the generated module
|
|
90
|
+
// is a no-op when nothing in the project mentions the network.
|
|
91
|
+
installNetworkService(runner);
|
|
92
|
+
// Debug mode (`/?debug`, dev only): the overlay subscribes before the scene loads, so a
|
|
93
|
+
// failed boot still shows its errors there.
|
|
94
|
+
installDevTools({ runner, renderer, audio: audioService, state });
|
|
95
|
+
runner.setBatching2DEnabled(true);
|
|
96
|
+
runner.setProjectFonts(runtimeFonts.length > 0 ? runtimeFonts : null);
|
|
97
|
+
if (runtimeLocalization) {
|
|
98
|
+
runner.setLocalizationConfig({
|
|
99
|
+
defaultLocale: runtimeLocalization.defaultLocale,
|
|
100
|
+
fallbackLocale: runtimeLocalization.fallbackLocale,
|
|
101
|
+
locales: runtimeLocalization.locales,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// No pre-packed atlas: nothing in 2.x writes `assets/.atlas/atlas-manifest.json` (the editor
|
|
105
|
+
// packs into IndexedDB for its own play mode), so the player does not probe for one
|
|
106
|
+
// (.plans/player-build.md P26).
|
|
107
|
+
// `loadAndStartScene` reads, parses and runs the graph directly; `startScene` would clone it
|
|
108
|
+
// through the serializer, which a player does not need (and must not bundle).
|
|
109
|
+
await runner.loadAndStartScene(scenePath);
|
|
110
|
+
state.status = 'running';
|
|
111
|
+
return runner;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
state.status = 'failed';
|
|
115
|
+
state.errors.push(describe(error));
|
|
116
|
+
console.error('[pix3] Failed to start the game:', error);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -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;
|