@rexezuge/tooling 0.0.0-stage → 1.0.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/eslint-boundaries.d.ts +75 -0
  4. package/dist/eslint-boundaries.d.ts.map +1 -0
  5. package/dist/eslint-boundaries.js +138 -0
  6. package/dist/eslint-boundaries.js.map +1 -0
  7. package/dist/eslint-rules.d.ts +41 -0
  8. package/dist/eslint-rules.d.ts.map +1 -0
  9. package/dist/eslint-rules.js +273 -0
  10. package/dist/eslint-rules.js.map +1 -0
  11. package/dist/eslint.d.ts +96 -0
  12. package/dist/eslint.d.ts.map +1 -0
  13. package/dist/eslint.js +189 -0
  14. package/dist/eslint.js.map +1 -0
  15. package/dist/functions/pages-proxy.d.ts +113 -0
  16. package/dist/functions/pages-proxy.d.ts.map +1 -0
  17. package/dist/functions/pages-proxy.js +131 -0
  18. package/dist/functions/pages-proxy.js.map +1 -0
  19. package/dist/index.d.ts +31 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +28 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/scripts/backup/d1-target.d.ts +35 -0
  24. package/dist/scripts/backup/d1-target.d.ts.map +1 -0
  25. package/dist/scripts/backup/d1-target.js +31 -0
  26. package/dist/scripts/backup/d1-target.js.map +1 -0
  27. package/dist/scripts/backup/destination-config.d.ts +61 -0
  28. package/dist/scripts/backup/destination-config.d.ts.map +1 -0
  29. package/dist/scripts/backup/destination-config.js +57 -0
  30. package/dist/scripts/backup/destination-config.js.map +1 -0
  31. package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
  32. package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
  33. package/dist/scripts/backup/encrypt-backup.js +98 -0
  34. package/dist/scripts/backup/encrypt-backup.js.map +1 -0
  35. package/dist/scripts/backup/naming.d.ts +44 -0
  36. package/dist/scripts/backup/naming.d.ts.map +1 -0
  37. package/dist/scripts/backup/naming.js +58 -0
  38. package/dist/scripts/backup/naming.js.map +1 -0
  39. package/dist/scripts/check-god-files.d.ts +164 -0
  40. package/dist/scripts/check-god-files.d.ts.map +1 -0
  41. package/dist/scripts/check-god-files.js +271 -0
  42. package/dist/scripts/check-god-files.js.map +1 -0
  43. package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
  44. package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
  45. package/dist/scripts/ensure-spa-shell-stub.js +30 -0
  46. package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
  47. package/dist/scripts/init-secrets.d.ts +63 -0
  48. package/dist/scripts/init-secrets.d.ts.map +1 -0
  49. package/dist/scripts/init-secrets.js +240 -0
  50. package/dist/scripts/init-secrets.js.map +1 -0
  51. package/dist/scripts/lib/cli-args.d.ts +78 -0
  52. package/dist/scripts/lib/cli-args.d.ts.map +1 -0
  53. package/dist/scripts/lib/cli-args.js +116 -0
  54. package/dist/scripts/lib/cli-args.js.map +1 -0
  55. package/dist/scripts/lib/github-actions.d.ts +26 -0
  56. package/dist/scripts/lib/github-actions.d.ts.map +1 -0
  57. package/dist/scripts/lib/github-actions.js +38 -0
  58. package/dist/scripts/lib/github-actions.js.map +1 -0
  59. package/dist/scripts/lib/wrangler-table.d.ts +46 -0
  60. package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
  61. package/dist/scripts/lib/wrangler-table.js +99 -0
  62. package/dist/scripts/lib/wrangler-table.js.map +1 -0
  63. package/dist/scripts/migrations-lock.d.ts +3 -0
  64. package/dist/scripts/migrations-lock.d.ts.map +1 -0
  65. package/dist/scripts/migrations-lock.js +46 -0
  66. package/dist/scripts/migrations-lock.js.map +1 -0
  67. package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
  68. package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
  69. package/dist/scripts/prepare-wrangler-config.js +50 -0
  70. package/dist/scripts/prepare-wrangler-config.js.map +1 -0
  71. package/dist/scripts/spa-shell.d.ts +41 -0
  72. package/dist/scripts/spa-shell.d.ts.map +1 -0
  73. package/dist/scripts/spa-shell.js +155 -0
  74. package/dist/scripts/spa-shell.js.map +1 -0
  75. package/dist/scripts/validate-locales.d.ts +26 -0
  76. package/dist/scripts/validate-locales.d.ts.map +1 -0
  77. package/dist/scripts/validate-locales.js +350 -0
  78. package/dist/scripts/validate-locales.js.map +1 -0
  79. package/dist/scripts/verify-migrations.d.ts +62 -0
  80. package/dist/scripts/verify-migrations.d.ts.map +1 -0
  81. package/dist/scripts/verify-migrations.js +302 -0
  82. package/dist/scripts/verify-migrations.js.map +1 -0
  83. package/dist/scripts/verify-spa-shell.d.ts +3 -0
  84. package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
  85. package/dist/scripts/verify-spa-shell.js +53 -0
  86. package/dist/scripts/verify-spa-shell.js.map +1 -0
  87. package/dist/scripts/wrangler-config/cli.d.ts +22 -0
  88. package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
  89. package/dist/scripts/wrangler-config/cli.js +51 -0
  90. package/dist/scripts/wrangler-config/cli.js.map +1 -0
  91. package/dist/scripts/wrangler-config/patches.d.ts +51 -0
  92. package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
  93. package/dist/scripts/wrangler-config/patches.js +140 -0
  94. package/dist/scripts/wrangler-config/patches.js.map +1 -0
  95. package/dist/scripts/wrangler-config/resources.d.ts +70 -0
  96. package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
  97. package/dist/scripts/wrangler-config/resources.js +290 -0
  98. package/dist/scripts/wrangler-config/resources.js.map +1 -0
  99. package/dist/scripts/wrangler-config/types.d.ts +103 -0
  100. package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
  101. package/dist/scripts/wrangler-config/types.js +49 -0
  102. package/dist/scripts/wrangler-config/types.js.map +1 -0
  103. package/dist/test/integration-migrations.d.ts +167 -0
  104. package/dist/test/integration-migrations.d.ts.map +1 -0
  105. package/dist/test/integration-migrations.js +171 -0
  106. package/dist/test/integration-migrations.js.map +1 -0
  107. package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
  108. package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
  109. package/dist/test/mocks/cloudflare-workers.js +90 -0
  110. package/dist/test/mocks/cloudflare-workers.js.map +1 -0
  111. package/dist/vite.d.ts +117 -0
  112. package/dist/vite.d.ts.map +1 -0
  113. package/dist/vite.js +125 -0
  114. package/dist/vite.js.map +1 -0
  115. package/dist/vitest-web.d.ts +73 -0
  116. package/dist/vitest-web.d.ts.map +1 -0
  117. package/dist/vitest-web.js +72 -0
  118. package/dist/vitest-web.js.map +1 -0
  119. package/dist/vitest.d.ts +92 -0
  120. package/dist/vitest.d.ts.map +1 -0
  121. package/dist/vitest.js +128 -0
  122. package/dist/vitest.js.map +1 -0
  123. package/package.json +58 -3
  124. package/src/eslint-boundaries.ts +169 -0
  125. package/src/eslint-rules.ts +276 -0
  126. package/src/eslint.test.ts +175 -0
  127. package/src/eslint.ts +257 -0
  128. package/src/functions/pages-proxy.test.ts +72 -0
  129. package/src/functions/pages-proxy.ts +187 -0
  130. package/src/github/actions/retry-step/action.yml +39 -0
  131. package/src/github/actions/setup-env/action.yml +20 -0
  132. package/src/github/dependabot.yml +30 -0
  133. package/src/github/workflows/backup-main.yml +46 -0
  134. package/src/github/workflows/continuous-deployment.yml +188 -0
  135. package/src/github/workflows/continuous-integration.yml +259 -0
  136. package/src/github/workflows/scheduled-version-update.yml +38 -0
  137. package/src/github/workflows/upstream-sync.yml +56 -0
  138. package/src/index.ts +42 -0
  139. package/src/scripts/backup/backup-rules.test.ts +105 -0
  140. package/src/scripts/backup/d1-target.ts +54 -0
  141. package/src/scripts/backup/destination-config.ts +92 -0
  142. package/src/scripts/backup/encrypt-backup.ts +107 -0
  143. package/src/scripts/backup/naming.ts +62 -0
  144. package/src/scripts/check-god-files.test.ts +131 -0
  145. package/src/scripts/check-god-files.ts +327 -0
  146. package/src/scripts/ensure-spa-shell-stub.ts +34 -0
  147. package/src/scripts/init-secrets.ts +265 -0
  148. package/src/scripts/lib/cli-args.ts +154 -0
  149. package/src/scripts/lib/github-actions.ts +41 -0
  150. package/src/scripts/lib/wrangler-table.ts +105 -0
  151. package/src/scripts/migrations-lock.ts +52 -0
  152. package/src/scripts/prepare-wrangler-config.ts +51 -0
  153. package/src/scripts/spa-shell.test.ts +91 -0
  154. package/src/scripts/spa-shell.ts +179 -0
  155. package/src/scripts/validate-locales.test.ts +89 -0
  156. package/src/scripts/validate-locales.ts +380 -0
  157. package/src/scripts/verify-migrations.test.ts +71 -0
  158. package/src/scripts/verify-migrations.ts +364 -0
  159. package/src/scripts/verify-spa-shell.ts +56 -0
  160. package/src/scripts/wrangler-config/cli.ts +51 -0
  161. package/src/scripts/wrangler-config/patches.ts +157 -0
  162. package/src/scripts/wrangler-config/resources.ts +330 -0
  163. package/src/scripts/wrangler-config/types.ts +113 -0
  164. package/src/test/integration-migrations.test.ts +169 -0
  165. package/src/test/integration-migrations.ts +267 -0
  166. package/src/test/mocks/cloudflare-workers.ts +115 -0
  167. package/src/vite.test.ts +83 -0
  168. package/src/vite.ts +202 -0
  169. package/src/vitest-web.ts +109 -0
  170. package/src/vitest.ts +185 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Applying the migration directory to a D1 binding — as a factory.
3
+ *
4
+ * Provenance: converged from `test/integration/helpers/migrations.ts` in six repos
5
+ * (AWS-AccessBridge, ChordDHT-Tracker, Durable-DAV, Durable-DAV-Router, Edge-Git,
6
+ * Mail-Meow) and `test/helpers/migrations.ts` in two more (CalDAV-Bridge,
7
+ * Edge-Sonic). All eight are the same file: a SQL splitter plus a range-addressed
8
+ * `applyMigrations`.
9
+ *
10
+ * ### Why it is a factory and not a module
11
+ *
12
+ * Every source file hardcodes where the migrations live, either as a
13
+ * `__INTEGRATION_MIGRATION_FILES__` binding injected by the Vitest config's
14
+ * `define`, or as a path derived from `import.meta.url`. The kit cannot know
15
+ * either, so the directory is an argument and the injection stays available as an
16
+ * option. One call to `createMigrationHelper({ migrationsDir })` gives the consumer
17
+ * the same helper their repo already has.
18
+ *
19
+ * ### What each source contributes
20
+ *
21
+ * - **Durable-DAV** — the trigger-aware splitter. `CREATE TRIGGER … BEGIN … END;`
22
+ * bodies contain semicolons that must not split, and a naive splitter produces
23
+ * statements SQLite rejects mid-migration. The BEGIN/END depth is tracked only
24
+ * outside strings and comments, and the word *before* `TRIGGER` disambiguates
25
+ * `CREATE TRIGGER` from a table called `trigger`. Durable-DAV also applies one
26
+ * file per `batch()` and names the offending statement in the error, which is
27
+ * the difference between "0007 failed" and a line number.
28
+ * - **AWS-AccessBridge** — the once-per-database guard. D1 records which
29
+ * migrations have run; this helper does the same, because `ALTER TABLE … ADD
30
+ * COLUMN` is not idempotent in SQLite and re-running the bundle fails with
31
+ * "duplicate column name". A `WeakMap` keyed on the binding is the right scope
32
+ * because each test file gets its own database. AWS also keeps `next` out of the
33
+ * line-comment branch, which never reads it, and preserves newlines inside block
34
+ * comments so an error's line numbers still point at the file.
35
+ * - **Edge-Sonic** — reading the directory off disk rather than a `define`
36
+ * binding, because a test that names one hardcoded file cannot tell **a new
37
+ * migration** from **an edit to an old one**, so it goes on passing through a
38
+ * schema change that never reached production.
39
+ *
40
+ * ### The splitter is not here
41
+ *
42
+ * The statement splitter — with all of Durable-DAV's trigger-aware rules and its
43
+ * comment-level history — lives once, in `@rexezuge/d1`. `@rexezuge/identity`'s
44
+ * migration applier delegates to the same module, so the splitter cannot drift
45
+ * between the suite helper and the production applier. The re-exports below keep
46
+ * this file's public surface identical to the source repos'.
47
+ *
48
+ * ### The lock is asserted, not reimplemented
49
+ *
50
+ * D1 records applied migrations by *filename*, so an applied migration is immutable
51
+ * in fact while being an ordinary text file in appearance. That fact lives in
52
+ * `migrations/migrations.lock.json`, and the rules that check it live once, in
53
+ * `scripts/verify-migrations.ts` — the same module the CI gate runs. This helper
54
+ * deliberately does not parse the lock itself: a second implementation of a thing
55
+ * already written is free to disagree with the first, and the two disagreeing would
56
+ * leave a suite green against a lock CI rejects.
57
+ *
58
+ * ### Generic over the binding
59
+ *
60
+ * `D1DatabaseLike` is structural — `prepare`, and `batch` when the runtime has it
61
+ * — so a fake D1 in a unit test, a real D1 in the workerd integration suite, and
62
+ * Miniflare's D1 all satisfy it without an import of `@cloudflare/workers-types`.
63
+ * `batch` is used when present (Durable-DAV's shape: one round trip per file, and
64
+ * the failure names the file) and a sequential `prepare().run()` loop otherwise
65
+ * (AWS's shape: works on a binding with no batch support).
66
+ */
67
+
68
+ import { executableStatements, splitSql } from '@rexezuge/d1';
69
+ import { readdirSync, readFileSync } from 'node:fs';
70
+ import { join } from 'node:path';
71
+
72
+ /**
73
+ * One migration file, as it is read.
74
+ */
75
+ export interface MigrationFile {
76
+ /** Bare filename, e.g. `0007_replication.sql`. */
77
+ name: string;
78
+ /** The file's full text. */
79
+ sql: string;
80
+ }
81
+
82
+ /**
83
+ * The subset of a D1 prepared statement this helper uses.
84
+ */
85
+ export interface D1PreparedStatementLike {
86
+ run(): Promise<unknown>;
87
+ }
88
+
89
+ /**
90
+ * The subset of a D1 binding this helper uses.
91
+ *
92
+ * `batch` is optional because not every binding that can prepare a statement can
93
+ * batch one; where it exists it is the better path, and where it does not the
94
+ * sequential loop is equivalent for a migration whose statements are independent.
95
+ */
96
+ export interface D1DatabaseLike {
97
+ prepare(sql: string): D1PreparedStatementLike;
98
+ batch?(statements: readonly D1PreparedStatementLike[]): Promise<unknown>;
99
+ }
100
+
101
+ /**
102
+ * A closed range of migration files, by filename.
103
+ *
104
+ * Both ends inclusive, because "up to and including 0006" is the natural way to
105
+ * seed a pre-migration database and then apply the migration under test.
106
+ */
107
+ export interface MigrationRange {
108
+ from?: string;
109
+ to?: string;
110
+ }
111
+
112
+ /**
113
+ * What `createMigrationHelper` accepts.
114
+ */
115
+ export interface MigrationHelperOptions {
116
+ /**
117
+ * Directory holding the `.sql` files. Read top-level only, matching wrangler's
118
+ * default `${migrationsDir}/*.sql`, so the helper describes exactly the set
119
+ * wrangler will run.
120
+ */
121
+ readonly migrationsDir: string;
122
+ /**
123
+ * Migration files to use instead of reading `migrationsDir`.
124
+ *
125
+ * The escape hatch for a harness that embeds the SQL at build time. Same shape
126
+ * as the `__INTEGRATION_MIGRATION_FILES__` binding the source repos inject.
127
+ */
128
+ readonly files?: readonly MigrationFile[];
129
+ }
130
+
131
+ /**
132
+ * A bound migration helper.
133
+ */
134
+ export interface MigrationHelper {
135
+ /** Every migration, in apply order. */
136
+ migrationFiles(): MigrationFile[];
137
+ /** The filenames, in apply order. */
138
+ migrationFileNames(): string[];
139
+ /** The concatenation of every file, in apply order — what a fresh database becomes. */
140
+ migrationSql(): string;
141
+ /** One file's SQL, by name. "What does *this* file say", not "what does the set say". */
142
+ migrationSqlOf(name: string): string;
143
+ /** Split one file into statements, comments included. */
144
+ splitSql(sql: string): string[];
145
+ /** The statements of one file that SQLite will actually execute. */
146
+ executableStatements(sql: string): string[];
147
+ /**
148
+ * Apply a range of migrations, at most once per database and range.
149
+ *
150
+ * Per file rather than one batch for everything, so a test can stop between
151
+ * files: the identity-upgrade test needs to seed the *pre-0004* shape and then
152
+ * apply 0004 to it.
153
+ */
154
+ applyMigrations(db: D1DatabaseLike, range?: MigrationRange): Promise<void>;
155
+ }
156
+
157
+ /**
158
+ * Reads every `.sql` file in `dir`, in apply order.
159
+ *
160
+ * Sort is the filename sort D1 itself uses, rather than a numeric parse of the
161
+ * prefix: wrangler hands the directory to SQLite in lexicographic order, so a
162
+ * suite that reads it numerically disagrees with production on any prefix wider
163
+ * than four digits.
164
+ */
165
+ export function readMigrationFiles(dir: string): MigrationFile[] {
166
+ const entries = readdirSync(dir, { withFileTypes: true });
167
+ return entries
168
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
169
+ .map((entry) => ({ name: entry.name, sql: readFileSync(join(dir, entry.name), 'utf8') }))
170
+ .sort((left, right) => left.name.localeCompare(right.name));
171
+ }
172
+
173
+ /**
174
+ * Strip leading `--` and block comments, which the splitter folds into the
175
+ * statement it then reports on.
176
+ */
177
+ function stripLeadingComments(statement: string): string {
178
+ let out = statement;
179
+ for (;;) {
180
+ const next = out.replace(/^\s*(?:--[^\n]*\n|\/\*[\s\S]*?\*\/)\s*/, '');
181
+ if (next === out) return out.trim();
182
+ out = next;
183
+ }
184
+ }
185
+
186
+ /** Re-exported for surface parity with the source repos — the rules live in @rexezuge/d1. */
187
+ export { executableStatements, splitSql };
188
+
189
+ /**
190
+ * Bind a migration directory into a helper.
191
+ *
192
+ * ```ts
193
+ * const migrations = createMigrationHelper({ migrationsDir: path.join(REPO_ROOT, 'migrations') });
194
+ * await migrations.applyMigrations(env.DB);
195
+ * ```
196
+ */
197
+ export function createMigrationHelper(options: MigrationHelperOptions): MigrationHelper {
198
+ const files = options.files !== undefined ? [...options.files] : readMigrationFiles(options.migrationsDir);
199
+
200
+ /**
201
+ * Databases this helper has already migrated, keyed by the range applied.
202
+ *
203
+ * A `WeakMap` rather than a module-level `Set`, because the guard is per
204
+ * binding: two databases in one suite each need the bundle applied once, and a
205
+ * module-level set would silently skip the second.
206
+ */
207
+ const applied = new WeakMap<D1DatabaseLike, string>();
208
+
209
+ const indexOf = (name: string | undefined, fallback: number): number => {
210
+ if (name === undefined) return fallback;
211
+ const found = files.findIndex((file) => file.name === name);
212
+ if (found === -1) {
213
+ throw new Error(`Unknown migration file: ${name}. Available: ${files.map((file) => file.name).join(', ')}`);
214
+ }
215
+ return found;
216
+ };
217
+
218
+ return {
219
+ migrationFiles: () => files.map((file) => ({ ...file })),
220
+ migrationFileNames: () => files.map((file) => file.name),
221
+ migrationSql: () => files.map((file) => file.sql).join('\n'),
222
+ migrationSqlOf: (name: string) => {
223
+ const file = files.find((candidate) => candidate.name === name);
224
+ if (file === undefined) {
225
+ throw new Error(`Unknown migration file: ${name}. Available: ${files.map((candidate) => candidate.name).join(', ')}`);
226
+ }
227
+ return file.sql;
228
+ },
229
+ splitSql,
230
+ executableStatements,
231
+ applyMigrations: async (db: D1DatabaseLike, range?: MigrationRange): Promise<void> => {
232
+ const start = indexOf(range?.from, 0);
233
+ const end = indexOf(range?.to, files.length - 1);
234
+ const key = `${start}:${end}`;
235
+ if (applied.get(db) === key) return;
236
+ const applicable = files.slice(start, end + 1);
237
+
238
+ for (const file of applicable) {
239
+ const statements = executableStatements(file.sql);
240
+ if (statements.length === 0) continue;
241
+
242
+ if (typeof db.batch === 'function') {
243
+ try {
244
+ await db.batch(statements.map((sql) => db.prepare(sql)));
245
+ } catch (error: unknown) {
246
+ // A batch reports only the file's single failure, so name the statement:
247
+ // a migration error that says nothing about which of its nine statements
248
+ // broke is a migration error nobody fixes.
249
+ throw new Error(
250
+ `${file.name}: batch of ${statements.length} statements failed at ${stripLeadingComments(statements.at(-1) ?? '').slice(0, 200)}: ${
251
+ error instanceof Error ? error.message : String(error)
252
+ }`,
253
+ { cause: error },
254
+ );
255
+ }
256
+ continue;
257
+ }
258
+
259
+ for (const sql of statements) {
260
+ await db.prepare(sql).run();
261
+ }
262
+ }
263
+
264
+ applied.set(db, key);
265
+ },
266
+ };
267
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Stand-in for the `cloudflare:workers` module, which has no Node equivalent.
3
+ *
4
+ * Provenance: converged from the eight copies at `test/mocks/cloudflare-workers.ts`
5
+ * (AWS-AccessBridge, CalDAV-Bridge, ChordDHT-Tracker, Durable-DAV, Edge-Git,
6
+ * Edge-Sonic, Mail-Meow, Mail-Otter). All eight are the same twenty lines; the
7
+ * union is three base classes and the union exists because each repo discovered a
8
+ * missing one the hard way.
9
+ *
10
+ * ### What each source contributes
11
+ *
12
+ * - **DurableObject** — every copy. Stores `ctx` and `env` so a real DO class can
13
+ * be constructed and its lifecycle RPCs driven directly, without Miniflare.
14
+ * - **`RpcTarget`** — Durable-DAV's, and the reason is load-bearing rather than
15
+ * tidy: `dofs`' `Fs` class `extends RpcTarget`, so the module fails to evaluate
16
+ * with *"Class extends value undefined"* when the base is missing. That is a
17
+ * module-load error, not a test failure, so it surfaces as an unimportable
18
+ * `dav-store` barrel and no unit test can reach `DavRepository`. Deliberately
19
+ * empty: on the platform it marks which methods are callable across an RPC
20
+ * boundary, and nothing in a Node suite calls those through RPC. An empty base
21
+ * keeps the fake honest instead of pretending to implement dispatch.
22
+ * - **`WorkflowEntrypoint`** — Edge-Git, Edge-Sonic, Mail-Meow, Mail-Otter. A
23
+ * Workflow class extends it, so the mock must provide it or the module fails the
24
+ * same way — a **startup** error, which therefore takes down every suite that
25
+ * reaches `apps/background` for an unrelated reason. Edge-Sonic's is the shape
26
+ * kept: it stores `ctx` as well as `env`, because the failure policy under test
27
+ * in `runWorkflow` lives in a plain function precisely so it can be exercised
28
+ * without workerd, and that policy reads the execution context.
29
+ *
30
+ * ### Why the ambient types are declared here rather than imported
31
+ *
32
+ * The source repos name `DurableObjectState`, `ExecutionContext` and `Env`
33
+ * unqualified, which resolves only because `worker-configuration.d.ts` and
34
+ * `@cloudflare/workers-types` are ambient inside a Wrangler project. This copy is
35
+ * imported by the kit's own typecheck, where neither exists, so the two state
36
+ * shapes are declared locally and `TEnv` defaults to `unknown` instead of `Env`
37
+ * (Edge-Sonic's form). A local declaration shadows the ambient one inside this
38
+ * file only, so a consumer's copy typechecks against their generated types
39
+ * exactly as the original did.
40
+ *
41
+ * ### Usage in a consumer
42
+ *
43
+ * Copy this file to `<repo>/test/mocks/cloudflare-workers.ts` and alias the
44
+ * specifier in the Vitest config — `defineUnitVitestConfig()` already does:
45
+ *
46
+ * ```jsonc
47
+ * // wrangler.jsonc / vitest alias
48
+ * "cloudflare:workers": "test/mocks/cloudflare-workers.ts"
49
+ * ```
50
+ *
51
+ * Only what a test actually imports is exported. A symbol here that nothing
52
+ * imports is not a safety net; it is a claim that something depends on it.
53
+ */
54
+
55
+ /**
56
+ * The subset of `DurableObjectState` a Node test reaches.
57
+ *
58
+ * Declared structurally rather than imported: `id`, `storage` and `waitUntil` are
59
+ * the three a hand-driven lifecycle touches, and nothing here needs the rest.
60
+ */
61
+ export interface DurableObjectState {
62
+ readonly id: { toString(): string; readonly name?: string };
63
+ }
64
+
65
+ /**
66
+ * The subset of `ExecutionContext` a Node test reaches.
67
+ *
68
+ * `waitUntil` is present because a Workflow's `run()` is driven through it, and
69
+ * `passThroughOnException` is absent on purpose — nothing in a Node suite wires a
70
+ * request through it.
71
+ */
72
+ export interface ExecutionContext {
73
+ waitUntil(promise: Promise<unknown>): void;
74
+ }
75
+
76
+ /**
77
+ * The base class every Durable Object extends.
78
+ *
79
+ * `protected`, exactly as on the platform: a DO subclass reads `this.env` and
80
+ * `this.ctx`, and nothing outside it may.
81
+ */
82
+ class DurableObject<TEnv = unknown> {
83
+ protected ctx: DurableObjectState;
84
+ protected env: TEnv;
85
+
86
+ constructor(ctx: DurableObjectState, env: TEnv) {
87
+ this.ctx = ctx;
88
+ this.env = env;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The base class `dofs`' `Fs` extends. See the header: an empty body is the
94
+ * whole point, and a missing class is a module-load failure.
95
+ */
96
+ class RpcTarget {}
97
+
98
+ /**
99
+ * The base class every Workflow extends.
100
+ *
101
+ * `ctx` is `ExecutionContext` rather than `unknown` (Mail-Meow/Mail-Otter's
102
+ * form) because a Workflow's step code reads it; `void ctx` to silence the unused
103
+ * warning would be a lie about which of the two a subclass uses.
104
+ */
105
+ class WorkflowEntrypoint<TEnv = unknown> {
106
+ protected ctx: ExecutionContext;
107
+ protected env: TEnv;
108
+
109
+ constructor(ctx: ExecutionContext, env: TEnv) {
110
+ this.ctx = ctx;
111
+ this.env = env;
112
+ }
113
+ }
114
+
115
+ export { DurableObject, RpcTarget, WorkflowEntrypoint };
@@ -0,0 +1,83 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { afterAll, describe, expect, it } from 'vitest';
5
+ import { defineWebViteConfig, spaShellEmbedPlugin } from './vite';
6
+
7
+ const created: string[] = [];
8
+
9
+ function tempRoot(): string {
10
+ const root = mkdtempSync(join(tmpdir(), 'vite-config-'));
11
+ created.push(root);
12
+ return root;
13
+ }
14
+
15
+ afterAll(() => {
16
+ for (const root of created) rmSync(root, { recursive: true, force: true });
17
+ });
18
+
19
+ const REAL_HTML = `<!doctype html><html><head><meta charset="utf-8" /><title>Fixture</title><script type="module" src="/assets/index-abc123.js"></script></head><body><div id="root"></div><!-- padding so the shell is a real document --></body></html>`;
20
+
21
+ /** A build output: `dist/index.html` plus the bundle it references. */
22
+ function writeBuild(root: string): void {
23
+ mkdirSync(join(root, 'dist', 'assets'), { recursive: true });
24
+ writeFileSync(join(root, 'dist', 'index.html'), REAL_HTML, 'utf8');
25
+ writeFileSync(join(root, 'dist', 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8');
26
+ }
27
+
28
+ describe('defineWebViteConfig', () => {
29
+ it('empties the output directory by default', () => {
30
+ // A stale asset in dist is served as a live bundle, which is the same
31
+ // half-refreshed build verify-spa-shell rejects.
32
+ expect(defineWebViteConfig().build).toMatchObject({ outDir: 'dist', emptyOutDir: true });
33
+ });
34
+
35
+ it('passes the proxy through as data', () => {
36
+ const config = defineWebViteConfig({ proxy: { '/user': { target: 'http://localhost:8787', changeOrigin: true } } });
37
+ expect(config.server).toMatchObject({ proxy: { '/user': { target: 'http://localhost:8787' } } });
38
+ });
39
+
40
+ it('registers the spa-shell plugin only when a target is given', () => {
41
+ const withShell = defineWebViteConfig({ plugins: [], spaShell: { path: 'apps/api/src/generated/spa-shell.ts' } });
42
+ const without = defineWebViteConfig({ plugins: [] });
43
+ expect(withShell.plugins?.map((plugin) => plugin.name)).toContain('spa-shell-embed');
44
+ expect(without.plugins ?? []).toStrictEqual([]);
45
+ });
46
+ });
47
+
48
+ describe('spaShellEmbedPlugin', () => {
49
+ it('writes the generated module from the built index.html', async () => {
50
+ const root = tempRoot();
51
+ writeBuild(root);
52
+
53
+ const plugin = spaShellEmbedPlugin({ path: 'apps/api/src/generated/spa-shell.ts', root });
54
+ await plugin.closeBundle?.();
55
+
56
+ const written = readFileSync(join(root, 'apps/api/src/generated/spa-shell.ts'), 'utf8');
57
+ expect(written).toContain('export const SPA_HTML: string = ');
58
+ expect(written).toContain('DO NOT EDIT');
59
+ // A verbatim copy: verify-spa-shell compares the two byte for byte.
60
+ expect(JSON.parse(written.slice(written.indexOf('= ') + 2, written.lastIndexOf(';')).trim())).toBe(REAL_HTML);
61
+ });
62
+
63
+ it('skips a build that emitted no index.html, rather than failing it', () => {
64
+ const root = tempRoot();
65
+ const plugin = spaShellEmbedPlugin({ path: 'apps/api/src/generated/spa-shell.ts', root });
66
+ // closeBundle runs on every build, including library builds with no HTML.
67
+ expect(() => plugin.closeBundle?.()).not.toThrow();
68
+ expect(existsSync(join(root, 'apps/api/src/generated/spa-shell.ts'))).toBe(false);
69
+ });
70
+
71
+ it('resolves against the build directory, never against this module', async () => {
72
+ const root = tempRoot();
73
+ writeBuild(root);
74
+ mkdirSync(dirname(join(root, 'apps/api/src/generated')), { recursive: true });
75
+
76
+ const plugin = spaShellEmbedPlugin({ path: 'apps/api/src/generated/spa-shell.ts', root });
77
+ await plugin.closeBundle?.();
78
+
79
+ expect(existsSync(join(root, 'apps/api/src/generated/spa-shell.ts'))).toBe(true);
80
+ // The kit's own directory must not gain a generated file from a consumer's build.
81
+ expect(existsSync(join(process.cwd(), 'apps/api/src/generated/spa-shell.ts'))).toBe(false);
82
+ });
83
+ });
package/src/vite.ts ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The SPA's Vite config.
3
+ *
4
+ * Provenance: converged from the `apps/web/vite.config.ts` of the eight repos
5
+ * that build a Vite SPA. They are one file with three variables: the plugin list
6
+ * (`@vitejs/plugin-react` + `@tailwindcss/vite`), the dev-server proxy target,
7
+ * and the `spa-shell-embed` plugin that copies `dist/index.html` into
8
+ * `apps/api/src/generated/spa-shell.ts`.
9
+ *
10
+ * Canonical decisions:
11
+ *
12
+ * 1. **The plugins are passed in, not imported.** The kit stays zero-dependency:
13
+ * `react`, `@vitejs/plugin-react` and `@tailwindcss/vite` belong to the app
14
+ * that uses them, and importing them here would make the tooling package
15
+ * undownloadable for a consumer without a React SPA.
16
+ * 2. **The `spa-shell-embed` hook stays per-repo, and is passed in.** It is the
17
+ * one genuinely repo-specific piece: each repo names its own worker and its
18
+ * own generated-module path. What the kit provides is the *contract* — see
19
+ * `spaShellEmbedPlugin`, which is the converged body of all eight copies.
20
+ * 3. **`server.proxy` is data, not code.** The paths differ per repo
21
+ * (`/user` + `/rest` in Edge-Sonic, `/api` in CalDAV-Bridge, `/tracker` in
22
+ * ChordDHT-Tracker) but the shape never does, so it is an option with a
23
+ * default.
24
+ * 4. **`build.emptyOutDir: true`** is the default, because a stale asset in
25
+ * `dist/` is served as a live bundle — the same half-refreshed build
26
+ * `scripts/verify-spa-shell.ts` rejects.
27
+ *
28
+ * The consumer's `apps/web/vite.config.ts` becomes a handful of lines — see the
29
+ * README.
30
+ */
31
+
32
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
33
+ import { dirname, resolve } from 'node:path';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
35
+ import { defineConfig, type Plugin } from 'vitest/config';
36
+
37
+ /**
38
+ * One dev-server proxy entry, as Vite's `server.proxy` accepts it.
39
+ */
40
+ export interface ProxyEntry {
41
+ readonly target: string;
42
+ readonly changeOrigin?: boolean;
43
+ readonly bypass?: (request: { headers: Record<string, string | string[] | undefined> }) => string | undefined;
44
+ }
45
+
46
+ /**
47
+ * Where the SPA's built `index.html` is copied to, and under what module name.
48
+ */
49
+ export interface SpaShellTarget {
50
+ /**
51
+ * Path of the generated module, relative to the repository root.
52
+ *
53
+ * `apps/api/src/generated/spa-shell.ts` in every repo that embeds a shell; the
54
+ * parameter exists because the path is the one thing that genuinely differs.
55
+ */
56
+ readonly path: string;
57
+ /**
58
+ * The repository root the path is resolved against, as a file URL or a path.
59
+ *
60
+ * Defaults to the directory the build runs in (`process.cwd()`), which is the
61
+ * repo root when the build is `pnpm --filter @scope/web build`. Passed in by the
62
+ * Vite config, not derived from `import.meta.url`: this plugin object is created
63
+ * inside the kit's own module, so a URL relative to it points into
64
+ * `node_modules/@rexezuge/tooling` and writes the shell somewhere no compiler
65
+ * will ever look.
66
+ */
67
+ readonly root?: string | URL;
68
+ /**
69
+ * The built `index.html`, as a path or file URL.
70
+ *
71
+ * Defaults to `dist/index.html` under the same root. Vite's `closeBundle` runs
72
+ * after the bundle is written, so this is the file the build just produced.
73
+ */
74
+ readonly distIndex?: string | URL;
75
+ }
76
+
77
+ /**
78
+ * What `defineWebViteConfig` accepts.
79
+ */
80
+ export interface WebViteConfigOptions {
81
+ /**
82
+ * The SPA's plugins — `react()`, `tailwindcss()`, and anything else the app
83
+ * needs. Applied before the spa-shell plugin so the shell is written after the
84
+ * bundle exists.
85
+ */
86
+ readonly plugins?: readonly Plugin[];
87
+ /**
88
+ * Dev-server proxy entries. Keyed by path prefix.
89
+ */
90
+ readonly proxy?: Readonly<Record<string, ProxyEntry>>;
91
+ /**
92
+ * Where the built shell is embedded. Omit to skip the embedding entirely.
93
+ */
94
+ readonly spaShell?: SpaShellTarget;
95
+ /**
96
+ * The build output directory.
97
+ */
98
+ readonly outDir?: string;
99
+ /**
100
+ * The directory this config is resolved from, as a file URL. Defaults to the
101
+ * caller's location.
102
+ */
103
+ readonly root?: string;
104
+ }
105
+
106
+ /**
107
+ * The `spa-shell-embed` plugin, converged from all eight copies.
108
+ *
109
+ * It exists because the API worker serves `SPA_HTML` from a module in its own
110
+ * source tree, and both that module and `dist/` are gitignored — so a fresh
111
+ * clone gets an empty stub from `postinstall` and every `GET /` returns a blank
112
+ * page until someone runs a build. This plugin is what makes the build write the
113
+ * real artefact, and `scripts/verify-spa-shell.ts` is what refuses to deploy
114
+ * without it.
115
+ *
116
+ * Written as a factory rather than inlined so the exit conditions are in one
117
+ * place: the `closeBundle` hook runs on every build, including the ones where
118
+ * `dist/index.html` was never produced, and a plugin that throws there fails
119
+ * builds that legitimately emit nothing.
120
+ */
121
+ /**
122
+ * Normalise a path-or-URL option to a URL, so `new URL(...)` can be used on it.
123
+ *
124
+ * Small, and here because two options accept either form: a path is what a consumer
125
+ * naturally writes in a config, and a URL is what `import.meta.url` hands them.
126
+ * Accepting both is one line and saves a `fileURLToPath` at every call site.
127
+ */
128
+ function toUrl(value: string | URL): URL {
129
+ return typeof value === 'string' ? pathToFileURL(value) : value;
130
+ }
131
+
132
+ /**
133
+ * The same, as a **directory** URL — with the trailing separator `new URL(rel, base)`
134
+ * needs.
135
+ *
136
+ * `new URL('apps/api/x.ts', 'file:///repo')` resolves to `/apps/api/x.ts`, silently
137
+ * dropping the base. The base has to end in a separator, and a path from
138
+ * `process.cwd()` never does, so this is where the plugin would otherwise write the
139
+ * shell into the filesystem root.
140
+ */
141
+ function toDirUrl(value: string | URL): URL {
142
+ const url = toUrl(value);
143
+ return url.href.endsWith('/') ? url : new URL(`${url.href}/`);
144
+ }
145
+
146
+ export function spaShellEmbedPlugin(target: SpaShellTarget): Plugin {
147
+ return {
148
+ name: 'spa-shell-embed',
149
+ closeBundle() {
150
+ // Resolved from the directory the build runs in, never from this module's own
151
+ // URL: the plugin object is created here but executes in the consumer's Vite
152
+ // process, and `new URL(path, import.meta.url)` would resolve against
153
+ // `node_modules/@rexezuge/tooling`. `process.cwd()` is the repo root for
154
+ // `pnpm --filter @scope/web build`, which is how every consumer runs it.
155
+ const root = toDirUrl(target.root ?? process.cwd());
156
+ const outputPath = fileURLToPath(new URL(target.path, root));
157
+ const indexPath = target.distIndex === undefined ? fileURLToPath(new URL('dist/index.html', root)) : fileURLToPath(toUrl(target.distIndex));
158
+
159
+ if (!existsSync(indexPath)) {
160
+ console.warn('spa-shell-embed: dist/index.html not found, skipping');
161
+ return;
162
+ }
163
+
164
+ const html = readFileSync(indexPath, 'utf8');
165
+ const output = `// Auto-generated by Vite build plugin - DO NOT EDIT.\nexport const SPA_HTML: string = ${JSON.stringify(html)};\n`;
166
+
167
+ mkdirSync(dirname(outputPath), { recursive: true });
168
+ writeFileSync(outputPath, output);
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Build the SPA's Vite config.
175
+ *
176
+ * ```ts
177
+ * // apps/web/vite.config.ts
178
+ * import react from '@vitejs/plugin-react';
179
+ * import { defineWebViteConfig, spaShellEmbedPlugin } from '@rexezuge/tooling/vite';
180
+ *
181
+ * export default defineWebViteConfig({
182
+ * plugins: [react()],
183
+ * proxy: { '/user': { target: 'http://localhost:8787', changeOrigin: true } },
184
+ * spaShell: { path: '../../apps/api/src/generated/spa-shell.ts' },
185
+ * });
186
+ * ```
187
+ */
188
+ export function defineWebViteConfig(options: WebViteConfigOptions = {}): ReturnType<typeof defineConfig> {
189
+ const plugins: Plugin[] = [...(options.plugins ?? [])];
190
+ if (options.spaShell !== undefined) {
191
+ plugins.push(spaShellEmbedPlugin(options.spaShell));
192
+ }
193
+
194
+ return defineConfig({
195
+ ...(plugins.length > 0 && { plugins }),
196
+ ...(options.proxy !== undefined && { server: { proxy: { ...options.proxy } } }),
197
+ build: {
198
+ outDir: options.outDir ?? 'dist',
199
+ emptyOutDir: true,
200
+ },
201
+ });
202
+ }