@pix3/vite-plugin 0.0.0-stage → 2.0.0-alpha.0

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 (85) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +38 -3
  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 +99 -0
  22. package/dist/build/player-modules.js +204 -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 +278 -0
  61. package/dist/module-paths.d.ts +13 -0
  62. package/dist/module-paths.js +32 -0
  63. package/dist/player/index.d.ts +31 -0
  64. package/dist/player/index.js +102 -0
  65. package/dist/server/editor-socket.d.ts +53 -0
  66. package/dist/server/editor-socket.js +134 -0
  67. package/dist/server/guard.d.ts +61 -0
  68. package/dist/server/guard.js +165 -0
  69. package/dist/server/http.d.ts +24 -0
  70. package/dist/server/http.js +70 -0
  71. package/dist/server/image-keys.d.ts +46 -0
  72. package/dist/server/image-keys.js +118 -0
  73. package/dist/server/image-proxy.d.ts +32 -0
  74. package/dist/server/image-proxy.js +124 -0
  75. package/dist/server/router.d.ts +54 -0
  76. package/dist/server/router.js +147 -0
  77. package/dist/sync/barrier.d.ts +69 -0
  78. package/dist/sync/barrier.js +240 -0
  79. package/dist/sync/hot-update.d.ts +16 -0
  80. package/dist/sync/hot-update.js +58 -0
  81. package/dist/sync/script-graph.d.ts +79 -0
  82. package/dist/sync/script-graph.js +214 -0
  83. package/dist/vite-config.d.ts +12 -0
  84. package/dist/vite-config.js +62 -0
  85. package/package.json +60 -6
@@ -0,0 +1,144 @@
1
+ import { lstat, realpath } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { errnoCode, HttpError } from "../server/http.js";
4
+ /**
5
+ * Wire paths of the file API (port of `packages/cli/src/serve/paths.ts`): POSIX, relative to the
6
+ * project root (Vite's `root`), case preserved.
7
+ *
8
+ * The plugin enforces the root, so the rules are strict rather than normalising: a path that is
9
+ * not already canonical is refused, not repaired. Percent-decoding happens exactly once, by
10
+ * `URLSearchParams` for `?path=` (JSON bodies are not decoded at all) — a literal `%2e%2e` that
11
+ * survives that one decoding is an ordinary file name, never a second chance to spell `..`.
12
+ */
13
+ /**
14
+ * Directory under the root that holds the plugin's own state (`dev.json`, staging, the version
15
+ * journal). Never part of the revision set; through the API only its plugin-private entries are
16
+ * refused, so the editor can still keep its own files there.
17
+ */
18
+ export const RESERVED_ROOT_DIR = '.pix3';
19
+ /**
20
+ * Entries directly under `.pix3/` that belong to the plugin (compared case-insensitively, so a
21
+ * case-insensitive disk cannot be used to spell them differently). Anything below a private
22
+ * directory is private too.
23
+ */
24
+ const PLUGIN_PRIVATE_ENTRIES = new Set([
25
+ 'dev.json',
26
+ 'dev.log',
27
+ 'tmp',
28
+ 'tx',
29
+ 'history',
30
+ 'local',
31
+ ]);
32
+ /**
33
+ * True for paths the file API refuses with `403 reserved_path`: `.pix3` itself (so it can be
34
+ * neither deleted nor moved) and the plugin-private entries under it.
35
+ */
36
+ export const isPluginPrivatePath = (wirePath) => {
37
+ const segments = wirePath.split('/');
38
+ if (segments[0].toLowerCase() !== RESERVED_ROOT_DIR)
39
+ return false;
40
+ if (segments.length === 1)
41
+ return true;
42
+ return PLUGIN_PRIVATE_ENTRIES.has(segments[1].toLowerCase());
43
+ };
44
+ const MAX_PATH_LENGTH = 4096;
45
+ const bad = (message) => new HttpError(400, 'bad_path', message);
46
+ /** Validate a wire path and return it unchanged (it is already canonical when accepted). */
47
+ export const parseWirePath = (raw, field = 'path') => {
48
+ if (typeof raw !== 'string' || raw.length === 0)
49
+ throw bad(`\`${field}\` must be a non-empty string.`);
50
+ if (raw.length > MAX_PATH_LENGTH)
51
+ throw bad(`\`${field}\` is too long.`);
52
+ if (raw.includes('\0'))
53
+ throw bad(`\`${field}\` contains a NUL byte.`);
54
+ if (raw.includes('\\'))
55
+ throw bad(`\`${field}\` must use "/" separators, not backslashes.`);
56
+ if (raw.startsWith('/'))
57
+ throw bad(`\`${field}\` must be relative to the project root.`);
58
+ if (/^[A-Za-z]:/.test(raw))
59
+ throw bad(`\`${field}\` must not carry a drive letter.`);
60
+ for (const segment of raw.split('/')) {
61
+ if (segment === '')
62
+ throw bad(`\`${field}\` has an empty segment (leading, trailing or double "/").`);
63
+ if (segment === '.' || segment === '..')
64
+ throw bad(`\`${field}\` must not contain "." or "..".`);
65
+ // Windows spellings of ANOTHER name, refused on every platform so a path means the same thing
66
+ // everywhere: `name:stream` is an NTFS alternate data stream, and Win32 strips trailing dots
67
+ // and spaces (`dev.json.` opens `dev.json`).
68
+ if (segment.includes(':'))
69
+ throw bad(`\`${field}\` must not contain ":" (a drive or an NTFS stream name).`);
70
+ if (segment.endsWith('.') || segment.endsWith(' '))
71
+ throw bad(`\`${field}\` has a segment ending in "." or a space.`);
72
+ }
73
+ if (isPluginPrivatePath(raw)) {
74
+ throw new HttpError(403, 'reserved_path', `\`${RESERVED_ROOT_DIR}\` itself and \`${RESERVED_ROOT_DIR}/{dev.json,dev.log,tmp,tx,history,` +
75
+ 'local}` belong to the plugin.');
76
+ }
77
+ return raw;
78
+ };
79
+ /**
80
+ * Resolve a validated wire path under `root` without ever passing through a symlink.
81
+ *
82
+ * Every existing component (parents included, which is what protects a path about to be CREATED)
83
+ * is `lstat`ed: a symlink anywhere is refused with 403 `symlink`, a file where a directory is
84
+ * needed with 409 `not_a_directory`. This is a check, not a lock: a local process that swaps a
85
+ * directory for a symlink between the check and the use wins the race (same-user local processes
86
+ * are trusted).
87
+ */
88
+ export const resolveInsideRoot = async (root, wirePath, options) => {
89
+ const segments = wirePath.split('/');
90
+ let current = root;
91
+ for (let i = 0; i < segments.length; i++) {
92
+ current = join(current, segments[i]);
93
+ const last = i === segments.length - 1;
94
+ let stats;
95
+ try {
96
+ stats = await lstat(current);
97
+ }
98
+ catch (error) {
99
+ const code = errnoCode(error);
100
+ if (code === 'ENOENT') {
101
+ if (!options.allowMissing)
102
+ throw new HttpError(404, 'not_found', `${wirePath} does not exist.`);
103
+ return { absolute: join(root, ...segments), kind: null };
104
+ }
105
+ if (code === 'ENOTDIR') {
106
+ throw new HttpError(409, 'not_a_directory', `A parent of ${wirePath} is not a directory.`);
107
+ }
108
+ throw error;
109
+ }
110
+ if (stats.isSymbolicLink()) {
111
+ throw new HttpError(403, 'symlink', `${wirePath} goes through a symbolic link; the file API does not follow them.`);
112
+ }
113
+ if (!last && !stats.isDirectory()) {
114
+ throw new HttpError(409, 'not_a_directory', `A parent of ${wirePath} is not a directory.`);
115
+ }
116
+ if (process.platform === 'win32') {
117
+ await refuseShortNameAlias(current, segments[i], wirePath);
118
+ }
119
+ if (last) {
120
+ return {
121
+ absolute: current,
122
+ kind: stats.isFile() ? 'file' : stats.isDirectory() ? 'dir' : 'other',
123
+ };
124
+ }
125
+ }
126
+ // Unreachable: `parseWirePath` guarantees at least one segment.
127
+ throw bad('Empty path.');
128
+ };
129
+ /**
130
+ * Windows only: refuse a segment that opened the entry under a name other than its own — an 8.3
131
+ * short name (`.pix3/DEV~1.JSO`) would otherwise slip past {@link isPluginPrivatePath}, which only
132
+ * knows the long names. NTFS names are case-insensitive, and case is not an alias.
133
+ */
134
+ const refuseShortNameAlias = async (absolute, segment, wirePath) => {
135
+ const longName = basename(await realpath(absolute));
136
+ if (longName.toLowerCase() !== segment.toLowerCase()) {
137
+ throw bad(`${wirePath} uses "${segment}", an alias of "${longName}"; use the long name.`);
138
+ }
139
+ };
140
+ /** Parent wire path, or `null` for a top-level entry. */
141
+ export const parentOf = (wirePath) => {
142
+ const slash = wirePath.lastIndexOf('/');
143
+ return slash < 0 ? null : wirePath.slice(0, slash);
144
+ };
@@ -0,0 +1,208 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type ChangesetEntry } from './changeset.ts';
3
+ import { VersionJournal, type HistoryEntry } from './history.ts';
4
+ import { type ChangeEvent } from './scan.ts';
5
+ /**
6
+ * The project's files as the editor sees them (plan §B.1, port of the file half of
7
+ * `packages/cli/src/serve/workspace-server.ts`): a table of every file in the revision set with
8
+ * its sha256, conditional atomic writes, mkdir/delete/move, batch hashing, and the change events
9
+ * the editor tab receives over `/__pix3/ws`.
10
+ *
11
+ * Every table mutation — own writes, watcher batches, scans, the writer hand-over — runs one at a
12
+ * time through {@link serial}; that queue is the mutex plan §C.3/§C.4 put the write path under.
13
+ */
14
+ export type ChangeAuthor = 'editor' | 'external';
15
+ export interface FsEvent extends ChangeEvent {
16
+ /** `editor` when the bytes are the plugin's last write of that path, else `external`. */
17
+ readonly author: ChangeAuthor;
18
+ }
19
+ /** One `pix3:fs` frame: every change of one batch, under one `seq`. */
20
+ export interface FsFrame {
21
+ readonly type: 'pix3:fs';
22
+ readonly seq: number;
23
+ readonly revision: string;
24
+ readonly events: readonly FsEvent[];
25
+ /** The writer tab that made an `editor` batch through the API. */
26
+ readonly writerId?: string;
27
+ }
28
+ export interface ProjectFilesOptions {
29
+ /** Absolute project root (Vite's `root`). */
30
+ readonly root: string;
31
+ readonly log?: (line: string) => void;
32
+ /** Called for every batch of changes, own writes included. */
33
+ readonly onChange?: (frame: FsFrame) => void;
34
+ /** Quiet time after the last watch event before a batch is scanned (plan §B.1: 300 ms). */
35
+ readonly stabilityMs?: number;
36
+ /** Clock of the version journal (retention specs). */
37
+ readonly now?: () => number;
38
+ /**
39
+ * Test-only fault injection into a changeset. `afterRename(n)` runs after entry n was renamed
40
+ * into place: throwing makes the changeset fail (and roll back), resolving `'kill'` stops it dead
41
+ * — nothing more is renamed or cleaned up, as if the process died — for the recovery specs.
42
+ */
43
+ readonly faults?: {
44
+ readonly afterRename?: (index: number) => void | 'kill' | Promise<void | 'kill'>;
45
+ };
46
+ }
47
+ /** The answer of a changeset (`HostChangesetResult`). */
48
+ export interface ChangesetResult {
49
+ readonly seq: number;
50
+ readonly files: readonly {
51
+ path: string;
52
+ sha256: string;
53
+ size: number;
54
+ seq: number;
55
+ }[];
56
+ }
57
+ export declare const STABILITY_INTERVAL_MS = 300;
58
+ export declare class ProjectFiles {
59
+ readonly root: string;
60
+ private readonly log;
61
+ private readonly onChange;
62
+ private readonly stabilityMs;
63
+ private readonly cache;
64
+ private readonly table;
65
+ private readonly journal;
66
+ /** sha256 of the plugin's last write per path — what makes a change `author: 'editor'`. */
67
+ private readonly ownWrites;
68
+ private seq;
69
+ private revisionMemo;
70
+ private serialTail;
71
+ private dirty;
72
+ private batchTimer;
73
+ private firstDirtyAt;
74
+ private writer;
75
+ private closed;
76
+ private readonly faults;
77
+ /** The version journal `.pix3/history/` (plan §C.4). */
78
+ readonly history: VersionJournal;
79
+ constructor(options: ProjectFilesOptions);
80
+ get currentSeq(): number;
81
+ get writerId(): string | null;
82
+ /** sha256 over the sorted `path:sha256` lines of every file (see `computeRevision`). */
83
+ revision(): string;
84
+ /** sha256 of a file in the table, or null. */
85
+ hashOf(wirePath: string): string | null;
86
+ /** Every file of the revision set with its sha256. */
87
+ hashes(): Record<string, string>;
88
+ /**
89
+ * Recover interrupted changesets (plan §C.4) before the first scan, so the table never sees a
90
+ * half-applied transaction; then journal every journaled file whose bytes the journal does not
91
+ * have as its newest version (`external`: an agent or an IDE changed it while no dev server
92
+ * ran), so the version before the next change always exists.
93
+ */
94
+ start(): Promise<void>;
95
+ /**
96
+ * Journal what is on disk at `wirePath` now (if it is a journaled path). Never fails the caller:
97
+ * the write it follows already happened, and a lost journal entry is not a lost file.
98
+ */
99
+ private journalFile;
100
+ /** Journal the files of a batch the watcher or a rescan found (author as the frame says). */
101
+ private journalEvents;
102
+ close(): Promise<void>;
103
+ /**
104
+ * Every table mutation (own writes, watcher batches, scans) runs one at a time. Once closed,
105
+ * new work is refused (503): what was queued before `close()` still finishes, nothing after it
106
+ * can land behind the back of the next server's instance.
107
+ */
108
+ serial<T>(work: () => Promise<T>): Promise<T>;
109
+ private nextSeq;
110
+ private authored;
111
+ private emit;
112
+ /** A watch event for an absolute path (Vite's `server.watcher`); ignored outside the set. */
113
+ noteFsEvent(absolute: string): void;
114
+ private flushDirty;
115
+ /**
116
+ * Plan §B.1 stabilisation: scan, wait {@link stabilityMs}, scan again; a file whose hash differs
117
+ * between the two scans is still being written and stays out of this batch (its old entry is
118
+ * kept, and the caller retries it). Returns the unstable paths.
119
+ */
120
+ private stableRescan;
121
+ /**
122
+ * Full rescan now, without stabilisation (plan §B.3 step 1: the agent says it is done). Changes
123
+ * are broadcast like any batch and returned as `{path: sha256 | null}`.
124
+ */
125
+ rescanAll(): Promise<{
126
+ events: FsEvent[];
127
+ seq: number;
128
+ }>;
129
+ /**
130
+ * `POST /__pix3/api/handover/claim {writerId}` — under the write mutex, so a write the previous
131
+ * writer already had accepted completes first. Returns the disk the new writer starts from.
132
+ */
133
+ claim(raw: unknown): Promise<Record<string, unknown>>;
134
+ /** Mutations are taken only from the current writer (`X-Pix3-Writer`), once one has claimed. */
135
+ private checkWriter;
136
+ manifest(): Promise<Record<string, unknown>>;
137
+ hashPaths(body: Record<string, unknown>): Promise<Record<string, unknown>>;
138
+ /** sha256 of what is on disk at `wirePath` now, or null (missing, a directory, unreachable). */
139
+ diskHash(wirePath: string): Promise<string | null>;
140
+ readFile(req: IncomingMessage, res: ServerResponse, url: URL, headOnly: boolean): Promise<void>;
141
+ private mutationId;
142
+ /**
143
+ * Run `work` at most once per mutation id in this dev-server session. A retry of an id already
144
+ * applied (or still in flight) gets the recorded answer and `X-Mutation-Replayed: true`, and
145
+ * `work` does not run again — the rule that keeps a lost response from deleting or moving twice.
146
+ * Reusing an id for a different request is refused (422), not silently replayed.
147
+ */
148
+ private journaled;
149
+ /**
150
+ * The file's sha256 now. `precondition: true` (an `If-Match` check before a write) hashes the
151
+ * bytes for real and refreshes the cache: the cache is keyed `ino:size:mtimeNs`, and an agent's
152
+ * same-size rewrite within one timestamp tick keeps the key — a stale sha would let the editor's
153
+ * write pass the check and overwrite the agent's bytes without a 412. Reads (an ETag) keep the
154
+ * cheap path.
155
+ */
156
+ private currentHashOf;
157
+ /**
158
+ * Record a write of the plugin in the table; the watcher's echo then finds nothing new. `own`
159
+ * false is a restore: the bytes are on disk because of the plugin, but no tab wrote them, so
160
+ * a later batch must not attribute them to the editor either.
161
+ */
162
+ private recordOwnFile;
163
+ /** Make sure every ancestor directory of `wirePath` that now exists is in the table. */
164
+ private recordAncestors;
165
+ writeFile(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void>;
166
+ private performPut;
167
+ private streamBodyTo;
168
+ jsonMutation(req: IncomingMessage, res: ServerResponse, op: 'mkdir' | 'delete' | 'move', body: Record<string, unknown>): Promise<void>;
169
+ /** Rescan `prefixes` after an own mutation and broadcast the result as the writer's batch. */
170
+ private ownRescan;
171
+ private performMkdir;
172
+ /** The outermost ancestor (or the path itself) the table did not know — what an own write created. */
173
+ private topmostNew;
174
+ private performDelete;
175
+ private performMove;
176
+ /** `POST /__pix3/api/changeset` `{files: [{path, text? | base64?, ifMatch?, createOnly?}]}`. */
177
+ writeChangeset(req: IncomingMessage, res: ServerResponse): Promise<void>;
178
+ /**
179
+ * Several files as one transaction, under {@link serial} like every write and the hand-over: so
180
+ * a changeset accepted before a claim completes before the claim answers, and one arriving after
181
+ * it gets `409 writer_superseded` (plan §C.3 step 3). Preflight → stage → rename each →
182
+ * `committed` → table, journal, ONE `pix3:fs` frame. A failed rename rolls the renamed files back
183
+ * from `old/` before the error is answered; a crash is recovered at the next {@link start}.
184
+ */
185
+ performChangeset(entries: readonly ChangesetEntry[], writerId: string | null): Promise<ChangesetResult>;
186
+ /**
187
+ * Undo the renames of a failed changeset, newest first. If that fails too the transaction
188
+ * directory stays (still `prepared`), and recovery at the next start finishes the rollback.
189
+ */
190
+ private rollBack;
191
+ /** `GET /__pix3/api/history?path=` → `{entries}`, newest first. */
192
+ historyList(url: URL): Promise<Record<string, unknown>>;
193
+ /** `GET /__pix3/api/history/version?path=&id=` → the version's raw bytes. */
194
+ historyVersion(res: ServerResponse, url: URL, headOnly: boolean): Promise<void>;
195
+ /**
196
+ * `POST /__pix3/api/history/record {path, text, author: 'rejected-draft', note?}`. No writer
197
+ * check: a superseded tab is exactly the one that has to keep its draft (§C.3 step 3).
198
+ */
199
+ historyRecord(body: Record<string, unknown>): Promise<HistoryEntry>;
200
+ /** `POST /__pix3/api/history/restore {path, id}` (+ `If-Match`, `X-Mutation-Id`). */
201
+ historyRestore(req: IncomingMessage, res: ServerResponse): Promise<void>;
202
+ /**
203
+ * Write a journaled version back, like a PUT from the writer — but NOT as an own write: the
204
+ * frame says `external` and carries no writer, so the editor follows the disk (reload or §C.3
205
+ * merge) exactly as after an agent's change, instead of taking it for the echo of its flush.
206
+ */
207
+ private performRestore;
208
+ }