@pix3/vite-plugin 0.0.0-stage → 2.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +39 -2
- package/dist/build/bundle-hooks.d.ts +23 -0
- package/dist/build/bundle-hooks.js +79 -0
- package/dist/build/classic-script.d.ts +11 -0
- package/dist/build/classic-script.js +46 -0
- package/dist/build/compress.d.ts +29 -0
- package/dist/build/compress.js +63 -0
- package/dist/build/dependency-imports.d.ts +53 -0
- package/dist/build/dependency-imports.js +239 -0
- package/dist/build/dev-manifest.d.ts +10 -0
- package/dist/build/dev-manifest.js +25 -0
- package/dist/build/editor-flush.d.ts +42 -0
- package/dist/build/editor-flush.js +91 -0
- package/dist/build/finish.d.ts +11 -0
- package/dist/build/finish.js +126 -0
- package/dist/build/output-plugins.d.ts +26 -0
- package/dist/build/output-plugins.js +88 -0
- package/dist/build/plan.d.ts +75 -0
- package/dist/build/plan.js +162 -0
- package/dist/build/player-modules.d.ts +123 -0
- package/dist/build/player-modules.js +254 -0
- package/dist/build/project-manifest.d.ts +57 -0
- package/dist/build/project-manifest.js +169 -0
- package/dist/build/record.d.ts +25 -0
- package/dist/build/record.js +14 -0
- package/dist/build/report.d.ts +114 -0
- package/dist/build/report.js +102 -0
- package/dist/build/scan.d.ts +84 -0
- package/dist/build/scan.js +375 -0
- package/dist/build/strip-decision.d.ts +27 -0
- package/dist/build/strip-decision.js +82 -0
- package/dist/build/strippable-runtime-modules.d.ts +85 -0
- package/dist/build/strippable-runtime-modules.js +309 -0
- package/dist/build/zip.d.ts +6 -0
- package/dist/build/zip.js +28 -0
- package/dist/client/host-files.d.ts +96 -0
- package/dist/client/host-files.js +169 -0
- package/dist/client/index.d.ts +218 -0
- package/dist/client/index.js +381 -0
- package/dist/dev-info.d.ts +55 -0
- package/dist/dev-info.js +116 -0
- package/dist/dev-server.d.ts +28 -0
- package/dist/dev-server.js +139 -0
- package/dist/editor-page.d.ts +31 -0
- package/dist/editor-page.js +74 -0
- package/dist/files/changeset.d.ts +84 -0
- package/dist/files/changeset.js +321 -0
- package/dist/files/content-type.d.ts +1 -0
- package/dist/files/content-type.js +56 -0
- package/dist/files/history.d.ts +67 -0
- package/dist/files/history.js +162 -0
- package/dist/files/paths.d.ts +41 -0
- package/dist/files/paths.js +144 -0
- package/dist/files/project-files.d.ts +208 -0
- package/dist/files/project-files.js +1062 -0
- package/dist/files/scan.d.ts +71 -0
- package/dist/files/scan.js +252 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +290 -0
- package/dist/module-paths.d.ts +13 -0
- package/dist/module-paths.js +32 -0
- package/dist/player/debug/context.d.ts +9 -0
- package/dist/player/debug/context.js +1 -0
- package/dist/player/debug/device-info.d.ts +15 -0
- package/dist/player/debug/device-info.js +36 -0
- package/dist/player/debug/frame-stats.d.ts +79 -0
- package/dist/player/debug/frame-stats.js +159 -0
- package/dist/player/debug/index.d.ts +10 -0
- package/dist/player/debug/index.js +20 -0
- package/dist/player/debug/long-frames.d.ts +46 -0
- package/dist/player/debug/long-frames.js +79 -0
- package/dist/player/debug/overlay.d.ts +25 -0
- package/dist/player/debug/overlay.js +320 -0
- package/dist/player/debug/session.d.ts +92 -0
- package/dist/player/debug/session.js +156 -0
- package/dist/player/dev/editor-link.d.ts +16 -0
- package/dist/player/dev/editor-link.js +125 -0
- package/dist/player/index.d.ts +38 -0
- package/dist/player/index.js +119 -0
- package/dist/server/editor-socket.d.ts +53 -0
- package/dist/server/editor-socket.js +134 -0
- package/dist/server/guard.d.ts +61 -0
- package/dist/server/guard.js +165 -0
- package/dist/server/http.d.ts +24 -0
- package/dist/server/http.js +70 -0
- package/dist/server/image-keys.d.ts +46 -0
- package/dist/server/image-keys.js +118 -0
- package/dist/server/image-proxy.d.ts +32 -0
- package/dist/server/image-proxy.js +124 -0
- package/dist/server/router.d.ts +54 -0
- package/dist/server/router.js +147 -0
- package/dist/sync/barrier.d.ts +69 -0
- package/dist/sync/barrier.js +240 -0
- package/dist/sync/hot-update.d.ts +16 -0
- package/dist/sync/hot-update.js +58 -0
- package/dist/sync/script-graph.d.ts +79 -0
- package/dist/sync/script-graph.js +214 -0
- package/dist/vite-config.d.ts +12 -0
- package/dist/vite-config.js +65 -0
- package/package.json +58 -4
|
@@ -0,0 +1,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
|
+
};
|