@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,239 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { extname, join, relative, sep } from 'node:path';
3
+ import * as vite from 'vite';
4
+ import { findPackageDir } from "../dev-info.js";
5
+ import { collectImportedSpecifiers, collectMentionedNames } from "./scan.js";
6
+ /**
7
+ * N11 (plan §B.6 item 2): what a project dependency imports from `@pix3/runtime`. The scan of
8
+ * `mentionedNames` reads the project's own text sources, never `node_modules`, so a library with
9
+ * `import { GeometryMesh } from '@pix3/runtime'` would otherwise get the stub — the barrel
10
+ * re-exports it and the stub's importer is internal. Every package the project depends on that
11
+ * itself declares `@pix3/runtime` is parsed here before the build decides what to strip:
12
+ *
13
+ * - a named import (or `export { X } from`) adds `X` to the names that keep modules;
14
+ * - `import * as`, a dynamic `import('@pix3/runtime')`, `export * from`, `require(…)` or a file
15
+ * that does not parse is **opaque**: the build cannot see which modules the package reaches,
16
+ * so strip goes off with a message naming `pix3({ strip: { keep } })`.
17
+ *
18
+ * The AST comes from the bundler's own parser (`this.parse` in a hook; `parseAst` in a spec).
19
+ * TypeScript sources (a workspace package, a library shipping `.ts`) are lowered to JS first
20
+ * with Vite's own transform (`transformWithOxc` under Vite 8, `transformWithEsbuild` under 7).
21
+ * A `transform`-hook safety net in the plugin runs the same walk over every `node_modules`
22
+ * module the bundle actually pulls in, for packages this pre-scan did not know about.
23
+ */
24
+ export const RUNTIME_SPECIFIER = '@pix3/runtime';
25
+ const isNode = (value) => typeof value === 'object' && value !== null && typeof value.type === 'string';
26
+ const literalString = (node) => {
27
+ if (!isNode(node))
28
+ return null;
29
+ if (node.type === 'Literal' && typeof node.value === 'string')
30
+ return node.value;
31
+ if (node.type === 'TemplateLiteral' &&
32
+ Array.isArray(node.expressions) &&
33
+ node.expressions.length === 0 &&
34
+ Array.isArray(node.quasis) &&
35
+ node.quasis.length === 1) {
36
+ const cooked = node.quasis[0].value?.cooked;
37
+ return typeof cooked === 'string' ? cooked : null;
38
+ }
39
+ return null;
40
+ };
41
+ const isRuntimeSource = (source) => source === RUNTIME_SPECIFIER || (source !== null && source.startsWith(`${RUNTIME_SPECIFIER}/`));
42
+ const specifierName = (node) => {
43
+ if (!isNode(node))
44
+ return null;
45
+ if (node.type === 'Identifier' && typeof node.name === 'string')
46
+ return node.name;
47
+ if (node.type === 'Literal' && typeof node.value === 'string')
48
+ return node.value;
49
+ return null;
50
+ };
51
+ /** Walk one module's AST for what it takes from `@pix3/runtime`; `file` names it in messages. */
52
+ export const collectRuntimeImports = (ast, file, findings = { names: new Set(), opaque: [] }) => {
53
+ const opaque = (what) => {
54
+ findings.opaque.push(`${file}: ${what}`);
55
+ };
56
+ const visit = (node) => {
57
+ if (Array.isArray(node)) {
58
+ for (const child of node)
59
+ visit(child);
60
+ return;
61
+ }
62
+ if (!isNode(node))
63
+ return;
64
+ switch (node.type) {
65
+ case 'ImportDeclaration': {
66
+ if (!isRuntimeSource(literalString(node.source)))
67
+ break;
68
+ if (node.importKind === 'type')
69
+ break;
70
+ for (const specifier of node.specifiers ?? []) {
71
+ if (!isNode(specifier))
72
+ continue;
73
+ if (specifier.importKind === 'type')
74
+ continue;
75
+ if (specifier.type === 'ImportSpecifier') {
76
+ const name = specifierName(specifier.imported);
77
+ if (name)
78
+ findings.names.add(name);
79
+ }
80
+ else if (specifier.type === 'ImportNamespaceSpecifier') {
81
+ opaque(`import * as ${specifierName(specifier.local) ?? '…'} from '${RUNTIME_SPECIFIER}'`);
82
+ }
83
+ // A default import: the runtime has no default export; the bundler reports it.
84
+ }
85
+ return;
86
+ }
87
+ case 'ExportNamedDeclaration': {
88
+ if (isRuntimeSource(literalString(node.source)) && node.exportKind !== 'type') {
89
+ for (const specifier of node.specifiers ?? []) {
90
+ if (!isNode(specifier) || specifier.exportKind === 'type')
91
+ continue;
92
+ const name = specifierName(specifier.local);
93
+ if (name)
94
+ findings.names.add(name);
95
+ }
96
+ return;
97
+ }
98
+ break;
99
+ }
100
+ case 'ExportAllDeclaration': {
101
+ if (isRuntimeSource(literalString(node.source))) {
102
+ opaque(`export * from '${RUNTIME_SPECIFIER}'`);
103
+ return;
104
+ }
105
+ break;
106
+ }
107
+ case 'ImportExpression': {
108
+ if (isRuntimeSource(literalString(node.source))) {
109
+ opaque(`import('${RUNTIME_SPECIFIER}')`);
110
+ return;
111
+ }
112
+ break;
113
+ }
114
+ case 'CallExpression': {
115
+ const callee = node.callee;
116
+ const args = node.arguments;
117
+ if (isNode(callee) &&
118
+ callee.type === 'Identifier' &&
119
+ callee.name === 'require' &&
120
+ isRuntimeSource(literalString(args?.[0]))) {
121
+ opaque(`require('${RUNTIME_SPECIFIER}')`);
122
+ return;
123
+ }
124
+ break;
125
+ }
126
+ default:
127
+ break;
128
+ }
129
+ for (const [key, value] of Object.entries(node)) {
130
+ if (key === 'type' || key === 'loc' || key === 'range' || key === 'start' || key === 'end')
131
+ continue;
132
+ if (typeof value === 'object' && value !== null)
133
+ visit(value);
134
+ }
135
+ };
136
+ visit(ast);
137
+ return findings;
138
+ };
139
+ const SOURCE_EXTENSIONS = new Set([
140
+ '.js',
141
+ '.mjs',
142
+ '.cjs',
143
+ '.jsx',
144
+ '.ts',
145
+ '.mts',
146
+ '.cts',
147
+ '.tsx',
148
+ ]);
149
+ const NEEDS_LOWERING = /\.(?:[cm]?ts|[jt]sx)$/;
150
+ const NOT_A_SOURCE = /\.(?:spec|test)\.[cm]?[jt]sx?$|\.d\.[cm]?ts$/;
151
+ const SKIPPED_DIRECTORIES = new Set([
152
+ 'node_modules',
153
+ '.git',
154
+ '__tests__',
155
+ '__mocks__',
156
+ 'test',
157
+ 'tests',
158
+ ]);
159
+ /** Every parseable source of a package, as absolute paths (sorted; nested packages excluded). */
160
+ export const listPackageSources = (packageDir) => {
161
+ const out = [];
162
+ const walk = (dir) => {
163
+ let entries;
164
+ try {
165
+ entries = readdirSync(dir, { withFileTypes: true });
166
+ }
167
+ catch {
168
+ return;
169
+ }
170
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
171
+ for (const entry of entries) {
172
+ const absolute = join(dir, entry.name);
173
+ if (entry.isDirectory()) {
174
+ if (!SKIPPED_DIRECTORIES.has(entry.name))
175
+ walk(absolute);
176
+ }
177
+ else if (entry.isFile() &&
178
+ SOURCE_EXTENSIONS.has(extname(entry.name)) &&
179
+ !NOT_A_SOURCE.test(entry.name)) {
180
+ out.push(absolute);
181
+ }
182
+ }
183
+ };
184
+ walk(packageDir);
185
+ return out;
186
+ };
187
+ /** TypeScript / JSX → plain JS, with whichever transform the installed Vite ships. */
188
+ export const lowerToJs = async (code, filename) => {
189
+ const api = vite;
190
+ const transform = api.transformWithOxc ?? api.transformWithEsbuild;
191
+ if (!transform)
192
+ throw new Error('this Vite has neither transformWithOxc nor transformWithEsbuild');
193
+ return (await transform(code, filename)).code;
194
+ };
195
+ /**
196
+ * Parse every source of the given packages that mentions `@pix3/runtime` at all (a cheap text
197
+ * test first: three's one file is a megabyte). Opaque findings carry the package name and the
198
+ * path relative to it.
199
+ *
200
+ * Every source read is also scanned the way the project's own are (`mentionedNames`, the import
201
+ * specifiers): a library script that imports only `Script` and calls `this.scene.tween`,
202
+ * `.physics2d`, `juice.trail()` or `addComponent('core:Fade')` reaches modules no import names.
203
+ */
204
+ export const scanDependencyImports = async (options) => {
205
+ const findings = { names: new Set(), opaque: [] };
206
+ const parsed = {};
207
+ const mentionedNames = new Set();
208
+ const importedSpecifiers = new Set();
209
+ for (const name of options.packages) {
210
+ const dir = findPackageDir(options.root, name);
211
+ if (!dir)
212
+ continue;
213
+ parsed[name] = 0;
214
+ for (const file of listPackageSources(dir)) {
215
+ let code;
216
+ try {
217
+ code = readFileSync(file, 'utf8');
218
+ }
219
+ catch {
220
+ continue;
221
+ }
222
+ collectMentionedNames(code, mentionedNames);
223
+ collectImportedSpecifiers(code, importedSpecifiers);
224
+ if (!code.includes(RUNTIME_SPECIFIER))
225
+ continue;
226
+ const label = `${name}/${relative(dir, file).split(sep).join('/')}`;
227
+ try {
228
+ if (NEEDS_LOWERING.test(file))
229
+ code = await lowerToJs(code, file);
230
+ collectRuntimeImports(options.parse(code), label, findings);
231
+ parsed[name]++;
232
+ }
233
+ catch (error) {
234
+ findings.opaque.push(`${label}: could not be parsed (${error instanceof Error ? error.message : String(error)})`);
235
+ }
236
+ }
237
+ }
238
+ return { ...findings, parsed, mentionedNames, importedSpecifiers };
239
+ };
@@ -0,0 +1,10 @@
1
+ /** Dev: `virtual:pix3/scene-manifest` from the disk as it is now (a build uses its scan). */
2
+ export declare const devSceneManifestSource: (options: {
3
+ readonly root: string;
4
+ readonly resRoot: string;
5
+ readonly entryScene: string | null;
6
+ /** Vite's `base`, ending in `/`. */
7
+ readonly base: string;
8
+ /** Where an entry scene that does not exist is reported (as a build reports it). */
9
+ readonly warn: (line: string) => void;
10
+ }) => Promise<string>;
@@ -0,0 +1,25 @@
1
+ import { sceneManifestSource } from "./player-modules.js";
2
+ import { readProjectManifest, wireToRes } from "./project-manifest.js";
3
+ import { discoverLocalization, isPlayableScenePath, listProjectFiles, resolveEntryScene, } from "./scan.js";
4
+ /** Dev: `virtual:pix3/scene-manifest` from the disk as it is now (a build uses its scan). */
5
+ export const devSceneManifestSource = async (options) => {
6
+ const { root, resRoot } = options;
7
+ const manifest = readProjectManifest(root);
8
+ const resPaths = (await listProjectFiles(root))
9
+ .map(path => wireToRes(path, resRoot))
10
+ .filter((path) => path !== null);
11
+ const scenePaths = resPaths.filter(isPlayableScenePath).sort();
12
+ const warnings = [];
13
+ const entryScenePath = resolveEntryScene(scenePaths, manifest, options.entryScene, warnings);
14
+ for (const line of warnings)
15
+ options.warn(line);
16
+ return sceneManifestSource({
17
+ scenePaths,
18
+ entryScenePath,
19
+ manifest,
20
+ // As the build and the editor: the manifest's block, else the tables `locales/` has.
21
+ localization: manifest.localization ?? discoverLocalization(new Set(resPaths)),
22
+ netKindPrefabs: [],
23
+ resourceBase: `${options.base}${resRoot === '.' ? '' : `${resRoot}/`}`,
24
+ });
25
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Before a build reads the disk (plan §B.6 «npm run build, pix3 check, pix3 smoke»): if
3
+ * `.pix3/dev.json` points at a live dev server, ask it to flush the editor's unsaved scenes first
4
+ * (`POST /__pix3/api/flush`, up to {@link FLUSH_TIMEOUT_MS}). A server that refuses the
5
+ * connection is gone (a stale `dev.json` after a crash); one that accepts it but does not answer
6
+ * `hello` within {@link PROBE_TIMEOUT_MS} is alive and busy, and — like an editor that cannot flush
7
+ * in time — is `E_EDITOR_UNSYNCED`: the build would otherwise ship a scene older than what the
8
+ * designer sees.
9
+ *
10
+ * `PIX3_NO_SYNC=1` skips the step (the CLI's `--no-sync`). The CLI carries its own copy of this logic
11
+ * (`packages/cli/src/editor-sync.ts`); keep the two in step.
12
+ */
13
+ export declare const FLUSH_TIMEOUT_MS = 15000;
14
+ export type EditorFlushResult = {
15
+ readonly status: 'skipped';
16
+ }
17
+ /** No `dev.json` (no `url`), or one whose server is gone (`url` and why). */
18
+ | {
19
+ readonly status: 'no-server';
20
+ readonly url?: string;
21
+ readonly reason?: string;
22
+ } | {
23
+ readonly status: 'no-editor';
24
+ readonly url: string;
25
+ } | {
26
+ readonly status: 'flushed';
27
+ readonly url: string;
28
+ };
29
+ export declare class EditorUnsyncedError extends Error {
30
+ readonly code = "E_EDITOR_UNSYNCED";
31
+ constructor(message: string);
32
+ }
33
+ /**
34
+ * Flush the editor that `.pix3/dev.json` points at, if any. Throws {@link EditorUnsyncedError}
35
+ * when an editor is connected but did not confirm the flush in time.
36
+ */
37
+ export declare const flushEditorBeforeBuild: (root: string, options?: {
38
+ readonly skip?: boolean;
39
+ readonly timeoutMs?: number;
40
+ /** How long `hello` may take before a listening server counts as busy (specs). */
41
+ readonly probeTimeoutMs?: number;
42
+ }) => Promise<EditorFlushResult>;
@@ -0,0 +1,91 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { devJsonPath } from "../dev-info.js";
3
+ /**
4
+ * Before a build reads the disk (plan §B.6 «npm run build, pix3 check, pix3 smoke»): if
5
+ * `.pix3/dev.json` points at a live dev server, ask it to flush the editor's unsaved scenes first
6
+ * (`POST /__pix3/api/flush`, up to {@link FLUSH_TIMEOUT_MS}). A server that refuses the
7
+ * connection is gone (a stale `dev.json` after a crash); one that accepts it but does not answer
8
+ * `hello` within {@link PROBE_TIMEOUT_MS} is alive and busy, and — like an editor that cannot flush
9
+ * in time — is `E_EDITOR_UNSYNCED`: the build would otherwise ship a scene older than what the
10
+ * designer sees.
11
+ *
12
+ * `PIX3_NO_SYNC=1` skips the step (the CLI's `--no-sync`). The CLI carries its own copy of this logic
13
+ * (`packages/cli/src/editor-sync.ts`); keep the two in step.
14
+ */
15
+ export const FLUSH_TIMEOUT_MS = 15_000;
16
+ const PROBE_TIMEOUT_MS = 2_000;
17
+ export class EditorUnsyncedError extends Error {
18
+ code = 'E_EDITOR_UNSYNCED';
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'EditorUnsyncedError';
22
+ }
23
+ }
24
+ const readDevInfo = (root) => {
25
+ try {
26
+ const parsed = JSON.parse(readFileSync(devJsonPath(root), 'utf8'));
27
+ return typeof parsed.url === 'string' ? parsed : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ };
33
+ const fetchWithTimeout = (url, init, timeoutMs) => fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
34
+ const isTimeout = (error) => error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
35
+ const causeOf = (error) => {
36
+ const cause = error?.cause;
37
+ if (typeof cause?.code === 'string')
38
+ return cause.code;
39
+ if (typeof cause?.message === 'string')
40
+ return cause.message;
41
+ return error instanceof Error ? error.message : String(error);
42
+ };
43
+ /**
44
+ * Flush the editor that `.pix3/dev.json` points at, if any. Throws {@link EditorUnsyncedError}
45
+ * when an editor is connected but did not confirm the flush in time.
46
+ */
47
+ export const flushEditorBeforeBuild = async (root, options = {}) => {
48
+ if (options.skip ?? process.env.PIX3_NO_SYNC === '1')
49
+ return { status: 'skipped' };
50
+ const info = readDevInfo(root);
51
+ if (!info)
52
+ return { status: 'no-server' };
53
+ const timeoutMs = options.timeoutMs ?? FLUSH_TIMEOUT_MS;
54
+ const api = `${info.url.endsWith('/') ? info.url : `${info.url}/`}__pix3/api/`;
55
+ const probeMs = options.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
56
+ try {
57
+ const hello = await fetchWithTimeout(`${api}hello`, {}, probeMs);
58
+ // Something else answers on that port now (the server is gone, the port reused).
59
+ if (!hello.ok)
60
+ return { status: 'no-server', url: info.url, reason: `HTTP ${hello.status}` };
61
+ }
62
+ catch (error) {
63
+ if (isTimeout(error)) {
64
+ throw new EditorUnsyncedError(`The Pix3 dev server at ${info.url} accepted the connection but did not answer within ` +
65
+ `${probeMs / 1000} s, so its editor's unsaved scenes could not be flushed. Retry once ` +
66
+ `it responds, stop it (a stale .pix3/dev.json is ignored), or skip with PIX3_NO_SYNC=1.`);
67
+ }
68
+ return { status: 'no-server', url: info.url, reason: causeOf(error) };
69
+ }
70
+ let reply;
71
+ try {
72
+ const response = await fetchWithTimeout(`${api}flush`, {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json', 'X-Pix3': '1' },
75
+ body: JSON.stringify({ timeoutMs }),
76
+ }, timeoutMs + PROBE_TIMEOUT_MS);
77
+ reply = (await response.json());
78
+ }
79
+ catch (error) {
80
+ throw new EditorUnsyncedError(`The Pix3 editor at ${info.url} did not confirm the flush of its unsaved scenes within ` +
81
+ `${Math.round(timeoutMs / 1000)} s (${error instanceof Error ? error.message : String(error)}). ` +
82
+ `Save in the editor and retry, or skip with PIX3_NO_SYNC=1.`);
83
+ }
84
+ if (reply.ok === false) {
85
+ throw new EditorUnsyncedError(`The Pix3 editor at ${info.url} could not flush its unsaved scenes (${reply.reason ?? 'unknown'}). ` +
86
+ `Save in the editor and retry, or skip with PIX3_NO_SYNC=1.`);
87
+ }
88
+ return reply.editor
89
+ ? { status: 'flushed', url: info.url }
90
+ : { status: 'no-editor', url: info.url };
91
+ };
@@ -0,0 +1,11 @@
1
+ import type { BuildState } from './plan.ts';
2
+ /**
3
+ * The last build hook: the artifact is final, so the zip is written here, then
4
+ * `dist/<name>.report.json` and `.pix3/build.json`.
5
+ */
6
+ export declare const finishBuild: (build: BuildState, options: {
7
+ readonly root: string;
8
+ readonly outDir: string;
9
+ readonly log: (line: string) => void;
10
+ readonly warn: (line: string) => void;
11
+ }) => void;
@@ -0,0 +1,126 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { fileDigest, writeBuildRecord } from "./record.js";
4
+ import { buildReportPath, gzipBytesOf, summarizeCode, writeBuildReport, } from "./report.js";
5
+ import { zipDirectory } from "./zip.js";
6
+ const kib = (bytes) => (bytes / 1024).toFixed(1);
7
+ /** Strip as it happened: off when the runtime did not resolve or nothing planned was replaced. */
8
+ const stripOutcome = (build, stripped) => {
9
+ if (!build.stripEnabled)
10
+ return { enabled: false, reason: build.stripReason };
11
+ if (build.runtimeSrc === null)
12
+ return { enabled: false, reason: '@pix3/runtime did not resolve from the project' };
13
+ if (build.stripped.size > 0 && stripped.length === 0)
14
+ return {
15
+ enabled: false,
16
+ reason: `no runtime module loaded from ${build.runtimeSrc}: nothing was replaced`,
17
+ };
18
+ return { enabled: true, reason: build.stripReason };
19
+ };
20
+ /** How each optional library resolved, for the report. */
21
+ const librariesOf = (build) => {
22
+ const { applied, scan } = build;
23
+ return {
24
+ yaml: applied.yaml
25
+ ? 'stub: scenes and prefabs ship as JSON'
26
+ : build.stubYaml
27
+ ? 'bundled (scenes ship as JSON)'
28
+ : 'bundled',
29
+ GLTFLoader: applied.gltf ? 'stub: no .glb/.gltf in the project' : 'bundled',
30
+ postprocessing: !build.postprocessingInstalled
31
+ ? 'not installed'
32
+ : applied.postprocessing
33
+ ? 'stub: no PostProcess node'
34
+ : scan.usesPostProcessing
35
+ ? 'bundled (static import)'
36
+ : build.format !== 'html'
37
+ ? 'lazy chunk (the runtime import())'
38
+ : 'bundled (the runtime import(), inlined)',
39
+ spine: !scan.usesSpine
40
+ ? 'not bundled: no SpineSkeleton2D'
41
+ : build.spineInstalled
42
+ ? 'bundled (static import)'
43
+ : 'not installed',
44
+ network: scan.usesNetwork ? 'bundled (NetworkService installed)' : 'no-op',
45
+ };
46
+ };
47
+ /**
48
+ * The last build hook: the artifact is final, so the zip is written here, then
49
+ * `dist/<name>.report.json` and `.pix3/build.json`.
50
+ */
51
+ export const finishBuild = (build, options) => {
52
+ const { root, outDir, log, warn } = options;
53
+ const htmlPath = join(outDir, 'index.html');
54
+ let artifact = htmlPath;
55
+ if (build.format === 'zip') {
56
+ artifact = zipDirectory(outDir, `${build.manifest.projectName.replace(/[\\/:*?"<>|]+/g, '-')}.zip`);
57
+ }
58
+ else if (!existsSync(htmlPath)) {
59
+ warn(`no ${htmlPath} after the build: is index.html the project's entry?`);
60
+ return;
61
+ }
62
+ const digest = fileDigest(artifact);
63
+ const at = new Date().toISOString();
64
+ // What the hooks replaced, not what `buildStart` planned: the two differ only when the
65
+ // runtime's modules arrived under ids the plan did not recognise — say so.
66
+ const stripped = [...build.applied.modules].sort();
67
+ const missed = [...build.stripped].filter(path => !build.applied.modules.has(path));
68
+ if (missed.length > 0)
69
+ warn(`${missed.length} runtime module(s) planned for stripping were never replaced ` +
70
+ `(${missed.slice(0, 3).join(', ')}${missed.length > 3 ? ', …' : ''}): ` +
71
+ `they did not load from ${build.runtimeSrc ?? 'the resolved runtime'}`);
72
+ const strip = stripOutcome(build, stripped);
73
+ const reportPath = buildReportPath(artifact, outDir);
74
+ const { compressed, assets, scan } = build;
75
+ writeBuildReport(reportPath, {
76
+ format: build.format,
77
+ path: artifact,
78
+ at,
79
+ entryScene: scan.entryScenePath,
80
+ scenes: scan.scenePaths.length,
81
+ bytes: digest.bytes,
82
+ gzipBytes: gzipBytesOf(readFileSync(artifact)),
83
+ compress: compressed
84
+ ? {
85
+ enabled: true,
86
+ bundleBytes: compressed.bundleBytes,
87
+ gzipBytes: compressed.gzipBytes,
88
+ base64Bytes: compressed.base64Bytes,
89
+ ratio: Math.round((compressed.gzipBytes / Math.max(1, compressed.bundleBytes)) * 1000) / 1000,
90
+ savedBytes: compressed.bundleBytes - compressed.base64Bytes,
91
+ }
92
+ : { enabled: false },
93
+ code: summarizeCode({ bundleBytes: build.bundleBytes, modules: build.moduleSizes }),
94
+ strip: {
95
+ ...strip,
96
+ keep: build.keep,
97
+ stripped,
98
+ dependencies: build.dependencyParsed,
99
+ dependencyImports: [...build.dependencyImports].sort(),
100
+ },
101
+ libraries: librariesOf(build),
102
+ assets: {
103
+ count: scan.assetPaths.length,
104
+ rawBytes: assets?.rawBytes ?? 0,
105
+ base64Bytes: assets?.base64Bytes ?? 0,
106
+ entries: assets?.entries ?? [],
107
+ },
108
+ warnings: scan.warnings,
109
+ });
110
+ writeBuildRecord(root, {
111
+ format: build.format,
112
+ path: artifact,
113
+ ...digest,
114
+ at,
115
+ entryScene: scan.entryScenePath,
116
+ assets: scan.assetPaths.length,
117
+ stripped,
118
+ warnings: scan.warnings,
119
+ report: reportPath,
120
+ });
121
+ log(`${build.format}: ${relative(root, artifact)} (${kib(digest.bytes)} KiB` +
122
+ (compressed
123
+ ? `, bundle ${kib(compressed.bundleBytes)} → gzip ${kib(compressed.gzipBytes)} KiB`
124
+ : '') +
125
+ `); report ${relative(root, reportPath)}`);
126
+ };
@@ -0,0 +1,26 @@
1
+ import type { Plugin } from 'vite';
2
+ import type { BuildState } from './plan.ts';
3
+ /**
4
+ * The optional libraries a build leaves out (CLAUDE.md «Playable export size»), part of the
5
+ * strip (`strip: false` keeps them all). `enforce: 'pre'`, because Vite's own resolver runs
6
+ * before a normal plugin's `resolveId`. Every one is stubbed **for the runtime's own importers
7
+ * only** (real paths compared): a project script or a dependency that imports the library
8
+ * itself always gets the real one — and the scan counts that import as a use, so the runtime
9
+ * shares the real copy then too.
10
+ *
11
+ * - `postprocessing` in a single-file build with no PostProcess node and no import of it — the
12
+ * runtime's `import('postprocessing')` would be inlined whole;
13
+ * - `yaml` — the scenes ship as JSON (`shippedBytes`), so `SceneLoader`'s `parse` is
14
+ * `JSON.parse` (plan §B.6 item 4, done the 1.x way: no runtime change, no second format);
15
+ * - `GLTFLoader` when no scene, script, library or asset names a model.
16
+ */
17
+ export declare const libraryStubsPlugin: (getBuild: () => BuildState | null) => Plugin;
18
+ /**
19
+ * `build: 'html'` (only then are these in the plugin list): inline every chunk and stylesheet into `index.html` (plan §B.6 item 5), then
20
+ * make that one script classic, at the end of `<body>` (DeepCore's compatibility plugin), and
21
+ * with `compress` replace it by the gzip payload and its bootstrap (`build/compress.ts`).
22
+ */
23
+ export declare const singleFilePlugins: (options: {
24
+ readonly compress: boolean;
25
+ readonly getBuild: () => BuildState | null;
26
+ }) => Plugin[];
@@ -0,0 +1,88 @@
1
+ import { viteSingleFile } from 'vite-plugin-singlefile';
2
+ import { isUnder, realFileOf } from "../module-paths.js";
3
+ import { resolvedId } from "../sync/script-graph.js";
4
+ import { toClassicScriptHtml } from "./classic-script.js";
5
+ import { toCompressedHtml } from "./compress.js";
6
+ import { GLTF_LOADER_SPECIFIER, GLTF_LOADER_STUB_ID, POSTPROCESSING_STUB_ID, YAML_STUB_ID, } from "./player-modules.js";
7
+ /**
8
+ * The optional libraries a build leaves out (CLAUDE.md «Playable export size»), part of the
9
+ * strip (`strip: false` keeps them all). `enforce: 'pre'`, because Vite's own resolver runs
10
+ * before a normal plugin's `resolveId`. Every one is stubbed **for the runtime's own importers
11
+ * only** (real paths compared): a project script or a dependency that imports the library
12
+ * itself always gets the real one — and the scan counts that import as a use, so the runtime
13
+ * shares the real copy then too.
14
+ *
15
+ * - `postprocessing` in a single-file build with no PostProcess node and no import of it — the
16
+ * runtime's `import('postprocessing')` would be inlined whole;
17
+ * - `yaml` — the scenes ship as JSON (`shippedBytes`), so `SceneLoader`'s `parse` is
18
+ * `JSON.parse` (plan §B.6 item 4, done the 1.x way: no runtime change, no second format);
19
+ * - `GLTFLoader` when no scene, script, library or asset names a model.
20
+ */
21
+ export const libraryStubsPlugin = (getBuild) => ({
22
+ name: 'pix3:library-stubs',
23
+ apply: 'build',
24
+ enforce: 'pre',
25
+ resolveId(id, importer) {
26
+ const build = getBuild();
27
+ if (!build?.stripEnabled || !importer || importer.startsWith('\0'))
28
+ return null;
29
+ if (id !== 'postprocessing' && id !== 'yaml' && id !== GLTF_LOADER_SPECIFIER)
30
+ return null;
31
+ if (!isUnder(realFileOf(importer), build.runtimeSrc))
32
+ return null;
33
+ if (id === 'postprocessing' && build.format === 'html' && !build.scan.usesPostProcessing) {
34
+ build.applied.postprocessing = true;
35
+ return resolvedId(POSTPROCESSING_STUB_ID);
36
+ }
37
+ if (id === 'yaml' && build.stubYaml) {
38
+ build.applied.yaml = true;
39
+ return resolvedId(YAML_STUB_ID);
40
+ }
41
+ if (id === GLTF_LOADER_SPECIFIER && build.stubGltf) {
42
+ build.applied.gltf = true;
43
+ return resolvedId(GLTF_LOADER_STUB_ID);
44
+ }
45
+ return null;
46
+ },
47
+ });
48
+ /**
49
+ * `build: 'html'` (only then are these in the plugin list): inline every chunk and stylesheet into `index.html` (plan §B.6 item 5), then
50
+ * make that one script classic, at the end of `<body>` (DeepCore's compatibility plugin), and
51
+ * with `compress` replace it by the gzip payload and its bootstrap (`build/compress.ts`).
52
+ */
53
+ export const singleFilePlugins = (options) => {
54
+ const singleFile = {
55
+ ...viteSingleFile({
56
+ useRecommendedBuildConfig: false,
57
+ // `modulePreload: false` (vite-config.ts) means there is no preload polyfill to remove — and
58
+ // the removal's regex takes the first IIFE it sees for that polyfill, which in a compressed
59
+ // (`iife`) build is the head of the bundle itself.
60
+ removeViteModuleLoader: false,
61
+ }),
62
+ name: 'pix3:single-file',
63
+ apply: 'build',
64
+ };
65
+ const classicScript = {
66
+ name: 'pix3:classic-script',
67
+ apply: 'build',
68
+ enforce: 'post',
69
+ generateBundle(_options, bundle) {
70
+ for (const output of Object.values(bundle)) {
71
+ if (output.type !== 'asset' || !output.fileName.endsWith('.html'))
72
+ continue;
73
+ const html = typeof output.source === 'string'
74
+ ? output.source
75
+ : Buffer.from(output.source).toString('utf8');
76
+ const classic = toClassicScriptHtml(html);
77
+ const build = options.compress ? options.getBuild() : null;
78
+ if (!build) {
79
+ output.source = classic;
80
+ continue;
81
+ }
82
+ build.compressed = toCompressedHtml(classic);
83
+ output.source = build.compressed.html;
84
+ }
85
+ },
86
+ };
87
+ return [singleFile, classicScript];
88
+ };