@pithy-sh/vite 0.1.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/package.json +51 -0
- package/src/clientEnvDeclaration.ts +314 -0
- package/src/devOrigin.ts +81 -0
- package/src/plugin.ts +214 -0
- package/src/testPlugin.ts +68 -0
- package/src/virtualModule.ts +118 -0
- package/src/workerConfig.ts +215 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/vite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/vite"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"!src/**/*.test.*"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
"./src/*": "./src/*.ts"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"generate": "bun scripts/generateClientEnv.ts",
|
|
23
|
+
"build": "bun run generate && tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:node": "vitest run --project=node",
|
|
27
|
+
"clean": "rm -rf dist .turbo",
|
|
28
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"vite": "^6.1.0 || ^7.0.0 || ^8.0.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@pithy-sh/core": "workspace:*"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@pithy-sh/auth": "workspace:*",
|
|
38
|
+
"@pithy-sh/i18n": "workspace:*",
|
|
39
|
+
"@pithy-sh/payments": "workspace:*",
|
|
40
|
+
"@pithy-sh/support": "workspace:*",
|
|
41
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
42
|
+
"@pithy-sh/turnstile": "workspace:*",
|
|
43
|
+
"@pithy-sh/ui-react": "workspace:*",
|
|
44
|
+
"@types/node": "^22.15.0",
|
|
45
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
46
|
+
"typescript": "^7.0.2",
|
|
47
|
+
"vite": "^8.0.16",
|
|
48
|
+
"vitest": "^4.1.0",
|
|
49
|
+
"zod": "^4.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **`client-env.d.ts` is generated from the four declared client projections (#398).**
|
|
9
|
+
*
|
|
10
|
+
* `@pithy-sh/ui-react`'s `templates/client-env.d.ts` is the ambient declaration for the four
|
|
11
|
+
* `virtual:pithy/*` modules, copied into an adopter's Worker by `pithy ui add react`. It used to be
|
|
12
|
+
* hand-written, three packages away from the `client:` projections that produce the values it
|
|
13
|
+
* describes, and #392 held the two together with a spawned compiler because there was no faithful
|
|
14
|
+
* source to generate from: `Capability.client` is typed `(context) => ClientProjection`, so each
|
|
15
|
+
* capability's real shape existed only as an inferred literal inside a closure.
|
|
16
|
+
*
|
|
17
|
+
* #395 removed that. The four capabilities now declare their projection types, and the declaration is
|
|
18
|
+
* the source of truth rather than a restatement of the literal. So there is exactly one statement of
|
|
19
|
+
* each shape, and this module copies it — **a gate that watches two things agree is strictly worse
|
|
20
|
+
* than one thing.**
|
|
21
|
+
*
|
|
22
|
+
* ## Why the declared type is copied as text rather than re-printed from a type
|
|
23
|
+
*
|
|
24
|
+
* Because the doc comments are half of what the file is worth. A declaration emitted from a resolved
|
|
25
|
+
* type carries the shape and loses every sentence attached to it, and those sentences are what tell a
|
|
26
|
+
* screen that `action` must be rendered and never retyped, or that a Paddle client token is
|
|
27
|
+
* publishable by design. Copying the type node's own source text keeps the unions, the `| null` arms
|
|
28
|
+
* and the prose exactly as the capability wrote them.
|
|
29
|
+
*
|
|
30
|
+
* The one thing that is *not* copied is what is not a shape at all — see {@link PREAMBLE} and
|
|
31
|
+
* {@link ENABLED_EXPORT}, both of which are policy and are written as fixed text.
|
|
32
|
+
*
|
|
33
|
+
* ## Nothing an adopter runs
|
|
34
|
+
*
|
|
35
|
+
* The kit generates at its own build time and commits the artifact. `pithy ui add react` still copies
|
|
36
|
+
* a plain static `.d.ts` into a Worker, and a scaffolded project typechecks with no new step.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** One `virtual:pithy/*` module, and the declared type that is its whole shape. */
|
|
40
|
+
export interface DeclaredModule {
|
|
41
|
+
/** The capability name — the segment after `virtual:pithy/`. */
|
|
42
|
+
readonly module: string;
|
|
43
|
+
/** The specifier the projection is read from. Resolved through the package's own exports map. */
|
|
44
|
+
readonly specifier: string;
|
|
45
|
+
/** The exported type alias that declares what a browser receives. */
|
|
46
|
+
readonly type: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The modules the declaration covers, in the order they are emitted.
|
|
51
|
+
*
|
|
52
|
+
* This list is the whole reach of the generated file: a further capability projecting to a browser is
|
|
53
|
+
* a further entry here, and until it is one, nothing declares it. The specifiers resolve because each
|
|
54
|
+
* capability is a devDependency of `@pithy-sh/vite` — a build-time dependency, like the compiler. An
|
|
55
|
+
* adopter installs none of them to use the plugin, and never runs this module.
|
|
56
|
+
*/
|
|
57
|
+
export const DECLARED_MODULES: readonly DeclaredModule[] = [
|
|
58
|
+
{ module: "auth", specifier: "@pithy-sh/auth/src/client/projection", type: "AuthClientProjection" },
|
|
59
|
+
{ module: "i18n", specifier: "@pithy-sh/i18n/src/client/projection", type: "I18nClientProjection" },
|
|
60
|
+
{ module: "payments", specifier: "@pithy-sh/payments/src/client/projection", type: "PaymentsClientProjection" },
|
|
61
|
+
{ module: "support", specifier: "@pithy-sh/support/src/client/projection", type: "SupportClientProjection" },
|
|
62
|
+
{ module: "turnstile", specifier: "@pithy-sh/turnstile/src/client/projection", type: "TurnstileClientProjection" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* **Fixed text, item one: the preamble.**
|
|
67
|
+
*
|
|
68
|
+
* The `vite/client` reference and the paragraph explaining why the default export is a union are
|
|
69
|
+
* policy about how the modules are consumed, not a projection of anything, so there is nothing to
|
|
70
|
+
* derive them from. They are written here.
|
|
71
|
+
*/
|
|
72
|
+
const PREAMBLE = `/// <reference types="vite/client" />
|
|
73
|
+
|
|
74
|
+
// The client-safe projection of this worker's composed capabilities, served by @pithy-sh/vite.
|
|
75
|
+
//
|
|
76
|
+
// Each module is a DEFAULT export whose type is a union discriminated on \`enabled\`. That shape is
|
|
77
|
+
// deliberate. A capability that is not composed projects \`{ enabled: false }\` and nothing else, so a
|
|
78
|
+
// NAMED import of any other key would be a missing export and the build would fail — on exactly the
|
|
79
|
+
// case this mechanism exists to make survivable. Importing the default and narrowing cannot fail:
|
|
80
|
+
//
|
|
81
|
+
// import turnstile from "virtual:pithy/turnstile";
|
|
82
|
+
// if (!turnstile.enabled) return null; // narrowed: sitekey, mode and token exist below this line
|
|
83
|
+
//
|
|
84
|
+
// The \`virtual:pithy/*\` modules are never written to disk. These declarations describe modules the
|
|
85
|
+
// Vite plugin serves, built from the Worker's own pithy.config.ts.
|
|
86
|
+
//
|
|
87
|
+
// **Generated, and copied here as it was emitted (#398).** Each declaration below is a capability's
|
|
88
|
+
// declared client projection — \`src/client/projection.ts\` in @pithy-sh/auth, @pithy-sh/payments,
|
|
89
|
+
// @pithy-sh/support and @pithy-sh/turnstile — written out by @pithy-sh/vite's
|
|
90
|
+
// \`src/clientEnvDeclaration.ts\` at kit build time. In the kit that is one statement of each shape and
|
|
91
|
+
// nothing to keep in step: a field a projection stops emitting is a compile error where it is
|
|
92
|
+
// projected, and this file moves in the same commit.
|
|
93
|
+
//
|
|
94
|
+
// In your repository it is yours, like every other seeded file. Editing it changes what your compiler
|
|
95
|
+
// believes and nothing about what the plugin serves.
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* **Fixed text, item two: the one named export.**
|
|
100
|
+
*
|
|
101
|
+
* `enabled` is declared alone on purpose, and the refusal is the feature. A capability nobody composed
|
|
102
|
+
* serves `{ enabled: false }` and nothing else, so `import { sitekey } from "virtual:pithy/turnstile"`
|
|
103
|
+
* has to fail the build — on exactly the case the union exists to make survivable. Deriving the named
|
|
104
|
+
* exports from the projection's keys would declare every one of them and take that refusal away.
|
|
105
|
+
*/
|
|
106
|
+
const ENABLED_EXPORT = ` /**
|
|
107
|
+
* Whether this capability is composed and serving on this worker. Also the union's discriminant.
|
|
108
|
+
*
|
|
109
|
+
* The only named export, deliberately. Every other key is reached through the default export and a
|
|
110
|
+
* narrowing — see the note at the top of this file.
|
|
111
|
+
*/
|
|
112
|
+
export const enabled: boolean;
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Which characters of a TypeScript source are code — everything outside a comment or a string.
|
|
117
|
+
*
|
|
118
|
+
* Both passes below need it, and for the same reason: a brace in `` `POST {basePath}/feedback` `` and
|
|
119
|
+
* a type name in an `{@link …}` tag are text, and counting or rewriting them would be a silent wrong
|
|
120
|
+
* answer rather than an error. Template literals with `${}` substitutions are not handled, because a
|
|
121
|
+
* type position cannot hold one — `parseTypeAliases` refuses anything it cannot finish instead.
|
|
122
|
+
*/
|
|
123
|
+
function codeMask(source: string): boolean[] {
|
|
124
|
+
const mask: boolean[] = new Array<boolean>(source.length).fill(true);
|
|
125
|
+
let index = 0;
|
|
126
|
+
while (index < source.length) {
|
|
127
|
+
if (source.startsWith("//", index)) {
|
|
128
|
+
while (index < source.length && source[index] !== "\n") mask[index++] = false;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (source.startsWith("/*", index)) {
|
|
132
|
+
const close = source.indexOf("*/", index + 2);
|
|
133
|
+
const stop = close === -1 ? source.length : close + 2;
|
|
134
|
+
while (index < stop) mask[index++] = false;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const quote = source[index];
|
|
138
|
+
if (quote === '"' || quote === "'" || quote === "`") {
|
|
139
|
+
mask[index++] = false;
|
|
140
|
+
while (index < source.length) {
|
|
141
|
+
const char = source[index];
|
|
142
|
+
mask[index++] = false;
|
|
143
|
+
if (char === "\\") {
|
|
144
|
+
if (index < source.length) mask[index++] = false;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (char === quote) break;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
index += 1;
|
|
152
|
+
}
|
|
153
|
+
return mask;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* An identifier, wherever one may start, and the head of a top-level exported type alias.
|
|
158
|
+
*
|
|
159
|
+
* Both are built per call rather than shared. A `g` flag carries `lastIndex` between uses, and
|
|
160
|
+
* {@link inlineAliases} recurses — one shared instance would have an inner call move the outer's
|
|
161
|
+
* cursor, which reads as a reference the generator simply skipped.
|
|
162
|
+
*/
|
|
163
|
+
const identifierPattern = (): RegExp => /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
164
|
+
const typeAliasPattern = (): RegExp => /^export type ([A-Za-z_$][A-Za-z0-9_$]*)\s*=/gm;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The source text of every top-level `export type X = …;` in a module, keyed by name.
|
|
168
|
+
*
|
|
169
|
+
* The value is the type node's own text, verbatim: every doc comment, every union arm, every `| null`
|
|
170
|
+
* exactly as written. It is found by scanning for the `;` that closes the alias at bracket depth zero,
|
|
171
|
+
* over code characters only.
|
|
172
|
+
*/
|
|
173
|
+
export function parseTypeAliases(source: string): Map<string, string> {
|
|
174
|
+
const mask = codeMask(source);
|
|
175
|
+
const aliases = new Map<string, string>();
|
|
176
|
+
const pattern = typeAliasPattern();
|
|
177
|
+
for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
|
|
178
|
+
const [head, name] = match;
|
|
179
|
+
const start = match.index;
|
|
180
|
+
if (!mask[start] || !name) continue;
|
|
181
|
+
const from = start + head.length;
|
|
182
|
+
let depth = 0;
|
|
183
|
+
let end = -1;
|
|
184
|
+
for (let index = from; index < source.length; index += 1) {
|
|
185
|
+
if (!mask[index]) continue;
|
|
186
|
+
const char = source[index];
|
|
187
|
+
if (char === "{" || char === "(" || char === "[") depth += 1;
|
|
188
|
+
else if (char === "}" || char === ")" || char === "]") depth -= 1;
|
|
189
|
+
else if (char === ";" && depth === 0) {
|
|
190
|
+
end = index;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (end === -1) throw new Error(`\`export type ${name}\` is never closed by a \`;\` at depth zero.`);
|
|
195
|
+
aliases.set(name, source.slice(from, end));
|
|
196
|
+
}
|
|
197
|
+
return aliases;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The leading spaces of the line `index` falls on. What an inlined alias has to be pushed out by. */
|
|
201
|
+
function indentAt(text: string, index: number): string {
|
|
202
|
+
const lineStart = text.lastIndexOf("\n", index - 1) + 1;
|
|
203
|
+
return /^[ ]*/.exec(text.slice(lineStart, index))?.[0] ?? "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Replace every reference to a sibling type alias with that alias's own text, re-indented to sit where
|
|
208
|
+
* the reference did.
|
|
209
|
+
*
|
|
210
|
+
* `PaymentsClientProduct` is the only one today, and it is named rather than inlined at the source for
|
|
211
|
+
* a reason #395 recorded — the annotation goes on the `.map` callback, where a fresh literal is still
|
|
212
|
+
* checked in both directions. The declaration an adopter reads has no such seam to hang a name on, so
|
|
213
|
+
* the shape is written where it is used.
|
|
214
|
+
*/
|
|
215
|
+
export function inlineAliases(
|
|
216
|
+
text: string,
|
|
217
|
+
aliases: ReadonlyMap<string, string>,
|
|
218
|
+
seen: readonly string[] = [],
|
|
219
|
+
): string {
|
|
220
|
+
const mask = codeMask(text);
|
|
221
|
+
const parts: string[] = [];
|
|
222
|
+
let cursor = 0;
|
|
223
|
+
const pattern = identifierPattern();
|
|
224
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
225
|
+
const name = match[0];
|
|
226
|
+
const body = aliases.get(name);
|
|
227
|
+
if (body === undefined || !mask[match.index]) continue;
|
|
228
|
+
if (seen.includes(name)) throw new Error(`\`${name}\` refers to itself through ${seen.join(" → ")}.`);
|
|
229
|
+
const indent = indentAt(text, match.index);
|
|
230
|
+
const inlined = inlineAliases(body, aliases, [...seen, name]);
|
|
231
|
+
parts.push(text.slice(cursor, match.index), reindent(inlined.trim(), indent));
|
|
232
|
+
cursor = match.index + name.length;
|
|
233
|
+
}
|
|
234
|
+
parts.push(text.slice(cursor));
|
|
235
|
+
return parts.join("");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Push every line but the first out by `indent`. The first is already sitting where it was written. */
|
|
239
|
+
function reindent(text: string, indent: string): string {
|
|
240
|
+
return text
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((line, position) => (position === 0 || line === "" ? line : `${indent}${line}`))
|
|
243
|
+
.join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Move a block from the column it was written at to the one it is emitted at.
|
|
248
|
+
*
|
|
249
|
+
* The alias text arrives at whatever depth its source file put it — a union's arms at two spaces, its
|
|
250
|
+
* members at six. Inside a `declare module` block those want four and eight. So the block is dedented
|
|
251
|
+
* by its own first line's indent and then pushed out uniformly, which keeps every relative depth,
|
|
252
|
+
* including the ` *` continuation of a doc comment.
|
|
253
|
+
*/
|
|
254
|
+
function shift(text: string, indent: string): string {
|
|
255
|
+
const lines = text.split("\n");
|
|
256
|
+
while (lines.length > 0 && (lines[0] ?? "").trim() === "") lines.shift();
|
|
257
|
+
while (lines.length > 0 && (lines.at(-1) ?? "").trim() === "") lines.pop();
|
|
258
|
+
const base = /^[ ]*/.exec(lines[0] ?? "")?.[0] ?? "";
|
|
259
|
+
return lines
|
|
260
|
+
.map((line) => (line.startsWith(base) ? `${indent}${line.slice(base.length)}` : `${indent}${line.trimStart()}`))
|
|
261
|
+
.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** One `declare module "virtual:pithy/<module>"` block, fixed text and declared shape together. */
|
|
265
|
+
export function renderModule(module: string, declaredType: string): string {
|
|
266
|
+
return [
|
|
267
|
+
`declare module "virtual:pithy/${module}" {`,
|
|
268
|
+
ENABLED_EXPORT,
|
|
269
|
+
" const config:",
|
|
270
|
+
`${shift(declaredType, " ")};`,
|
|
271
|
+
" export default config;",
|
|
272
|
+
"}",
|
|
273
|
+
"",
|
|
274
|
+
].join("\n");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The whole file, from each module's projection source.
|
|
279
|
+
*
|
|
280
|
+
* Pure, and the module list is an argument, so the test can hand it a projection this repository does
|
|
281
|
+
* not contain and read what came out. A generator asserted only against the real four would pass just
|
|
282
|
+
* as well if it ignored them and printed a constant.
|
|
283
|
+
*/
|
|
284
|
+
export function renderClientEnv(
|
|
285
|
+
sources: ReadonlyMap<string, string>,
|
|
286
|
+
modules: readonly DeclaredModule[] = DECLARED_MODULES,
|
|
287
|
+
): string {
|
|
288
|
+
const blocks: string[] = [];
|
|
289
|
+
for (const declared of modules) {
|
|
290
|
+
const source = sources.get(declared.module);
|
|
291
|
+
if (source === undefined) throw new Error(`No projection source for virtual:pithy/${declared.module}.`);
|
|
292
|
+
const aliases = parseTypeAliases(source);
|
|
293
|
+
const body = aliases.get(declared.type);
|
|
294
|
+
if (body === undefined) throw new Error(`${declared.specifier} exports no type \`${declared.type}\`.`);
|
|
295
|
+
aliases.delete(declared.type);
|
|
296
|
+
blocks.push(renderModule(declared.module, inlineAliases(body, aliases)));
|
|
297
|
+
}
|
|
298
|
+
return `${PREAMBLE}\n${blocks.join("\n")}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Read the four projection sources through their own packages' exports maps. */
|
|
302
|
+
export async function readProjectionSources(): Promise<Map<string, string>> {
|
|
303
|
+
const sources = new Map<string, string>();
|
|
304
|
+
for (const declared of DECLARED_MODULES) {
|
|
305
|
+
const path = fileURLToPath(import.meta.resolve(declared.specifier));
|
|
306
|
+
sources.set(declared.module, await readFile(path, "utf8"));
|
|
307
|
+
}
|
|
308
|
+
return sources;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The declaration as it should be on disk. What `bun run generate` writes and the gate compares. */
|
|
312
|
+
export async function generateClientEnv(): Promise<string> {
|
|
313
|
+
return renderClientEnv(await readProjectionSources());
|
|
314
|
+
}
|
package/src/devOrigin.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { LOCAL_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
|
|
5
|
+
import { ENVIRONMENT_VAR, WORKER_ORIGIN_VAR } from "@pithy-sh/core/src/worker/identity";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The slice of `@cloudflare/vite-plugin`'s worker config this touches.
|
|
9
|
+
*
|
|
10
|
+
* Structural rather than imported. `@pithy-sh/vite` does not depend on that plugin — an adopter picks
|
|
11
|
+
* its own version — and naming one field is enough to be checked against it at the call site, where the
|
|
12
|
+
* real type applies.
|
|
13
|
+
*/
|
|
14
|
+
export interface WorkerVars {
|
|
15
|
+
/** The Worker's plain environment variables, as `wrangler.jsonc` declared them. */
|
|
16
|
+
vars?: Record<string, WorkerVarValue>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a wrangler `vars` entry may hold — wrangler's own `Json`, restated.
|
|
21
|
+
*
|
|
22
|
+
* Restated rather than imported for the reason {@link WorkerVars} is structural, and it has to be this
|
|
23
|
+
* exact shape rather than `unknown`: the customizer's return type is checked against
|
|
24
|
+
* `Partial<WorkerConfig>` at the call site, and `Record<string, unknown>` is not assignable to
|
|
25
|
+
* `Record<string, Json>` — an index signature is invariant. A looser type here compiles in this package
|
|
26
|
+
* and fails in every adopter's, which is the worst place to find out.
|
|
27
|
+
*/
|
|
28
|
+
export type WorkerVarValue = string | number | boolean | null | WorkerVarValue[] | { [key: string]: WorkerVarValue };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Give the Worker the origin `pithy dev` allocated this checkout, as `BASE_URL`.
|
|
32
|
+
*
|
|
33
|
+
* Spread into `@cloudflare/vite-plugin`'s `config` customizer:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* cloudflare({ config: devWorkerConfig() })
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* **Why an adopter writes a line at all.** A Worker launched through `wrangler dev` is handed its own
|
|
40
|
+
* origin on the argv, by `pithy dev`, with nothing to configure. A Worker launched through a custom
|
|
41
|
+
* `dev.command` is a Vite dev server, and there is no argv to append a `--var` to: `@cloudflare/vite-plugin`
|
|
42
|
+
* takes the Worker's `vars` from `wrangler.jsonc`, and the only documented way in is this customizer,
|
|
43
|
+
* which lives in the adopter's `vite.config.ts`. `pithy init` scaffolds it; an existing project adds
|
|
44
|
+
* the line once and `pithy doctor` says so until it does.
|
|
45
|
+
*
|
|
46
|
+
* **Why it has to be given rather than written down.** A dev port is *allocated* — every checkout
|
|
47
|
+
* reserves its own block — so a `vars.BASE_URL` in `wrangler.jsonc` is right in the first checkout on a
|
|
48
|
+
* machine and wrong in every other one. That is not cosmetic: `BASE_URL` is the `iss` on every
|
|
49
|
+
* control-plane token a Worker signs and the origin its callback links are built against, so a second
|
|
50
|
+
* checkout signed tokens as the first one and its own seam denied every call, 401, with every stored
|
|
51
|
+
* value agreeing (#462, `pithy-sh/dashboard#95`).
|
|
52
|
+
*
|
|
53
|
+
* **Outside `pithy dev` it does nothing at all.** A plain `vite dev`, a build, or CI has no
|
|
54
|
+
* {@link WORKER_ORIGIN_VAR} in its environment, and the Worker keeps whatever `wrangler.jsonc` says —
|
|
55
|
+
* which for a deployed environment is the value `applyDomains` generated from `domains`, and is
|
|
56
|
+
* correct. This never invents an origin; it only passes on one that was allocated.
|
|
57
|
+
*/
|
|
58
|
+
export function devWorkerConfig(env: NodeJS.ProcessEnv = process.env): (config: WorkerVars) => WorkerVars {
|
|
59
|
+
return (config) => {
|
|
60
|
+
const origin = env[WORKER_ORIGIN_VAR];
|
|
61
|
+
// Absent, blank, or whitespace is "nobody allocated one", never "use the empty string". An empty
|
|
62
|
+
// `BASE_URL` is worse than a wrong one: it fails a URL parse somewhere far from here rather than
|
|
63
|
+
// being denied at the seam with the origin named.
|
|
64
|
+
if (origin === undefined || origin.trim() === "") return {};
|
|
65
|
+
// And never over a config that says, itself, that it is not dev.
|
|
66
|
+
//
|
|
67
|
+
// The customizer is applied on `build` as well as on `serve`, and it beats the `vars` the file
|
|
68
|
+
// declared — so the whole of what keeps a localhost origin out of a deploy is that only
|
|
69
|
+
// `pithy dev` ever sets `WORKER_ORIGIN_VAR`, on the children it spawns. That is true, and it is
|
|
70
|
+
// one exported variable away from not being true, and the failure would be silent and severe: a
|
|
71
|
+
// deployed Worker signing every control-plane token with a `localhost` issuer, and mailing magic
|
|
72
|
+
// links to an address nobody can reach.
|
|
73
|
+
//
|
|
74
|
+
// **Only ever removes the override, and only on a positive answer.** A stanza that does not stamp
|
|
75
|
+
// `ENVIRONMENT` cannot say which environment it is, and refusing there would silently withhold
|
|
76
|
+
// the fix from the dev run it exists for — the failure this whole issue was.
|
|
77
|
+
const environment = config.vars?.[ENVIRONMENT_VAR];
|
|
78
|
+
if (typeof environment === "string" && environment !== LOCAL_ENVIRONMENT) return {};
|
|
79
|
+
return { vars: { ...config.vars, BASE_URL: origin } };
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { resolveClientProjection } from "@pithy-sh/core/src/capability/client";
|
|
6
|
+
import { type EnvironmentModuleNode, normalizePath, type Plugin, type ViteDevServer } from "vite";
|
|
7
|
+
import {
|
|
8
|
+
capabilityNameFromResolvedId,
|
|
9
|
+
isResolvedVirtualId,
|
|
10
|
+
renderVirtualModule,
|
|
11
|
+
resolveVirtualId,
|
|
12
|
+
} from "./virtualModule";
|
|
13
|
+
import { type LoadedWorkerConfig, loadWorkerConfig } from "./workerConfig";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What {@link pithy} hands back: a Vite plugin, described without naming a Vite type.
|
|
17
|
+
*
|
|
18
|
+
* **The return type used to be Vite's own `Plugin`, and that made the peer range a lie — Jim,
|
|
19
|
+
* 2026-08-21 (#414).** `package.json` declares `vite: ^6.1.0 || ^7.0.0 || ^8.0.0`, but a `Plugin` in
|
|
20
|
+
* the signature is a `Plugin` out of *this package's* `node_modules`. An adopter who resolved any
|
|
21
|
+
* other copy — which is every adopter developing against a symlinked kit, and every adopter whose
|
|
22
|
+
* install did not deduplicate — had to prove the kit's copy assignable to theirs, field by field,
|
|
23
|
+
* before `plugins: [pithy()]` would compile. Across majors that comparison fails outright: `hotUpdate`
|
|
24
|
+
* does not carry the same `this` in 6 as in 8, so this plugin was not a Vite 6 plugin however identical
|
|
25
|
+
* the hook body is. Within a major it does not even finish — `Plugin` is deep and recursive enough to
|
|
26
|
+
* exhaust tsc's depth budget, which is the `TS2321: Excessive stack depth` that stopped
|
|
27
|
+
* `pithy-sh/dashboard` typechecking the `vite.config.ts` `pithy init` had just written for it. A peer
|
|
28
|
+
* dependency that only compiles when the install deduplicates is not a peer dependency.
|
|
29
|
+
*
|
|
30
|
+
* So the signature promises the two things every Vite in that range agrees on and an adopter's
|
|
31
|
+
* checker can settle in two comparisons: the plugin has a name, and it runs early. No recursion, no
|
|
32
|
+
* hook signatures, nothing that can differ between two copies of the same library.
|
|
33
|
+
*
|
|
34
|
+
* **What is still checked.** The object below is written `satisfies Plugin` against the Vite this
|
|
35
|
+
* package develops against, so every hook keeps its parameter types, its `this`, and its return type.
|
|
36
|
+
* A hook that reads the wrong field off `config`, returns the wrong shape, or is spelled with a name
|
|
37
|
+
* Vite does not call is still a red build — here, where the kit is compiled, and in
|
|
38
|
+
* `tooling/vite-adopter`, which compiles this file too.
|
|
39
|
+
*
|
|
40
|
+
* **What is not.** The adopter's checker no longer re-derives any of that against their copy. It sees
|
|
41
|
+
* two properties and takes the hooks on trust. Four things pay for that trust and none of them is a
|
|
42
|
+
* type an adopter has to compare: `tooling/vite-adopter` compiles the return against every major in
|
|
43
|
+
* the peer range and runs a real `vite build` through the plugin at each of them, {@link
|
|
44
|
+
* PITHY_PLUGIN_HOOKS} holds every hook name to something all three call, `plugin.test.ts` drives each
|
|
45
|
+
* hook by hand, and it runs a real `vite build` and reads the bundle.
|
|
46
|
+
*
|
|
47
|
+
* **One gap, named rather than papered over — Jim, 2026-08-21.** The hook *signatures* are checked
|
|
48
|
+
* against one Vite, and there is no arrangement in which they could be checked against three. Vite 8
|
|
49
|
+
* is rolldown-based and Vite 6 and 7 are rollup-based, so `hotUpdate`'s `this` is
|
|
50
|
+
* `MinimalPluginContext & { environment: DevEnvironment }` out of two different bundlers — `meta`
|
|
51
|
+
* carries `rolldownVersion` in one and not the other. A single object cannot be written `satisfies
|
|
52
|
+
* Plugin` against both; restoring `pithy(): Plugin` reports exactly that at 6.1.6 and 7.0.0, and it is
|
|
53
|
+
* a fact about the two Vites rather than about this plugin. What is checked across the range is the
|
|
54
|
+
* hook *set* and the hook *behavior*: a name Vite 6 never calls is a red build, and a build that does
|
|
55
|
+
* not inline the projection at 6.1.6 is a red test. What is taken on trust across the range is that a
|
|
56
|
+
* hook Vite 6 calls with an argument of its own shape reaches the same field.
|
|
57
|
+
*
|
|
58
|
+
* **Why the floor is `^6.1.0` is not recorded here, because the reason first written down was wrong —
|
|
59
|
+
* Jim, 2026-08-21.** It said `hotUpdate` arrived in 6.1 and that below it this plugin would be silently
|
|
60
|
+
* inert. It did not: `npm pack vite@6.0.0` carries `hotUpdate?: ObjectHook<…>` in `index.d.ts`, and its
|
|
61
|
+
* `HotUpdatePluginContext` is byte-identical to 6.1.6's. The floor may still be right for a reason
|
|
62
|
+
* nobody has written down, and `6.0.0` is not pinned in `tooling/vite-adopter`, so nothing here has ever
|
|
63
|
+
* compiled against it.
|
|
64
|
+
*
|
|
65
|
+
* Two honest next steps, neither taken in this change: pin `6.0.0` in the fixture and find out, or drop
|
|
66
|
+
* the floor to `^6.0.0` and let the fixture say whether that holds. **An invented reason is worse than
|
|
67
|
+
* an absent one**, because the next person reads it instead of measuring.
|
|
68
|
+
*/
|
|
69
|
+
export interface PithyPlugin {
|
|
70
|
+
/** `"pithy"`. The plugin's identity in Vite's plugin list, and in any error raised from a hook. */
|
|
71
|
+
name: string;
|
|
72
|
+
/**
|
|
73
|
+
* `"pre"`, always. `virtual:pithy/*` is claimed before any other plugin can, so the order of an
|
|
74
|
+
* adopter's `plugins` array never decides whether the front end can read its own backend.
|
|
75
|
+
*/
|
|
76
|
+
enforce: "pre";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every hook {@link pithy} defines, by name.
|
|
81
|
+
*
|
|
82
|
+
* **The one thing about the hooks an adopter's checker can be given for free.** {@link PithyPlugin}
|
|
83
|
+
* deliberately names no Vite type, so nothing in the return says which hooks exist — and a hook Vite
|
|
84
|
+
* never calls is dead code that looks like a feature. A name is not a Vite type: `tooling/vite-adopter`
|
|
85
|
+
* asserts each of these is a `keyof Plugin` in 6, 7 and 8, which costs an adopter nothing and catches
|
|
86
|
+
* the regression that matters here. Adding a `buildApp` hook, which Vite 7 introduced and Vite 6 has no
|
|
87
|
+
* name for, reddens the fixture rather than shipping under a range that claims 6.
|
|
88
|
+
*
|
|
89
|
+
* `plugin.test.ts` holds this list to the object itself, both directions, at runtime — a hook added
|
|
90
|
+
* without listing it is a failure there, and a name listed that no longer exists is one too. A list
|
|
91
|
+
* that can drift from what it lists is a worse gate than none.
|
|
92
|
+
*/
|
|
93
|
+
export const PITHY_PLUGIN_HOOKS = ["configResolved", "configureServer", "resolveId", "load", "hotUpdate"] as const;
|
|
94
|
+
|
|
95
|
+
/** One of the names {@link PITHY_PLUGIN_HOOKS} lists. */
|
|
96
|
+
export type PithyPluginHook = (typeof PITHY_PLUGIN_HOOKS)[number];
|
|
97
|
+
|
|
98
|
+
/** Options for {@link pithy}. Every one has a working default; `pithy()` is the usual call. */
|
|
99
|
+
export interface PithyPluginOptions {
|
|
100
|
+
/**
|
|
101
|
+
* The Worker's `pithy.config.ts`. Relative paths resolve against the Vite root, which for a Pithy
|
|
102
|
+
* Worker is `apps/<name>/` — so the default, `pithy.config.ts`, is already the right file.
|
|
103
|
+
*/
|
|
104
|
+
configFile?: string;
|
|
105
|
+
/**
|
|
106
|
+
* The environment this bundle is built for. Defaults to `ENVIRONMENT` in the process env, then
|
|
107
|
+
* `dev`. `pithy deploy --env <name>` is what threads a real value in, so a production bundle sees
|
|
108
|
+
* production's projection and a dev bundle sees dev's.
|
|
109
|
+
*/
|
|
110
|
+
environment?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Serve one `virtual:pithy/<capability>` module per capability, projected from the Worker's own
|
|
115
|
+
* `pithy.config.ts`.
|
|
116
|
+
*
|
|
117
|
+
* A screen imports what it needs — `import auth from "virtual:pithy/auth"` — and gets that
|
|
118
|
+
* capability's client-safe projection inlined at build time. A capability the Worker does not compose
|
|
119
|
+
* resolves to `{ enabled: false }` rather than failing the import, including a name nobody has ever
|
|
120
|
+
* heard of: a screen branches on `enabled`, it does not guard on whether the module exists.
|
|
121
|
+
*/
|
|
122
|
+
export function pithy(options: PithyPluginOptions = {}): PithyPlugin {
|
|
123
|
+
const environment = options.environment ?? process.env.ENVIRONMENT ?? "dev";
|
|
124
|
+
let configFile = "";
|
|
125
|
+
// Memoize the promise, not the value: `load` runs concurrently for every virtual module a bundle
|
|
126
|
+
// imports, and each `runnerImport` builds a fresh environment. One promise means one config load.
|
|
127
|
+
let loading: Promise<LoadedWorkerConfig> | null = null;
|
|
128
|
+
let server: ViteDevServer | undefined;
|
|
129
|
+
const watched = new Set<string>();
|
|
130
|
+
|
|
131
|
+
function watch(files: Iterable<string>): void {
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
const path = normalizePath(file);
|
|
134
|
+
if (watched.has(path)) continue;
|
|
135
|
+
watched.add(path);
|
|
136
|
+
server?.watcher.add(path);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function loadOnce(): Promise<LoadedWorkerConfig> {
|
|
141
|
+
loading ??= loadWorkerConfig(configFile)
|
|
142
|
+
.then((loaded) => {
|
|
143
|
+
// The config's transitive imports are watched too, so editing a file the config imports
|
|
144
|
+
// reloads the front end just like editing the config itself.
|
|
145
|
+
watch(loaded.dependencies);
|
|
146
|
+
return loaded;
|
|
147
|
+
})
|
|
148
|
+
.catch((cause: unknown) => {
|
|
149
|
+
// Never memoize a failure. A first load can fail for reasons the developer is about to fix —
|
|
150
|
+
// most often `pithy dev` run before `bun install`, so the config's `@pithy-sh/*` imports do
|
|
151
|
+
// not resolve. Caching the rejection would wedge the dev server on that error until restart,
|
|
152
|
+
// and `hotUpdate` could not rescue it: the watch set only learns the config's transitive
|
|
153
|
+
// imports from a load that SUCCEEDED, so a file the developer then edits may not be watched.
|
|
154
|
+
loading = null;
|
|
155
|
+
throw cause;
|
|
156
|
+
});
|
|
157
|
+
return loading;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// `satisfies` rather than an annotation, and the difference is the whole fix. It contextually types
|
|
161
|
+
// every hook below against Vite's `Plugin` — same checking as the old `: Plugin` return type gave —
|
|
162
|
+
// while leaving the literal's own type intact, so the narrow `PithyPlugin` the signature promises is
|
|
163
|
+
// still derived from what is actually returned rather than asserted over it. A `const` because a
|
|
164
|
+
// fresh object literal returned straight out would be excess-property-checked against `PithyPlugin`,
|
|
165
|
+
// which names none of the hooks.
|
|
166
|
+
const plugin = {
|
|
167
|
+
name: "pithy",
|
|
168
|
+
// Claim `virtual:pithy/*` before any other plugin can, so the plugin array's order in an
|
|
169
|
+
// adopter's vite.config.ts never decides whether the front end can read its own backend.
|
|
170
|
+
enforce: "pre",
|
|
171
|
+
|
|
172
|
+
configResolved(config) {
|
|
173
|
+
// `resolve` leaves an absolute option alone and anchors a relative one to the Vite root.
|
|
174
|
+
configFile = normalizePath(resolve(config.root, options.configFile ?? "pithy.config.ts"));
|
|
175
|
+
watch([configFile]);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
configureServer(devServer) {
|
|
179
|
+
server = devServer;
|
|
180
|
+
// Watch before the first load: a config that fails to import is still watched, so fixing it
|
|
181
|
+
// recovers the dev server instead of requiring a restart.
|
|
182
|
+
for (const path of watched) devServer.watcher.add(path);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
resolveId(id) {
|
|
186
|
+
return resolveVirtualId(id);
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async load(id) {
|
|
190
|
+
const name = capabilityNameFromResolvedId(id);
|
|
191
|
+
if (name === null) return null;
|
|
192
|
+
const { capabilities } = await loadOnce();
|
|
193
|
+
return renderVirtualModule(resolveClientProjection(capabilities.get(name), { environment }));
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
hotUpdate({ file }) {
|
|
197
|
+
if (!watched.has(normalizePath(file))) return;
|
|
198
|
+
// Drop the memo so the next `load` re-reads the config, then invalidate what it produced.
|
|
199
|
+
loading = null;
|
|
200
|
+
const invalidated: EnvironmentModuleNode[] = [];
|
|
201
|
+
for (const [id, module] of this.environment.moduleGraph.idToModuleMap) {
|
|
202
|
+
if (!isResolvedVirtualId(id)) continue;
|
|
203
|
+
this.environment.moduleGraph.invalidateModule(module);
|
|
204
|
+
invalidated.push(module);
|
|
205
|
+
}
|
|
206
|
+
// Config is read at module scope by screens all over the app; a full reload is the honest
|
|
207
|
+
// update, and returning the invalidated modules keeps Vite from also guessing at one.
|
|
208
|
+
this.environment.hot.send({ type: "full-reload" });
|
|
209
|
+
return invalidated;
|
|
210
|
+
},
|
|
211
|
+
} satisfies Plugin;
|
|
212
|
+
|
|
213
|
+
return plugin;
|
|
214
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { runnerImport } from "vite";
|
|
6
|
+
import type { PithyPlugin, PithyPluginOptions } from "./plugin";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `./plugin.ts`, as an absolute path.
|
|
10
|
+
*
|
|
11
|
+
* From `import.meta.url` rather than from a bare specifier: this file's own realpath is the one place
|
|
12
|
+
* the build plugin is certainly beside, whatever an adopter's `node_modules` looks like — a symlinked
|
|
13
|
+
* checkout, a hoisted install, a worktree.
|
|
14
|
+
*/
|
|
15
|
+
const BUILD_PLUGIN = fileURLToPath(new URL("./plugin.ts", import.meta.url));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The Pithy plugin, for a test runner's config.
|
|
19
|
+
*
|
|
20
|
+
* **It is not a second plugin.** It resolves to the object {@link pithy} returns, built from
|
|
21
|
+
* `./plugin.ts` itself, so a test imports the module a build inlines — same config, same
|
|
22
|
+
* `resolveClientProjection`, same `renderVirtualModule`, same bytes. There is no fixture here, and
|
|
23
|
+
* therefore nothing that can drift from the projection. `testPlugin.test.ts` holds that: it renders a
|
|
24
|
+
* module through the build plugin's own `load` hook and makes a real child `vitest` run compare what it
|
|
25
|
+
* imports against it.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the indirection exists at all
|
|
28
|
+
*
|
|
29
|
+
* Vitest loads its config by bundling it and leaving every **bare** specifier to node. So
|
|
30
|
+
* `import { pithy } from "@pithy-sh/vite/src/plugin"` in a `vitest.config.ts` is handed to node, which
|
|
31
|
+
* loads `plugin.ts` — and stops at its `@pithy-sh/core/src/capability/client` import, whose own
|
|
32
|
+
* `../error/pithyError` has no extension for node to resolve. The config never loads and no test runs.
|
|
33
|
+
* `vitest.shared.ts` in this repository documents the same wall from the other side, for the same reason.
|
|
34
|
+
*
|
|
35
|
+
* This module's whole static graph is `vite` and `node:url` — both things node loads — and the kit
|
|
36
|
+
* source is reached through {@link runnerImport}, which is vite's own loader and resolves extensionless
|
|
37
|
+
* TypeScript the way every other Pithy import site does. That constraint is load-bearing: **any
|
|
38
|
+
* `@pithy-sh/*` import added to this file breaks every adopter's test run at config load.** The child
|
|
39
|
+
* run in `testPlugin.test.ts` is what says so out loud.
|
|
40
|
+
*
|
|
41
|
+
* ## What an adopter writes
|
|
42
|
+
*
|
|
43
|
+
* One line, in the test project that mounts client modules — never per consumer:
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* import { pithyTest } from "@pithy-sh/vite/src/testPlugin";
|
|
47
|
+
*
|
|
48
|
+
* export default defineConfig({
|
|
49
|
+
* plugins: [pithyTest({ configFile: "apps/board/pithy.config.ts" })],
|
|
50
|
+
* test: { environment: "happy-dom" },
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* `configFile` resolves against the Vite root, which for a test run is usually the repository root
|
|
55
|
+
* rather than the Worker's directory — so a monorepo names the Worker, and a single-Worker project takes
|
|
56
|
+
* the default.
|
|
57
|
+
*
|
|
58
|
+
* A promise, because vite awaits a plugin. That is a documented `PluginOption`, and it is what lets the
|
|
59
|
+
* real plugin be built by vite's loader instead of restated for node's.
|
|
60
|
+
*
|
|
61
|
+
* `Promise<PithyPlugin>` rather than `Promise<Plugin>` for the reason {@link PithyPlugin} gives (#414):
|
|
62
|
+
* a Vite type in this signature is *this package's* Vite type, and an adopter's `vitest.config.ts`
|
|
63
|
+
* cannot be typechecked against it unless their install happened to deduplicate onto the same copy.
|
|
64
|
+
* The test config is the same surface as the build config and takes the same rule.
|
|
65
|
+
*/
|
|
66
|
+
export function pithyTest(options: PithyPluginOptions = {}): Promise<PithyPlugin> {
|
|
67
|
+
return runnerImport<typeof import("./plugin")>(BUILD_PLUGIN).then(({ module }) => module.pithy(options));
|
|
68
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ClientProjection } from "@pithy-sh/core/src/capability/client";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The import specifier a screen writes: `import auth from "virtual:pithy/auth"`. One module per
|
|
8
|
+
* capability, resolved from the Worker's own `pithy.config.ts` — so a front end reads the backend it
|
|
9
|
+
* is served by, and never a hand-copied duplicate of its config.
|
|
10
|
+
*/
|
|
11
|
+
export const VIRTUAL_PREFIX = "virtual:pithy/";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The resolved id. The leading NUL is Rollup's convention for "this module has no file" — it stops
|
|
15
|
+
* other plugins and the filesystem resolver from touching it.
|
|
16
|
+
*/
|
|
17
|
+
export const RESOLVED_PREFIX = "\0virtual:pithy/";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A capability name as it may appear after the prefix: one segment, the same lowercase-hyphenated
|
|
21
|
+
* shape as `pithy add <capability>`. Anything else (a path, an empty name, a traversal) is not ours,
|
|
22
|
+
* so `resolveVirtualId` declines it and Vite reports its usual unresolved-import error.
|
|
23
|
+
*/
|
|
24
|
+
const CAPABILITY_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
25
|
+
|
|
26
|
+
/** A key that can legally be re-exported as `export const <key>`. */
|
|
27
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Keys that are valid object keys but not valid binding names. A projection carrying one still
|
|
31
|
+
* reaches the default export; only its named export is skipped, so nothing is silently lost.
|
|
32
|
+
*/
|
|
33
|
+
const RESERVED = new Set([
|
|
34
|
+
"await",
|
|
35
|
+
"break",
|
|
36
|
+
"case",
|
|
37
|
+
"catch",
|
|
38
|
+
"class",
|
|
39
|
+
"const",
|
|
40
|
+
"continue",
|
|
41
|
+
"debugger",
|
|
42
|
+
"default",
|
|
43
|
+
"delete",
|
|
44
|
+
"do",
|
|
45
|
+
"else",
|
|
46
|
+
"enum",
|
|
47
|
+
"export",
|
|
48
|
+
"extends",
|
|
49
|
+
"false",
|
|
50
|
+
"finally",
|
|
51
|
+
"for",
|
|
52
|
+
"function",
|
|
53
|
+
"if",
|
|
54
|
+
"implements",
|
|
55
|
+
"import",
|
|
56
|
+
"in",
|
|
57
|
+
"instanceof",
|
|
58
|
+
"interface",
|
|
59
|
+
"let",
|
|
60
|
+
"new",
|
|
61
|
+
"null",
|
|
62
|
+
"package",
|
|
63
|
+
"private",
|
|
64
|
+
"protected",
|
|
65
|
+
"public",
|
|
66
|
+
"return",
|
|
67
|
+
"static",
|
|
68
|
+
"super",
|
|
69
|
+
"switch",
|
|
70
|
+
"this",
|
|
71
|
+
"throw",
|
|
72
|
+
"true",
|
|
73
|
+
"try",
|
|
74
|
+
"typeof",
|
|
75
|
+
"var",
|
|
76
|
+
"void",
|
|
77
|
+
"while",
|
|
78
|
+
"with",
|
|
79
|
+
"yield",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** Map an import specifier to its resolved id, or `null` when the specifier is not a Pithy virtual module. */
|
|
83
|
+
export function resolveVirtualId(id: string): string | null {
|
|
84
|
+
if (!id.startsWith(VIRTUAL_PREFIX)) return null;
|
|
85
|
+
const name = id.slice(VIRTUAL_PREFIX.length);
|
|
86
|
+
if (!CAPABILITY_NAME.test(name)) return null;
|
|
87
|
+
return `${RESOLVED_PREFIX}${name}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The capability name carried by a resolved id, or `null` when the id is not one of ours. */
|
|
91
|
+
export function capabilityNameFromResolvedId(id: string): string | null {
|
|
92
|
+
if (!id.startsWith(RESOLVED_PREFIX)) return null;
|
|
93
|
+
const name = id.slice(RESOLVED_PREFIX.length);
|
|
94
|
+
return CAPABILITY_NAME.test(name) ? name : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether a module id is a resolved Pithy virtual module — used to sweep the dev module graph. */
|
|
98
|
+
export function isResolvedVirtualId(id: string): boolean {
|
|
99
|
+
return capabilityNameFromResolvedId(id) !== null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render a projection as module source. Both shapes are emitted on purpose: the default export for
|
|
104
|
+
* `import auth from "virtual:pithy/auth"`, and one named export per key so a bundler can tree-shake
|
|
105
|
+
* `import { enabled } from "virtual:pithy/auth"` down to a single inlined literal.
|
|
106
|
+
*
|
|
107
|
+
* Every value is emitted through `JSON.stringify`, which is the second half of the security boundary
|
|
108
|
+
* — the projection was already validated as JSON, and nothing but JSON can be written here.
|
|
109
|
+
*/
|
|
110
|
+
export function renderVirtualModule(projection: ClientProjection): string {
|
|
111
|
+
const lines = [`export default ${JSON.stringify(projection)};`];
|
|
112
|
+
for (const [key, value] of Object.entries(projection)) {
|
|
113
|
+
if (value === undefined) continue;
|
|
114
|
+
if (!IDENTIFIER.test(key) || RESERVED.has(key)) continue;
|
|
115
|
+
lines.push(`export const ${key} = ${JSON.stringify(value)};`);
|
|
116
|
+
}
|
|
117
|
+
return `${lines.join("\n")}\n`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { access } from "node:fs/promises";
|
|
5
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import {
|
|
7
|
+
causeMessage,
|
|
8
|
+
failurePosition,
|
|
9
|
+
isBuildFailureWrapper,
|
|
10
|
+
prop,
|
|
11
|
+
rootCause,
|
|
12
|
+
safeReason,
|
|
13
|
+
unresolvedSpecifier,
|
|
14
|
+
} from "@pithy-sh/core/src/error/cause";
|
|
15
|
+
import { InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
16
|
+
import { runnerImport } from "vite";
|
|
17
|
+
|
|
18
|
+
/** The default export of a Worker's `pithy.config.ts`, before it is trusted. */
|
|
19
|
+
interface WorkerConfigModule {
|
|
20
|
+
default?: { capabilities?: unknown; app?: unknown };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One Worker's composed capabilities, indexed by name, plus the files the config was built from. */
|
|
24
|
+
export interface LoadedWorkerConfig {
|
|
25
|
+
/** Capability name → the capability, library capabilities first and the app capability last. */
|
|
26
|
+
capabilities: Map<string, Capability>;
|
|
27
|
+
/** Every file the config transitively imported — what the dev server watches so an edit reloads. */
|
|
28
|
+
dependencies: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Why a `pithy.config.ts` would not load — and therefore what may be said about it (#217).
|
|
33
|
+
*
|
|
34
|
+
* This loader built the same refusal the CLI's did, with the same defect. #207 fixed the CLI's
|
|
35
|
+
* (`packages/cli/src/project/config.ts`, `classifyConfigLoadFailure`); this file could not import that
|
|
36
|
+
* fix, because `@pithy-sh/vite` depends on `@pithy-sh/core` and must not depend on the CLI. So it is
|
|
37
|
+
* restated here, with the same rule behind it: **a `catch` reachable by more than one underlying failure
|
|
38
|
+
* may not name a single specific remedy.** Classify, or hedge. The convention is in `docs/CONVENTIONS.md`.
|
|
39
|
+
*
|
|
40
|
+
* What the copies *cannot* be allowed to disagree about is anything that is not a sentence. Two kinds of
|
|
41
|
+
* that were found, one issue apart, and both live in `@pithy-sh/core` — the one package this plugin
|
|
42
|
+
* already depends on. What a runtime does to an error on its way out is the first: `rootCause`, `prop`
|
|
43
|
+
* and `isBuildFailureWrapper`, facts no copy can derive and every copy got wrong (#223). Whether one of
|
|
44
|
+
* its strings is safe to show is the second: `safeReason`, `failurePosition`, `unresolvedSpecifier` and
|
|
45
|
+
* `causeMessage`, because a path or a stack frame is a property of the string and not of the surface
|
|
46
|
+
* quoting it — three near-verbatim copies of that filter meant the hole #223 found in it had to be closed
|
|
47
|
+
* three times (#228).
|
|
48
|
+
*
|
|
49
|
+
* What stays restated here is the policy: which causes this loader recognizes, and what it says about
|
|
50
|
+
* each. Those are genuinely per-surface, and this plugin still cannot import the CLI's.
|
|
51
|
+
*/
|
|
52
|
+
export type WorkerConfigFailureKind =
|
|
53
|
+
/** An import in the config does not resolve. `bun install`, or a corrected specifier. */
|
|
54
|
+
| "unresolved-import"
|
|
55
|
+
/** The file does not parse. Installing will not help, and the adopter has read the opposite for years. */
|
|
56
|
+
| "parse-error"
|
|
57
|
+
/** The config loaded far enough to throw its own error. Neither of the above. */
|
|
58
|
+
| "threw-on-load"
|
|
59
|
+
/** None of the above. No remedy: a wrong action is worse than no action, because it is followed. */
|
|
60
|
+
| "unknown";
|
|
61
|
+
|
|
62
|
+
/** What may be said about a config that would not load. */
|
|
63
|
+
export interface WorkerConfigFailure {
|
|
64
|
+
/** Which failure this was, decided from the cause and never asserted over it. */
|
|
65
|
+
kind: WorkerConfigFailureKind;
|
|
66
|
+
/** The remedy, chosen from the kind. Carries no absolute path, no source line, no stack. */
|
|
67
|
+
action: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The message, de-colored, for this classifier's own pattern tests — never for output.
|
|
72
|
+
*
|
|
73
|
+
* What may be *said* is core's {@link safeReason}, and only core's. This plugin cannot import the CLI's
|
|
74
|
+
* refusals and never could, which is why the sentences below are restated here — but the *filter* those
|
|
75
|
+
* sentences pass a string through is not a sentence. Whether a string carries an absolute path or half a
|
|
76
|
+
* parser's ANSI box is a property of the string, so it is decided in `@pithy-sh/core`, the one package
|
|
77
|
+
* this plugin depends on, and the suppression #223 had to write three times is written once (#228).
|
|
78
|
+
*/
|
|
79
|
+
function rawMessage(cause: unknown): string {
|
|
80
|
+
return causeMessage(cause) ?? "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isUnresolvedImport(cause: unknown): boolean {
|
|
84
|
+
const code = prop(cause, "code");
|
|
85
|
+
if (
|
|
86
|
+
code === "ERR_MODULE_NOT_FOUND" ||
|
|
87
|
+
code === "MODULE_NOT_FOUND" ||
|
|
88
|
+
code === "ERR_PACKAGE_PATH_NOT_EXPORTED" ||
|
|
89
|
+
code === "ERR_UNSUPPORTED_DIR_IMPORT"
|
|
90
|
+
) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (prop(cause, "name") === "ResolveMessage") return true;
|
|
94
|
+
return /Cannot find (?:package|module) |Failed to resolve (?:import|module)|Failed to load url /.test(
|
|
95
|
+
rawMessage(cause),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isParseError(cause: unknown): boolean {
|
|
100
|
+
const name = prop(cause, "name");
|
|
101
|
+
if (name === "SyntaxError" || name === "BuildMessage") return true;
|
|
102
|
+
if (prop(cause, "code") === "PARSE_ERROR") return true;
|
|
103
|
+
// Bun's build wrapper with its diagnostics already dropped — the shape every caller after the first
|
|
104
|
+
// sees, since a failed module is cached and re-thrown emptied out. It proves a build produced
|
|
105
|
+
// diagnostics, so it is a parse error with no reason to quote. See core's `cause.ts` (#223).
|
|
106
|
+
if (isBuildFailureWrapper(cause)) return true;
|
|
107
|
+
return /Transform failed|\[PARSE_ERROR]|Parse (?:error|failure)|Unexpected (?:token|end of input)/.test(
|
|
108
|
+
rawMessage(cause),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Choose the refusal's `action` **from** the failure rather than asserting one over it (#217). */
|
|
113
|
+
export function classifyWorkerConfigFailure(wrapped: unknown): WorkerConfigFailure {
|
|
114
|
+
// Bun hands `import()` failures over inside an `AggregateError`. Classify what is inside it.
|
|
115
|
+
const cause = rootCause(wrapped);
|
|
116
|
+
if (isUnresolvedImport(cause)) {
|
|
117
|
+
const specifier = unresolvedSpecifier(cause);
|
|
118
|
+
return {
|
|
119
|
+
kind: "unresolved-import",
|
|
120
|
+
action: specifier
|
|
121
|
+
? `Nothing resolves "${specifier}". Install the project's dependencies (bun install), or correct that import.`
|
|
122
|
+
: "An import in the config does not resolve. Install the project's dependencies (bun install), then check its imports.",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isParseError(cause)) {
|
|
127
|
+
// Both of these can answer "nothing", and Bun's build wrapper — a count and a path, and never a
|
|
128
|
+
// reason or a position — is one of the reasons they do. That suppression is core's, once (#223, #228).
|
|
129
|
+
const reason = safeReason(cause);
|
|
130
|
+
const at = failurePosition(cause);
|
|
131
|
+
const where = at ? ` Line ${at.line}, column ${at.column}.` : "";
|
|
132
|
+
return {
|
|
133
|
+
kind: "parse-error",
|
|
134
|
+
action: `The config does not parse${reason ? `: ${reason}` : ""}.${where} Fix the file — installing dependencies will not help.`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Anything that carries a message, whether or not it extends `Error` — the config's own throw.
|
|
139
|
+
if (causeMessage(cause) !== undefined) {
|
|
140
|
+
const reason = safeReason(cause);
|
|
141
|
+
return {
|
|
142
|
+
kind: "threw-on-load",
|
|
143
|
+
action: reason
|
|
144
|
+
? `The config threw while loading: ${reason}. Fix that in the config.`
|
|
145
|
+
: "The config threw while loading. Run the file directly to see what it throws.",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { kind: "unknown", action: "Check pithy.config.ts. Run the file directly to see how it fails." };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isCapability(value: unknown): value is Capability {
|
|
153
|
+
return typeof value === "object" && value !== null && typeof (value as Capability).name === "string";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Load a Worker's `pithy.config.ts` and index the capabilities it composes.
|
|
158
|
+
*
|
|
159
|
+
* `runnerImport` is Vite's own loader: it builds a throwaway runnable environment with
|
|
160
|
+
* `configFile: false`, so the config's TypeScript, its extensionless relative imports, and its
|
|
161
|
+
* `@pithy-sh/*` source imports all resolve — none of which a bare dynamic `import()` handles — and it
|
|
162
|
+
* behaves identically under `vite dev` and `vite build`. It also reports the transitive files it
|
|
163
|
+
* touched, which is what makes HMR on a config edit possible.
|
|
164
|
+
*/
|
|
165
|
+
export async function loadWorkerConfig(configFile: string): Promise<LoadedWorkerConfig> {
|
|
166
|
+
try {
|
|
167
|
+
await access(configFile);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new NotFoundError({
|
|
170
|
+
message: `No pithy.config.ts at ${configFile}.`,
|
|
171
|
+
action: "Run pithy ui add from the project root, or point the plugin at it with pithy({ configFile }).",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let module: WorkerConfigModule;
|
|
176
|
+
let dependencies: readonly string[];
|
|
177
|
+
try {
|
|
178
|
+
const imported = await runnerImport<WorkerConfigModule>(configFile);
|
|
179
|
+
module = imported.module;
|
|
180
|
+
dependencies = imported.dependencies.filter((file) => typeof file === "string" && file.length > 0);
|
|
181
|
+
} catch (cause) {
|
|
182
|
+
// The config is there and would not load. Which of the four ways it failed decides what to tell the
|
|
183
|
+
// adopter — see {@link classifyWorkerConfigFailure}. The raw cause still goes to `detail` and stops
|
|
184
|
+
// there: the CLI renderer prints `message` and `action` only, and the HTTP codec strips `detail`.
|
|
185
|
+
const { kind, action } = classifyWorkerConfigFailure(cause);
|
|
186
|
+
throw new InternalError({
|
|
187
|
+
message: `Could not load ${configFile}.`,
|
|
188
|
+
action,
|
|
189
|
+
detail: `${kind}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const config = module.default;
|
|
194
|
+
if (!config || !Array.isArray(config.capabilities)) {
|
|
195
|
+
throw new InternalError({
|
|
196
|
+
message: `${configFile} doesn't default-export a worker config.`,
|
|
197
|
+
action: "Export default { capabilities, app }.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const capabilities = new Map<string, Capability>();
|
|
202
|
+
for (const entry of config.capabilities) {
|
|
203
|
+
if (!isCapability(entry)) {
|
|
204
|
+
throw new InternalError({
|
|
205
|
+
message: `${configFile} lists something that isn't a capability.`,
|
|
206
|
+
action: "Every entry in capabilities is a capability object with a name. Remove the odd one out.",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
capabilities.set(entry.name, entry);
|
|
210
|
+
}
|
|
211
|
+
// The app capability composes last, so a Worker's own projection wins over a library's.
|
|
212
|
+
if (isCapability(config.app)) capabilities.set(config.app.name, config.app);
|
|
213
|
+
|
|
214
|
+
return { capabilities, dependencies };
|
|
215
|
+
}
|