@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,71 @@
1
+ import { type BigIntStats } from 'node:fs';
2
+ /**
3
+ * The revision set and the file table the plugin keeps of it (port of
4
+ * `packages/cli/src/serve/scan.ts`).
5
+ *
6
+ * "Everything that ships" is decided the way `ProjectBuildService.collectShippableProjectFiles`
7
+ * decides it: walk the whole root, skip directories named in `NON_SHIPPABLE_DIRECTORIES` at any
8
+ * depth — plus `.pix3/` at the root (the plugin's state). Scenes, prefabs, scripts, assets of every
9
+ * type, `design/tests/`, `locales/` and `pix3project.yaml` all fall inside that walk; nothing is
10
+ * filtered by extension. Symlinks are neither listed nor followed.
11
+ */
12
+ /**
13
+ * Mirror of `NON_SHIPPABLE_DIRECTORIES` in
14
+ * `packages/editor-core/src/services/export/ProjectBuildService.ts` (the editor cannot be imported
15
+ * from here). Keep the two in sync.
16
+ */
17
+ export declare const NON_SHIPPABLE_DIRECTORIES: ReadonlySet<string>;
18
+ /**
19
+ * Name of the temp file a write uses when `.pix3/tmp/` is on another device than the target
20
+ * (rename cannot cross devices). Never part of the table, so it never shows up as a change.
21
+ */
22
+ export declare const SIBLING_TEMP_PATTERN: RegExp;
23
+ /** True when the wire path is outside the revision set (inside an excluded directory, or one itself). */
24
+ export declare const isExcludedPath: (wirePath: string) => boolean;
25
+ export interface FileEntry {
26
+ readonly kind: 'file' | 'dir';
27
+ readonly size: number;
28
+ /** Integer epoch milliseconds (what goes on the wire). */
29
+ readonly mtime: number;
30
+ /** `ino:size:mtimeNs` — the hash cache key; an atomic replace changes `ino` even at equal mtime. */
31
+ readonly statKey: string;
32
+ readonly sha256?: string;
33
+ }
34
+ export type FileTable = Map<string, FileEntry>;
35
+ export declare const statKeyOf: (stats: BigIntStats) => string;
36
+ export declare const mtimeOf: (stats: BigIntStats) => number;
37
+ export declare const hashFile: (absolute: string) => Promise<string>;
38
+ /**
39
+ * sha256 per wire path, reused while `(ino, size, mtimeNs)` is unchanged. Shared by the table
40
+ * scans and the read-only routes (`file` ETag, `hash`).
41
+ */
42
+ export declare class HashCache {
43
+ private readonly entries;
44
+ private static readonly MAX_ENTRIES;
45
+ hash(wirePath: string, absolute: string, stats: BigIntStats): Promise<string>;
46
+ remember(wirePath: string, key: string, sha256: string): void;
47
+ forget(wirePath: string): void;
48
+ }
49
+ /**
50
+ * Scan `wirePath` (a file or a directory subtree; `''` = the whole root) into `out`.
51
+ * Missing paths, symlinks and excluded directories contribute nothing.
52
+ */
53
+ export declare const scanInto: (root: string, wirePath: string, cache: HashCache, out: FileTable) => Promise<void>;
54
+ /** `revision` = sha256 over the sorted `path:sha256` lines of every FILE, joined by `\n`. */
55
+ export declare const computeRevision: (table: FileTable) => string;
56
+ export interface ChangeEvent {
57
+ readonly op: 'create' | 'modify' | 'delete' | 'rename';
58
+ readonly path: string;
59
+ readonly kind: 'file' | 'dir';
60
+ readonly sha256?: string;
61
+ /** Only on `rename`: where the file was. */
62
+ readonly from?: string;
63
+ }
64
+ /**
65
+ * Replace the part of `table` at/under `prefix` with `fresh` (a scan of that prefix) and return
66
+ * what changed. A file whose stat changed but whose content did not is updated silently.
67
+ *
68
+ * A `delete` + `create` of identical content in the same batch becomes one `rename` when the
69
+ * pairing is unambiguous (exactly one deleted and one created file carry that hash).
70
+ */
71
+ export declare const applySubtree: (table: FileTable, prefixes: readonly string[], fresh: FileTable) => ChangeEvent[];
@@ -0,0 +1,252 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { lstat, readdir } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { errnoCode } from "../server/http.js";
6
+ import { RESERVED_ROOT_DIR } from "./paths.js";
7
+ /**
8
+ * The revision set and the file table the plugin keeps of it (port of
9
+ * `packages/cli/src/serve/scan.ts`).
10
+ *
11
+ * "Everything that ships" is decided the way `ProjectBuildService.collectShippableProjectFiles`
12
+ * decides it: walk the whole root, skip directories named in `NON_SHIPPABLE_DIRECTORIES` at any
13
+ * depth — plus `.pix3/` at the root (the plugin's state). Scenes, prefabs, scripts, assets of every
14
+ * type, `design/tests/`, `locales/` and `pix3project.yaml` all fall inside that walk; nothing is
15
+ * filtered by extension. Symlinks are neither listed nor followed.
16
+ */
17
+ /**
18
+ * Mirror of `NON_SHIPPABLE_DIRECTORIES` in
19
+ * `packages/editor-core/src/services/export/ProjectBuildService.ts` (the editor cannot be imported
20
+ * from here). Keep the two in sync.
21
+ */
22
+ export const NON_SHIPPABLE_DIRECTORIES = new Set([
23
+ 'node_modules',
24
+ '.git',
25
+ '.yalc',
26
+ '.vscode',
27
+ '.idea',
28
+ 'dist',
29
+ 'build',
30
+ 'out',
31
+ 'coverage',
32
+ '.cache',
33
+ ]);
34
+ /**
35
+ * Name of the temp file a write uses when `.pix3/tmp/` is on another device than the target
36
+ * (rename cannot cross devices). Never part of the table, so it never shows up as a change.
37
+ */
38
+ export const SIBLING_TEMP_PATTERN = /^\..+\.pix3-tmp-[0-9a-f]+$/;
39
+ /** True when the wire path is outside the revision set (inside an excluded directory, or one itself). */
40
+ export const isExcludedPath = (wirePath) => {
41
+ const segments = wirePath.split('/');
42
+ if (segments[0] === RESERVED_ROOT_DIR)
43
+ return true;
44
+ if (SIBLING_TEMP_PATTERN.test(segments[segments.length - 1]))
45
+ return true;
46
+ // The last segment is excluded only as a directory; a FILE named `dist` is ordinary.
47
+ for (let i = 0; i < segments.length - 1; i++) {
48
+ if (NON_SHIPPABLE_DIRECTORIES.has(segments[i]))
49
+ return true;
50
+ }
51
+ return false;
52
+ };
53
+ const isExcludedDirectory = (wirePath, name) => wirePath === RESERVED_ROOT_DIR || NON_SHIPPABLE_DIRECTORIES.has(name);
54
+ export const statKeyOf = (stats) => `${stats.ino}:${stats.size}:${stats.mtimeNs}`;
55
+ export const mtimeOf = (stats) => Number(stats.mtimeNs / 1000000n);
56
+ export const hashFile = (absolute) => new Promise((resolve, reject) => {
57
+ const hash = createHash('sha256');
58
+ const stream = createReadStream(absolute);
59
+ stream.on('data', chunk => hash.update(chunk));
60
+ stream.on('error', reject);
61
+ stream.on('end', () => resolve(hash.digest('hex')));
62
+ });
63
+ /**
64
+ * sha256 per wire path, reused while `(ino, size, mtimeNs)` is unchanged. Shared by the table
65
+ * scans and the read-only routes (`file` ETag, `hash`).
66
+ */
67
+ export class HashCache {
68
+ entries = new Map();
69
+ static MAX_ENTRIES = 200_000;
70
+ async hash(wirePath, absolute, stats) {
71
+ const key = statKeyOf(stats);
72
+ const cached = this.entries.get(wirePath);
73
+ if (cached && cached.key === key)
74
+ return cached.sha256;
75
+ const sha256 = await hashFile(absolute);
76
+ this.remember(wirePath, key, sha256);
77
+ return sha256;
78
+ }
79
+ remember(wirePath, key, sha256) {
80
+ if (this.entries.size >= HashCache.MAX_ENTRIES)
81
+ this.entries.clear();
82
+ this.entries.set(wirePath, { key, sha256 });
83
+ }
84
+ forget(wirePath) {
85
+ this.entries.delete(wirePath);
86
+ }
87
+ }
88
+ /**
89
+ * Scan `wirePath` (a file or a directory subtree; `''` = the whole root) into `out`.
90
+ * Missing paths, symlinks and excluded directories contribute nothing.
91
+ */
92
+ export const scanInto = async (root, wirePath, cache, out) => {
93
+ const absolute = wirePath ? join(root, ...wirePath.split('/')) : root;
94
+ if (wirePath && isExcludedPath(wirePath))
95
+ return;
96
+ let stats;
97
+ try {
98
+ stats = await lstat(absolute, { bigint: true });
99
+ }
100
+ catch (error) {
101
+ const code = errnoCode(error);
102
+ if (code === 'ENOENT' || code === 'ENOTDIR')
103
+ return;
104
+ throw error;
105
+ }
106
+ if (stats.isFile()) {
107
+ if (!wirePath)
108
+ return;
109
+ let sha256;
110
+ try {
111
+ sha256 = await cache.hash(wirePath, absolute, stats);
112
+ }
113
+ catch (error) {
114
+ if (errnoCode(error) === 'ENOENT')
115
+ return; // vanished mid-scan; the next event says so
116
+ throw error;
117
+ }
118
+ out.set(wirePath, {
119
+ kind: 'file',
120
+ size: Number(stats.size),
121
+ mtime: mtimeOf(stats),
122
+ statKey: statKeyOf(stats),
123
+ sha256,
124
+ });
125
+ return;
126
+ }
127
+ if (!stats.isDirectory())
128
+ return;
129
+ const name = wirePath.slice(wirePath.lastIndexOf('/') + 1);
130
+ if (wirePath && isExcludedDirectory(wirePath, name))
131
+ return;
132
+ if (wirePath) {
133
+ out.set(wirePath, { kind: 'dir', size: 0, mtime: mtimeOf(stats), statKey: statKeyOf(stats) });
134
+ }
135
+ let names;
136
+ try {
137
+ names = await readdir(absolute);
138
+ }
139
+ catch (error) {
140
+ const code = errnoCode(error);
141
+ if (code === 'ENOENT' || code === 'ENOTDIR')
142
+ return;
143
+ throw error;
144
+ }
145
+ names.sort();
146
+ for (const child of names) {
147
+ await scanInto(root, wirePath ? `${wirePath}/${child}` : child, cache, out);
148
+ }
149
+ };
150
+ /** `revision` = sha256 over the sorted `path:sha256` lines of every FILE, joined by `\n`. */
151
+ export const computeRevision = (table) => {
152
+ const lines = [];
153
+ for (const [path, entry] of table) {
154
+ if (entry.kind === 'file' && entry.sha256)
155
+ lines.push(`${path}:${entry.sha256}`);
156
+ }
157
+ lines.sort();
158
+ return createHash('sha256').update(lines.join('\n')).digest('hex');
159
+ };
160
+ const isUnder = (path, prefix) => prefix === '' || path === prefix || path.startsWith(`${prefix}/`);
161
+ /**
162
+ * Replace the part of `table` at/under `prefix` with `fresh` (a scan of that prefix) and return
163
+ * what changed. A file whose stat changed but whose content did not is updated silently.
164
+ *
165
+ * A `delete` + `create` of identical content in the same batch becomes one `rename` when the
166
+ * pairing is unambiguous (exactly one deleted and one created file carry that hash).
167
+ */
168
+ export const applySubtree = (table, prefixes, fresh) => {
169
+ const created = [];
170
+ const deleted = [];
171
+ const modified = [];
172
+ for (const [path, entry] of [...table]) {
173
+ if (!prefixes.some(prefix => isUnder(path, prefix)))
174
+ continue;
175
+ if (fresh.has(path))
176
+ continue;
177
+ table.delete(path);
178
+ deleted.push(deletion(path, entry));
179
+ }
180
+ for (const [path, entry] of fresh) {
181
+ const previous = table.get(path);
182
+ table.set(path, entry);
183
+ if (!previous) {
184
+ created.push({
185
+ op: 'create',
186
+ path,
187
+ kind: entry.kind,
188
+ ...(entry.sha256 ? { sha256: entry.sha256 } : {}),
189
+ });
190
+ }
191
+ else if (previous.kind !== entry.kind) {
192
+ deleted.push(deletion(path, previous));
193
+ created.push({
194
+ op: 'create',
195
+ path,
196
+ kind: entry.kind,
197
+ ...(entry.sha256 ? { sha256: entry.sha256 } : {}),
198
+ });
199
+ }
200
+ else if (entry.kind === 'file' && previous.sha256 !== entry.sha256) {
201
+ modified.push({
202
+ op: 'modify',
203
+ path,
204
+ kind: 'file',
205
+ ...(entry.sha256 ? { sha256: entry.sha256 } : {}),
206
+ });
207
+ }
208
+ }
209
+ return pairRenames(deleted, created, modified);
210
+ };
211
+ /** Deletes carry the hash they had only until pairing; `pairRenames` strips it from the output. */
212
+ const deletion = (path, entry) => ({
213
+ op: 'delete',
214
+ path,
215
+ kind: entry.kind,
216
+ ...(entry.sha256 ? { sha256: entry.sha256 } : {}),
217
+ });
218
+ const pairRenames = (deleted, created, modified) => {
219
+ const countBy = (events) => {
220
+ const counts = new Map();
221
+ for (const event of events) {
222
+ if (event.kind === 'file' && event.sha256)
223
+ counts.set(event.sha256, (counts.get(event.sha256) ?? 0) + 1);
224
+ }
225
+ return counts;
226
+ };
227
+ const deletedCounts = countBy(deleted);
228
+ const createdCounts = countBy(created);
229
+ const renames = [];
230
+ const pairedDeletes = new Set();
231
+ const pairedCreates = new Set();
232
+ for (const create of created) {
233
+ const hash = create.sha256;
234
+ if (create.kind !== 'file' || !hash)
235
+ continue;
236
+ if (deletedCounts.get(hash) !== 1 || createdCounts.get(hash) !== 1)
237
+ continue;
238
+ const del = deleted.find(event => event.sha256 === hash);
239
+ if (!del)
240
+ continue;
241
+ pairedDeletes.add(del);
242
+ pairedCreates.add(create);
243
+ renames.push({ op: 'rename', path: create.path, from: del.path, kind: 'file', sha256: hash });
244
+ }
245
+ const strip = (event) => event.op === 'delete' ? { op: 'delete', path: event.path, kind: event.kind } : event;
246
+ return [
247
+ ...deleted.filter(event => !pairedDeletes.has(event)).map(strip),
248
+ ...renames,
249
+ ...created.filter(event => !pairedCreates.has(event)),
250
+ ...modified,
251
+ ].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
252
+ };
@@ -0,0 +1,56 @@
1
+ import { type Plugin } from 'vite';
2
+ import type { StripOption } from './build/strip-decision.ts';
3
+ /**
4
+ * `@pix3/vite-plugin`: the editor at `/__pix3/` with the file API and the sync barrier
5
+ * (`dev-server.ts`), the scripts' hot path (`sync/`), the player's `virtual:pix3/*` modules and
6
+ * the playable build (`build/`). This file wires the hooks; each concern lives in its module.
7
+ */
8
+ export interface Pix3Options {
9
+ /** Where `res://` points, relative to the Vite root (DeepCore: `'src/assets'`). */
10
+ readonly resRoot?: string;
11
+ /** Serve the editor and the file API at `/__pix3/` (default true). */
12
+ readonly editor?: boolean;
13
+ /**
14
+ * Playable build format (plan §B.6): `'html'` (default) — one self-contained `dist/index.html`;
15
+ * `'zip'` — `dist/<name>.zip` of the plain build with the assets beside it; `false` leaves
16
+ * `vite build` alone.
17
+ */
18
+ readonly build?: 'html' | 'zip' | false;
19
+ /**
20
+ * `build: 'html'` only: ship the bundle gzip'd (`iife`, base64) behind a bootstrap that
21
+ * inflates it with `DecompressionStream` and injects it as a classic script's text (plan §B.6
22
+ * item 5). About two thirds off the file; a wash over a gzip channel, +21% over brotli
23
+ * (CLAUDE.md «Playable export size»). Needs Chrome 80+ / Safari 16.4+ / Firefox 113+.
24
+ */
25
+ readonly compress?: boolean;
26
+ /**
27
+ * Replace runtime modules nothing in the project mentions with throwing stubs (and `yaml`,
28
+ * `GLTFLoader`, `postprocessing` when unused). On by default; a dependency that declares
29
+ * `@pix3/runtime` has its imports parsed (N11), and one the build cannot follow (`import * as`,
30
+ * a dynamic import) turns strip off with a message. `{ keep: ['GeometryMesh', …] }` names
31
+ * what such a dependency uses and keeps strip on; `false` strips nothing; `true` strips
32
+ * regardless of what the dependencies import.
33
+ */
34
+ readonly strip?: StripOption;
35
+ /** The scene a build boots into (`res://`-relative); default `defaultExportScenePath`. */
36
+ readonly entryScene?: string;
37
+ /** Answer `/__pix3/*` for non-loopback peers too (a dev server started with `--host`). */
38
+ readonly allowRemote?: boolean;
39
+ /**
40
+ * The image-generation proxy (plan §B.1). `home` replaces `~/.pix3` as the place of
41
+ * `keys.json` (default `PIX3_HOME`, else `~/.pix3`); `upstreams` replace the providers' API
42
+ * origins (default `PIX3_PROXY_GEMINI_URL` / `PIX3_PROXY_OPENAI_URL`, else the real ones) — a
43
+ * local mock in a test.
44
+ */
45
+ readonly imageGen?: {
46
+ readonly home?: string;
47
+ readonly upstreams?: {
48
+ readonly gemini?: string;
49
+ readonly openai?: string;
50
+ };
51
+ };
52
+ }
53
+ /** Bare specifier of the player entry a project's `src/main.ts` imports. */
54
+ export declare const PLAYER_SPECIFIER = "@pix3/vite-plugin/player";
55
+ export declare function pix3(options?: Pix3Options): Plugin[];
56
+ export default pix3;
package/dist/index.js ADDED
@@ -0,0 +1,290 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { normalizePath, version as viteVersion, } from 'vite';
5
+ import { emitZipAssets, recordModuleSizes, strippedImportError } from "./build/bundle-hooks.js";
6
+ import { devSceneManifestSource } from "./build/dev-manifest.js";
7
+ import { finishBuild } from "./build/finish.js";
8
+ import { libraryStubsPlugin, singleFilePlugins } from "./build/output-plugins.js";
9
+ import { flushEditor, planBuild, runtimeModulePath, shippedBytes, } from "./build/plan.js";
10
+ import { DEV_ID, devSource, EMBEDDED_ASSETS_ID, embeddedAssetsSource, GLTF_LOADER_STUB_ID, gltfLoaderStubSource, NETWORK_ID, networkSource, noEmbeddedAssetsSource, PLAYER_VIRTUAL_IDS, POSTPROCESSING_ID, POSTPROCESSING_STUB_ID, postprocessingSource, postprocessingStubSource, PROJECT_SCRIPTS_ID, projectScriptsSource, SCENE_MANIFEST_ID, sceneManifestSource, SPINE_ID, spineSource, YAML_STUB_ID, yamlStubSource, } from "./build/player-modules.js";
11
+ import { normalizeResRoot } from "./build/project-manifest.js";
12
+ import { buildStrippedModuleSource } from "./build/strippable-runtime-modules.js";
13
+ import { installedVersion, pluginVersion } from "./dev-info.js";
14
+ import { startEditorServer } from "./dev-server.js";
15
+ import { EDITOR_HOST_ID, editorHostSource, SPINE_LOADER_ID } from "./editor-page.js";
16
+ import { fileOf } from "./module-paths.js";
17
+ import { scriptHotUpdate } from "./sync/hot-update.js";
18
+ import { BOT_POLICIES_ID, EDITOR_SCRIPTS_ID, resolvedId, rootModuleSource, ScriptGraph, } from "./sync/script-graph.js";
19
+ import { pix3ViteConfig } from "./vite-config.js";
20
+ /** Bare specifier of the player entry a project's `src/main.ts` imports. */
21
+ export const PLAYER_SPECIFIER = '@pix3/vite-plugin/player';
22
+ const VIRTUAL_IDS = new Set([
23
+ EDITOR_HOST_ID,
24
+ EDITOR_SCRIPTS_ID,
25
+ BOT_POLICIES_ID,
26
+ SPINE_LOADER_ID,
27
+ ...PLAYER_VIRTUAL_IDS,
28
+ ]);
29
+ /** Directory of this package (`src/` or `dist/` sits under it). */
30
+ const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
31
+ const FROM_SOURCE = import.meta.url.endsWith('.ts');
32
+ /** The page client, as source under vitest and as emitted JS from `dist/`. */
33
+ const CLIENT_ENTRY = fileURLToPath(new URL(`./client/index.${FROM_SOURCE ? 'ts' : 'js'}`, import.meta.url));
34
+ /** The player (`player/` beside `src/`; `dist/player/` once built). */
35
+ const PLAYER_ENTRY = fileURLToPath(new URL(FROM_SOURCE ? '../player/index.ts' : './player/index.js', import.meta.url));
36
+ /** The player's dev tools, imported by path from `virtual:pix3/dev`: the debug overlay (`/?debug`)… */
37
+ const DEBUG_OVERLAY_ENTRY = fileURLToPath(new URL(FROM_SOURCE ? '../player/debug/index.ts' : './player/debug/index.js', import.meta.url));
38
+ /** …and the "Pix3 editor" link on the game. */
39
+ const EDITOR_LINK_ENTRY = fileURLToPath(new URL(FROM_SOURCE ? '../player/dev/editor-link.ts' : './player/dev/editor-link.js', import.meta.url));
40
+ export function pix3(options = {}) {
41
+ const settings = {
42
+ resRoot: normalizeResRoot(options.resRoot ?? '.'),
43
+ editor: options.editor ?? true,
44
+ build: options.build ?? 'html',
45
+ compress: options.compress ?? false,
46
+ strip: options.strip,
47
+ entryScene: options.entryScene ?? null,
48
+ allowRemote: options.allowRemote ?? false,
49
+ };
50
+ let config = null;
51
+ let server = null;
52
+ let scripts = null;
53
+ /** The dev server's editor side, once `configureServer` started it (editor on). */
54
+ let editor = null;
55
+ let build = null;
56
+ const log = (line) => config?.logger.info(`[pix3] ${line}`, { timestamp: true });
57
+ const warn = (line) => config?.logger.warn(`[pix3] ${line}`, { timestamp: true });
58
+ const isBuild = () => config?.command === 'build' && settings.build !== false;
59
+ const root = () => config?.root ?? process.cwd();
60
+ const baseUrl = () => {
61
+ const base = config?.base ?? '/';
62
+ return base.endsWith('/') ? base : `${base}/`;
63
+ };
64
+ const outDir = () => {
65
+ const dir = config?.build.outDir ?? 'dist';
66
+ return isAbsolute(dir) ? dir : join(root(), dir);
67
+ };
68
+ const core = {
69
+ name: 'pix3',
70
+ config(user, env) {
71
+ return pix3ViteConfig({
72
+ user,
73
+ packageDir: PACKAGE_DIR,
74
+ format: env.command === 'build' ? settings.build : false,
75
+ compress: settings.compress,
76
+ rolldown: typeof this.meta?.rolldownVersion ===
77
+ 'string',
78
+ });
79
+ },
80
+ configResolved(resolved) {
81
+ config = resolved;
82
+ scripts = new ScriptGraph(resolved.root, () => server, [resolved.cacheDir]);
83
+ },
84
+ /** `vite build` only (plan §B.6 items 1–2): flush the editor, then plan what ships. */
85
+ async buildStart() {
86
+ if (config?.command !== 'build' || settings.build === false)
87
+ return;
88
+ await flushEditor(root(), log, message => this.error(message));
89
+ const importer = join(root(), 'index.html');
90
+ build = await planBuild({
91
+ root: root(),
92
+ format: settings.build,
93
+ resRoot: settings.resRoot,
94
+ entryScene: settings.entryScene,
95
+ strip: settings.strip,
96
+ compress: settings.compress,
97
+ packageDir: PACKAGE_DIR,
98
+ resolve: async (specifier) => (await this.resolve(specifier, importer))?.id ?? null,
99
+ parse: code => this.parse(code),
100
+ log,
101
+ warn,
102
+ });
103
+ },
104
+ /**
105
+ * Vite awaits this in `server.close()` — also on an in-process restart (a `vite.config` edit):
106
+ * every write already accepted finishes before a new server (and a new `ProjectFiles`) starts,
107
+ * so two instances never write the same project at once.
108
+ *
109
+ * In a build it is the last hook: the artifact is final here (`build/finish.ts`).
110
+ */
111
+ async closeBundle() {
112
+ await editor?.files.close();
113
+ editor = null;
114
+ if (build && isBuild())
115
+ finishBuild(build, { root: root(), outDir: outDir(), log, warn });
116
+ },
117
+ async configureServer(devServer) {
118
+ server = devServer;
119
+ if (!settings.editor || !config || !scripts)
120
+ return;
121
+ const projectRoot = config.root;
122
+ editor = await startEditorServer(devServer, {
123
+ root: projectRoot,
124
+ base: baseUrl(),
125
+ resRoot: settings.resRoot,
126
+ build: settings.build,
127
+ allowRemote: settings.allowRemote,
128
+ imageGen: options.imageGen,
129
+ versions: {
130
+ plugin: pluginVersion(),
131
+ runtime: installedVersion(projectRoot, '@pix3/runtime'),
132
+ editorCore: installedVersion(projectRoot, '@pix3/editor-core'),
133
+ vite: viteVersion,
134
+ },
135
+ scripts,
136
+ log,
137
+ });
138
+ },
139
+ resolveId(id) {
140
+ if (VIRTUAL_IDS.has(id))
141
+ return resolvedId(id);
142
+ // The player ships in this package; resolved here so a workspace link, pnpm or a project
143
+ // without the package in its own `node_modules` all find the same file.
144
+ if (id === PLAYER_SPECIFIER)
145
+ return PLAYER_ENTRY;
146
+ return null;
147
+ },
148
+ async load(id) {
149
+ if (!id.startsWith('\0')) {
150
+ // Plan §B.6 item 2: an unmentioned runtime module becomes a stub with the same exports.
151
+ const modulePath = runtimeModulePath(build, id);
152
+ if (modulePath === null || !build?.stripped.has(modulePath))
153
+ return null;
154
+ build.applied.modules.add(modulePath);
155
+ return buildStrippedModuleSource(readFileSync(fileOf(id), 'utf8'), modulePath);
156
+ }
157
+ const virtual = id.slice(1);
158
+ const importer = join(root(), 'index.html');
159
+ switch (virtual) {
160
+ case EDITOR_SCRIPTS_ID:
161
+ case BOT_POLICIES_ID:
162
+ return rootModuleSource(virtual, editor?.files.currentSeq ?? 0);
163
+ case SPINE_LOADER_ID:
164
+ // Spine is an optional peer: a literal import() of a package that is not installed
165
+ // kills the importing module outright (S1, finding 6), so the loader is decided here.
166
+ return (await this.resolve('@esotericsoftware/spine-threejs', importer))
167
+ ? `export const loadSpine = () => import('@esotericsoftware/spine-threejs');\n`
168
+ : `export const loadSpine = () => Promise.resolve(null);\n`;
169
+ case EDITOR_HOST_ID: {
170
+ const base = baseUrl();
171
+ return editorHostSource({
172
+ base,
173
+ clientUrl: `${base}@fs/${normalizePath(CLIENT_ENTRY).replace(/^\//, '')}`,
174
+ editorCore: (await this.resolve('@pix3/editor-core', importer)) !== null,
175
+ });
176
+ }
177
+ // --- the player's modules (plan §B.5) ---
178
+ case SCENE_MANIFEST_ID:
179
+ return build
180
+ ? sceneManifestSource({
181
+ scenePaths: build.scan.scenePaths,
182
+ entryScenePath: build.scan.entryScenePath,
183
+ manifest: build.manifest,
184
+ localization: build.scan.localization,
185
+ netKindPrefabs: build.scan.netKindPrefabs,
186
+ resourceBase: './',
187
+ })
188
+ : devSceneManifestSource({
189
+ root: root(),
190
+ resRoot: settings.resRoot,
191
+ entryScene: settings.entryScene,
192
+ base: baseUrl(),
193
+ warn,
194
+ });
195
+ case EMBEDDED_ASSETS_ID: {
196
+ if (build?.format !== 'html')
197
+ return noEmbeddedAssetsSource();
198
+ const current = build;
199
+ const embedded = await embeddedAssetsSource({
200
+ root: root(),
201
+ resRoot: settings.resRoot,
202
+ assetPaths: current.scan.assetPaths,
203
+ shippedBytes: (resPath, bytes) => shippedBytes(current, resPath, bytes, warn),
204
+ });
205
+ current.assets = embedded;
206
+ log(`embedded ${embedded.count} asset(s), ${(embedded.rawBytes / 1024).toFixed(1)} KiB raw`);
207
+ return embedded.source;
208
+ }
209
+ case YAML_STUB_ID:
210
+ return yamlStubSource();
211
+ case GLTF_LOADER_STUB_ID:
212
+ return gltfLoaderStubSource();
213
+ case PROJECT_SCRIPTS_ID:
214
+ return projectScriptsSource();
215
+ case SPINE_ID:
216
+ if (!build)
217
+ return spineSource('dev');
218
+ return spineSource(build.scan.usesSpine && build.spineInstalled ? 'static' : 'none');
219
+ case POSTPROCESSING_ID:
220
+ return postprocessingSource(build?.format === 'html' &&
221
+ build.scan.usesPostProcessing &&
222
+ build.postprocessingInstalled
223
+ ? 'static'
224
+ : 'default');
225
+ case POSTPROCESSING_STUB_ID:
226
+ return postprocessingStubSource();
227
+ case NETWORK_ID:
228
+ return networkSource(build ? build.scan.usesNetwork : true);
229
+ case DEV_ID:
230
+ return config?.command === 'serve'
231
+ ? devSource('dev', {
232
+ overlayPath: normalizePath(DEBUG_OVERLAY_ENTRY),
233
+ editorLinkPath: normalizePath(EDITOR_LINK_ENTRY),
234
+ editorUrl: settings.editor ? `${baseUrl()}__pix3/` : null,
235
+ })
236
+ : devSource('none');
237
+ default:
238
+ return null;
239
+ }
240
+ },
241
+ /** Every format: the chunks' per-module sizes, for the report; zip: the assets beside it. */
242
+ generateBundle(_options, bundle) {
243
+ if (!build || !isBuild())
244
+ return;
245
+ recordModuleSizes(build, bundle);
246
+ if (build.format !== 'zip')
247
+ return;
248
+ emitZipAssets(build, {
249
+ root: root(),
250
+ resRoot: settings.resRoot,
251
+ emit: (fileName, source) => this.emitFile({ type: 'asset', fileName, source }),
252
+ warn,
253
+ });
254
+ },
255
+ transform(code, id, transformOptions) {
256
+ if (transformOptions?.ssr)
257
+ return null;
258
+ if (config?.command === 'serve')
259
+ return scripts ? scripts.stamp(code, id) : null;
260
+ if (!build)
261
+ return null;
262
+ const error = strippedImportError(build, code, id, {
263
+ root: root(),
264
+ packageDir: PACKAGE_DIR,
265
+ forced: settings.strip === true,
266
+ parse: source => this.parse(source),
267
+ warn,
268
+ });
269
+ if (error)
270
+ this.error(error);
271
+ return null;
272
+ },
273
+ async hotUpdate(update) {
274
+ if (!scripts || !editor)
275
+ return;
276
+ return scriptHotUpdate(update, this.environment, { root: root(), scripts, editor });
277
+ },
278
+ };
279
+ if (settings.build === false)
280
+ return [core];
281
+ const getBuild = () => build;
282
+ return [
283
+ libraryStubsPlugin(getBuild),
284
+ core,
285
+ ...(settings.build === 'html'
286
+ ? singleFilePlugins({ compress: settings.compress, getBuild })
287
+ : []),
288
+ ];
289
+ }
290
+ export default pix3;
@@ -0,0 +1,13 @@
1
+ /** A module id without its query (`/a/b.ts?v=1` → `/a/b.ts`). */
2
+ export declare const fileOf: (id: string) => string;
3
+ /** A platform path with `/` separators. */
4
+ export declare const posixPath: (path: string) => string;
5
+ /** `file` relative to `dir`, `/`-separated, when it lies strictly inside `dir`; else null. */
6
+ export declare const relativeInside: (dir: string, file: string) => string | null;
7
+ export declare const isUnder: (file: string, dir: string | null) => boolean;
8
+ /**
9
+ * The real path of a module id (query dropped; the id itself when it is not a file), cached. The
10
+ * runtime's `src/` is a real path, and module ids are not always (`resolve.preserveSymlinks`, a
11
+ * linked workspace): comparing the two as written would silently strip nothing.
12
+ */
13
+ export declare const realFileOf: (id: string) => string;