@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,1062 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { chmod, lstat, mkdir, open, readFile, rename, rm, rmdir, stat, unlink, } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { relativeInside } from "../module-paths.js";
8
+ import { errnoCode, headerValue, HttpError, readJson, sendJson } from "../server/http.js";
9
+ import { changesetFingerprint, fsyncDir, MAX_CHANGESET_BYTES, parseChangeset, recoverTransactions, renameIntoPlace, rollBackFile, stageTransaction, writeIntent, } from "./changeset.js";
10
+ import { contentTypeFor } from "./content-type.js";
11
+ import { isJournaledPath, parseRecordAuthor, VersionJournal, } from "./history.js";
12
+ import { parentOf, parseWirePath, resolveInsideRoot, RESERVED_ROOT_DIR } from "./paths.js";
13
+ import { applySubtree, computeRevision, HashCache, hashFile, isExcludedPath, mtimeOf, scanInto, statKeyOf, } from "./scan.js";
14
+ const MAX_PUT_BYTES = 1024 * 1024 * 1024;
15
+ /** Files up to this size are read whole, so the ETag is the hash of exactly the bytes sent. */
16
+ const WHOLE_READ_BYTES = 8 * 1024 * 1024;
17
+ const MAX_JOURNAL = 2_000;
18
+ const MAX_HASH_PATHS = 20_000;
19
+ const MUTATION_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
20
+ const WRITER_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
21
+ /** Longest a burst of watch events may keep postponing its batch. */
22
+ const MAX_BATCH_WAIT_MS = 1_000;
23
+ export const STABILITY_INTERVAL_MS = 300;
24
+ /** `"<hex>"` → hex; `W/"x"` is treated like `"x"` (weak comparison is enough for a hash). */
25
+ const parseEntityTags = (header) => header
26
+ .split(',')
27
+ .map(part => part.trim())
28
+ .filter(Boolean)
29
+ .map(part => (part.startsWith('W/') ? part.slice(2) : part))
30
+ .map(part => part.startsWith('"') && part.endsWith('"') && part.length >= 2 ? part.slice(1, -1) : part);
31
+ /** One `bytes=` range; null = serve the whole file (absent, multi-range or unparsable). */
32
+ const parseRange = (header, size) => {
33
+ if (!header)
34
+ return null;
35
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
36
+ if (!match)
37
+ return null;
38
+ const [, startRaw, endRaw] = match;
39
+ if (!startRaw && !endRaw)
40
+ return null;
41
+ if (!startRaw) {
42
+ const suffix = Number(endRaw);
43
+ if (suffix === 0)
44
+ return 'unsatisfiable';
45
+ return { start: Math.max(0, size - suffix), end: size - 1 };
46
+ }
47
+ const start = Number(startRaw);
48
+ const end = endRaw ? Math.min(Number(endRaw), size - 1) : size - 1;
49
+ if (start >= size || end < start)
50
+ return 'unsatisfiable';
51
+ return { start, end };
52
+ };
53
+ const hashHandle = async (handle) => {
54
+ const hash = createHash('sha256');
55
+ const stream = handle.createReadStream({ start: 0, autoClose: false });
56
+ for await (const chunk of stream)
57
+ hash.update(chunk);
58
+ return hash.digest('hex');
59
+ };
60
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
61
+ /** Keep only the outermost paths: a directory's rescan covers everything under it. */
62
+ const outermost = (paths) => {
63
+ const prefixes = [];
64
+ for (const path of [...paths].sort()) {
65
+ if (prefixes.some(prefix => prefix === '' || path === prefix || path.startsWith(`${prefix}/`)))
66
+ continue;
67
+ prefixes.push(path);
68
+ }
69
+ return prefixes;
70
+ };
71
+ /**
72
+ * `If-None-Match: *` / `If-Match` against the disk's sha256 (`null` = no file): 412 `exists` or
73
+ * `base_mismatch`, with the file's path and `currentHash` in the body so a changeset's caller
74
+ * knows which of its files was stale.
75
+ */
76
+ const checkPreconditions = (wirePath, currentHash, ifMatch, createOnly) => {
77
+ if (createOnly && currentHash !== null) {
78
+ throw new HttpError(412, 'exists', `${wirePath} already exists.`, {
79
+ path: wirePath,
80
+ currentHash,
81
+ });
82
+ }
83
+ if (ifMatch && !ifMatch.includes('*') && !ifMatch.includes(currentHash ?? '')) {
84
+ throw new HttpError(412, 'base_mismatch', `${wirePath} changed since the base you edited.`, {
85
+ path: wirePath,
86
+ currentHash,
87
+ });
88
+ }
89
+ if (ifMatch && ifMatch.includes('*') && currentHash === null) {
90
+ throw new HttpError(412, 'base_mismatch', `${wirePath} does not exist.`, {
91
+ path: wirePath,
92
+ currentHash: null,
93
+ });
94
+ }
95
+ };
96
+ /** The same error, naming the changeset entry it is about. */
97
+ const naming = (error, wirePath) => error instanceof HttpError && !('path' in error.extra)
98
+ ? new HttpError(error.status, error.code, error.message, { ...error.extra, path: wirePath })
99
+ : error;
100
+ const MAX_NOTE_LENGTH = 2_000;
101
+ export class ProjectFiles {
102
+ root;
103
+ log;
104
+ onChange;
105
+ stabilityMs;
106
+ cache = new HashCache();
107
+ table = new Map();
108
+ journal = new Map();
109
+ /** sha256 of the plugin's last write per path — what makes a change `author: 'editor'`. */
110
+ ownWrites = new Map();
111
+ seq = 0;
112
+ revisionMemo = null;
113
+ serialTail = Promise.resolve();
114
+ dirty = new Set();
115
+ batchTimer = null;
116
+ firstDirtyAt = 0;
117
+ writer = null;
118
+ closed = false;
119
+ faults;
120
+ /** The version journal `.pix3/history/` (plan §C.4). */
121
+ history;
122
+ constructor(options) {
123
+ this.root = options.root;
124
+ this.log = options.log ?? (() => undefined);
125
+ this.onChange = options.onChange ?? (() => undefined);
126
+ this.stabilityMs = options.stabilityMs ?? STABILITY_INTERVAL_MS;
127
+ this.faults = options.faults ?? {};
128
+ this.history = new VersionJournal({ root: this.root, log: this.log, now: options.now });
129
+ }
130
+ get currentSeq() {
131
+ return this.seq;
132
+ }
133
+ get writerId() {
134
+ return this.writer;
135
+ }
136
+ /** sha256 over the sorted `path:sha256` lines of every file (see `computeRevision`). */
137
+ revision() {
138
+ this.revisionMemo ??= computeRevision(this.table);
139
+ return this.revisionMemo;
140
+ }
141
+ /** sha256 of a file in the table, or null. */
142
+ hashOf(wirePath) {
143
+ return this.table.get(wirePath)?.sha256 ?? null;
144
+ }
145
+ /** Every file of the revision set with its sha256. */
146
+ hashes() {
147
+ const out = {};
148
+ for (const [path, entry] of this.table)
149
+ if (entry.sha256)
150
+ out[path] = entry.sha256;
151
+ return out;
152
+ }
153
+ /**
154
+ * Recover interrupted changesets (plan §C.4) before the first scan, so the table never sees a
155
+ * half-applied transaction; then journal every journaled file whose bytes the journal does not
156
+ * have as its newest version (`external`: an agent or an IDE changed it while no dev server
157
+ * ran), so the version before the next change always exists.
158
+ */
159
+ async start() {
160
+ await this.serial(async () => {
161
+ const recovered = await recoverTransactions(this.root, line => this.log(line));
162
+ const fresh = new Map();
163
+ await scanInto(this.root, '', this.cache, fresh);
164
+ applySubtree(this.table, [''], fresh);
165
+ this.revisionMemo = null;
166
+ for (const file of recovered)
167
+ await this.journalFile(file.path, file.author);
168
+ for (const [path, entry] of this.table) {
169
+ if (entry.kind !== 'file' || !isJournaledPath(path))
170
+ continue;
171
+ if ((await this.history.newest(path))?.sha256 === entry.sha256)
172
+ continue;
173
+ await this.journalFile(path, 'external');
174
+ }
175
+ });
176
+ }
177
+ /**
178
+ * Journal what is on disk at `wirePath` now (if it is a journaled path). Never fails the caller:
179
+ * the write it follows already happened, and a lost journal entry is not a lost file.
180
+ */
181
+ async journalFile(wirePath, author) {
182
+ if (!isJournaledPath(wirePath))
183
+ return;
184
+ try {
185
+ const bytes = await readFile(join(this.root, ...wirePath.split('/')));
186
+ await this.history.record(wirePath, bytes, author);
187
+ }
188
+ catch (error) {
189
+ if (errnoCode(error) === 'ENOENT')
190
+ return;
191
+ this.log(`history: could not journal ${wirePath}: ${error instanceof Error ? error.message : String(error)}`);
192
+ }
193
+ }
194
+ /** Journal the files of a batch the watcher or a rescan found (author as the frame says). */
195
+ async journalEvents(events) {
196
+ for (const event of events) {
197
+ if (event.kind !== 'file' || event.op === 'delete' || !event.sha256)
198
+ continue;
199
+ // An `editor` event is the echo of an own write, journaled when it was written.
200
+ if (event.author === 'editor')
201
+ continue;
202
+ await this.journalFile(event.path, 'external');
203
+ }
204
+ }
205
+ async close() {
206
+ this.closed = true;
207
+ if (this.batchTimer)
208
+ clearTimeout(this.batchTimer);
209
+ await this.serialTail.catch(() => undefined);
210
+ }
211
+ /**
212
+ * Every table mutation (own writes, watcher batches, scans) runs one at a time. Once closed,
213
+ * new work is refused (503): what was queued before `close()` still finishes, nothing after it
214
+ * can land behind the back of the next server's instance.
215
+ */
216
+ serial(work) {
217
+ if (this.closed) {
218
+ return Promise.reject(new HttpError(503, 'closed', 'The dev server is shutting down; retry on the new one.'));
219
+ }
220
+ const run = this.serialTail.then(work, work);
221
+ this.serialTail = run.catch(() => undefined);
222
+ return run;
223
+ }
224
+ nextSeq() {
225
+ this.seq += 1;
226
+ return this.seq;
227
+ }
228
+ authored(events) {
229
+ return events.map(event => ({
230
+ ...event,
231
+ author: event.kind === 'file' &&
232
+ event.sha256 !== undefined &&
233
+ this.ownWrites.get(event.path) === event.sha256
234
+ ? 'editor'
235
+ : 'external',
236
+ }));
237
+ }
238
+ emit(events, writerId) {
239
+ const seq = this.nextSeq();
240
+ if (events.length === 0)
241
+ return seq;
242
+ this.onChange({
243
+ type: 'pix3:fs',
244
+ seq,
245
+ revision: this.revision(),
246
+ events,
247
+ ...(writerId ? { writerId } : {}),
248
+ });
249
+ return seq;
250
+ }
251
+ // --- Watcher ---------------------------------------------------------------------------------
252
+ /** A watch event for an absolute path (Vite's `server.watcher`); ignored outside the set. */
253
+ noteFsEvent(absolute) {
254
+ if (this.closed)
255
+ return;
256
+ const wirePath = relativeInside(this.root, absolute);
257
+ if (wirePath === null || isExcludedPath(wirePath))
258
+ return;
259
+ if (this.dirty.size === 0)
260
+ this.firstDirtyAt = Date.now();
261
+ this.dirty.add(wirePath);
262
+ if (this.batchTimer)
263
+ clearTimeout(this.batchTimer);
264
+ const waited = Date.now() - this.firstDirtyAt;
265
+ const delay = Math.max(0, Math.min(this.stabilityMs, MAX_BATCH_WAIT_MS - waited));
266
+ this.batchTimer = setTimeout(() => void this.flushDirty(), delay);
267
+ }
268
+ async flushDirty() {
269
+ this.batchTimer = null;
270
+ const prefixes = outermost(this.dirty);
271
+ this.dirty = new Set();
272
+ try {
273
+ await this.serial(async () => {
274
+ if (this.closed)
275
+ return;
276
+ const unstable = await this.stableRescan(prefixes);
277
+ for (const path of unstable)
278
+ this.dirty.add(path);
279
+ });
280
+ }
281
+ catch (error) {
282
+ this.log(`rescan failed: ${error instanceof Error ? error.message : String(error)}`);
283
+ }
284
+ if (this.dirty.size > 0 && !this.closed) {
285
+ this.firstDirtyAt = Date.now();
286
+ this.batchTimer = setTimeout(() => void this.flushDirty(), this.stabilityMs);
287
+ }
288
+ }
289
+ /**
290
+ * Plan §B.1 stabilisation: scan, wait {@link stabilityMs}, scan again; a file whose hash differs
291
+ * between the two scans is still being written and stays out of this batch (its old entry is
292
+ * kept, and the caller retries it). Returns the unstable paths.
293
+ */
294
+ async stableRescan(prefixes) {
295
+ const first = new Map();
296
+ for (const prefix of prefixes)
297
+ await scanInto(this.root, prefix, this.cache, first);
298
+ await sleep(this.stabilityMs);
299
+ const second = new Map();
300
+ for (const prefix of prefixes)
301
+ await scanInto(this.root, prefix, this.cache, second);
302
+ const unstable = [];
303
+ const paths = new Set([...first.keys(), ...second.keys()]);
304
+ for (const path of paths) {
305
+ const a = first.get(path);
306
+ const b = second.get(path);
307
+ if (a?.kind !== b?.kind || a?.sha256 !== b?.sha256)
308
+ unstable.push(path);
309
+ }
310
+ if (unstable.length > 0) {
311
+ // Keep what the table already says about unstable paths, so applying `second` neither
312
+ // creates nor deletes them yet.
313
+ const isUnstable = (path) => unstable.includes(path);
314
+ for (const path of unstable)
315
+ second.delete(path);
316
+ for (const [path, entry] of this.table) {
317
+ if (isUnstable(path) &&
318
+ prefixes.some(p => p === '' || path === p || path.startsWith(`${p}/`)))
319
+ second.set(path, entry);
320
+ }
321
+ }
322
+ const events = this.authored(applySubtree(this.table, prefixes, second));
323
+ if (events.length > 0) {
324
+ this.revisionMemo = null;
325
+ this.emit(events);
326
+ await this.journalEvents(events);
327
+ }
328
+ return unstable;
329
+ }
330
+ /**
331
+ * Full rescan now, without stabilisation (plan §B.3 step 1: the agent says it is done). Changes
332
+ * are broadcast like any batch and returned as `{path: sha256 | null}`.
333
+ */
334
+ async rescanAll() {
335
+ return this.serial(async () => {
336
+ const fresh = new Map();
337
+ await scanInto(this.root, '', this.cache, fresh);
338
+ const events = this.authored(applySubtree(this.table, [''], fresh));
339
+ if (events.length > 0) {
340
+ this.revisionMemo = null;
341
+ this.emit(events);
342
+ await this.journalEvents(events);
343
+ }
344
+ return { events, seq: this.seq };
345
+ });
346
+ }
347
+ // --- Writer (plan §C.3) ------------------------------------------------------------------------
348
+ /**
349
+ * `POST /__pix3/api/handover/claim {writerId}` — under the write mutex, so a write the previous
350
+ * writer already had accepted completes first. Returns the disk the new writer starts from.
351
+ */
352
+ async claim(raw) {
353
+ if (typeof raw !== 'string' || !WRITER_ID_PATTERN.test(raw)) {
354
+ throw new HttpError(400, 'bad_writer', '`writerId` must be 1–128 chars of [A-Za-z0-9_.:-].');
355
+ }
356
+ return this.serial(async () => {
357
+ const previous = this.writer;
358
+ this.writer = raw;
359
+ if (previous !== raw)
360
+ this.log(`writer ${raw}${previous ? ` (was ${previous})` : ''}`);
361
+ return { writerId: raw, seq: this.seq, revision: this.revision(), hashes: this.hashes() };
362
+ });
363
+ }
364
+ /** Mutations are taken only from the current writer (`X-Pix3-Writer`), once one has claimed. */
365
+ checkWriter(writerId) {
366
+ if (this.writer === null || writerId === this.writer)
367
+ return;
368
+ throw new HttpError(409, 'writer_superseded', 'Another editor tab took over writing to this project; this tab is read-only now.', { writerId: this.writer });
369
+ }
370
+ // --- Routes ------------------------------------------------------------------------------------
371
+ async manifest() {
372
+ return this.serial(async () => {
373
+ // A full scan, not the watcher's view: the manifest is what reconciles missed events.
374
+ const fresh = new Map();
375
+ await scanInto(this.root, '', this.cache, fresh);
376
+ const events = this.authored(applySubtree(this.table, [''], fresh));
377
+ if (events.length > 0) {
378
+ this.revisionMemo = null;
379
+ this.emit(events);
380
+ await this.journalEvents(events);
381
+ }
382
+ const files = [...this.table.entries()]
383
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
384
+ .map(([path, entry]) => ({
385
+ path,
386
+ kind: entry.kind,
387
+ size: entry.size,
388
+ mtime: entry.mtime,
389
+ ...(entry.sha256 ? { sha256: entry.sha256 } : {}),
390
+ }));
391
+ return { revision: this.revision(), seq: this.seq, files };
392
+ });
393
+ }
394
+ async hashPaths(body) {
395
+ const raw = body.paths;
396
+ if (!Array.isArray(raw))
397
+ throw new HttpError(400, 'bad_request', '`paths` must be an array of strings.');
398
+ if (raw.length > MAX_HASH_PATHS)
399
+ throw new HttpError(413, 'too_many_paths', `At most ${MAX_HASH_PATHS} paths.`);
400
+ const paths = raw.map((value, index) => parseWirePath(value, `paths[${index}]`));
401
+ const hashes = {};
402
+ for (const wirePath of paths)
403
+ hashes[wirePath] = await this.diskHash(wirePath);
404
+ return { hashes, seq: this.seq };
405
+ }
406
+ /** sha256 of what is on disk at `wirePath` now, or null (missing, a directory, unreachable). */
407
+ async diskHash(wirePath) {
408
+ let absolute;
409
+ try {
410
+ const resolved = await resolveInsideRoot(this.root, wirePath, { allowMissing: true });
411
+ if (resolved.kind !== 'file')
412
+ return null;
413
+ absolute = resolved.absolute;
414
+ }
415
+ catch (error) {
416
+ if (error instanceof HttpError && error.code === 'not_a_directory')
417
+ return null;
418
+ throw error;
419
+ }
420
+ try {
421
+ return await this.cache.hash(wirePath, absolute, await lstat(absolute, { bigint: true }));
422
+ }
423
+ catch (error) {
424
+ if (errnoCode(error) === 'ENOENT')
425
+ return null;
426
+ throw error;
427
+ }
428
+ }
429
+ async readFile(req, res, url, headOnly) {
430
+ const wirePath = parseWirePath(url.searchParams.get('path') ?? '');
431
+ const resolved = await resolveInsideRoot(this.root, wirePath, { allowMissing: false });
432
+ if (resolved.kind !== 'file')
433
+ throw new HttpError(400, 'not_a_file', `${wirePath} is not a file.`);
434
+ const handle = await open(resolved.absolute, 'r');
435
+ let handedOff = false;
436
+ try {
437
+ const stats = await handle.stat({ bigint: true });
438
+ const size = Number(stats.size);
439
+ let whole = null;
440
+ let sha256;
441
+ if (size <= WHOLE_READ_BYTES) {
442
+ whole = await handle.readFile();
443
+ sha256 = createHash('sha256').update(whole).digest('hex');
444
+ this.cache.remember(wirePath, statKeyOf(stats), sha256);
445
+ }
446
+ else {
447
+ const key = statKeyOf(stats);
448
+ const entry = this.table.get(wirePath);
449
+ sha256 =
450
+ entry && entry.statKey === key && entry.sha256 ? entry.sha256 : await hashHandle(handle);
451
+ this.cache.remember(wirePath, key, sha256);
452
+ }
453
+ const base = {
454
+ ETag: `"${sha256}"`,
455
+ 'Accept-Ranges': 'bytes',
456
+ 'Cache-Control': 'private, no-cache',
457
+ 'X-Content-Type-Options': 'nosniff',
458
+ };
459
+ const ifNoneMatch = headerValue(req, 'if-none-match');
460
+ if (ifNoneMatch && parseEntityTags(ifNoneMatch).some(tag => tag === '*' || tag === sha256)) {
461
+ res.writeHead(304, base);
462
+ res.end();
463
+ return;
464
+ }
465
+ const range = parseRange(headerValue(req, 'range'), size);
466
+ if (range === 'unsatisfiable') {
467
+ res.writeHead(416, { ...base, 'Content-Range': `bytes */${size}` });
468
+ res.end();
469
+ return;
470
+ }
471
+ const start = range ? range.start : 0;
472
+ const end = range ? range.end : size - 1;
473
+ const length = size === 0 ? 0 : end - start + 1;
474
+ res.writeHead(range ? 206 : 200, {
475
+ ...base,
476
+ 'Content-Type': contentTypeFor(wirePath),
477
+ 'Content-Length': String(length),
478
+ ...(range ? { 'Content-Range': `bytes ${start}-${end}/${size}` } : {}),
479
+ });
480
+ if (headOnly || length === 0) {
481
+ res.end();
482
+ return;
483
+ }
484
+ if (whole) {
485
+ res.end(range ? whole.subarray(start, end + 1) : whole);
486
+ return;
487
+ }
488
+ handedOff = true;
489
+ const stream = handle.createReadStream({ start, end, autoClose: true });
490
+ await pipeline(stream, res).catch(() => undefined);
491
+ }
492
+ finally {
493
+ if (!handedOff)
494
+ await handle.close();
495
+ }
496
+ }
497
+ // --- Mutations ---------------------------------------------------------------------------------
498
+ mutationId(req) {
499
+ const id = headerValue(req, 'x-mutation-id');
500
+ if (id === null)
501
+ return null;
502
+ if (!MUTATION_ID_PATTERN.test(id)) {
503
+ throw new HttpError(400, 'bad_mutation_id', 'X-Mutation-Id must be 1–128 chars of [A-Za-z0-9_.:-].');
504
+ }
505
+ return id;
506
+ }
507
+ /**
508
+ * Run `work` at most once per mutation id in this dev-server session. A retry of an id already
509
+ * applied (or still in flight) gets the recorded answer and `X-Mutation-Replayed: true`, and
510
+ * `work` does not run again — the rule that keeps a lost response from deleting or moving twice.
511
+ * Reusing an id for a different request is refused (422), not silently replayed.
512
+ */
513
+ async journaled(req, res, fingerprint, work) {
514
+ const id = this.mutationId(req);
515
+ const known = id ? this.journal.get(id) : undefined;
516
+ if (id && known) {
517
+ req.resume();
518
+ if (known.fingerprint !== fingerprint) {
519
+ throw new HttpError(422, 'mutation_id_reused', 'That X-Mutation-Id was used for a different request.');
520
+ }
521
+ const outcome = await known.outcome;
522
+ sendJson(res, outcome.status, outcome.body, { 'X-Mutation-Replayed': 'true' });
523
+ return;
524
+ }
525
+ const outcome = work().catch((error) => {
526
+ if (!(error instanceof HttpError))
527
+ this.log(`mutation failed: ${error instanceof Error ? error.stack : String(error)}`);
528
+ return error instanceof HttpError
529
+ ? {
530
+ status: error.status,
531
+ body: { error: error.code, message: error.message, ...error.extra },
532
+ }
533
+ : { status: 500, body: { error: 'internal', message: 'Internal error.' } };
534
+ });
535
+ if (id) {
536
+ this.journal.set(id, { fingerprint, outcome });
537
+ if (this.journal.size > MAX_JOURNAL) {
538
+ const oldest = this.journal.keys().next().value;
539
+ if (oldest !== undefined)
540
+ this.journal.delete(oldest);
541
+ }
542
+ }
543
+ const result = await outcome;
544
+ req.resume();
545
+ sendJson(res, result.status, result.body);
546
+ }
547
+ /**
548
+ * The file's sha256 now. `precondition: true` (an `If-Match` check before a write) hashes the
549
+ * bytes for real and refreshes the cache: the cache is keyed `ino:size:mtimeNs`, and an agent's
550
+ * same-size rewrite within one timestamp tick keeps the key — a stale sha would let the editor's
551
+ * write pass the check and overwrite the agent's bytes without a 412. Reads (an ETag) keep the
552
+ * cheap path.
553
+ */
554
+ async currentHashOf(wirePath, absolute, options = {}) {
555
+ try {
556
+ const stats = await lstat(absolute, { bigint: true });
557
+ if (!stats.isFile())
558
+ return null;
559
+ if (!options.precondition)
560
+ return await this.cache.hash(wirePath, absolute, stats);
561
+ const sha256 = await hashFile(absolute);
562
+ this.cache.remember(wirePath, statKeyOf(stats), sha256);
563
+ return sha256;
564
+ }
565
+ catch (error) {
566
+ if (errnoCode(error) === 'ENOENT')
567
+ return null;
568
+ throw error;
569
+ }
570
+ }
571
+ /**
572
+ * Record a write of the plugin in the table; the watcher's echo then finds nothing new. `own`
573
+ * false is a restore: the bytes are on disk because of the plugin, but no tab wrote them, so
574
+ * a later batch must not attribute them to the editor either.
575
+ */
576
+ recordOwnFile(wirePath, stats, sha256, own = true) {
577
+ if (own)
578
+ this.ownWrites.set(wirePath, sha256);
579
+ else
580
+ this.ownWrites.delete(wirePath);
581
+ if (isExcludedPath(wirePath))
582
+ return;
583
+ const entry = {
584
+ kind: 'file',
585
+ size: Number(stats.size),
586
+ mtime: mtimeOf(stats),
587
+ statKey: statKeyOf(stats),
588
+ sha256,
589
+ };
590
+ this.cache.remember(wirePath, entry.statKey, sha256);
591
+ this.table.set(wirePath, entry);
592
+ this.revisionMemo = null;
593
+ }
594
+ /** Make sure every ancestor directory of `wirePath` that now exists is in the table. */
595
+ async recordAncestors(wirePath) {
596
+ const parents = [];
597
+ for (let parent = parentOf(wirePath); parent !== null; parent = parentOf(parent))
598
+ parents.unshift(parent);
599
+ const created = [];
600
+ for (const parent of parents) {
601
+ if (this.table.has(parent) || isExcludedPath(`${parent}/x`))
602
+ continue;
603
+ try {
604
+ const stats = await lstat(join(this.root, ...parent.split('/')), { bigint: true });
605
+ if (!stats.isDirectory())
606
+ continue;
607
+ this.table.set(parent, {
608
+ kind: 'dir',
609
+ size: 0,
610
+ mtime: mtimeOf(stats),
611
+ statKey: statKeyOf(stats),
612
+ });
613
+ created.push({ op: 'create', path: parent, kind: 'dir' });
614
+ }
615
+ catch {
616
+ // raced away; the watcher reports it
617
+ }
618
+ }
619
+ return created;
620
+ }
621
+ async writeFile(req, res, url) {
622
+ const wirePath = parseWirePath(url.searchParams.get('path') ?? '');
623
+ const ifMatchRaw = headerValue(req, 'if-match');
624
+ const ifNoneMatchRaw = headerValue(req, 'if-none-match');
625
+ const ifMatch = ifMatchRaw ? parseEntityTags(ifMatchRaw) : null;
626
+ const createOnly = ifNoneMatchRaw !== null && parseEntityTags(ifNoneMatchRaw).includes('*');
627
+ const writerId = headerValue(req, 'x-pix3-writer');
628
+ const fingerprint = `put\n${wirePath}\n${ifMatchRaw ?? ''}\n${ifNoneMatchRaw ?? ''}`;
629
+ await this.journaled(req, res, fingerprint, () => this.performPut(req, wirePath, ifMatch, createOnly, writerId));
630
+ }
631
+ async performPut(req, wirePath, ifMatch, createOnly, writerId) {
632
+ const declared = Number(headerValue(req, 'content-length') ?? '0');
633
+ if (declared > MAX_PUT_BYTES)
634
+ throw new HttpError(413, 'too_large', 'File too large.');
635
+ this.checkWriter(writerId);
636
+ // Validate the target before accepting a body (parents too — a symlinked parent is refused).
637
+ await resolveInsideRoot(this.root, wirePath, { allowMissing: true });
638
+ const tempDir = join(this.root, RESERVED_ROOT_DIR, 'tmp');
639
+ await mkdir(tempDir, { recursive: true, mode: 0o700 });
640
+ const temp = join(tempDir, `put-${randomBytes(8).toString('hex')}`);
641
+ let tempLive = true;
642
+ try {
643
+ const { sha256, size } = await this.streamBodyTo(req, temp);
644
+ return await this.serial(async () => {
645
+ // Again under the mutex: a hand-over may have landed while the body streamed in.
646
+ this.checkWriter(writerId);
647
+ const resolved = await resolveInsideRoot(this.root, wirePath, { allowMissing: true });
648
+ if (resolved.kind === 'dir' || resolved.kind === 'other') {
649
+ throw new HttpError(409, 'not_a_file', `${wirePath} exists and is not a file.`);
650
+ }
651
+ const currentHash = resolved.kind === 'file'
652
+ ? await this.currentHashOf(wirePath, resolved.absolute, { precondition: true })
653
+ : null;
654
+ checkPreconditions(wirePath, currentHash, ifMatch, createOnly);
655
+ await mkdir(dirname(resolved.absolute), { recursive: true });
656
+ if (resolved.kind === 'file') {
657
+ const previous = await stat(resolved.absolute);
658
+ await chmod(temp, previous.mode & 0o7777);
659
+ }
660
+ await renameIntoPlace(temp, resolved.absolute);
661
+ tempLive = false;
662
+ const stats = await lstat(resolved.absolute, { bigint: true });
663
+ const dirs = await this.recordAncestors(wirePath);
664
+ const existed = this.table.has(wirePath);
665
+ const unchanged = existed && this.table.get(wirePath)?.sha256 === sha256;
666
+ this.recordOwnFile(wirePath, stats, sha256);
667
+ const events = [
668
+ ...dirs.map(event => ({ ...event, author: 'editor' })),
669
+ ...(unchanged
670
+ ? []
671
+ : [
672
+ {
673
+ op: existed ? 'modify' : 'create',
674
+ path: wirePath,
675
+ kind: 'file',
676
+ sha256,
677
+ author: 'editor',
678
+ },
679
+ ]),
680
+ ];
681
+ const seq = this.emit(events, writerId ?? undefined);
682
+ await this.journalFile(wirePath, 'editor');
683
+ return { status: 200, body: { path: wirePath, sha256, size, mtime: mtimeOf(stats), seq } };
684
+ });
685
+ }
686
+ finally {
687
+ if (tempLive)
688
+ await rm(temp, { force: true });
689
+ }
690
+ }
691
+ async streamBodyTo(req, temp) {
692
+ const hash = createHash('sha256');
693
+ let size = 0;
694
+ const meter = new Transform({
695
+ transform(chunk, _encoding, callback) {
696
+ size += chunk.length;
697
+ if (size > MAX_PUT_BYTES) {
698
+ callback(new HttpError(413, 'too_large', 'File too large.'));
699
+ return;
700
+ }
701
+ hash.update(chunk);
702
+ callback(null, chunk);
703
+ },
704
+ });
705
+ await pipeline(req, meter, createWriteStream(temp, { flags: 'wx' }));
706
+ return { sha256: hash.digest('hex'), size };
707
+ }
708
+ async jsonMutation(req, res, op, body) {
709
+ const writerId = headerValue(req, 'x-pix3-writer');
710
+ if (op === 'mkdir') {
711
+ const wirePath = parseWirePath(body.path);
712
+ await this.journaled(req, res, `mkdir\n${wirePath}`, () => this.serial(() => this.performMkdir(wirePath, writerId)));
713
+ return;
714
+ }
715
+ if (op === 'delete') {
716
+ const wirePath = parseWirePath(body.path);
717
+ const recursive = body.recursive === true;
718
+ await this.journaled(req, res, `delete\n${wirePath}\n${recursive}`, () => this.serial(() => this.performDelete(wirePath, recursive, writerId)));
719
+ return;
720
+ }
721
+ const from = parseWirePath(body.from, 'from');
722
+ const to = parseWirePath(body.to, 'to');
723
+ const overwrite = body.overwrite === true;
724
+ await this.journaled(req, res, `move\n${from}\n${to}\n${overwrite}`, () => this.serial(() => this.performMove(from, to, overwrite, writerId)));
725
+ }
726
+ /** Rescan `prefixes` after an own mutation and broadcast the result as the writer's batch. */
727
+ async ownRescan(prefixes, writerId) {
728
+ const fresh = new Map();
729
+ for (const prefix of prefixes)
730
+ await scanInto(this.root, prefix, this.cache, fresh);
731
+ const events = applySubtree(this.table, prefixes, fresh);
732
+ if (events.length > 0)
733
+ this.revisionMemo = null;
734
+ for (const event of events) {
735
+ if (event.kind === 'file' && event.sha256)
736
+ this.ownWrites.set(event.path, event.sha256);
737
+ }
738
+ const seq = this.emit(events.map(event => ({ ...event, author: 'editor' })), writerId ?? undefined);
739
+ for (const event of events) {
740
+ if (event.kind === 'file' && event.op !== 'delete')
741
+ await this.journalFile(event.path, 'editor');
742
+ }
743
+ return seq;
744
+ }
745
+ async performMkdir(wirePath, writerId) {
746
+ this.checkWriter(writerId);
747
+ const resolved = await resolveInsideRoot(this.root, wirePath, { allowMissing: true });
748
+ if (resolved.kind === 'dir')
749
+ return { status: 200, body: { path: wirePath, created: false, seq: this.seq } };
750
+ if (resolved.kind !== null)
751
+ throw new HttpError(409, 'exists', `${wirePath} exists and is not a directory.`);
752
+ await mkdir(resolved.absolute, { recursive: true });
753
+ const seq = await this.ownRescan([this.topmostNew(wirePath)], writerId);
754
+ return { status: 200, body: { path: wirePath, created: true, seq } };
755
+ }
756
+ /** The outermost ancestor (or the path itself) the table did not know — what an own write created. */
757
+ topmostNew(wirePath) {
758
+ let top = wirePath;
759
+ for (let parent = parentOf(wirePath); parent !== null; parent = parentOf(parent)) {
760
+ if (this.table.has(parent))
761
+ break;
762
+ top = parent;
763
+ }
764
+ return top;
765
+ }
766
+ async performDelete(wirePath, recursive, writerId) {
767
+ this.checkWriter(writerId);
768
+ const resolved = await resolveInsideRoot(this.root, wirePath, { allowMissing: false });
769
+ if (resolved.kind === 'dir') {
770
+ if (recursive) {
771
+ await rm(resolved.absolute, { recursive: true });
772
+ }
773
+ else {
774
+ try {
775
+ await rmdir(resolved.absolute);
776
+ }
777
+ catch (error) {
778
+ if (errnoCode(error) === 'ENOTEMPTY' || errnoCode(error) === 'EEXIST') {
779
+ throw new HttpError(409, 'not_empty', `${wirePath} is not empty; pass recursive: true.`);
780
+ }
781
+ throw error;
782
+ }
783
+ }
784
+ }
785
+ else {
786
+ await unlink(resolved.absolute);
787
+ }
788
+ const seq = await this.ownRescan([wirePath], writerId);
789
+ return {
790
+ status: 200,
791
+ body: { path: wirePath, kind: resolved.kind === 'dir' ? 'dir' : 'file', seq },
792
+ };
793
+ }
794
+ async performMove(from, to, overwrite, writerId) {
795
+ this.checkWriter(writerId);
796
+ if (to === from || to.startsWith(`${from}/`)) {
797
+ throw new HttpError(400, 'bad_move', '`to` must not be `from` or inside it.');
798
+ }
799
+ const source = await resolveInsideRoot(this.root, from, { allowMissing: false });
800
+ const target = await resolveInsideRoot(this.root, to, { allowMissing: true });
801
+ if (target.kind !== null) {
802
+ if (!overwrite)
803
+ throw new HttpError(409, 'exists', `${to} already exists.`);
804
+ if (target.kind === 'dir' || source.kind === 'dir') {
805
+ throw new HttpError(409, 'exists', `${to} exists; only a file may overwrite a file.`);
806
+ }
807
+ }
808
+ await mkdir(dirname(target.absolute), { recursive: true });
809
+ // rename keeps ino/size/mtime, so seed the hash cache with the new paths: no re-hashing.
810
+ for (const [path, entry] of this.table) {
811
+ if ((path === from || path.startsWith(`${from}/`)) && entry.sha256) {
812
+ this.cache.remember(to + path.slice(from.length), entry.statKey, entry.sha256);
813
+ }
814
+ }
815
+ await rename(source.absolute, target.absolute);
816
+ const seq = await this.ownRescan([from, this.topmostNew(to)], writerId);
817
+ const moved = this.table.get(to);
818
+ return {
819
+ status: 200,
820
+ body: {
821
+ from,
822
+ to,
823
+ kind: source.kind === 'dir' ? 'dir' : 'file',
824
+ ...(moved?.sha256 ? { sha256: moved.sha256 } : {}),
825
+ seq,
826
+ },
827
+ };
828
+ }
829
+ // --- Changeset (plan §C.4) ---------------------------------------------------------------------
830
+ /** `POST /__pix3/api/changeset` `{files: [{path, text? | base64?, ifMatch?, createOnly?}]}`. */
831
+ async writeChangeset(req, res) {
832
+ const writerId = headerValue(req, 'x-pix3-writer');
833
+ const entries = parseChangeset(await readJson(req, MAX_CHANGESET_BYTES));
834
+ await this.journaled(req, res, changesetFingerprint(entries), async () => ({
835
+ status: 200,
836
+ body: { ...(await this.performChangeset(entries, writerId)) },
837
+ }));
838
+ }
839
+ /**
840
+ * Several files as one transaction, under {@link serial} like every write and the hand-over: so
841
+ * a changeset accepted before a claim completes before the claim answers, and one arriving after
842
+ * it gets `409 writer_superseded` (plan §C.3 step 3). Preflight → stage → rename each →
843
+ * `committed` → table, journal, ONE `pix3:fs` frame. A failed rename rolls the renamed files back
844
+ * from `old/` before the error is answered; a crash is recovered at the next {@link start}.
845
+ */
846
+ async performChangeset(entries, writerId) {
847
+ this.checkWriter(writerId);
848
+ return this.serial(async () => {
849
+ this.checkWriter(writerId);
850
+ const files = [];
851
+ for (const entry of entries) {
852
+ try {
853
+ const resolved = await resolveInsideRoot(this.root, entry.path, { allowMissing: true });
854
+ if (resolved.kind === 'dir' || resolved.kind === 'other') {
855
+ throw new HttpError(409, 'not_a_file', `${entry.path} exists and is not a file.`);
856
+ }
857
+ const currentHash = resolved.kind === 'file'
858
+ ? await this.currentHashOf(entry.path, resolved.absolute, { precondition: true })
859
+ : null;
860
+ checkPreconditions(entry.path, currentHash, entry.ifMatch, entry.createOnly);
861
+ const mode = resolved.kind === 'file' ? (await stat(resolved.absolute)).mode & 0o7777 : null;
862
+ files.push({ entry, absolute: resolved.absolute, mode });
863
+ }
864
+ catch (error) {
865
+ throw naming(error, entry.path);
866
+ }
867
+ }
868
+ const { txDir, intent } = await stageTransaction(this.root, files);
869
+ const renamed = [];
870
+ let killed = false;
871
+ try {
872
+ for (const [n, file] of files.entries()) {
873
+ await mkdir(dirname(file.absolute), { recursive: true });
874
+ await renameIntoPlace(join(txDir, 'new', String(n)), file.absolute);
875
+ renamed.push(n);
876
+ if ((await this.faults.afterRename?.(n)) === 'kill') {
877
+ killed = true;
878
+ throw new Error(`changeset ${intent.id} killed after rename ${n} (test fault)`);
879
+ }
880
+ }
881
+ }
882
+ catch (error) {
883
+ if (!killed)
884
+ await this.rollBack(txDir, intent.files, renamed);
885
+ throw error;
886
+ }
887
+ for (const dir of new Set(files.map(file => dirname(file.absolute))))
888
+ await fsyncDir(dir);
889
+ await writeIntent(txDir, { ...intent, state: 'committed' });
890
+ const events = [];
891
+ for (const file of files) {
892
+ const stats = await lstat(file.absolute, { bigint: true });
893
+ const dirs = await this.recordAncestors(file.entry.path);
894
+ const existed = this.table.has(file.entry.path);
895
+ const unchanged = existed && this.table.get(file.entry.path)?.sha256 === file.entry.sha256;
896
+ this.recordOwnFile(file.entry.path, stats, file.entry.sha256);
897
+ events.push(...dirs.map(event => ({ ...event, author: 'editor' })));
898
+ if (!unchanged) {
899
+ events.push({
900
+ op: existed ? 'modify' : 'create',
901
+ path: file.entry.path,
902
+ kind: 'file',
903
+ sha256: file.entry.sha256,
904
+ author: 'editor',
905
+ });
906
+ }
907
+ }
908
+ await rm(txDir, { recursive: true, force: true });
909
+ const seq = this.emit(events, writerId ?? undefined);
910
+ for (const file of files)
911
+ await this.journalFile(file.entry.path, 'editor');
912
+ return {
913
+ seq,
914
+ files: files.map(file => ({
915
+ path: file.entry.path,
916
+ sha256: file.entry.sha256,
917
+ size: file.entry.data.byteLength,
918
+ seq,
919
+ })),
920
+ };
921
+ });
922
+ }
923
+ /**
924
+ * Undo the renames of a failed changeset, newest first. If that fails too the transaction
925
+ * directory stays (still `prepared`), and recovery at the next start finishes the rollback.
926
+ */
927
+ async rollBack(txDir, files, renamed) {
928
+ try {
929
+ for (const n of [...renamed].reverse())
930
+ await rollBackFile(this.root, txDir, files[n]);
931
+ await rm(txDir, { recursive: true, force: true });
932
+ if (renamed.length > 0)
933
+ this.log(`changeset: rolled back ${renamed.length} renamed file(s)`);
934
+ }
935
+ catch (error) {
936
+ this.log(`changeset: rollback failed, left for recovery at the next start: ${error instanceof Error ? error.message : String(error)}`);
937
+ }
938
+ }
939
+ // --- History (plan §C.4 "Журнал") --------------------------------------------------------------
940
+ /** `GET /__pix3/api/history?path=` → `{entries}`, newest first. */
941
+ async historyList(url) {
942
+ const wirePath = parseWirePath(url.searchParams.get('path') ?? '');
943
+ return { entries: await this.history.list(wirePath) };
944
+ }
945
+ /** `GET /__pix3/api/history/version?path=&id=` → the version's raw bytes. */
946
+ async historyVersion(res, url, headOnly) {
947
+ const wirePath = parseWirePath(url.searchParams.get('path') ?? '');
948
+ const id = url.searchParams.get('id') ?? '';
949
+ const bytes = await this.history.read(wirePath, id);
950
+ if (!bytes)
951
+ throw new HttpError(404, 'not_found', `No version ${id} of ${wirePath}.`);
952
+ res.writeHead(200, {
953
+ 'Content-Type': contentTypeFor(wirePath),
954
+ 'Content-Length': String(bytes.byteLength),
955
+ ETag: `"${createHash('sha256').update(bytes).digest('hex')}"`,
956
+ 'Cache-Control': 'private, no-cache',
957
+ 'X-Content-Type-Options': 'nosniff',
958
+ });
959
+ res.end(headOnly ? undefined : bytes);
960
+ }
961
+ /**
962
+ * `POST /__pix3/api/history/record {path, text, author: 'rejected-draft', note?}`. No writer
963
+ * check: a superseded tab is exactly the one that has to keep its draft (§C.3 step 3).
964
+ */
965
+ async historyRecord(body) {
966
+ const wirePath = parseWirePath(body.path);
967
+ if (!isJournaledPath(wirePath)) {
968
+ throw new HttpError(400, 'not_journaled', `${wirePath} is not a scene, prefab, locale table or project file.`);
969
+ }
970
+ if (typeof body.text !== 'string')
971
+ throw new HttpError(400, 'bad_request', '`text` must be a string.');
972
+ const author = parseRecordAuthor(body.author);
973
+ const note = body.note;
974
+ if (note !== undefined && (typeof note !== 'string' || note.length > MAX_NOTE_LENGTH)) {
975
+ throw new HttpError(400, 'bad_request', `\`note\` must be a string of at most ${MAX_NOTE_LENGTH} chars.`);
976
+ }
977
+ const text = body.text;
978
+ return this.serial(() => this.history.record(wirePath, Buffer.from(text, 'utf8'), author, note || undefined));
979
+ }
980
+ /** `POST /__pix3/api/history/restore {path, id}` (+ `If-Match`, `X-Mutation-Id`). */
981
+ async historyRestore(req, res) {
982
+ const body = await readJson(req);
983
+ const wirePath = parseWirePath(body.path);
984
+ if (typeof body.id !== 'string')
985
+ throw new HttpError(400, 'bad_request', '`id` must be a string.');
986
+ const id = body.id;
987
+ const ifMatchRaw = headerValue(req, 'if-match');
988
+ const ifMatch = ifMatchRaw ? parseEntityTags(ifMatchRaw) : null;
989
+ const writerId = headerValue(req, 'x-pix3-writer');
990
+ await this.journaled(req, res, `restore\n${wirePath}\n${id}\n${ifMatchRaw ?? ''}`, () => this.performRestore(wirePath, id, ifMatch, writerId));
991
+ }
992
+ /**
993
+ * Write a journaled version back, like a PUT from the writer — but NOT as an own write: the
994
+ * frame says `external` and carries no writer, so the editor follows the disk (reload or §C.3
995
+ * merge) exactly as after an agent's change, instead of taking it for the echo of its flush.
996
+ */
997
+ async performRestore(wirePath, id, ifMatch, writerId) {
998
+ this.checkWriter(writerId);
999
+ return this.serial(async () => {
1000
+ this.checkWriter(writerId);
1001
+ const bytes = await this.history.read(wirePath, id);
1002
+ if (!bytes)
1003
+ throw new HttpError(404, 'not_found', `No version ${id} of ${wirePath}.`);
1004
+ const resolved = await resolveInsideRoot(this.root, wirePath, { allowMissing: true });
1005
+ if (resolved.kind === 'dir' || resolved.kind === 'other') {
1006
+ throw new HttpError(409, 'not_a_file', `${wirePath} exists and is not a file.`);
1007
+ }
1008
+ const currentHash = resolved.kind === 'file'
1009
+ ? await this.currentHashOf(wirePath, resolved.absolute, { precondition: true })
1010
+ : null;
1011
+ checkPreconditions(wirePath, currentHash, ifMatch, false);
1012
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
1013
+ const tempDir = join(this.root, RESERVED_ROOT_DIR, 'tmp');
1014
+ await mkdir(tempDir, { recursive: true, mode: 0o700 });
1015
+ const temp = join(tempDir, `restore-${randomBytes(8).toString('hex')}`);
1016
+ try {
1017
+ const handle = await open(temp, 'wx');
1018
+ try {
1019
+ await handle.writeFile(bytes);
1020
+ await handle.sync();
1021
+ if (resolved.kind === 'file')
1022
+ await handle.chmod((await stat(resolved.absolute)).mode & 0o7777);
1023
+ }
1024
+ finally {
1025
+ await handle.close();
1026
+ }
1027
+ await mkdir(dirname(resolved.absolute), { recursive: true });
1028
+ await renameIntoPlace(temp, resolved.absolute);
1029
+ }
1030
+ finally {
1031
+ await rm(temp, { force: true });
1032
+ }
1033
+ const stats = await lstat(resolved.absolute, { bigint: true });
1034
+ const dirs = await this.recordAncestors(wirePath);
1035
+ const existed = this.table.has(wirePath);
1036
+ const unchanged = existed && this.table.get(wirePath)?.sha256 === sha256;
1037
+ this.recordOwnFile(wirePath, stats, sha256, false);
1038
+ const events = [
1039
+ ...dirs.map(event => ({ ...event, author: 'external' })),
1040
+ ...(unchanged
1041
+ ? []
1042
+ : [
1043
+ {
1044
+ op: existed ? 'modify' : 'create',
1045
+ path: wirePath,
1046
+ kind: 'file',
1047
+ sha256,
1048
+ author: 'external',
1049
+ },
1050
+ ]),
1051
+ ];
1052
+ const seq = this.emit(events);
1053
+ try {
1054
+ await this.history.record(wirePath, bytes, 'restore', `version ${id}`);
1055
+ }
1056
+ catch (error) {
1057
+ this.log(`history: could not journal the restore of ${wirePath}: ${String(error)}`);
1058
+ }
1059
+ return { status: 200, body: { path: wirePath, sha256, size: bytes.byteLength, seq } };
1060
+ });
1061
+ }
1062
+ }