@rexezuge/tooling 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +297 -2
- package/dist/eslint.d.ts +120 -0
- package/dist/eslint.d.ts.map +1 -0
- package/dist/eslint.js +551 -0
- package/dist/eslint.js.map +1 -0
- package/dist/functions/pages-proxy.d.ts +113 -0
- package/dist/functions/pages-proxy.d.ts.map +1 -0
- package/dist/functions/pages-proxy.js +131 -0
- package/dist/functions/pages-proxy.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/scripts/backup/d1-target.d.ts +35 -0
- package/dist/scripts/backup/d1-target.d.ts.map +1 -0
- package/dist/scripts/backup/d1-target.js +31 -0
- package/dist/scripts/backup/d1-target.js.map +1 -0
- package/dist/scripts/backup/destination-config.d.ts +61 -0
- package/dist/scripts/backup/destination-config.d.ts.map +1 -0
- package/dist/scripts/backup/destination-config.js +57 -0
- package/dist/scripts/backup/destination-config.js.map +1 -0
- package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
- package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
- package/dist/scripts/backup/encrypt-backup.js +98 -0
- package/dist/scripts/backup/encrypt-backup.js.map +1 -0
- package/dist/scripts/backup/naming.d.ts +44 -0
- package/dist/scripts/backup/naming.d.ts.map +1 -0
- package/dist/scripts/backup/naming.js +58 -0
- package/dist/scripts/backup/naming.js.map +1 -0
- package/dist/scripts/check-god-files.d.ts +164 -0
- package/dist/scripts/check-god-files.d.ts.map +1 -0
- package/dist/scripts/check-god-files.js +271 -0
- package/dist/scripts/check-god-files.js.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.js +30 -0
- package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
- package/dist/scripts/init-secrets.d.ts +63 -0
- package/dist/scripts/init-secrets.d.ts.map +1 -0
- package/dist/scripts/init-secrets.js +240 -0
- package/dist/scripts/init-secrets.js.map +1 -0
- package/dist/scripts/lib/cli-args.d.ts +78 -0
- package/dist/scripts/lib/cli-args.d.ts.map +1 -0
- package/dist/scripts/lib/cli-args.js +116 -0
- package/dist/scripts/lib/cli-args.js.map +1 -0
- package/dist/scripts/lib/github-actions.d.ts +26 -0
- package/dist/scripts/lib/github-actions.d.ts.map +1 -0
- package/dist/scripts/lib/github-actions.js +38 -0
- package/dist/scripts/lib/github-actions.js.map +1 -0
- package/dist/scripts/lib/wrangler-table.d.ts +46 -0
- package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
- package/dist/scripts/lib/wrangler-table.js +99 -0
- package/dist/scripts/lib/wrangler-table.js.map +1 -0
- package/dist/scripts/migrations-lock.d.ts +3 -0
- package/dist/scripts/migrations-lock.d.ts.map +1 -0
- package/dist/scripts/migrations-lock.js +46 -0
- package/dist/scripts/migrations-lock.js.map +1 -0
- package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
- package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
- package/dist/scripts/prepare-wrangler-config.js +50 -0
- package/dist/scripts/prepare-wrangler-config.js.map +1 -0
- package/dist/scripts/spa-shell.d.ts +41 -0
- package/dist/scripts/spa-shell.d.ts.map +1 -0
- package/dist/scripts/spa-shell.js +155 -0
- package/dist/scripts/spa-shell.js.map +1 -0
- package/dist/scripts/validate-locales.d.ts +26 -0
- package/dist/scripts/validate-locales.d.ts.map +1 -0
- package/dist/scripts/validate-locales.js +350 -0
- package/dist/scripts/validate-locales.js.map +1 -0
- package/dist/scripts/verify-migrations.d.ts +62 -0
- package/dist/scripts/verify-migrations.d.ts.map +1 -0
- package/dist/scripts/verify-migrations.js +302 -0
- package/dist/scripts/verify-migrations.js.map +1 -0
- package/dist/scripts/verify-spa-shell.d.ts +3 -0
- package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
- package/dist/scripts/verify-spa-shell.js +53 -0
- package/dist/scripts/verify-spa-shell.js.map +1 -0
- package/dist/scripts/wrangler-config/cli.d.ts +22 -0
- package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/cli.js +51 -0
- package/dist/scripts/wrangler-config/cli.js.map +1 -0
- package/dist/scripts/wrangler-config/patches.d.ts +51 -0
- package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/patches.js +140 -0
- package/dist/scripts/wrangler-config/patches.js.map +1 -0
- package/dist/scripts/wrangler-config/resources.d.ts +70 -0
- package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/resources.js +290 -0
- package/dist/scripts/wrangler-config/resources.js.map +1 -0
- package/dist/scripts/wrangler-config/types.d.ts +103 -0
- package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/types.js +49 -0
- package/dist/scripts/wrangler-config/types.js.map +1 -0
- package/dist/test/integration-migrations.d.ts +167 -0
- package/dist/test/integration-migrations.d.ts.map +1 -0
- package/dist/test/integration-migrations.js +171 -0
- package/dist/test/integration-migrations.js.map +1 -0
- package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
- package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
- package/dist/test/mocks/cloudflare-workers.js +90 -0
- package/dist/test/mocks/cloudflare-workers.js.map +1 -0
- package/dist/vite.d.ts +117 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +125 -0
- package/dist/vite.js.map +1 -0
- package/dist/vitest-web.d.ts +73 -0
- package/dist/vitest-web.d.ts.map +1 -0
- package/dist/vitest-web.js +72 -0
- package/dist/vitest-web.js.map +1 -0
- package/dist/vitest.d.ts +92 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +128 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +58 -3
- package/src/eslint.test.ts +175 -0
- package/src/eslint.ts +640 -0
- package/src/functions/pages-proxy.test.ts +72 -0
- package/src/functions/pages-proxy.ts +187 -0
- package/src/github/actions/retry-step/action.yml +39 -0
- package/src/github/actions/setup-env/action.yml +20 -0
- package/src/github/dependabot.yml +30 -0
- package/src/github/workflows/backup-main.yml +46 -0
- package/src/github/workflows/continuous-deployment.yml +188 -0
- package/src/github/workflows/continuous-integration.yml +259 -0
- package/src/github/workflows/scheduled-version-update.yml +38 -0
- package/src/github/workflows/upstream-sync.yml +56 -0
- package/src/index.ts +42 -0
- package/src/scripts/backup/backup-rules.test.ts +105 -0
- package/src/scripts/backup/d1-target.ts +54 -0
- package/src/scripts/backup/destination-config.ts +92 -0
- package/src/scripts/backup/encrypt-backup.ts +107 -0
- package/src/scripts/backup/naming.ts +62 -0
- package/src/scripts/check-god-files.test.ts +131 -0
- package/src/scripts/check-god-files.ts +327 -0
- package/src/scripts/ensure-spa-shell-stub.ts +34 -0
- package/src/scripts/init-secrets.ts +265 -0
- package/src/scripts/lib/cli-args.ts +154 -0
- package/src/scripts/lib/github-actions.ts +41 -0
- package/src/scripts/lib/wrangler-table.ts +105 -0
- package/src/scripts/migrations-lock.ts +52 -0
- package/src/scripts/prepare-wrangler-config.ts +51 -0
- package/src/scripts/spa-shell.test.ts +91 -0
- package/src/scripts/spa-shell.ts +179 -0
- package/src/scripts/validate-locales.test.ts +89 -0
- package/src/scripts/validate-locales.ts +380 -0
- package/src/scripts/verify-migrations.test.ts +71 -0
- package/src/scripts/verify-migrations.ts +364 -0
- package/src/scripts/verify-spa-shell.ts +56 -0
- package/src/scripts/wrangler-config/cli.ts +51 -0
- package/src/scripts/wrangler-config/patches.ts +157 -0
- package/src/scripts/wrangler-config/resources.ts +330 -0
- package/src/scripts/wrangler-config/types.ts +113 -0
- package/src/test/integration-migrations.test.ts +169 -0
- package/src/test/integration-migrations.ts +267 -0
- package/src/test/mocks/cloudflare-workers.ts +115 -0
- package/src/vite.test.ts +83 -0
- package/src/vite.ts +202 -0
- package/src/vitest-web.ts +109 -0
- 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 };
|
package/src/vite.test.ts
ADDED
|
@@ -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
|
+
}
|