@pithy-sh/vite 0.9.0 → 0.11.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.
@@ -46,6 +46,11 @@ export type WorkerVarValue = string | number | boolean | null | WorkerVarValue[]
46
46
  * checkout signed tokens as the first one and its own seam denied every call, 401, with every stored
47
47
  * value agreeing (#462, `pithy-sh/dashboard#95`).
48
48
  *
49
+ * **It also hands on where each service binding answers (#617).** `pithy dev` publishes the Worker's
50
+ * {@link SERVICE_ORIGINS_VAR} into this process's environment, for the reason it publishes the origin there:
51
+ * no argv. Passed on as the var of the same name, it is what core's `withLocalServices` reads to send
52
+ * `env.<BINDING>.fetch()` to the target's pinned port. A Worker that binds nothing is handed nothing.
53
+ *
49
54
  * **Outside `pithy dev` it does nothing at all.** A plain `vite dev`, a build, or CI has no
50
55
  * {@link WORKER_ORIGIN_VAR} in its environment, and the Worker keeps whatever `wrangler.jsonc` says —
51
56
  * which for a deployed environment is the value `applyDomains` generated from `domains`, and is
@@ -1 +1 @@
1
- {"version":3,"file":"devOrigin.d.ts","sourceRoot":"","sources":["../src/devOrigin.ts"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IACzB,mFAAmF;IACnF,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,cAAc,EAAE,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAAA;CAAE,CAAC;AAErH;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,CAAC,MAAM,EAAE,UAAU,KAAK,UAAU,CAuBxG"}
1
+ {"version":3,"file":"devOrigin.d.ts","sourceRoot":"","sources":["../src/devOrigin.ts"],"names":[],"mappings":"AAOA;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IACzB,mFAAmF;IACnF,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,cAAc,EAAE,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAAA;CAAE,CAAC;AAErH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,CAAC,MAAM,EAAE,UAAU,KAAK,UAAU,CA4BxG"}
package/dist/devOrigin.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { LOCAL_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
4
4
  import { ENVIRONMENT_VAR, WORKER_ORIGIN_VAR } from "@pithy-sh/core/src/worker/identity";
5
+ import { SERVICE_ORIGINS_VAR } from "@pithy-sh/core/src/worker/serviceLoopback";
5
6
  //#region src/devOrigin.ts
6
7
  /**
7
8
  * Give the Worker the origin `pithy dev` allocated this checkout, as `BASE_URL`.
@@ -26,6 +27,11 @@ import { ENVIRONMENT_VAR, WORKER_ORIGIN_VAR } from "@pithy-sh/core/src/worker/id
26
27
  * checkout signed tokens as the first one and its own seam denied every call, 401, with every stored
27
28
  * value agreeing (#462, `pithy-sh/dashboard#95`).
28
29
  *
30
+ * **It also hands on where each service binding answers (#617).** `pithy dev` publishes the Worker's
31
+ * {@link SERVICE_ORIGINS_VAR} into this process's environment, for the reason it publishes the origin there:
32
+ * no argv. Passed on as the var of the same name, it is what core's `withLocalServices` reads to send
33
+ * `env.<BINDING>.fetch()` to the target's pinned port. A Worker that binds nothing is handed nothing.
34
+ *
29
35
  * **Outside `pithy dev` it does nothing at all.** A plain `vite dev`, a build, or CI has no
30
36
  * {@link WORKER_ORIGIN_VAR} in its environment, and the Worker keeps whatever `wrangler.jsonc` says —
31
37
  * which for a deployed environment is the value `applyDomains` generated from `domains`, and is
@@ -34,12 +40,17 @@ import { ENVIRONMENT_VAR, WORKER_ORIGIN_VAR } from "@pithy-sh/core/src/worker/id
34
40
  function devWorkerConfig(env = process.env) {
35
41
  return (config) => {
36
42
  const origin = env[WORKER_ORIGIN_VAR];
37
- if (origin === void 0 || origin.trim() === "") return {};
43
+ const services = env[SERVICE_ORIGINS_VAR];
44
+ const given = {
45
+ ...origin === void 0 || origin.trim() === "" ? {} : { BASE_URL: origin },
46
+ ...services === void 0 || services.trim() === "" ? {} : { [SERVICE_ORIGINS_VAR]: services }
47
+ };
48
+ if (Object.keys(given).length === 0) return {};
38
49
  const environment = config.vars?.[ENVIRONMENT_VAR];
39
50
  if (typeof environment === "string" && environment !== LOCAL_ENVIRONMENT) return {};
40
51
  return { vars: {
41
52
  ...config.vars,
42
- BASE_URL: origin
53
+ ...given
43
54
  } };
44
55
  };
45
56
  }
@@ -19,19 +19,23 @@ export interface LoadedWorkerConfig {
19
19
  *
20
20
  * What the copies *cannot* be allowed to disagree about is anything that is not a sentence. Two kinds of
21
21
  * that were found, one issue apart, and both live in `@pithy-sh/core` — the one package this plugin
22
- * already depends on. What a runtime does to an error on its way out is the first: `rootCause`, `prop`
23
- * and `isBuildFailureWrapper`, facts no copy can derive and every copy got wrong (#223). Whether one of
24
- * its strings is safe to show is the second: `safeReason`, `failurePosition`, `unresolvedSpecifier` and
25
- * `causeMessage`, because a path or a stack frame is a property of the string and not of the surface
26
- * quoting it — three near-verbatim copies of that filter meant the hole #223 found in it had to be closed
27
- * three times (#228).
22
+ * already depends on. What a runtime does to an error on its way out is the first: `rootCause` and
23
+ * `prop`, facts no copy can derive and every copy got wrong (#223). Whether one of
24
+ * its strings is safe to show is the second: `safeReason`, `failurePosition` and `causeMessage`, because a
25
+ * path or a stack frame is a property of the string and not of the surface quoting it — three
26
+ * near-verbatim copies of that filter meant the hole #223 found in it had to be closed three times (#228).
27
+ * An import that did not resolve is the third: which kind, whose, and what to do are `unresolvedImport`'s,
28
+ * because three surfaces deciding it kept giving three different remedies (#709). A file inside an
29
+ * installed package that throws as it loads is the fourth, `packageLoadFailure`'s, for the same reason.
28
30
  *
29
- * What stays restated here is the policy: which causes this loader recognizes, and what it says about
30
- * each. Those are genuinely per-surface, and this plugin still cannot import the CLI's.
31
+ * What stays restated here is the rest of the policy: a parse error and the config's own throw. This
32
+ * plugin still cannot import the CLI's.
31
33
  */
32
34
  export type WorkerConfigFailureKind =
33
- /** An import in the config does not resolve. `bun install`, or a corrected specifier. */
35
+ /** An import in the config does not resolve. Which cause it was decides whether installing helps. */
34
36
  "unresolved-import"
37
+ /** A file inside an installed package would not load: its `"type"` contradicts its code, or it does not parse. */
38
+ | "package-load"
35
39
  /** The file does not parse. Installing will not help, and the adopter has read the opposite for years. */
36
40
  | "parse-error"
37
41
  /** The config loaded far enough to throw its own error. Neither of the above. */
@@ -48,15 +52,13 @@ export interface WorkerConfigFailure {
48
52
  /**
49
53
  * Choose the refusal's `action` **from** the failure rather than asserting one over it (#217).
50
54
  *
51
- * An unresolved import names its **importer** alongside its specifier when the runtime gave one (#480,
52
- * #489). A specifier alone is honest and can still send the reader to the wrong package, because the
53
- * import that failed is often not the config's own: `pithy add email` refused with *nothing resolves
54
- * `@pithy-sh/core/src/capability/capability`* while core resolved from the Worker throughout, because
55
- * the failing import was `@pithy-sh/email`'s. The importer is the half that pointed at `email`, and
56
- * `importingPackage` is where core records that both runtimes hand it over. **It asserts nothing about
57
- * which package is absent** — nothing here can know that — only where the import is written.
55
+ * An unresolved import is core's {@link unresolvedImport}: the same sentence the CLI gives for the same
56
+ * cause, naming the package the import is written in when it is not the config (#480, #489), and an
57
+ * install only where an install can help (#709). `relativeTo` is the config's directory, so an adopter's
58
+ * own missing file is shown as the import they wrote and never as an absolute path. A parser's position is
59
+ * quoted only when it is in `configFile`, never when it is in a file the config imports.
58
60
  */
59
- export declare function classifyWorkerConfigFailure(wrapped: unknown): WorkerConfigFailure;
61
+ export declare function classifyWorkerConfigFailure(wrapped: unknown, relativeTo?: string, configFile?: string): WorkerConfigFailure;
60
62
  /**
61
63
  * Load a Worker's `pithy.config.ts` and index the capabilities it composes.
62
64
  *
@@ -1 +1 @@
1
- {"version":3,"file":"workerConfig.d.ts","sourceRoot":"","sources":["../src/workerConfig.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAmB3E,qGAAqG;AACrG,MAAM,WAAW,kBAAkB;IACjC,gGAAgG;IAChG,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACtC,oGAAoG;IACpG,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,MAAM,uBAAuB;AACjC,yFAAyF;AACvF,mBAAmB;AACrB,0GAA0G;GACxG,aAAa;AACf,iFAAiF;GAC/E,eAAe;AACjB,oGAAoG;GAClG,SAAS,CAAC;AAEd,2DAA2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC,iFAAiF;IACjF,IAAI,EAAE,uBAAuB,CAAC;IAC9B,4FAA4F;IAC5F,MAAM,EAAE,MAAM,CAAC;CAChB;AA4CD;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,OAAO,GAAG,mBAAmB,CA8CjF;AAMD;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAkDtF"}
1
+ {"version":3,"file":"workerConfig.d.ts","sourceRoot":"","sources":["../src/workerConfig.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAiB3E,qGAAqG;AACrG,MAAM,WAAW,kBAAkB;IACjC,gGAAgG;IAChG,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACtC,oGAAoG;IACpG,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,MAAM,uBAAuB;AACjC,qGAAqG;AACnG,mBAAmB;AACrB,kHAAkH;GAChH,cAAc;AAChB,0GAA0G;GACxG,aAAa;AACf,iFAAiF;GAC/E,eAAe;AACjB,oGAAoG;GAClG,SAAS,CAAC;AAEd,2DAA2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC,iFAAiF;IACjF,IAAI,EAAE,uBAAuB,CAAC;IAC9B,4FAA4F;IAC5F,MAAM,EAAE,MAAM,CAAC;CAChB;AA0CD;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,OAAO,EAChB,UAAU,CAAC,EAAE,MAAM,EACnB,UAAU,CAAC,EAAE,MAAM,GAClB,mBAAmB,CAuCrB;AAMD;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAoDtF"}
@@ -1,9 +1,13 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { access } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
4
5
  import { runnerImport } from "vite";
5
- import { causeMessage, failurePosition, importingPackage, isBuildFailureWrapper, prop, rootCause, safeReason, unresolvedSpecifier } from "@pithy-sh/core/src/error/cause";
6
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
7
+ import { commandName } from "@pithy-sh/core/src/cli/command";
8
+ import { causeMessage, failurePosition, prop, rootCause, safeReason } from "@pithy-sh/core/src/error/cause";
6
9
  import { InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
10
+ import { nodeModulesProbe, packageEntryProbe, packageLoadFailure, unresolvedImport } from "@pithy-sh/core/src/error/unresolved";
7
11
  //#region src/workerConfig.ts
8
12
  /**
9
13
  * The message, de-colored, for this classifier's own pattern tests — never for output.
@@ -17,47 +21,60 @@ import { InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyErro
17
21
  function rawMessage(cause) {
18
22
  return causeMessage(cause) ?? "";
19
23
  }
20
- function isUnresolvedImport(cause) {
21
- const code = prop(cause, "code");
22
- if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED" || code === "ERR_UNSUPPORTED_DIR_IMPORT") return true;
23
- if (prop(cause, "name") === "ResolveMessage") return true;
24
- return /Cannot find (?:package|module) |Failed to resolve (?:import|module)|Failed to load url /.test(rawMessage(cause));
25
- }
26
24
  function isParseError(cause) {
27
25
  const name = prop(cause, "name");
28
26
  if (name === "SyntaxError" || name === "BuildMessage") return true;
29
27
  if (prop(cause, "code") === "PARSE_ERROR") return true;
30
- if (isBuildFailureWrapper(cause)) return true;
31
28
  return /Transform failed|\[PARSE_ERROR]|Parse (?:error|failure)|Unexpected (?:token|end of input)/.test(rawMessage(cause));
32
29
  }
33
30
  /**
31
+ * Whether a package is installed where the importing file would look — core's walk over `node:fs`, the
32
+ * same one the CLI passes, so a deep import of an absent package is told to install and one of an
33
+ * installed package without that path is not (#709). This plugin runs on Node, never in a Worker.
34
+ */
35
+ const installed = nodeModulesProbe({
36
+ exists: existsSync,
37
+ realpath: realpathSync
38
+ });
39
+ /**
40
+ * What an installed package's bare name resolves to, from its own `package.json`: core's read, the same the
41
+ * CLI passes. Vite's runner says *Failed to resolve entry* for a package that declares no entry and for one
42
+ * whose declared entry is not on disk, and names no importer for either (#709).
43
+ */
44
+ const entry = packageEntryProbe({
45
+ exists: existsSync,
46
+ realpath: realpathSync,
47
+ read: (path) => readFileSync(path, "utf8")
48
+ });
49
+ /**
34
50
  * Choose the refusal's `action` **from** the failure rather than asserting one over it (#217).
35
51
  *
36
- * An unresolved import names its **importer** alongside its specifier when the runtime gave one (#480,
37
- * #489). A specifier alone is honest and can still send the reader to the wrong package, because the
38
- * import that failed is often not the config's own: `pithy add email` refused with *nothing resolves
39
- * `@pithy-sh/core/src/capability/capability`* while core resolved from the Worker throughout, because
40
- * the failing import was `@pithy-sh/email`'s. The importer is the half that pointed at `email`, and
41
- * `importingPackage` is where core records that both runtimes hand it over. **It asserts nothing about
42
- * which package is absent** — nothing here can know that — only where the import is written.
52
+ * An unresolved import is core's {@link unresolvedImport}: the same sentence the CLI gives for the same
53
+ * cause, naming the package the import is written in when it is not the config (#480, #489), and an
54
+ * install only where an install can help (#709). `relativeTo` is the config's directory, so an adopter's
55
+ * own missing file is shown as the import they wrote and never as an absolute path. A parser's position is
56
+ * quoted only when it is in `configFile`, never when it is in a file the config imports.
43
57
  */
44
- function classifyWorkerConfigFailure(wrapped) {
58
+ function classifyWorkerConfigFailure(wrapped, relativeTo, configFile) {
45
59
  const cause = rootCause(wrapped);
46
- if (isUnresolvedImport(cause)) {
47
- const specifier = unresolvedSpecifier(cause);
48
- const importer = importingPackage(cause);
49
- if (specifier === void 0) return {
50
- kind: "unresolved-import",
51
- action: "An import in the config does not resolve. Install the project's dependencies (bun install), then check its imports."
52
- };
53
- return {
54
- kind: "unresolved-import",
55
- action: importer ? `Nothing resolves "${specifier}". ${importer} is what imports it, not the config. Install the project's dependencies (bun install).` : `Nothing resolves "${specifier}". Install the project's dependencies (bun install), or correct that import.`
56
- };
57
- }
60
+ const unresolved = unresolvedImport(cause, {
61
+ installed,
62
+ entry,
63
+ relativeTo,
64
+ loader: "@pithy-sh/vite"
65
+ });
66
+ if (unresolved !== void 0) return {
67
+ kind: "unresolved-import",
68
+ action: unresolved.remedy
69
+ };
70
+ const foreign = packageLoadFailure(cause, { loader: "@pithy-sh/vite" });
71
+ if (foreign !== void 0) return {
72
+ kind: "package-load",
73
+ action: foreign.remedy
74
+ };
58
75
  if (isParseError(cause)) {
59
76
  const reason = safeReason(cause);
60
- const at = failurePosition(cause);
77
+ const at = failurePosition(cause, configFile === void 0 ? void 0 : (file) => resolve(file) === resolve(configFile));
61
78
  const where = at ? ` Line ${at.line}, column ${at.column}.` : "";
62
79
  return {
63
80
  kind: "parse-error",
@@ -94,7 +111,7 @@ async function loadWorkerConfig(configFile) {
94
111
  } catch {
95
112
  throw new NotFoundError({
96
113
  message: `No pithy.config.ts at ${configFile}.`,
97
- action: "Run pithy ui add from the project root, or point the plugin at it with pithy({ configFile })."
114
+ action: `Point the plugin at the Worker's pithy.config.ts with pithy({ configFile }). A front end scaffolded by ${commandName("ui add")} is pointed at it already.`
98
115
  });
99
116
  }
100
117
  let module;
@@ -104,7 +121,7 @@ async function loadWorkerConfig(configFile) {
104
121
  module = imported.module;
105
122
  dependencies = imported.dependencies.filter((file) => typeof file === "string" && file.length > 0);
106
123
  } catch (cause) {
107
- const { kind, action } = classifyWorkerConfigFailure(cause);
124
+ const { kind, action } = classifyWorkerConfigFailure(cause, dirname(configFile), configFile);
108
125
  throw new InternalError({
109
126
  message: `Could not load ${configFile}.`,
110
127
  action,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/vite",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,14 +26,14 @@
26
26
  "scripts": {
27
27
  "generate": "bun scripts/generateClientEnv.ts",
28
28
  "build": "bun run generate && tsdown && tsc -p tsconfig.build.json && bun ../../tooling/build/src/stampDeclarations.ts",
29
- "typecheck": "tsc -p tsconfig.json",
29
+ "typecheck": "bun check -p tsconfig.json",
30
30
  "test": "vitest run",
31
31
  "test:node": "vitest run --project=node",
32
32
  "clean": "rm -rf dist .turbo",
33
33
  "reset": "bun run clean && rm -rf node_modules"
34
34
  },
35
35
  "peerDependencies": {
36
- "@pithy-sh/core": "^0.9.0",
36
+ "@pithy-sh/core": "^0.11.0",
37
37
  "vite": "^6.1.0 || ^7.0.0 || ^8.0.0"
38
38
  },
39
39
  "devDependencies": {
package/src/devOrigin.ts CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { LOCAL_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
5
5
  import { ENVIRONMENT_VAR, WORKER_ORIGIN_VAR } from "@pithy-sh/core/src/worker/identity";
6
+ import { SERVICE_ORIGINS_VAR } from "@pithy-sh/core/src/worker/serviceLoopback";
6
7
 
7
8
  /**
8
9
  * The slice of `@cloudflare/vite-plugin`'s worker config this touches.
@@ -50,6 +51,11 @@ export type WorkerVarValue = string | number | boolean | null | WorkerVarValue[]
50
51
  * checkout signed tokens as the first one and its own seam denied every call, 401, with every stored
51
52
  * value agreeing (#462, `pithy-sh/dashboard#95`).
52
53
  *
54
+ * **It also hands on where each service binding answers (#617).** `pithy dev` publishes the Worker's
55
+ * {@link SERVICE_ORIGINS_VAR} into this process's environment, for the reason it publishes the origin there:
56
+ * no argv. Passed on as the var of the same name, it is what core's `withLocalServices` reads to send
57
+ * `env.<BINDING>.fetch()` to the target's pinned port. A Worker that binds nothing is handed nothing.
58
+ *
53
59
  * **Outside `pithy dev` it does nothing at all.** A plain `vite dev`, a build, or CI has no
54
60
  * {@link WORKER_ORIGIN_VAR} in its environment, and the Worker keeps whatever `wrangler.jsonc` says —
55
61
  * which for a deployed environment is the value `applyDomains` generated from `domains`, and is
@@ -58,10 +64,15 @@ export type WorkerVarValue = string | number | boolean | null | WorkerVarValue[]
58
64
  export function devWorkerConfig(env: NodeJS.ProcessEnv = process.env): (config: WorkerVars) => WorkerVars {
59
65
  return (config) => {
60
66
  const origin = env[WORKER_ORIGIN_VAR];
67
+ const services = env[SERVICE_ORIGINS_VAR];
61
68
  // Absent, blank, or whitespace is "nobody allocated one", never "use the empty string". An empty
62
69
  // `BASE_URL` is worse than a wrong one: it fails a URL parse somewhere far from here rather than
63
70
  // being denied at the seam with the origin named.
64
- if (origin === undefined || origin.trim() === "") return {};
71
+ const given = {
72
+ ...(origin === undefined || origin.trim() === "" ? {} : { BASE_URL: origin }),
73
+ ...(services === undefined || services.trim() === "" ? {} : { [SERVICE_ORIGINS_VAR]: services }),
74
+ };
75
+ if (Object.keys(given).length === 0) return {};
65
76
  // And never over a config that says, itself, that it is not dev.
66
77
  //
67
78
  // The customizer is applied on `build` as well as on `serve`, and it beats the `vars` the file
@@ -76,6 +87,6 @@ export function devWorkerConfig(env: NodeJS.ProcessEnv = process.env): (config:
76
87
  // the fix from the dev run it exists for — the failure this whole issue was.
77
88
  const environment = config.vars?.[ENVIRONMENT_VAR];
78
89
  if (typeof environment === "string" && environment !== LOCAL_ENVIRONMENT) return {};
79
- return { vars: { ...config.vars, BASE_URL: origin } };
90
+ return { vars: { ...config.vars, ...given } };
80
91
  };
81
92
  }
@@ -1,19 +1,19 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
5
  import { access } from "node:fs/promises";
6
+ import { dirname, resolve } from "node:path";
5
7
  import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
- import {
7
- causeMessage,
8
- failurePosition,
9
- importingPackage,
10
- isBuildFailureWrapper,
11
- prop,
12
- rootCause,
13
- safeReason,
14
- unresolvedSpecifier,
15
- } from "@pithy-sh/core/src/error/cause";
8
+ import { commandName } from "@pithy-sh/core/src/cli/command";
9
+ import { causeMessage, failurePosition, prop, rootCause, safeReason } from "@pithy-sh/core/src/error/cause";
16
10
  import { InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
11
+ import {
12
+ nodeModulesProbe,
13
+ packageEntryProbe,
14
+ packageLoadFailure,
15
+ unresolvedImport,
16
+ } from "@pithy-sh/core/src/error/unresolved";
17
17
  import { runnerImport } from "vite";
18
18
 
19
19
  /** The default export of a Worker's `pithy.config.ts`, before it is trusted. */
@@ -40,19 +40,23 @@ export interface LoadedWorkerConfig {
40
40
  *
41
41
  * What the copies *cannot* be allowed to disagree about is anything that is not a sentence. Two kinds of
42
42
  * that were found, one issue apart, and both live in `@pithy-sh/core` — the one package this plugin
43
- * already depends on. What a runtime does to an error on its way out is the first: `rootCause`, `prop`
44
- * and `isBuildFailureWrapper`, facts no copy can derive and every copy got wrong (#223). Whether one of
45
- * its strings is safe to show is the second: `safeReason`, `failurePosition`, `unresolvedSpecifier` and
46
- * `causeMessage`, because a path or a stack frame is a property of the string and not of the surface
47
- * quoting it — three near-verbatim copies of that filter meant the hole #223 found in it had to be closed
48
- * three times (#228).
43
+ * already depends on. What a runtime does to an error on its way out is the first: `rootCause` and
44
+ * `prop`, facts no copy can derive and every copy got wrong (#223). Whether one of
45
+ * its strings is safe to show is the second: `safeReason`, `failurePosition` and `causeMessage`, because a
46
+ * path or a stack frame is a property of the string and not of the surface quoting it — three
47
+ * near-verbatim copies of that filter meant the hole #223 found in it had to be closed three times (#228).
48
+ * An import that did not resolve is the third: which kind, whose, and what to do are `unresolvedImport`'s,
49
+ * because three surfaces deciding it kept giving three different remedies (#709). A file inside an
50
+ * installed package that throws as it loads is the fourth, `packageLoadFailure`'s, for the same reason.
49
51
  *
50
- * What stays restated here is the policy: which causes this loader recognizes, and what it says about
51
- * each. Those are genuinely per-surface, and this plugin still cannot import the CLI's.
52
+ * What stays restated here is the rest of the policy: a parse error and the config's own throw. This
53
+ * plugin still cannot import the CLI's.
52
54
  */
53
55
  export type WorkerConfigFailureKind =
54
- /** An import in the config does not resolve. `bun install`, or a corrected specifier. */
56
+ /** An import in the config does not resolve. Which cause it was decides whether installing helps. */
55
57
  | "unresolved-import"
58
+ /** A file inside an installed package would not load: its `"type"` contradicts its code, or it does not parse. */
59
+ | "package-load"
56
60
  /** The file does not parse. Installing will not help, and the adopter has read the opposite for years. */
57
61
  | "parse-error"
58
62
  /** The config loaded far enough to throw its own error. Neither of the above. */
@@ -81,73 +85,66 @@ function rawMessage(cause: unknown): string {
81
85
  return causeMessage(cause) ?? "";
82
86
  }
83
87
 
84
- function isUnresolvedImport(cause: unknown): boolean {
85
- const code = prop(cause, "code");
86
- if (
87
- code === "ERR_MODULE_NOT_FOUND" ||
88
- code === "MODULE_NOT_FOUND" ||
89
- code === "ERR_PACKAGE_PATH_NOT_EXPORTED" ||
90
- code === "ERR_UNSUPPORTED_DIR_IMPORT"
91
- ) {
92
- return true;
93
- }
94
- if (prop(cause, "name") === "ResolveMessage") return true;
95
- return /Cannot find (?:package|module) |Failed to resolve (?:import|module)|Failed to load url /.test(
96
- rawMessage(cause),
97
- );
98
- }
99
-
100
88
  function isParseError(cause: unknown): boolean {
101
89
  const name = prop(cause, "name");
102
90
  if (name === "SyntaxError" || name === "BuildMessage") return true;
103
91
  if (prop(cause, "code") === "PARSE_ERROR") return true;
104
- // Bun's build wrapper with its diagnostics already dropped — the shape every caller after the first
105
- // sees, since a failed module is cached and re-thrown emptied out. It proves a build produced
106
- // diagnostics, so it is a parse error with no reason to quote. See core's `cause.ts` (#223).
107
- if (isBuildFailureWrapper(cause)) return true;
108
92
  return /Transform failed|\[PARSE_ERROR]|Parse (?:error|failure)|Unexpected (?:token|end of input)/.test(
109
93
  rawMessage(cause),
110
94
  );
111
95
  }
112
96
 
97
+ /**
98
+ * Whether a package is installed where the importing file would look — core's walk over `node:fs`, the
99
+ * same one the CLI passes, so a deep import of an absent package is told to install and one of an
100
+ * installed package without that path is not (#709). This plugin runs on Node, never in a Worker.
101
+ */
102
+ const installed = nodeModulesProbe({ exists: existsSync, realpath: realpathSync });
103
+
104
+ /**
105
+ * What an installed package's bare name resolves to, from its own `package.json`: core's read, the same the
106
+ * CLI passes. Vite's runner says *Failed to resolve entry* for a package that declares no entry and for one
107
+ * whose declared entry is not on disk, and names no importer for either (#709).
108
+ */
109
+ const entry = packageEntryProbe({
110
+ exists: existsSync,
111
+ realpath: realpathSync,
112
+ read: (path) => readFileSync(path, "utf8"),
113
+ });
114
+
113
115
  /**
114
116
  * Choose the refusal's `action` **from** the failure rather than asserting one over it (#217).
115
117
  *
116
- * An unresolved import names its **importer** alongside its specifier when the runtime gave one (#480,
117
- * #489). A specifier alone is honest and can still send the reader to the wrong package, because the
118
- * import that failed is often not the config's own: `pithy add email` refused with *nothing resolves
119
- * `@pithy-sh/core/src/capability/capability`* while core resolved from the Worker throughout, because
120
- * the failing import was `@pithy-sh/email`'s. The importer is the half that pointed at `email`, and
121
- * `importingPackage` is where core records that both runtimes hand it over. **It asserts nothing about
122
- * which package is absent** — nothing here can know that — only where the import is written.
118
+ * An unresolved import is core's {@link unresolvedImport}: the same sentence the CLI gives for the same
119
+ * cause, naming the package the import is written in when it is not the config (#480, #489), and an
120
+ * install only where an install can help (#709). `relativeTo` is the config's directory, so an adopter's
121
+ * own missing file is shown as the import they wrote and never as an absolute path. A parser's position is
122
+ * quoted only when it is in `configFile`, never when it is in a file the config imports.
123
123
  */
124
- export function classifyWorkerConfigFailure(wrapped: unknown): WorkerConfigFailure {
124
+ export function classifyWorkerConfigFailure(
125
+ wrapped: unknown,
126
+ relativeTo?: string,
127
+ configFile?: string,
128
+ ): WorkerConfigFailure {
125
129
  // Bun hands `import()` failures over inside an `AggregateError`. Classify what is inside it.
126
130
  const cause = rootCause(wrapped);
127
- if (isUnresolvedImport(cause)) {
128
- const specifier = unresolvedSpecifier(cause);
129
- // A package name or nothing — core never hands a path back, so there is none to keep out of here.
130
- const importer = importingPackage(cause);
131
- if (specifier === undefined) {
132
- return {
133
- kind: "unresolved-import",
134
- action:
135
- "An import in the config does not resolve. Install the project's dependencies (bun install), then check its imports.",
136
- };
137
- }
138
- return {
139
- kind: "unresolved-import",
140
- action: importer
141
- ? `Nothing resolves "${specifier}". ${importer} is what imports it, not the config. Install the project's dependencies (bun install).`
142
- : `Nothing resolves "${specifier}". Install the project's dependencies (bun install), or correct that import.`,
143
- };
144
- }
131
+ // Which import, whose, and what to do: core's, for all three surfaces that refuse a module (#709).
132
+ const unresolved = unresolvedImport(cause, { installed, entry, relativeTo, loader: "@pithy-sh/vite" });
133
+ if (unresolved !== undefined) return { kind: "unresolved-import", action: unresolved.remedy };
134
+
135
+ // A package's own file that would not load is the package's, before it can read as the config's (#709).
136
+ const foreign = packageLoadFailure(cause, { loader: "@pithy-sh/vite" });
137
+ if (foreign !== undefined) return { kind: "package-load", action: foreign.remedy };
145
138
 
146
139
  if (isParseError(cause)) {
147
- // Both of these can answer "nothing", and Bun's build wrapper — a count and a path, and never a
148
- // reason or a position — is one of the reasons they do. That suppression is core's, once (#223, #228).
140
+ // Both of these can answer "nothing": a diagnostic that carries a path, a stack frame or a newline is
141
+ // not quoted, and a position is never invented. That filter is core's, once (#228).
149
142
  const reason = safeReason(cause);
150
- const at = failurePosition(cause);
143
+ // A position in a file the config imports is not the config's, and is never quoted as if it were (#709).
144
+ const at = failurePosition(
145
+ cause,
146
+ configFile === undefined ? undefined : (file) => resolve(file) === resolve(configFile),
147
+ );
151
148
  const where = at ? ` Line ${at.line}, column ${at.column}.` : "";
152
149
  return {
153
150
  kind: "parse-error",
@@ -188,7 +185,9 @@ export async function loadWorkerConfig(configFile: string): Promise<LoadedWorker
188
185
  } catch {
189
186
  throw new NotFoundError({
190
187
  message: `No pithy.config.ts at ${configFile}.`,
191
- action: "Run pithy ui add from the project root, or point the plugin at it with pithy({ configFile }).",
188
+ // No command here (#711): `pithy ui add` acts on one Worker and takes a framework, and a plugin that has
189
+ // lost its config cannot say which of either. Pointing the plugin is the remedy that always applies.
190
+ action: `Point the plugin at the Worker's pithy.config.ts with pithy({ configFile }). A front end scaffolded by ${commandName("ui add")} is pointed at it already.`,
192
191
  });
193
192
  }
194
193
 
@@ -202,7 +201,7 @@ export async function loadWorkerConfig(configFile: string): Promise<LoadedWorker
202
201
  // The config is there and would not load. Which of the four ways it failed decides what to tell the
203
202
  // adopter — see {@link classifyWorkerConfigFailure}. The raw cause still goes to `detail` and stops
204
203
  // there: the CLI renderer prints `message` and `action` only, and the HTTP codec strips `detail`.
205
- const { kind, action } = classifyWorkerConfigFailure(cause);
204
+ const { kind, action } = classifyWorkerConfigFailure(cause, dirname(configFile), configFile);
206
205
  throw new InternalError({
207
206
  message: `Could not load ${configFile}.`,
208
207
  action,