@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,321 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { chmod, copyFile, mkdir, open, readdir, readFile, rename, rm, unlink, } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { errnoCode, HttpError, isRecord } from "../server/http.js";
|
|
7
|
+
import { parseWirePath, RESERVED_ROOT_DIR } from "./paths.js";
|
|
8
|
+
export const MAX_CHANGESET_BYTES = 256 * 1024 * 1024;
|
|
9
|
+
const MAX_CHANGESET_FILES = 1_000;
|
|
10
|
+
export const txRootOf = (root) => join(root, RESERVED_ROOT_DIR, 'tx');
|
|
11
|
+
const sha256Of = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
/** `"<hex>"`, `<hex>` or `*` from a JSON `ifMatch`. */
|
|
13
|
+
const parseIfMatch = (raw, index) => {
|
|
14
|
+
if (raw === undefined || raw === null)
|
|
15
|
+
return null;
|
|
16
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
17
|
+
throw new HttpError(400, 'bad_request', `files[${index}].ifMatch must be a string.`);
|
|
18
|
+
}
|
|
19
|
+
const tag = raw.trim().replace(/^W\//, '');
|
|
20
|
+
return [tag.startsWith('"') && tag.endsWith('"') && tag.length >= 2 ? tag.slice(1, -1) : tag];
|
|
21
|
+
};
|
|
22
|
+
/** Validate `POST /__pix3/api/changeset` `{files: [{path, text? | base64?, ifMatch?, createOnly?}]}`. */
|
|
23
|
+
export const parseChangeset = (body) => {
|
|
24
|
+
const raw = body.files;
|
|
25
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
26
|
+
throw new HttpError(400, 'bad_request', '`files` must be a non-empty array.');
|
|
27
|
+
}
|
|
28
|
+
if (raw.length > MAX_CHANGESET_FILES) {
|
|
29
|
+
throw new HttpError(413, 'too_many_files', `At most ${MAX_CHANGESET_FILES} files.`);
|
|
30
|
+
}
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
return raw.map((item, index) => {
|
|
33
|
+
if (!isRecord(item))
|
|
34
|
+
throw new HttpError(400, 'bad_request', `files[${index}] is not an object.`);
|
|
35
|
+
const path = parseWirePath(item.path, `files[${index}].path`);
|
|
36
|
+
if (seen.has(path)) {
|
|
37
|
+
throw new HttpError(400, 'bad_request', `${path} appears twice in one changeset.`);
|
|
38
|
+
}
|
|
39
|
+
seen.add(path);
|
|
40
|
+
const hasText = typeof item.text === 'string';
|
|
41
|
+
const hasBase64 = typeof item.base64 === 'string';
|
|
42
|
+
if (hasText === hasBase64) {
|
|
43
|
+
throw new HttpError(400, 'bad_request', `files[${index}] needs exactly one of \`text\` or \`base64\`.`);
|
|
44
|
+
}
|
|
45
|
+
const data = hasText
|
|
46
|
+
? Buffer.from(item.text, 'utf8')
|
|
47
|
+
: Buffer.from(item.base64, 'base64');
|
|
48
|
+
return {
|
|
49
|
+
path,
|
|
50
|
+
data,
|
|
51
|
+
sha256: sha256Of(data),
|
|
52
|
+
ifMatch: parseIfMatch(item.ifMatch, index),
|
|
53
|
+
createOnly: item.createOnly === true,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
/** A request fingerprint for `X-Mutation-Id` replay: same paths, conditions and bytes. */
|
|
58
|
+
export const changesetFingerprint = (entries) => [
|
|
59
|
+
'changeset',
|
|
60
|
+
...entries.map(entry => `${entry.path}\n${entry.sha256}\n${entry.ifMatch?.join(',') ?? ''}\n${entry.createOnly}`),
|
|
61
|
+
].join('\n');
|
|
62
|
+
/** fsync a directory, so a rename or a create inside it is durable. A no-op where unsupported. */
|
|
63
|
+
export const fsyncDir = async (dir) => {
|
|
64
|
+
let handle;
|
|
65
|
+
try {
|
|
66
|
+
handle = await open(dir, 'r');
|
|
67
|
+
await handle.sync();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
// Windows cannot open a directory for fsync (EPERM/EISDIR); NTFS journals the rename itself.
|
|
71
|
+
const code = errnoCode(error);
|
|
72
|
+
if (code !== 'EPERM' && code !== 'EISDIR' && code !== 'EINVAL' && code !== 'EBADF')
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await handle?.close();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/** Write `bytes` to a new file and fsync it. */
|
|
80
|
+
const writeSynced = async (path, bytes) => {
|
|
81
|
+
const handle = await open(path, 'wx');
|
|
82
|
+
try {
|
|
83
|
+
await handle.writeFile(bytes);
|
|
84
|
+
await handle.sync();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await handle.close();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** Replace `intent.json` atomically and durably (temp + fsync + rename + fsync of the dir). */
|
|
91
|
+
export const writeIntent = async (txDir, intent) => {
|
|
92
|
+
const temp = join(txDir, `intent.${randomBytes(4).toString('hex')}.tmp`);
|
|
93
|
+
await writeSynced(temp, Buffer.from(`${JSON.stringify(intent, null, 2)}\n`));
|
|
94
|
+
await rename(temp, join(txDir, 'intent.json'));
|
|
95
|
+
await fsyncDir(txDir);
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Rename `temp` over `target`. When `.pix3/` is on another device than the target, the bytes are
|
|
99
|
+
* copied to a sibling first and renamed from there, so the replace itself stays atomic.
|
|
100
|
+
*/
|
|
101
|
+
export const renameIntoPlace = async (temp, target) => {
|
|
102
|
+
try {
|
|
103
|
+
await rename(temp, target);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (errnoCode(error) !== 'EXDEV')
|
|
107
|
+
throw error;
|
|
108
|
+
const sibling = join(dirname(target), `.${basename(target)}.pix3-tmp-${randomBytes(6).toString('hex')}`);
|
|
109
|
+
const source = await open(temp, 'r');
|
|
110
|
+
try {
|
|
111
|
+
await pipeline(source.createReadStream(), createWriteStream(sibling, { flags: 'wx' }));
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await source.close().catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
await rename(sibling, target);
|
|
117
|
+
await rm(temp, { force: true });
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Steps 3 of plan §C.4: stage the new bytes and copies of the old ones, then write the intent
|
|
122
|
+
* (`prepared`). Returns the transaction directory and its intent; nothing in the project changed.
|
|
123
|
+
*/
|
|
124
|
+
export const stageTransaction = async (root, files) => {
|
|
125
|
+
const id = `${Date.now()}-${randomBytes(6).toString('hex')}`;
|
|
126
|
+
const txDir = join(txRootOf(root), id);
|
|
127
|
+
await mkdir(join(txDir, 'new'), { recursive: true, mode: 0o700 });
|
|
128
|
+
await mkdir(join(txDir, 'old'), { recursive: true, mode: 0o700 });
|
|
129
|
+
const intentFiles = [];
|
|
130
|
+
for (const [n, file] of files.entries()) {
|
|
131
|
+
const staged = join(txDir, 'new', String(n));
|
|
132
|
+
await writeSynced(staged, file.entry.data);
|
|
133
|
+
if (file.mode !== null) {
|
|
134
|
+
const handle = await open(staged, 'r');
|
|
135
|
+
try {
|
|
136
|
+
await handle.chmod(file.mode);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await handle.close();
|
|
140
|
+
}
|
|
141
|
+
const old = join(txDir, 'old', String(n));
|
|
142
|
+
await copyFile(file.absolute, old);
|
|
143
|
+
// Windows requires a writable handle for fsync. A copied read-only file must also be
|
|
144
|
+
// writable while opening it; restore its original mode on the handle before syncing.
|
|
145
|
+
await chmod(old, file.mode | 0o600);
|
|
146
|
+
const oldHandle = await open(old, 'r+');
|
|
147
|
+
try {
|
|
148
|
+
await oldHandle.chmod(file.mode);
|
|
149
|
+
await oldHandle.sync();
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await oldHandle.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
intentFiles.push({
|
|
156
|
+
path: file.entry.path,
|
|
157
|
+
sha256: file.entry.sha256,
|
|
158
|
+
old: file.mode === null ? 'absent' : `old/${n}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
await fsyncDir(join(txDir, 'new'));
|
|
162
|
+
await fsyncDir(join(txDir, 'old'));
|
|
163
|
+
const intent = { id, files: intentFiles, state: 'prepared' };
|
|
164
|
+
await writeIntent(txDir, intent);
|
|
165
|
+
await fsyncDir(txRootOf(root));
|
|
166
|
+
return { txDir, intent };
|
|
167
|
+
};
|
|
168
|
+
/** Put entry `n`'s old bytes back (or remove the file it created). */
|
|
169
|
+
export const rollBackFile = async (root, txDir, file) => {
|
|
170
|
+
const target = join(root, ...file.path.split('/'));
|
|
171
|
+
if (file.old === 'absent') {
|
|
172
|
+
await unlink(target).catch((error) => {
|
|
173
|
+
if (errnoCode(error) !== 'ENOENT')
|
|
174
|
+
throw error;
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
await renameIntoPlace(join(txDir, file.old), target);
|
|
179
|
+
};
|
|
180
|
+
const hashFile = async (absolute) => {
|
|
181
|
+
try {
|
|
182
|
+
return sha256Of(await readFile(absolute));
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const code = errnoCode(error);
|
|
186
|
+
if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR')
|
|
187
|
+
return null;
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const exists = async (path) => {
|
|
192
|
+
try {
|
|
193
|
+
await open(path, 'r').then(handle => handle.close());
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (errnoCode(error) === 'ENOENT')
|
|
198
|
+
return false;
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const isIntent = (value) => {
|
|
203
|
+
if (!isRecord(value))
|
|
204
|
+
return false;
|
|
205
|
+
if (typeof value.id !== 'string')
|
|
206
|
+
return false;
|
|
207
|
+
if (value.state !== 'prepared' && value.state !== 'committed')
|
|
208
|
+
return false;
|
|
209
|
+
if (!Array.isArray(value.files))
|
|
210
|
+
return false;
|
|
211
|
+
return value.files.every((file) => isRecord(file) &&
|
|
212
|
+
typeof file.path === 'string' &&
|
|
213
|
+
typeof file.sha256 === 'string' &&
|
|
214
|
+
(file.old === 'absent' || (typeof file.old === 'string' && /^old\/\d+$/.test(file.old))));
|
|
215
|
+
};
|
|
216
|
+
const readIntent = async (txDir) => {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(await readFile(join(txDir, 'intent.json'), 'utf8'));
|
|
219
|
+
if (!isIntent(parsed))
|
|
220
|
+
return null;
|
|
221
|
+
// A path in the intent is written to and deleted on recovery: it must be a valid wire path.
|
|
222
|
+
for (const file of parsed.files)
|
|
223
|
+
parseWirePath(file.path);
|
|
224
|
+
return parsed;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Recovery at plugin start (plan §C.4), before the first scan. For every `.pix3/tx/<id>/`:
|
|
232
|
+
*
|
|
233
|
+
* - no readable intent → nothing was renamed yet (renames start after the intent is durable):
|
|
234
|
+
* the directory is removed;
|
|
235
|
+
* - `committed` → every file is in place: clean up (the versions are journaled as `editor`);
|
|
236
|
+
* - `prepared` and consistent — every entry is either still staged with the intended bytes, or
|
|
237
|
+
* gone from `new/` with the target at the intended sha → roll forward;
|
|
238
|
+
* - `prepared` and anything else (a staged file lost, a renamed target with other bytes) → roll
|
|
239
|
+
* back every renamed entry from `old/` (a target whose old was `absent` is deleted).
|
|
240
|
+
*
|
|
241
|
+
* Either way both files of a two-file changeset end up all old or all new (gate row N6).
|
|
242
|
+
*/
|
|
243
|
+
export const recoverTransactions = async (root, log) => {
|
|
244
|
+
const txRoot = txRootOf(root);
|
|
245
|
+
let ids;
|
|
246
|
+
try {
|
|
247
|
+
ids = (await readdir(txRoot, { withFileTypes: true }))
|
|
248
|
+
.filter(entry => entry.isDirectory())
|
|
249
|
+
.map(entry => entry.name);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (errnoCode(error) === 'ENOENT')
|
|
253
|
+
return [];
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
const recovered = [];
|
|
257
|
+
for (const id of ids.sort()) {
|
|
258
|
+
const txDir = join(txRoot, id);
|
|
259
|
+
const intent = await readIntent(txDir);
|
|
260
|
+
if (!intent) {
|
|
261
|
+
log(`changeset ${id}: no readable intent, nothing was renamed; discarded`);
|
|
262
|
+
await rm(txDir, { recursive: true, force: true });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (intent.state === 'committed') {
|
|
266
|
+
log(`changeset ${id}: committed; cleaned up`);
|
|
267
|
+
for (const file of intent.files)
|
|
268
|
+
recovered.push({ path: file.path, author: 'editor' });
|
|
269
|
+
await rm(txDir, { recursive: true, force: true });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const status = [];
|
|
273
|
+
for (const [n, file] of intent.files.entries()) {
|
|
274
|
+
const staged = join(txDir, 'new', String(n));
|
|
275
|
+
const target = join(root, ...file.path.split('/'));
|
|
276
|
+
if (await exists(staged)) {
|
|
277
|
+
status.push({
|
|
278
|
+
file,
|
|
279
|
+
staged,
|
|
280
|
+
pending: true,
|
|
281
|
+
consistent: (await hashFile(staged)) === file.sha256,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
status.push({
|
|
286
|
+
file,
|
|
287
|
+
staged,
|
|
288
|
+
pending: false,
|
|
289
|
+
consistent: (await hashFile(target)) === file.sha256,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (status.every(item => item.consistent)) {
|
|
294
|
+
for (const item of status) {
|
|
295
|
+
const target = join(root, ...item.file.path.split('/'));
|
|
296
|
+
if (item.pending) {
|
|
297
|
+
await mkdir(dirname(target), { recursive: true });
|
|
298
|
+
await renameIntoPlace(item.staged, target);
|
|
299
|
+
}
|
|
300
|
+
recovered.push({ path: item.file.path, author: 'editor' });
|
|
301
|
+
}
|
|
302
|
+
log(`changeset ${id}: interrupted after staging; rolled forward (${intent.files.length} files)`);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
for (const item of status) {
|
|
306
|
+
// A still-staged entry never reached its target: the old bytes are what is there.
|
|
307
|
+
if (item.pending)
|
|
308
|
+
continue;
|
|
309
|
+
if (item.file.old !== 'absent' && !(await exists(join(txDir, item.file.old)))) {
|
|
310
|
+
log(`changeset ${id}: cannot roll back ${item.file.path}, its old copy is missing`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
await rollBackFile(root, txDir, item.file);
|
|
314
|
+
recovered.push({ path: item.file.path, author: 'restore' });
|
|
315
|
+
}
|
|
316
|
+
log(`changeset ${id}: staging incomplete; rolled back (${intent.files.length} files)`);
|
|
317
|
+
}
|
|
318
|
+
await rm(txDir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
return recovered;
|
|
321
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const contentTypeFor: (wirePath: string) => string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Content-Type` for `GET /__pix3/api/file`, by extension. The editor mostly turns bytes into Blobs and
|
|
3
|
+
* decides by path itself; the header matters for `<audio>`/`<video>` range playback and for
|
|
4
|
+
* anyone reading the route with curl. Unknown → `application/octet-stream`.
|
|
5
|
+
*/
|
|
6
|
+
const TYPES = {
|
|
7
|
+
pix3scene: 'text/yaml; charset=utf-8',
|
|
8
|
+
prefab: 'text/yaml; charset=utf-8',
|
|
9
|
+
pix3anim: 'text/yaml; charset=utf-8',
|
|
10
|
+
yaml: 'text/yaml; charset=utf-8',
|
|
11
|
+
yml: 'text/yaml; charset=utf-8',
|
|
12
|
+
json: 'application/json; charset=utf-8',
|
|
13
|
+
ts: 'text/plain; charset=utf-8',
|
|
14
|
+
tsx: 'text/plain; charset=utf-8',
|
|
15
|
+
js: 'text/javascript; charset=utf-8',
|
|
16
|
+
mjs: 'text/javascript; charset=utf-8',
|
|
17
|
+
md: 'text/markdown; charset=utf-8',
|
|
18
|
+
txt: 'text/plain; charset=utf-8',
|
|
19
|
+
csv: 'text/csv; charset=utf-8',
|
|
20
|
+
atlas: 'text/plain; charset=utf-8',
|
|
21
|
+
html: 'text/html; charset=utf-8',
|
|
22
|
+
css: 'text/css; charset=utf-8',
|
|
23
|
+
xml: 'application/xml; charset=utf-8',
|
|
24
|
+
svg: 'image/svg+xml',
|
|
25
|
+
png: 'image/png',
|
|
26
|
+
jpg: 'image/jpeg',
|
|
27
|
+
jpeg: 'image/jpeg',
|
|
28
|
+
gif: 'image/gif',
|
|
29
|
+
webp: 'image/webp',
|
|
30
|
+
avif: 'image/avif',
|
|
31
|
+
ktx2: 'image/ktx2',
|
|
32
|
+
mp3: 'audio/mpeg',
|
|
33
|
+
ogg: 'audio/ogg',
|
|
34
|
+
oga: 'audio/ogg',
|
|
35
|
+
wav: 'audio/wav',
|
|
36
|
+
m4a: 'audio/mp4',
|
|
37
|
+
aac: 'audio/aac',
|
|
38
|
+
flac: 'audio/flac',
|
|
39
|
+
mp4: 'video/mp4',
|
|
40
|
+
webm: 'video/webm',
|
|
41
|
+
mov: 'video/quicktime',
|
|
42
|
+
glb: 'model/gltf-binary',
|
|
43
|
+
gltf: 'model/gltf+json',
|
|
44
|
+
ttf: 'font/ttf',
|
|
45
|
+
otf: 'font/otf',
|
|
46
|
+
woff: 'font/woff',
|
|
47
|
+
woff2: 'font/woff2',
|
|
48
|
+
wasm: 'application/wasm',
|
|
49
|
+
};
|
|
50
|
+
export const contentTypeFor = (wirePath) => {
|
|
51
|
+
const name = wirePath.slice(wirePath.lastIndexOf('/') + 1);
|
|
52
|
+
const dot = name.lastIndexOf('.');
|
|
53
|
+
if (dot <= 0)
|
|
54
|
+
return 'application/octet-stream';
|
|
55
|
+
return TYPES[name.slice(dot + 1).toLowerCase()] ?? 'application/octet-stream';
|
|
56
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version journal `.pix3/history/` (plan §C.4 "Журнал", Node port of the 1.x
|
|
3
|
+
* `RecoveryJournalService`). It is what the History panel lists and restores from, and what makes
|
|
4
|
+
* "the version before the agent's change" recoverable: every version a write, the watcher, a
|
|
5
|
+
* restore or a rejected editor draft produced is kept as raw bytes.
|
|
6
|
+
*
|
|
7
|
+
* Layout, per wire path `<p>` (the path's own segments, so `scenes/a.pix3scene` is a directory):
|
|
8
|
+
*
|
|
9
|
+
* .pix3/history/<p>/index.jsonl one JSON line per version, oldest first
|
|
10
|
+
* .pix3/history/<p>/<stamp>-<hash8> the version's bytes
|
|
11
|
+
*
|
|
12
|
+
* `<stamp>` is the epoch-ms time zero-padded to 13 digits (ids sort like their times), `<hash8>`
|
|
13
|
+
* the first 8 hex digits of the sha256. Every caller runs under `ProjectFiles.serial`, so appends
|
|
14
|
+
* and prunes of one index never interleave; readers skip a torn last line.
|
|
15
|
+
*/
|
|
16
|
+
export type HistoryAuthor = 'editor' | 'external' | 'restore' | 'rejected-draft';
|
|
17
|
+
export interface HistoryEntry {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly author: HistoryAuthor;
|
|
21
|
+
readonly at: number;
|
|
22
|
+
readonly sha256: string;
|
|
23
|
+
readonly size: number;
|
|
24
|
+
readonly note?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Retention per path (plan §C.4): the newest 200 versions, none older than 7 days — except the
|
|
28
|
+
* newest version, which age never prunes (the current disk must stay restorable).
|
|
29
|
+
*/
|
|
30
|
+
export declare const HISTORY_MAX_VERSIONS = 200;
|
|
31
|
+
export declare const HISTORY_MAX_AGE_MS: number;
|
|
32
|
+
/**
|
|
33
|
+
* Only scene-format files, locale tables (`locales/<id>.json`, written by the editor's
|
|
34
|
+
* Localization panel with the same merge) and the project file are journaled (plan §C.4: the
|
|
35
|
+
* History panel).
|
|
36
|
+
*/
|
|
37
|
+
export declare const isJournaledPath: (wirePath: string) => boolean;
|
|
38
|
+
export interface VersionJournalOptions {
|
|
39
|
+
readonly root: string;
|
|
40
|
+
readonly log?: (line: string) => void;
|
|
41
|
+
/** Clock, for retention specs. */
|
|
42
|
+
readonly now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
export declare class VersionJournal {
|
|
45
|
+
private readonly dir;
|
|
46
|
+
private readonly log;
|
|
47
|
+
private readonly now;
|
|
48
|
+
constructor(options: VersionJournalOptions);
|
|
49
|
+
private pathDir;
|
|
50
|
+
/** Every readable entry of `wirePath`, oldest first. */
|
|
51
|
+
private entries;
|
|
52
|
+
/** Newest first. */
|
|
53
|
+
list(wirePath: string): Promise<HistoryEntry[]>;
|
|
54
|
+
newest(wirePath: string): Promise<HistoryEntry | null>;
|
|
55
|
+
/** The bytes of version `id`, or null when the journal does not know it. */
|
|
56
|
+
read(wirePath: string, id: string): Promise<Buffer | null>;
|
|
57
|
+
/**
|
|
58
|
+
* Journal `bytes` as the newest version of `wirePath`. A version equal to the newest one is not
|
|
59
|
+
* recorded again (the watcher's echo of an own write, a restart over an unchanged disk): the
|
|
60
|
+
* newest entry is returned instead.
|
|
61
|
+
*/
|
|
62
|
+
record(wirePath: string, bytes: Uint8Array, author: HistoryAuthor, note?: string): Promise<HistoryEntry>;
|
|
63
|
+
/** Drop what is beyond the newest {@link HISTORY_MAX_VERSIONS} or older than the age limit. */
|
|
64
|
+
private prune;
|
|
65
|
+
}
|
|
66
|
+
/** `author` of a `POST history/record` body: only the editor's own kind may be posted. */
|
|
67
|
+
export declare const parseRecordAuthor: (raw: unknown) => "rejected-draft";
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { appendFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { errnoCode, HttpError } from "../server/http.js";
|
|
5
|
+
import { RESERVED_ROOT_DIR } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* Retention per path (plan §C.4): the newest 200 versions, none older than 7 days — except the
|
|
8
|
+
* newest version, which age never prunes (the current disk must stay restorable).
|
|
9
|
+
*/
|
|
10
|
+
export const HISTORY_MAX_VERSIONS = 200;
|
|
11
|
+
export const HISTORY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const AUTHORS = new Set(['editor', 'external', 'restore', 'rejected-draft']);
|
|
13
|
+
const ID_PATTERN = /^\d{13}-[0-9a-f]{8}(?:-\d{1,4})?$/;
|
|
14
|
+
const INDEX = 'index.jsonl';
|
|
15
|
+
/**
|
|
16
|
+
* Only scene-format files, locale tables (`locales/<id>.json`, written by the editor's
|
|
17
|
+
* Localization panel with the same merge) and the project file are journaled (plan §C.4: the
|
|
18
|
+
* History panel).
|
|
19
|
+
*/
|
|
20
|
+
export const isJournaledPath = (wirePath) => wirePath === 'pix3project.yaml' ||
|
|
21
|
+
wirePath.endsWith('.pix3scene') ||
|
|
22
|
+
wirePath.endsWith('.prefab') ||
|
|
23
|
+
/(?:^|\/)locales\/[^/]+\.json$/.test(wirePath);
|
|
24
|
+
const isEntry = (value) => {
|
|
25
|
+
if (!value || typeof value !== 'object')
|
|
26
|
+
return false;
|
|
27
|
+
const entry = value;
|
|
28
|
+
return (typeof entry.id === 'string' &&
|
|
29
|
+
ID_PATTERN.test(entry.id) &&
|
|
30
|
+
typeof entry.path === 'string' &&
|
|
31
|
+
typeof entry.author === 'string' &&
|
|
32
|
+
AUTHORS.has(entry.author) &&
|
|
33
|
+
typeof entry.at === 'number' &&
|
|
34
|
+
typeof entry.sha256 === 'string' &&
|
|
35
|
+
typeof entry.size === 'number' &&
|
|
36
|
+
(entry.note === undefined || typeof entry.note === 'string'));
|
|
37
|
+
};
|
|
38
|
+
export class VersionJournal {
|
|
39
|
+
dir;
|
|
40
|
+
log;
|
|
41
|
+
now;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.dir = join(options.root, RESERVED_ROOT_DIR, 'history');
|
|
44
|
+
this.log = options.log ?? (() => undefined);
|
|
45
|
+
this.now = options.now ?? Date.now;
|
|
46
|
+
}
|
|
47
|
+
pathDir(wirePath) {
|
|
48
|
+
return join(this.dir, ...wirePath.split('/'));
|
|
49
|
+
}
|
|
50
|
+
/** Every readable entry of `wirePath`, oldest first. */
|
|
51
|
+
async entries(wirePath) {
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = await readFile(join(this.pathDir(wirePath), INDEX), 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (errnoCode(error) === 'ENOENT' || errnoCode(error) === 'ENOTDIR')
|
|
58
|
+
return [];
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const line of raw.split('\n')) {
|
|
63
|
+
if (!line.trim())
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(line);
|
|
67
|
+
if (isEntry(parsed))
|
|
68
|
+
out.push(parsed);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// a torn line from a crash mid-append; the version it described is simply not listed
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** Newest first. */
|
|
77
|
+
async list(wirePath) {
|
|
78
|
+
return (await this.entries(wirePath)).reverse();
|
|
79
|
+
}
|
|
80
|
+
async newest(wirePath) {
|
|
81
|
+
const all = await this.entries(wirePath);
|
|
82
|
+
return all.length > 0 ? all[all.length - 1] : null;
|
|
83
|
+
}
|
|
84
|
+
/** The bytes of version `id`, or null when the journal does not know it. */
|
|
85
|
+
async read(wirePath, id) {
|
|
86
|
+
if (!ID_PATTERN.test(id))
|
|
87
|
+
return null;
|
|
88
|
+
if (!(await this.entries(wirePath)).some(entry => entry.id === id))
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
return await readFile(join(this.pathDir(wirePath), id));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (errnoCode(error) === 'ENOENT')
|
|
95
|
+
return null;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Journal `bytes` as the newest version of `wirePath`. A version equal to the newest one is not
|
|
101
|
+
* recorded again (the watcher's echo of an own write, a restart over an unchanged disk): the
|
|
102
|
+
* newest entry is returned instead.
|
|
103
|
+
*/
|
|
104
|
+
async record(wirePath, bytes, author, note) {
|
|
105
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
106
|
+
const all = await this.entries(wirePath);
|
|
107
|
+
const last = all.length > 0 ? all[all.length - 1] : null;
|
|
108
|
+
if (last && last.sha256 === sha256)
|
|
109
|
+
return last;
|
|
110
|
+
const at = this.now();
|
|
111
|
+
const base = `${String(at).padStart(13, '0')}-${sha256.slice(0, 8)}`;
|
|
112
|
+
// A, B, A within one millisecond would reuse an id; ids are unique per path.
|
|
113
|
+
let id = base;
|
|
114
|
+
for (let n = 1; all.some(entry => entry.id === id); n++)
|
|
115
|
+
id = `${base}-${n}`;
|
|
116
|
+
const dir = this.pathDir(wirePath);
|
|
117
|
+
await mkdir(dir, { recursive: true });
|
|
118
|
+
await writeFile(join(dir, id), bytes);
|
|
119
|
+
const entry = {
|
|
120
|
+
id,
|
|
121
|
+
path: wirePath,
|
|
122
|
+
author,
|
|
123
|
+
at,
|
|
124
|
+
sha256,
|
|
125
|
+
size: bytes.byteLength,
|
|
126
|
+
...(note ? { note } : {}),
|
|
127
|
+
};
|
|
128
|
+
await appendFile(join(dir, INDEX), `${JSON.stringify(entry)}\n`);
|
|
129
|
+
await this.prune(wirePath, [...all, entry]);
|
|
130
|
+
return entry;
|
|
131
|
+
}
|
|
132
|
+
/** Drop what is beyond the newest {@link HISTORY_MAX_VERSIONS} or older than the age limit. */
|
|
133
|
+
async prune(wirePath, all) {
|
|
134
|
+
const now = this.now();
|
|
135
|
+
const keep = [];
|
|
136
|
+
const drop = [];
|
|
137
|
+
all.forEach((entry, index) => {
|
|
138
|
+
const fromNewest = all.length - 1 - index;
|
|
139
|
+
const tooMany = fromNewest >= HISTORY_MAX_VERSIONS;
|
|
140
|
+
const tooOld = fromNewest > 0 && now - entry.at > HISTORY_MAX_AGE_MS;
|
|
141
|
+
(tooMany || tooOld ? drop : keep).push(entry);
|
|
142
|
+
});
|
|
143
|
+
if (drop.length === 0)
|
|
144
|
+
return;
|
|
145
|
+
const dir = this.pathDir(wirePath);
|
|
146
|
+
// Rewrite the index first (atomically), then delete the bytes: a crash in between leaves
|
|
147
|
+
// unlisted files, never a listed version without bytes.
|
|
148
|
+
const temp = join(dir, `${INDEX}.${randomBytes(6).toString('hex')}.tmp`);
|
|
149
|
+
await writeFile(temp, keep.map(entry => `${JSON.stringify(entry)}\n`).join(''));
|
|
150
|
+
await rename(temp, join(dir, INDEX));
|
|
151
|
+
for (const entry of drop)
|
|
152
|
+
await rm(join(dir, entry.id), { force: true });
|
|
153
|
+
this.log(`history: pruned ${drop.length} version(s) of ${wirePath}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** `author` of a `POST history/record` body: only the editor's own kind may be posted. */
|
|
157
|
+
export const parseRecordAuthor = (raw) => {
|
|
158
|
+
if (raw !== 'rejected-draft') {
|
|
159
|
+
throw new HttpError(400, 'bad_author', "Only `author: 'rejected-draft'` can be recorded.");
|
|
160
|
+
}
|
|
161
|
+
return raw;
|
|
162
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire paths of the file API (port of `packages/cli/src/serve/paths.ts`): POSIX, relative to the
|
|
3
|
+
* project root (Vite's `root`), case preserved.
|
|
4
|
+
*
|
|
5
|
+
* The plugin enforces the root, so the rules are strict rather than normalising: a path that is
|
|
6
|
+
* not already canonical is refused, not repaired. Percent-decoding happens exactly once, by
|
|
7
|
+
* `URLSearchParams` for `?path=` (JSON bodies are not decoded at all) — a literal `%2e%2e` that
|
|
8
|
+
* survives that one decoding is an ordinary file name, never a second chance to spell `..`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Directory under the root that holds the plugin's own state (`dev.json`, staging, the version
|
|
12
|
+
* journal). Never part of the revision set; through the API only its plugin-private entries are
|
|
13
|
+
* refused, so the editor can still keep its own files there.
|
|
14
|
+
*/
|
|
15
|
+
export declare const RESERVED_ROOT_DIR = ".pix3";
|
|
16
|
+
/**
|
|
17
|
+
* True for paths the file API refuses with `403 reserved_path`: `.pix3` itself (so it can be
|
|
18
|
+
* neither deleted nor moved) and the plugin-private entries under it.
|
|
19
|
+
*/
|
|
20
|
+
export declare const isPluginPrivatePath: (wirePath: string) => boolean;
|
|
21
|
+
/** Validate a wire path and return it unchanged (it is already canonical when accepted). */
|
|
22
|
+
export declare const parseWirePath: (raw: unknown, field?: string) => string;
|
|
23
|
+
export interface ResolvedPath {
|
|
24
|
+
readonly absolute: string;
|
|
25
|
+
/** What is at the path now; `null` when nothing is (only possible with `allowMissing`). */
|
|
26
|
+
readonly kind: 'file' | 'dir' | 'other' | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a validated wire path under `root` without ever passing through a symlink.
|
|
30
|
+
*
|
|
31
|
+
* Every existing component (parents included, which is what protects a path about to be CREATED)
|
|
32
|
+
* is `lstat`ed: a symlink anywhere is refused with 403 `symlink`, a file where a directory is
|
|
33
|
+
* needed with 409 `not_a_directory`. This is a check, not a lock: a local process that swaps a
|
|
34
|
+
* directory for a symlink between the check and the use wins the race (same-user local processes
|
|
35
|
+
* are trusted).
|
|
36
|
+
*/
|
|
37
|
+
export declare const resolveInsideRoot: (root: string, wirePath: string, options: {
|
|
38
|
+
readonly allowMissing: boolean;
|
|
39
|
+
}) => Promise<ResolvedPath>;
|
|
40
|
+
/** Parent wire path, or `null` for a top-level entry. */
|
|
41
|
+
export declare const parentOf: (wirePath: string) => string | null;
|