@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,240 @@
|
|
|
1
|
+
import { parseWirePath } from "../files/paths.js";
|
|
2
|
+
import { HttpError, isRecord } from "../server/http.js";
|
|
3
|
+
import { isGlobbedScript } from "./script-graph.js";
|
|
4
|
+
/**
|
|
5
|
+
* The sync barrier (plan §B.3), as S1 found it works:
|
|
6
|
+
*
|
|
7
|
+
* 0. **flush** — the editor writes its dirty scenes. The page's `pix3_sync` flushes itself before
|
|
8
|
+
* calling `POST /sync`; a caller without a tab (the CLI), or a tab that is not the writer (a
|
|
9
|
+
* read-only second tab's `pix3_sync`), gets the flush done here, through the writer tab
|
|
10
|
+
* (`POST /flush` is the same step on its own);
|
|
11
|
+
* 1. **rescan** — stat + sha256 of the revision set, changes broadcast as `pix3:fs`;
|
|
12
|
+
* 2. **hard-invalidate the roots**, so their `load()` runs again with the new revision;
|
|
13
|
+
* 3. **propagate** every changed file through the client graph (`environment.reloadModule`), and
|
|
14
|
+
* the roots themselves when a globbed script was added or removed;
|
|
15
|
+
* 4. **page confirmation** — the tab re-imports the roots with `?t=<13 digits>` and answers with the
|
|
16
|
+
* revision it executed and `globalThis.__pix3Executed`. Sync is ok only when that revision is
|
|
17
|
+
* current AND every changed module the editor runs carries a stamp equal to the sha on disk.
|
|
18
|
+
* A page behind on either is asked again (roots re-invalidated, bounded: {@link
|
|
19
|
+
* MAX_CONFIRM_ATTEMPTS}, within `timeoutMs`); still behind → `{ok:false, reason:'stale_modules',
|
|
20
|
+
* paths, ackRev}`.
|
|
21
|
+
*
|
|
22
|
+
* A non-ok answer is not a barrier: on `gesture_in_progress` and `stale_modules` the agent
|
|
23
|
+
* retries; on `expect_mismatch` it re-reads; on `stale` (play is running) it follows `playing`.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_SYNC_TIMEOUT_MS = 10_000;
|
|
26
|
+
export const VITE_CLIENT_WARNING = "/@vite/client is loaded on the editor page, so the game's full-reload can reload the editor: " +
|
|
27
|
+
'a script, a bot policy or a module they import uses import.meta.hot, a CSS import or a ' +
|
|
28
|
+
'non-literal import(). `pix3 check` names the file (W_EDITOR_*).';
|
|
29
|
+
const MAX_SYNC_TIMEOUT_MS = 120_000;
|
|
30
|
+
/**
|
|
31
|
+
* Step 4 asks the tab again when its answer is behind (`rev` older than the rescan's, or a changed
|
|
32
|
+
* module's stamp not the disk's): at most this many times, after 50, 100, 200 ms, and never past
|
|
33
|
+
* the caller's `timeoutMs`. A page that still runs old code after that is reported `stale_modules`
|
|
34
|
+
* — a module that genuinely failed to re-execute costs ~350 ms more, never a hang.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_CONFIRM_ATTEMPTS = 4;
|
|
37
|
+
const CONFIRM_RETRY_MS = 50;
|
|
38
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
39
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
40
|
+
export const parseTimeout = (raw, fallback) => {
|
|
41
|
+
if (raw === undefined)
|
|
42
|
+
return fallback;
|
|
43
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {
|
|
44
|
+
throw new HttpError(400, 'bad_request', '`timeoutMs` must be a non-negative number.');
|
|
45
|
+
}
|
|
46
|
+
return Math.min(raw, MAX_SYNC_TIMEOUT_MS);
|
|
47
|
+
};
|
|
48
|
+
export const parseSyncRequest = (body) => {
|
|
49
|
+
let expect;
|
|
50
|
+
if (body.expect !== undefined) {
|
|
51
|
+
if (!isRecord(body.expect)) {
|
|
52
|
+
throw new HttpError(400, 'bad_request', '`expect` must be an object {path: sha256}.');
|
|
53
|
+
}
|
|
54
|
+
expect = {};
|
|
55
|
+
for (const [key, value] of Object.entries(body.expect)) {
|
|
56
|
+
const wirePath = parseWirePath(key, 'expect');
|
|
57
|
+
if (typeof value !== 'string' || !SHA256_HEX.test(value.toLowerCase())) {
|
|
58
|
+
throw new HttpError(400, 'bad_request', `expect["${key}"] must be a hex sha256.`);
|
|
59
|
+
}
|
|
60
|
+
expect[wirePath] = value.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const tabId = typeof body.tabId === 'string' ? body.tabId : null;
|
|
64
|
+
return { expect, timeoutMs: parseTimeout(body.timeoutMs, DEFAULT_SYNC_TIMEOUT_MS), tabId };
|
|
65
|
+
};
|
|
66
|
+
export class SyncBarrier {
|
|
67
|
+
deps;
|
|
68
|
+
/** sha already propagated by a sync, per wire path: the watcher's late echo is suppressed. */
|
|
69
|
+
propagated = new Map();
|
|
70
|
+
/** One barrier at a time; a second sync waits for the first. */
|
|
71
|
+
tail = Promise.resolve();
|
|
72
|
+
constructor(deps) {
|
|
73
|
+
this.deps = deps;
|
|
74
|
+
}
|
|
75
|
+
/** The tab that answers for the editor: the asker, else the writer, else the oldest tab. */
|
|
76
|
+
targetTab(asker) {
|
|
77
|
+
const { socket, files } = this.deps;
|
|
78
|
+
if (asker && socket.hasTab(asker))
|
|
79
|
+
return asker;
|
|
80
|
+
const writer = files.writerId;
|
|
81
|
+
if (writer && socket.hasTab(writer))
|
|
82
|
+
return writer;
|
|
83
|
+
return socket.tabs()[0]?.tabId ?? null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Step 0 on its own (`POST /__pix3/api/flush`, and `npm run build` / `pix3 check` before they
|
|
87
|
+
* read the disk): the writer tab writes its dirty scenes. No writer tab = nothing unsaved here.
|
|
88
|
+
*/
|
|
89
|
+
async flush(timeoutMs) {
|
|
90
|
+
const { socket, files } = this.deps;
|
|
91
|
+
const writer = files.writerId;
|
|
92
|
+
if (!writer || !socket.hasTab(writer)) {
|
|
93
|
+
return { ok: true, editor: socket.tabs().length > 0, flushed: false };
|
|
94
|
+
}
|
|
95
|
+
const reply = await socket.request(writer, 'flush', { timeoutMs }, timeoutMs + 1_000);
|
|
96
|
+
return { ...reply, editor: true, flushed: reply.ok };
|
|
97
|
+
}
|
|
98
|
+
sync(request) {
|
|
99
|
+
const run = this.tail.then(() => this.run(request), () => this.run(request));
|
|
100
|
+
this.tail = run.catch(() => undefined);
|
|
101
|
+
return run;
|
|
102
|
+
}
|
|
103
|
+
async run(request) {
|
|
104
|
+
const { files, scripts, socket } = this.deps;
|
|
105
|
+
const started = Date.now();
|
|
106
|
+
// Step 0: the asking tab flushed itself before calling — but only the writer tab holds
|
|
107
|
+
// unsaved scenes. A caller without a tab, or a read-only tab asking, flushes the writer here.
|
|
108
|
+
if (request.tabId === null || request.tabId !== files.writerId) {
|
|
109
|
+
const flushed = await this.flush(request.timeoutMs);
|
|
110
|
+
if (!flushed.ok)
|
|
111
|
+
return { ...flushed, ok: false, step: 'flush' };
|
|
112
|
+
}
|
|
113
|
+
// 1. rescan
|
|
114
|
+
const { events, seq } = await files.rescanAll();
|
|
115
|
+
const changedFiles = events.filter(event => event.kind === 'file');
|
|
116
|
+
const changed = {};
|
|
117
|
+
for (const event of changedFiles) {
|
|
118
|
+
changed[event.path] = event.op === 'delete' ? null : (event.sha256 ?? null);
|
|
119
|
+
if (event.op === 'rename' && event.from)
|
|
120
|
+
changed[event.from] = null;
|
|
121
|
+
}
|
|
122
|
+
const expectMismatch = [];
|
|
123
|
+
for (const [path, expected] of Object.entries(request.expect ?? {})) {
|
|
124
|
+
const actual = files.hashOf(path);
|
|
125
|
+
if (actual !== expected)
|
|
126
|
+
expectMismatch.push({ path, expected, actual });
|
|
127
|
+
}
|
|
128
|
+
// 2–3. hard-invalidate the roots, propagate
|
|
129
|
+
scripts.hardInvalidateRoots();
|
|
130
|
+
let rootsNeedReload = false;
|
|
131
|
+
for (const event of changedFiles) {
|
|
132
|
+
if (event.op !== 'modify' &&
|
|
133
|
+
(isGlobbedScript(event.path) || isGlobbedScript(event.from ?? '')))
|
|
134
|
+
rootsNeedReload = true;
|
|
135
|
+
if (event.op === 'delete' || !event.sha256)
|
|
136
|
+
continue;
|
|
137
|
+
this.propagated.set(event.path, event.sha256);
|
|
138
|
+
await scripts.reload(event.path);
|
|
139
|
+
}
|
|
140
|
+
if (rootsNeedReload)
|
|
141
|
+
await scripts.reloadRoots();
|
|
142
|
+
const required = this.requiredStamps(changedFiles);
|
|
143
|
+
const base = {
|
|
144
|
+
rev: seq,
|
|
145
|
+
seq,
|
|
146
|
+
changed,
|
|
147
|
+
expectMismatch,
|
|
148
|
+
...(expectMismatch.length > 0 ? { reason: 'expect_mismatch' } : {}),
|
|
149
|
+
};
|
|
150
|
+
// 4. page confirmation
|
|
151
|
+
const tabId = this.targetTab(request.tabId);
|
|
152
|
+
if (tabId === null) {
|
|
153
|
+
return { ok: expectMismatch.length === 0, ...base, editor: false, ms: Date.now() - started };
|
|
154
|
+
}
|
|
155
|
+
const deadline = started + request.timeoutMs;
|
|
156
|
+
// `policyOnly`: modules only bot policies import — applied during play like a policy (S11).
|
|
157
|
+
const ask = {
|
|
158
|
+
rev: seq,
|
|
159
|
+
changed,
|
|
160
|
+
paths: Object.keys(changed),
|
|
161
|
+
policyOnly: scripts.policyOnlyModules(),
|
|
162
|
+
};
|
|
163
|
+
let attempts = 0;
|
|
164
|
+
let reply;
|
|
165
|
+
let verdict;
|
|
166
|
+
for (;;) {
|
|
167
|
+
attempts += 1;
|
|
168
|
+
reply = await socket.request(tabId, 'sync', ask, Math.max(0, deadline - Date.now()));
|
|
169
|
+
verdict = this.judge(reply, seq, required);
|
|
170
|
+
// Only a page that answered and is behind is asked again; a refusal is the answer.
|
|
171
|
+
if (verdict.ok || !reply.ok || attempts >= MAX_CONFIRM_ATTEMPTS)
|
|
172
|
+
break;
|
|
173
|
+
const pause = CONFIRM_RETRY_MS * 2 ** (attempts - 1);
|
|
174
|
+
if (deadline - Date.now() <= pause)
|
|
175
|
+
break;
|
|
176
|
+
this.deps.log?.(`sync rev ${seq}: the tab answered rev ${String(verdict.ackRev)}, stale ` +
|
|
177
|
+
`[${String(verdict.paths)}]; asking again (attempt ${attempts + 1})`);
|
|
178
|
+
await sleep(pause);
|
|
179
|
+
scripts.hardInvalidateRoots();
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
...base,
|
|
183
|
+
...verdict,
|
|
184
|
+
ok: verdict.ok && expectMismatch.length === 0,
|
|
185
|
+
...(verdict.ok && expectMismatch.length > 0 ? { reason: 'expect_mismatch' } : {}),
|
|
186
|
+
editor: true,
|
|
187
|
+
tabId,
|
|
188
|
+
...(attempts > 1 ? { attempts } : {}),
|
|
189
|
+
// Contract B broken: the page found /@vite/client loaded (plan §B.2).
|
|
190
|
+
...(reply.viteClient === true ? { viteClient: true, warning: VITE_CLIENT_WARNING } : {}),
|
|
191
|
+
ms: Date.now() - started,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** Changed files the editor executes, with the sha their stamp must carry. */
|
|
195
|
+
requiredStamps(events) {
|
|
196
|
+
const required = new Map();
|
|
197
|
+
for (const event of events) {
|
|
198
|
+
if (event.op === 'delete' || !event.sha256)
|
|
199
|
+
continue;
|
|
200
|
+
if (this.deps.scripts.isEditorModule(event.path))
|
|
201
|
+
required.set(event.path, event.sha256);
|
|
202
|
+
}
|
|
203
|
+
return required;
|
|
204
|
+
}
|
|
205
|
+
judge(reply, rev, required) {
|
|
206
|
+
if (!reply.ok) {
|
|
207
|
+
// `executed` is every stamp on the page (hundreds of modules): proof for the judge, noise
|
|
208
|
+
// in an answer the agent reads.
|
|
209
|
+
const { ok: _ok, executed: _executed, ...rest } = reply;
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
reason: typeof reply.reason === 'string' ? reply.reason : 'refused',
|
|
213
|
+
...rest,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const executed = isRecord(reply.executed) ? reply.executed : {};
|
|
217
|
+
const ackRev = typeof reply.rev === 'number' ? reply.rev : -1;
|
|
218
|
+
const stale = [];
|
|
219
|
+
for (const [path, sha] of required)
|
|
220
|
+
if (executed[path] !== sha)
|
|
221
|
+
stale.push(path);
|
|
222
|
+
if (ackRev < rev || stale.length > 0) {
|
|
223
|
+
return { ok: false, reason: 'stale_modules', paths: stale, ackRev };
|
|
224
|
+
}
|
|
225
|
+
return { ok: true };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `handleHotUpdate` for a file a sync already propagated with the same bytes: the watcher's
|
|
229
|
+
* late echo would otherwise propagate a second time (S1: chokidar repeats `change`).
|
|
230
|
+
*/
|
|
231
|
+
alreadyPropagated(wirePath, sha) {
|
|
232
|
+
return sha !== null && this.propagated.get(wirePath) === sha;
|
|
233
|
+
}
|
|
234
|
+
notePropagated(wirePath, sha) {
|
|
235
|
+
if (sha === null)
|
|
236
|
+
this.propagated.delete(wirePath);
|
|
237
|
+
else
|
|
238
|
+
this.propagated.set(wirePath, sha);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DevEnvironment, EnvironmentModuleNode, HotUpdateOptions } from 'vite';
|
|
2
|
+
import type { EditorServer } from '../dev-server.ts';
|
|
3
|
+
import { type ScriptGraph } from './script-graph.ts';
|
|
4
|
+
/**
|
|
5
|
+
* The plugin's `hotUpdate` — not the legacy `handleHotUpdate`: Vite (6+, the environment API)
|
|
6
|
+
* calls the legacy hook for `update` only, so a script file created or deleted outside a sync
|
|
7
|
+
* never reached the editor — its roots kept the old glob until the next sync, and a play restart
|
|
8
|
+
* ran without the new class (`.plans/scripts-vite.md` S13). This hook sees every kind.
|
|
9
|
+
*
|
|
10
|
+
* `undefined` leaves Vite's default propagation alone; `[]` means this hook did it.
|
|
11
|
+
*/
|
|
12
|
+
export declare const scriptHotUpdate: (update: Pick<HotUpdateOptions, "type" | "file" | "modules">, environment: Pick<DevEnvironment, "name" | "moduleGraph">, context: {
|
|
13
|
+
readonly root: string;
|
|
14
|
+
readonly scripts: ScriptGraph;
|
|
15
|
+
readonly editor: EditorServer;
|
|
16
|
+
}) => Promise<EnvironmentModuleNode[] | undefined>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { SCENE_MANIFEST_ID } from "../build/player-modules.js";
|
|
4
|
+
import { isGlobbedScript, resolvedId, sha256Hex } from "./script-graph.js";
|
|
5
|
+
/**
|
|
6
|
+
* The plugin's `hotUpdate` — not the legacy `handleHotUpdate`: Vite (6+, the environment API)
|
|
7
|
+
* calls the legacy hook for `update` only, so a script file created or deleted outside a sync
|
|
8
|
+
* never reached the editor — its roots kept the old glob until the next sync, and a play restart
|
|
9
|
+
* ran without the new class (`.plans/scripts-vite.md` S13). This hook sees every kind.
|
|
10
|
+
*
|
|
11
|
+
* `undefined` leaves Vite's default propagation alone; `[]` means this hook did it.
|
|
12
|
+
*/
|
|
13
|
+
export const scriptHotUpdate = async (update, environment, context) => {
|
|
14
|
+
const { type, file, modules } = update;
|
|
15
|
+
const { scripts, editor } = context;
|
|
16
|
+
// The player's scene manifest follows `pix3project.yaml` (entry scene, viewport, quality).
|
|
17
|
+
if (file === join(context.root, 'pix3project.yaml')) {
|
|
18
|
+
const manifest = environment.moduleGraph.getModuleById(resolvedId(SCENE_MANIFEST_ID));
|
|
19
|
+
if (!manifest)
|
|
20
|
+
return undefined;
|
|
21
|
+
environment.moduleGraph.invalidateModule(manifest);
|
|
22
|
+
return [...modules, manifest];
|
|
23
|
+
}
|
|
24
|
+
const wirePath = scripts.wirePathOf(file);
|
|
25
|
+
if (wirePath === null)
|
|
26
|
+
return undefined;
|
|
27
|
+
const globbed = isGlobbedScript(wirePath);
|
|
28
|
+
// A deleted helper has nothing to propagate (its importers fail on their next import, and
|
|
29
|
+
// a sync reports that); a created one is not in the graph yet and Vite's default retries
|
|
30
|
+
// the importers that failed to resolve it.
|
|
31
|
+
if (!globbed && (type !== 'update' || !scripts.isEditorModule(wirePath)))
|
|
32
|
+
return undefined;
|
|
33
|
+
// The work is done once, on the client graph (the editor's and the game tab's environment);
|
|
34
|
+
// every environment returns [] so Vite does not propagate a second time.
|
|
35
|
+
if (environment.name !== 'client')
|
|
36
|
+
return [];
|
|
37
|
+
let sha;
|
|
38
|
+
try {
|
|
39
|
+
sha = sha256Hex(readFileSync(file));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
sha = null;
|
|
43
|
+
}
|
|
44
|
+
// A sync already propagated these bytes; the watcher's late echo must not do it twice.
|
|
45
|
+
if (editor.barrier.alreadyPropagated(wirePath, sha))
|
|
46
|
+
return [];
|
|
47
|
+
editor.barrier.notePropagated(wirePath, sha);
|
|
48
|
+
// Propagate through the client environment's own nodes (never Vite 8's backward-compatible
|
|
49
|
+
// mixed nodes — S1, finding 5): a changed file through its importers, an added or deleted
|
|
50
|
+
// globbed script by re-running the roots' globs. Then tell the editor to re-import its roots.
|
|
51
|
+
scripts.hardInvalidateRoots();
|
|
52
|
+
if (type === 'update')
|
|
53
|
+
await scripts.reload(wirePath);
|
|
54
|
+
if (globbed && type !== 'update')
|
|
55
|
+
await scripts.reloadRoots();
|
|
56
|
+
editor.socket.broadcast({ type: 'pix3:scripts', path: wirePath, op: type });
|
|
57
|
+
return [];
|
|
58
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type EnvironmentModuleNode, type ViteDevServer } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* The scripts the editor executes, and the proof that it executed the bytes on disk (plan §B.2).
|
|
4
|
+
*
|
|
5
|
+
* - `virtual:pix3/editor-scripts` — eager `import.meta.glob` over `scripts/` and `src/scripts/`
|
|
6
|
+
* (no specs, tests or declarations), exporting `__pix3Revision`;
|
|
7
|
+
* - `virtual:pix3/bot-policies` — the same over `design/tests/bots/`;
|
|
8
|
+
* - every project source module the client environment transforms gets an **executed-content
|
|
9
|
+
* stamp**, `(globalThis.__pix3Executed ??= {})[path] = sha`, which runs only when that module
|
|
10
|
+
* body runs. S1 showed the root's own hashes prove nothing about nested modules; the stamp does.
|
|
11
|
+
*/
|
|
12
|
+
export declare const EDITOR_SCRIPTS_ID = "virtual:pix3/editor-scripts";
|
|
13
|
+
export declare const BOT_POLICIES_ID = "virtual:pix3/bot-policies";
|
|
14
|
+
export declare const ROOT_IDS: readonly ["virtual:pix3/editor-scripts", "virtual:pix3/bot-policies"];
|
|
15
|
+
export declare const resolvedId: (id: string) => string;
|
|
16
|
+
/**
|
|
17
|
+
* The project-script rule, mirrored from the runtime's `project-script-registration.ts`
|
|
18
|
+
* (`PROJECT_SCRIPT_DIRECTORIES`, `projectScriptGlobPatterns`, `isProjectScriptEntry`): the
|
|
19
|
+
* plugin runs in Node and cannot import the runtime's TypeScript sources, so `script-rule.spec.ts`
|
|
20
|
+
* pins these to the runtime's. A `.ts` under `scripts/` or `src/scripts/` (no specs, tests or
|
|
21
|
+
* declarations) is an entry — no `.js`, no textual gate.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SCRIPT_DIRS: readonly ["scripts", "src/scripts"];
|
|
24
|
+
export declare const BOT_POLICY_DIRS: readonly ["design/tests/bots"];
|
|
25
|
+
/** The glob patterns of a root over `dirs` (= the runtime's `projectScriptGlobPatterns`). */
|
|
26
|
+
export declare const globPatterns: (dirs: readonly string[]) => string[];
|
|
27
|
+
/** Source of a glob root; `revision` is the file table's `seq` when Vite loads it. */
|
|
28
|
+
export declare const rootModuleSource: (id: string, revision: number) => string;
|
|
29
|
+
/** True for a wire path the editor-scripts or bot-policies glob picks up. */
|
|
30
|
+
export declare const isGlobbedScript: (wirePath: string) => boolean;
|
|
31
|
+
export declare const sha256Hex: (bytes: Uint8Array | string) => string;
|
|
32
|
+
export declare class ScriptGraph {
|
|
33
|
+
private readonly root;
|
|
34
|
+
private readonly server;
|
|
35
|
+
/** Absolute directories inside the root that hold no project sources (Vite's `cacheDir`). */
|
|
36
|
+
private readonly ignored;
|
|
37
|
+
constructor(root: string, server: () => ViteDevServer | null, ignored?: readonly string[]);
|
|
38
|
+
/** Module id (absolute path, maybe with a query) → wire path, when it is a project source. */
|
|
39
|
+
wirePathOf(id: string): string | null;
|
|
40
|
+
absoluteOf(wirePath: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* `transform` hook body: append the executed-content stamp to a project source module. The sha
|
|
43
|
+
* is of the file on disk (what the barrier compares with), not of `code`.
|
|
44
|
+
*/
|
|
45
|
+
stamp(code: string, id: string): string | null;
|
|
46
|
+
private graph;
|
|
47
|
+
rootModules(): EnvironmentModuleNode[];
|
|
48
|
+
/**
|
|
49
|
+
* Vite ≥ 5.1 SOFT-invalidates static importers of a changed module: the cached transform is
|
|
50
|
+
* reused with re-stamped `?t=` and `load()` does not run again, so `__pix3Revision` would stay
|
|
51
|
+
* stale (S1, finding 1). The roots are hard-invalidated explicitly.
|
|
52
|
+
*
|
|
53
|
+
* Twice: as an HMR invalidation (`lastHMRTimestamp`, what the propagation stamps) and as a plain
|
|
54
|
+
* one, because only `lastInvalidationTimestamp` is what Vite checks a transform against. With
|
|
55
|
+
* the HMR call alone, a root request already in flight — the tab re-importing after a watcher
|
|
56
|
+
* `pix3:scripts`, its `load()` done at the old `seq` — is handed to the next request for the same
|
|
57
|
+
* URL (`transformRequest` dedupes on the URL without `?t=`), and its result is even cached after
|
|
58
|
+
* the invalidation. The sync's own re-import then executed the previous `__pix3Revision`
|
|
59
|
+
* (`.plans/scripts-vite.md` S15). Vite's own clock is used, so the invalidation is strictly later
|
|
60
|
+
* than every request that started before it.
|
|
61
|
+
*/
|
|
62
|
+
hardInvalidateRoots(): void;
|
|
63
|
+
/** Client-environment nodes of a file (never Vite 8's mixed nodes — S1, finding 5). */
|
|
64
|
+
modulesOf(wirePath: string): EnvironmentModuleNode[];
|
|
65
|
+
/** True when the editor executes `wirePath`: globbed, or imported (transitively) by a root. */
|
|
66
|
+
isEditorModule(wirePath: string): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Project modules only the bot policies reach (`.plans/scripts-vite.md` S4/S11): reachable from
|
|
69
|
+
* `virtual:pix3/bot-policies` and not from `virtual:pix3/editor-scripts`, so the game the editor
|
|
70
|
+
* plays never runs them and a change to one need not wait for play to stop. Read from the client
|
|
71
|
+
* graph as it is now (after the barrier's propagation); a module the graph has not seen is not
|
|
72
|
+
* listed, which errs on the side of holding play.
|
|
73
|
+
*/
|
|
74
|
+
policyOnlyModules(): string[];
|
|
75
|
+
/** HMR-propagate one file through the client graph (stamps `lastHMRTimestamp` up the chain). */
|
|
76
|
+
reload(wirePath: string): Promise<number>;
|
|
77
|
+
/** Re-run the roots' globs (a script was added or removed). */
|
|
78
|
+
reloadRoots(): Promise<void>;
|
|
79
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, sep } from 'node:path';
|
|
4
|
+
import { normalizePath } from 'vite';
|
|
5
|
+
import { isExcludedPath } from "../files/scan.js";
|
|
6
|
+
import { relativeInside } from "../module-paths.js";
|
|
7
|
+
/**
|
|
8
|
+
* The scripts the editor executes, and the proof that it executed the bytes on disk (plan §B.2).
|
|
9
|
+
*
|
|
10
|
+
* - `virtual:pix3/editor-scripts` — eager `import.meta.glob` over `scripts/` and `src/scripts/`
|
|
11
|
+
* (no specs, tests or declarations), exporting `__pix3Revision`;
|
|
12
|
+
* - `virtual:pix3/bot-policies` — the same over `design/tests/bots/`;
|
|
13
|
+
* - every project source module the client environment transforms gets an **executed-content
|
|
14
|
+
* stamp**, `(globalThis.__pix3Executed ??= {})[path] = sha`, which runs only when that module
|
|
15
|
+
* body runs. S1 showed the root's own hashes prove nothing about nested modules; the stamp does.
|
|
16
|
+
*/
|
|
17
|
+
export const EDITOR_SCRIPTS_ID = 'virtual:pix3/editor-scripts';
|
|
18
|
+
export const BOT_POLICIES_ID = 'virtual:pix3/bot-policies';
|
|
19
|
+
export const ROOT_IDS = [EDITOR_SCRIPTS_ID, BOT_POLICIES_ID];
|
|
20
|
+
export const resolvedId = (id) => `\0${id}`;
|
|
21
|
+
/**
|
|
22
|
+
* The project-script rule, mirrored from the runtime's `project-script-registration.ts`
|
|
23
|
+
* (`PROJECT_SCRIPT_DIRECTORIES`, `projectScriptGlobPatterns`, `isProjectScriptEntry`): the
|
|
24
|
+
* plugin runs in Node and cannot import the runtime's TypeScript sources, so `script-rule.spec.ts`
|
|
25
|
+
* pins these to the runtime's. A `.ts` under `scripts/` or `src/scripts/` (no specs, tests or
|
|
26
|
+
* declarations) is an entry — no `.js`, no textual gate.
|
|
27
|
+
*/
|
|
28
|
+
export const SCRIPT_DIRS = ['scripts', 'src/scripts'];
|
|
29
|
+
export const BOT_POLICY_DIRS = ['design/tests/bots'];
|
|
30
|
+
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?)$/;
|
|
31
|
+
const NOT_A_SCRIPT = /\.(?:spec|test)\.ts$|\.d\.ts$/;
|
|
32
|
+
/** The glob patterns of a root over `dirs` (= the runtime's `projectScriptGlobPatterns`). */
|
|
33
|
+
export const globPatterns = (dirs) => dirs.flatMap(dir => [
|
|
34
|
+
`/${dir}/**/*.ts`,
|
|
35
|
+
`!/${dir}/**/*.spec.ts`,
|
|
36
|
+
`!/${dir}/**/*.test.ts`,
|
|
37
|
+
`!/${dir}/**/*.d.ts`,
|
|
38
|
+
]);
|
|
39
|
+
/** Source of a glob root; `revision` is the file table's `seq` when Vite loads it. */
|
|
40
|
+
export const rootModuleSource = (id, revision) => {
|
|
41
|
+
const dirs = id === BOT_POLICIES_ID ? BOT_POLICY_DIRS : SCRIPT_DIRS;
|
|
42
|
+
return [
|
|
43
|
+
`export const __pix3Revision = ${revision};`,
|
|
44
|
+
`export const modules = import.meta.glob(${JSON.stringify(globPatterns(dirs))}, { eager: true });`,
|
|
45
|
+
'',
|
|
46
|
+
].join('\n');
|
|
47
|
+
};
|
|
48
|
+
/** True for a wire path the editor-scripts or bot-policies glob picks up. */
|
|
49
|
+
export const isGlobbedScript = (wirePath) => wirePath.endsWith('.ts') &&
|
|
50
|
+
!NOT_A_SCRIPT.test(wirePath) &&
|
|
51
|
+
[...SCRIPT_DIRS, ...BOT_POLICY_DIRS].some(dir => wirePath.startsWith(`${dir}/`));
|
|
52
|
+
export const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
53
|
+
export class ScriptGraph {
|
|
54
|
+
root;
|
|
55
|
+
server;
|
|
56
|
+
/** Absolute directories inside the root that hold no project sources (Vite's `cacheDir`). */
|
|
57
|
+
ignored;
|
|
58
|
+
constructor(root, server, ignored = []) {
|
|
59
|
+
this.root = root;
|
|
60
|
+
this.server = server;
|
|
61
|
+
this.ignored = ignored.map(dir => normalizePath(dir).replace(/\/+$/, ''));
|
|
62
|
+
}
|
|
63
|
+
/** Module id (absolute path, maybe with a query) → wire path, when it is a project source. */
|
|
64
|
+
wirePathOf(id) {
|
|
65
|
+
const file = id.split('?')[0];
|
|
66
|
+
if (!isAbsolute(file) || !SOURCE_EXTENSION.test(file))
|
|
67
|
+
return null;
|
|
68
|
+
// A pre-bundled dependency in an in-project `cacheDir` is not the project's code.
|
|
69
|
+
if (this.ignored.some(dir => normalizePath(file).startsWith(`${dir}/`)))
|
|
70
|
+
return null;
|
|
71
|
+
const wirePath = relativeInside(this.root, file);
|
|
72
|
+
return wirePath === null || isExcludedPath(wirePath) ? null : wirePath;
|
|
73
|
+
}
|
|
74
|
+
absoluteOf(wirePath) {
|
|
75
|
+
return `${this.root}${sep}${wirePath.split('/').join(sep)}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `transform` hook body: append the executed-content stamp to a project source module. The sha
|
|
79
|
+
* is of the file on disk (what the barrier compares with), not of `code`.
|
|
80
|
+
*/
|
|
81
|
+
stamp(code, id) {
|
|
82
|
+
const wirePath = this.wirePathOf(id);
|
|
83
|
+
if (wirePath === null)
|
|
84
|
+
return null;
|
|
85
|
+
let sha;
|
|
86
|
+
try {
|
|
87
|
+
sha = sha256Hex(readFileSync(id.split('?')[0]));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return (`${code}\n;(globalThis.__pix3Executed ??= {})[${JSON.stringify(wirePath)}] = ` +
|
|
93
|
+
`${JSON.stringify(sha)};\n`);
|
|
94
|
+
}
|
|
95
|
+
graph() {
|
|
96
|
+
return this.server()?.environments.client ?? null;
|
|
97
|
+
}
|
|
98
|
+
rootModules() {
|
|
99
|
+
const env = this.graph();
|
|
100
|
+
if (!env)
|
|
101
|
+
return [];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const id of ROOT_IDS) {
|
|
104
|
+
const mod = env.moduleGraph.getModuleById(resolvedId(id));
|
|
105
|
+
if (mod)
|
|
106
|
+
out.push(mod);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Vite ≥ 5.1 SOFT-invalidates static importers of a changed module: the cached transform is
|
|
112
|
+
* reused with re-stamped `?t=` and `load()` does not run again, so `__pix3Revision` would stay
|
|
113
|
+
* stale (S1, finding 1). The roots are hard-invalidated explicitly.
|
|
114
|
+
*
|
|
115
|
+
* Twice: as an HMR invalidation (`lastHMRTimestamp`, what the propagation stamps) and as a plain
|
|
116
|
+
* one, because only `lastInvalidationTimestamp` is what Vite checks a transform against. With
|
|
117
|
+
* the HMR call alone, a root request already in flight — the tab re-importing after a watcher
|
|
118
|
+
* `pix3:scripts`, its `load()` done at the old `seq` — is handed to the next request for the same
|
|
119
|
+
* URL (`transformRequest` dedupes on the URL without `?t=`), and its result is even cached after
|
|
120
|
+
* the invalidation. The sync's own re-import then executed the previous `__pix3Revision`
|
|
121
|
+
* (`.plans/scripts-vite.md` S15). Vite's own clock is used, so the invalidation is strictly later
|
|
122
|
+
* than every request that started before it.
|
|
123
|
+
*/
|
|
124
|
+
hardInvalidateRoots() {
|
|
125
|
+
const env = this.graph();
|
|
126
|
+
if (!env)
|
|
127
|
+
return;
|
|
128
|
+
for (const mod of this.rootModules()) {
|
|
129
|
+
env.moduleGraph.invalidateModule(mod, new Set(), Date.now(), true, false);
|
|
130
|
+
env.moduleGraph.invalidateModule(mod, new Set(), undefined, false, false);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Client-environment nodes of a file (never Vite 8's mixed nodes — S1, finding 5). */
|
|
134
|
+
modulesOf(wirePath) {
|
|
135
|
+
const env = this.graph();
|
|
136
|
+
if (!env)
|
|
137
|
+
return [];
|
|
138
|
+
return [...(env.moduleGraph.getModulesByFile(normalizePath(this.absoluteOf(wirePath))) ?? [])];
|
|
139
|
+
}
|
|
140
|
+
/** True when the editor executes `wirePath`: globbed, or imported (transitively) by a root. */
|
|
141
|
+
isEditorModule(wirePath) {
|
|
142
|
+
if (isGlobbedScript(wirePath))
|
|
143
|
+
return true;
|
|
144
|
+
const roots = new Set(ROOT_IDS.map(resolvedId));
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
const queue = this.modulesOf(wirePath);
|
|
147
|
+
while (queue.length > 0) {
|
|
148
|
+
const mod = queue.shift();
|
|
149
|
+
if (seen.has(mod))
|
|
150
|
+
continue;
|
|
151
|
+
seen.add(mod);
|
|
152
|
+
if (mod.id && roots.has(mod.id))
|
|
153
|
+
return true;
|
|
154
|
+
for (const importer of mod.importers)
|
|
155
|
+
queue.push(importer);
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Project modules only the bot policies reach (`.plans/scripts-vite.md` S4/S11): reachable from
|
|
161
|
+
* `virtual:pix3/bot-policies` and not from `virtual:pix3/editor-scripts`, so the game the editor
|
|
162
|
+
* plays never runs them and a change to one need not wait for play to stop. Read from the client
|
|
163
|
+
* graph as it is now (after the barrier's propagation); a module the graph has not seen is not
|
|
164
|
+
* listed, which errs on the side of holding play.
|
|
165
|
+
*/
|
|
166
|
+
policyOnlyModules() {
|
|
167
|
+
const env = this.graph();
|
|
168
|
+
if (!env)
|
|
169
|
+
return [];
|
|
170
|
+
const reach = (rootId) => {
|
|
171
|
+
const out = new Set();
|
|
172
|
+
const root = env.moduleGraph.getModuleById(resolvedId(rootId));
|
|
173
|
+
if (!root)
|
|
174
|
+
return out;
|
|
175
|
+
const seen = new Set([root]);
|
|
176
|
+
const queue = [...root.importedModules];
|
|
177
|
+
while (queue.length > 0) {
|
|
178
|
+
const mod = queue.shift();
|
|
179
|
+
if (seen.has(mod))
|
|
180
|
+
continue;
|
|
181
|
+
seen.add(mod);
|
|
182
|
+
const wirePath = mod.file ? this.wirePathOf(mod.file) : null;
|
|
183
|
+
if (wirePath !== null)
|
|
184
|
+
out.add(wirePath);
|
|
185
|
+
for (const imported of mod.importedModules)
|
|
186
|
+
queue.push(imported);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
};
|
|
190
|
+
// No editor root in the graph yet: nothing can be proven policy-only.
|
|
191
|
+
if (!env.moduleGraph.getModuleById(resolvedId(EDITOR_SCRIPTS_ID)))
|
|
192
|
+
return [];
|
|
193
|
+
const game = reach(EDITOR_SCRIPTS_ID);
|
|
194
|
+
return [...reach(BOT_POLICIES_ID)].filter(path => !game.has(path)).sort();
|
|
195
|
+
}
|
|
196
|
+
/** HMR-propagate one file through the client graph (stamps `lastHMRTimestamp` up the chain). */
|
|
197
|
+
async reload(wirePath) {
|
|
198
|
+
const env = this.graph();
|
|
199
|
+
if (!env)
|
|
200
|
+
return 0;
|
|
201
|
+
const mods = this.modulesOf(wirePath);
|
|
202
|
+
for (const mod of mods)
|
|
203
|
+
await env.reloadModule(mod);
|
|
204
|
+
return mods.length;
|
|
205
|
+
}
|
|
206
|
+
/** Re-run the roots' globs (a script was added or removed). */
|
|
207
|
+
async reloadRoots() {
|
|
208
|
+
const env = this.graph();
|
|
209
|
+
if (!env)
|
|
210
|
+
return;
|
|
211
|
+
for (const mod of this.rootModules())
|
|
212
|
+
await env.reloadModule(mod);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type UserConfig } from 'vite';
|
|
2
|
+
/** The plugin's `config` hook: what it adds to the user's Vite config. */
|
|
3
|
+
export declare const pix3ViteConfig: (options: {
|
|
4
|
+
readonly user: UserConfig;
|
|
5
|
+
/** This package's directory (the page client and the player are served from it). */
|
|
6
|
+
readonly packageDir: string;
|
|
7
|
+
/** The playable build this command makes, or false (dev, `build: false`). */
|
|
8
|
+
readonly format: "html" | "zip" | false;
|
|
9
|
+
readonly compress: boolean;
|
|
10
|
+
/** Vite 8 bundles with rolldown (`this.meta.rolldownVersion`), Vite 7 with rollup. */
|
|
11
|
+
readonly rolldown: boolean;
|
|
12
|
+
}) => UserConfig;
|