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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +39 -2
  3. package/dist/build/bundle-hooks.d.ts +23 -0
  4. package/dist/build/bundle-hooks.js +79 -0
  5. package/dist/build/classic-script.d.ts +11 -0
  6. package/dist/build/classic-script.js +46 -0
  7. package/dist/build/compress.d.ts +29 -0
  8. package/dist/build/compress.js +63 -0
  9. package/dist/build/dependency-imports.d.ts +53 -0
  10. package/dist/build/dependency-imports.js +239 -0
  11. package/dist/build/dev-manifest.d.ts +10 -0
  12. package/dist/build/dev-manifest.js +25 -0
  13. package/dist/build/editor-flush.d.ts +42 -0
  14. package/dist/build/editor-flush.js +91 -0
  15. package/dist/build/finish.d.ts +11 -0
  16. package/dist/build/finish.js +126 -0
  17. package/dist/build/output-plugins.d.ts +26 -0
  18. package/dist/build/output-plugins.js +88 -0
  19. package/dist/build/plan.d.ts +75 -0
  20. package/dist/build/plan.js +162 -0
  21. package/dist/build/player-modules.d.ts +123 -0
  22. package/dist/build/player-modules.js +254 -0
  23. package/dist/build/project-manifest.d.ts +57 -0
  24. package/dist/build/project-manifest.js +169 -0
  25. package/dist/build/record.d.ts +25 -0
  26. package/dist/build/record.js +14 -0
  27. package/dist/build/report.d.ts +114 -0
  28. package/dist/build/report.js +102 -0
  29. package/dist/build/scan.d.ts +84 -0
  30. package/dist/build/scan.js +375 -0
  31. package/dist/build/strip-decision.d.ts +27 -0
  32. package/dist/build/strip-decision.js +82 -0
  33. package/dist/build/strippable-runtime-modules.d.ts +85 -0
  34. package/dist/build/strippable-runtime-modules.js +309 -0
  35. package/dist/build/zip.d.ts +6 -0
  36. package/dist/build/zip.js +28 -0
  37. package/dist/client/host-files.d.ts +96 -0
  38. package/dist/client/host-files.js +169 -0
  39. package/dist/client/index.d.ts +218 -0
  40. package/dist/client/index.js +381 -0
  41. package/dist/dev-info.d.ts +55 -0
  42. package/dist/dev-info.js +116 -0
  43. package/dist/dev-server.d.ts +28 -0
  44. package/dist/dev-server.js +139 -0
  45. package/dist/editor-page.d.ts +31 -0
  46. package/dist/editor-page.js +74 -0
  47. package/dist/files/changeset.d.ts +84 -0
  48. package/dist/files/changeset.js +321 -0
  49. package/dist/files/content-type.d.ts +1 -0
  50. package/dist/files/content-type.js +56 -0
  51. package/dist/files/history.d.ts +67 -0
  52. package/dist/files/history.js +162 -0
  53. package/dist/files/paths.d.ts +41 -0
  54. package/dist/files/paths.js +144 -0
  55. package/dist/files/project-files.d.ts +208 -0
  56. package/dist/files/project-files.js +1062 -0
  57. package/dist/files/scan.d.ts +71 -0
  58. package/dist/files/scan.js +252 -0
  59. package/dist/index.d.ts +56 -0
  60. package/dist/index.js +290 -0
  61. package/dist/module-paths.d.ts +13 -0
  62. package/dist/module-paths.js +32 -0
  63. package/dist/player/debug/context.d.ts +9 -0
  64. package/dist/player/debug/context.js +1 -0
  65. package/dist/player/debug/device-info.d.ts +15 -0
  66. package/dist/player/debug/device-info.js +36 -0
  67. package/dist/player/debug/frame-stats.d.ts +79 -0
  68. package/dist/player/debug/frame-stats.js +159 -0
  69. package/dist/player/debug/index.d.ts +10 -0
  70. package/dist/player/debug/index.js +20 -0
  71. package/dist/player/debug/long-frames.d.ts +46 -0
  72. package/dist/player/debug/long-frames.js +79 -0
  73. package/dist/player/debug/overlay.d.ts +25 -0
  74. package/dist/player/debug/overlay.js +320 -0
  75. package/dist/player/debug/session.d.ts +92 -0
  76. package/dist/player/debug/session.js +156 -0
  77. package/dist/player/dev/editor-link.d.ts +16 -0
  78. package/dist/player/dev/editor-link.js +125 -0
  79. package/dist/player/index.d.ts +38 -0
  80. package/dist/player/index.js +119 -0
  81. package/dist/server/editor-socket.d.ts +53 -0
  82. package/dist/server/editor-socket.js +134 -0
  83. package/dist/server/guard.d.ts +61 -0
  84. package/dist/server/guard.js +165 -0
  85. package/dist/server/http.d.ts +24 -0
  86. package/dist/server/http.js +70 -0
  87. package/dist/server/image-keys.d.ts +46 -0
  88. package/dist/server/image-keys.js +118 -0
  89. package/dist/server/image-proxy.d.ts +32 -0
  90. package/dist/server/image-proxy.js +124 -0
  91. package/dist/server/router.d.ts +54 -0
  92. package/dist/server/router.js +147 -0
  93. package/dist/sync/barrier.d.ts +69 -0
  94. package/dist/sync/barrier.js +240 -0
  95. package/dist/sync/hot-update.d.ts +16 -0
  96. package/dist/sync/hot-update.js +58 -0
  97. package/dist/sync/script-graph.d.ts +79 -0
  98. package/dist/sync/script-graph.js +214 -0
  99. package/dist/vite-config.d.ts +12 -0
  100. package/dist/vite-config.js +65 -0
  101. package/package.json +58 -4
@@ -0,0 +1,147 @@
1
+ import { DEFAULT_SYNC_TIMEOUT_MS, parseSyncRequest, parseTimeout, } from "../sync/barrier.js";
2
+ import { editorSessionFor } from "./guard.js";
3
+ import { errorBody, HttpError, readJson, sendJson } from "./http.js";
4
+ export const createRouter = (deps) => {
5
+ const prefix = `${deps.base}__pix3`;
6
+ const apiPrefix = `${prefix}/api/`;
7
+ /** A handler whose answer is `200` + JSON. */
8
+ const json = (answer) => async (req, res, url) => sendJson(res, 200, await answer(req, url));
9
+ const { files } = deps;
10
+ const readFile = (req, res, url, head) => files.readFile(req, res, url, head);
11
+ const historyVersion = (_req, res, url, head) => files.historyVersion(res, url, head);
12
+ const jsonMutation = (op) => async (req, res) => files.jsonMutation(req, res, op, await readJson(req));
13
+ /** `api path → method → handler`; an unknown pair is a 404. */
14
+ const apiRoutes = {
15
+ hello: { GET: json(async () => deps.hello()) },
16
+ file: { GET: readFile, HEAD: readFile, PUT: (req, res, url) => files.writeFile(req, res, url) },
17
+ manifest: { GET: json(() => files.manifest()) },
18
+ hash: { POST: json(async (req) => files.hashPaths(await readJson(req))) },
19
+ mkdir: { POST: jsonMutation('mkdir') },
20
+ delete: { POST: jsonMutation('delete') },
21
+ move: { POST: jsonMutation('move') },
22
+ changeset: { POST: (req, res) => files.writeChangeset(req, res) },
23
+ history: { GET: json((_req, url) => files.historyList(url)) },
24
+ 'history/version': { GET: historyVersion, HEAD: historyVersion },
25
+ 'history/record': {
26
+ POST: json(async (req) => ({ ...(await files.historyRecord(await readJson(req))) })),
27
+ },
28
+ 'history/restore': { POST: (req, res) => files.historyRestore(req, res) },
29
+ 'handover/claim': {
30
+ POST: json(async (req) => {
31
+ const claimed = await files.claim((await readJson(req)).writerId);
32
+ deps.socket.broadcast({ type: 'pix3:writer', writerId: claimed.writerId });
33
+ return claimed;
34
+ }),
35
+ },
36
+ sync: { POST: json(async (req) => deps.barrier.sync(parseSyncRequest(await readJson(req)))) },
37
+ flush: {
38
+ POST: json(async (req) => deps.barrier.flush(parseTimeout((await readJson(req)).timeoutMs, DEFAULT_SYNC_TIMEOUT_MS))),
39
+ },
40
+ };
41
+ const route = async (req, res, url) => {
42
+ const method = req.method ?? 'GET';
43
+ const path = url.pathname;
44
+ deps.guard.checkPeer(req);
45
+ if (method !== 'GET' && method !== 'HEAD')
46
+ deps.guard.checkMutation(req);
47
+ if (path === prefix) {
48
+ res.writeHead(302, { Location: `${prefix}/${url.search}` });
49
+ res.end();
50
+ return;
51
+ }
52
+ if (path === `${prefix}/` || path === `${prefix}/index.html`) {
53
+ if (method !== 'GET' && method !== 'HEAD')
54
+ throw new HttpError(405, 'method', 'GET only.');
55
+ const page = deps.editorPage(editorSessionFor(req, deps.sessionToken));
56
+ res.writeHead(page.status, {
57
+ 'Content-Type': 'text/html; charset=utf-8',
58
+ 'Cache-Control': 'no-store',
59
+ // A window the game opens on the editor gets no handle into it (the session token, the
60
+ // host object): another page's popup with a different policy lands in its own group.
61
+ // `-allow-popups` keeps the editor's own game pop-out scriptable.
62
+ 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
63
+ });
64
+ res.end(method === 'HEAD' ? undefined : page.html);
65
+ return;
66
+ }
67
+ if (path === `${prefix}/editor.css` && (method === 'GET' || method === 'HEAD')) {
68
+ // Served statically, past the project's PostCSS (plan §B.0): no CSS import may reach the
69
+ // editor chain (contract B).
70
+ const css = deps.editorCss();
71
+ if (!css)
72
+ throw new HttpError(404, 'not_found', '@pix3/editor-core is not installed.');
73
+ res.writeHead(200, {
74
+ 'Content-Type': 'text/css; charset=utf-8',
75
+ 'Cache-Control': 'no-cache',
76
+ });
77
+ res.end(method === 'HEAD' ? undefined : css);
78
+ return;
79
+ }
80
+ if (!path.startsWith(apiPrefix))
81
+ throw new HttpError(404, 'not_found', 'Not found.');
82
+ const api = path.slice(apiPrefix.length);
83
+ if (api === 'keys' || api.startsWith('proxy/')) {
84
+ deps.guard.checkEditorOnly(req, `${prefix}/`, deps.sessionToken);
85
+ if (method === 'GET' && api === 'keys') {
86
+ await deps.imageProxy.keyStatus(res);
87
+ return;
88
+ }
89
+ if (method === 'PUT' && api === 'keys') {
90
+ await deps.imageProxy.setKey(req, res);
91
+ return;
92
+ }
93
+ if (api.startsWith('proxy/')) {
94
+ await deps.imageProxy.forward(req, res, api.slice('proxy/'.length));
95
+ return;
96
+ }
97
+ throw new HttpError(405, 'method', 'GET or PUT.');
98
+ }
99
+ const handler = Object.hasOwn(apiRoutes, api) ? apiRoutes[api][method] : undefined;
100
+ if (handler) {
101
+ await handler(req, res, url, method === 'HEAD');
102
+ return;
103
+ }
104
+ throw new HttpError(404, 'not_found', 'Not found.');
105
+ };
106
+ return (req, res, next) => {
107
+ const url = new URL(req.url ?? '/', 'http://localhost');
108
+ if (isProjectStatePath(url.pathname)) {
109
+ // `.pix3/` is the plugin's and the CLI's state (dev.json, the journal, local keys), not a
110
+ // static asset of the game.
111
+ req.resume();
112
+ sendJson(res, 404, { error: 'not_found', message: 'Not found.' });
113
+ return;
114
+ }
115
+ if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) {
116
+ next();
117
+ return;
118
+ }
119
+ route(req, res, url).catch((error) => {
120
+ if (!(error instanceof HttpError)) {
121
+ deps.log(`500 ${req.method} ${req.url}: ${error instanceof Error ? error.stack : String(error)}`);
122
+ }
123
+ if (res.headersSent) {
124
+ res.destroy();
125
+ return;
126
+ }
127
+ const { status, body } = errorBody(error);
128
+ // Drain what the client is still sending, so it reads our answer instead of EPIPE.
129
+ req.resume();
130
+ sendJson(res, status, body);
131
+ });
132
+ };
133
+ };
134
+ /**
135
+ * Any `.pix3` segment, however the browser spelled it (`%2e`, case): the project's own `.pix3/`
136
+ * at the root, by `/@fs/<abs>/.pix3/…`, and `~/.pix3` should `fs.allow` ever reach the home.
137
+ */
138
+ const isProjectStatePath = (pathname) => {
139
+ let decoded;
140
+ try {
141
+ decoded = decodeURIComponent(pathname);
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ return decoded.split('/').some(segment => segment.toLowerCase() === '.pix3');
147
+ };
@@ -0,0 +1,69 @@
1
+ import type { ProjectFiles } from '../files/project-files.ts';
2
+ import type { EditorSocket } from '../server/editor-socket.ts';
3
+ import { type ScriptGraph } from './script-graph.ts';
4
+ /**
5
+ * The sync barrier (plan §B.3), as S1 found it works:
6
+ *
7
+ * 0. **flush** — the editor writes its dirty scenes. The page's `pix3_sync` flushes itself before
8
+ * calling `POST /sync`; a caller without a tab (the CLI), or a tab that is not the writer (a
9
+ * read-only second tab's `pix3_sync`), gets the flush done here, through the writer tab
10
+ * (`POST /flush` is the same step on its own);
11
+ * 1. **rescan** — stat + sha256 of the revision set, changes broadcast as `pix3:fs`;
12
+ * 2. **hard-invalidate the roots**, so their `load()` runs again with the new revision;
13
+ * 3. **propagate** every changed file through the client graph (`environment.reloadModule`), and
14
+ * the roots themselves when a globbed script was added or removed;
15
+ * 4. **page confirmation** — the tab re-imports the roots with `?t=<13 digits>` and answers with the
16
+ * revision it executed and `globalThis.__pix3Executed`. Sync is ok only when that revision is
17
+ * current AND every changed module the editor runs carries a stamp equal to the sha on disk.
18
+ * A page behind on either is asked again (roots re-invalidated, bounded: {@link
19
+ * MAX_CONFIRM_ATTEMPTS}, within `timeoutMs`); still behind → `{ok:false, reason:'stale_modules',
20
+ * paths, ackRev}`.
21
+ *
22
+ * A non-ok answer is not a barrier: on `gesture_in_progress` and `stale_modules` the agent
23
+ * retries; on `expect_mismatch` it re-reads; on `stale` (play is running) it follows `playing`.
24
+ */
25
+ export declare const DEFAULT_SYNC_TIMEOUT_MS = 10000;
26
+ export declare const VITE_CLIENT_WARNING: string;
27
+ export interface SyncRequest {
28
+ readonly expect?: Record<string, string>;
29
+ readonly timeoutMs: number;
30
+ /** The tab that asked (the page's `pix3_sync`); it has flushed already. */
31
+ readonly tabId: string | null;
32
+ }
33
+ export type SyncResult = Record<string, unknown> & {
34
+ readonly ok: boolean;
35
+ };
36
+ export declare const parseTimeout: (raw: unknown, fallback: number) => number;
37
+ export declare const parseSyncRequest: (body: Record<string, unknown>) => SyncRequest;
38
+ export interface BarrierDeps {
39
+ readonly files: ProjectFiles;
40
+ readonly scripts: ScriptGraph;
41
+ readonly socket: EditorSocket;
42
+ readonly log?: (line: string) => void;
43
+ }
44
+ export declare class SyncBarrier {
45
+ private readonly deps;
46
+ /** sha already propagated by a sync, per wire path: the watcher's late echo is suppressed. */
47
+ private readonly propagated;
48
+ /** One barrier at a time; a second sync waits for the first. */
49
+ private tail;
50
+ constructor(deps: BarrierDeps);
51
+ /** The tab that answers for the editor: the asker, else the writer, else the oldest tab. */
52
+ private targetTab;
53
+ /**
54
+ * Step 0 on its own (`POST /__pix3/api/flush`, and `npm run build` / `pix3 check` before they
55
+ * read the disk): the writer tab writes its dirty scenes. No writer tab = nothing unsaved here.
56
+ */
57
+ flush(timeoutMs: number): Promise<SyncResult>;
58
+ sync(request: SyncRequest): Promise<SyncResult>;
59
+ private run;
60
+ /** Changed files the editor executes, with the sha their stamp must carry. */
61
+ private requiredStamps;
62
+ private judge;
63
+ /**
64
+ * `handleHotUpdate` for a file a sync already propagated with the same bytes: the watcher's
65
+ * late echo would otherwise propagate a second time (S1: chokidar repeats `change`).
66
+ */
67
+ alreadyPropagated(wirePath: string, sha: string | null): boolean;
68
+ notePropagated(wirePath: string, sha: string | null): void;
69
+ }
@@ -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
+ }