@ttsc/unplugin 0.20.0 → 0.21.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/src/bun.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import fs from "node:fs/promises";
2
- import type { UnpluginContextMeta } from "unplugin";
3
2
 
4
- import { unplugin } from "./api";
5
- import { sourceFilePattern } from "./core/index";
3
+ import {
4
+ beginTtscTransformBuild,
5
+ createTtscTransformCache,
6
+ isTransformTarget,
7
+ resolveOptions,
8
+ transformTtsc,
9
+ } from "./core/index";
6
10
  import type { TtscUnpluginOptions } from "./core/options";
7
11
 
12
+ /**
13
+ * Bun normally reports filesystem source paths, while plugin-created virtual
14
+ * ids may contain a NUL sentinel. A virtual id must stay with the plugin that
15
+ * created it: claiming it here and trying to read it from disk prevents that
16
+ * plugin's later loader from running.
17
+ */
18
+ const bunSourceFilePattern = /^[^\x00]*\.[cm]?tsx?$/;
19
+
8
20
  /**
9
21
  * Minimal subset of the Bun plugin API consumed by this adapter.
10
22
  *
@@ -38,7 +50,7 @@ export type TtscBunOptions =
38
50
  | (() => TtscUnpluginOptions | undefined);
39
51
 
40
52
  /**
41
- * Transform context handed to the raw unplugin transform under Bun.
53
+ * Transform hooks handed to the shared transform under Bun.
42
54
  *
43
55
  * The shared transform calls `addWatchFile` once per plugin-reported dependency
44
56
  * so type-only inputs can enter a bundler's watch graph. Bun's bundler and
@@ -49,7 +61,7 @@ export type TtscBunOptions =
49
61
  * `this.addWatchFile` `undefined`, so any plugin reporting dependencies threw
50
62
  * `TypeError: this.addWatchFile is not a function`.
51
63
  */
52
- const bunTransformContext = {
64
+ const bunTransformHooks = {
53
65
  addWatchFile(): void {},
54
66
  };
55
67
 
@@ -63,91 +75,159 @@ function resolveBunOptions(
63
75
  /**
64
76
  * Minimal subset of the Bun `BuildConfig` plugin build object.
65
77
  *
66
- * Only the `onLoad` hook is used; other hooks are not needed for a
67
- * source-to-source transform.
78
+ * `onLoad` drives the source transform. Bun's bundler also exposes `onStart`,
79
+ * which is used when available to forward the shared plugin's build lifecycle
80
+ * and clear its per-build cache. The runtime plugin API omits that hook, so
81
+ * plugin setup itself starts its one process-scoped module-loading session.
68
82
  */
69
83
  export interface BunLikeBuild {
84
+ /**
85
+ * Build configuration exposed unchanged by Bun's bundler plugin builder.
86
+ *
87
+ * Runtime plugin builders do not supply `files`. Bun's bundler accepts an
88
+ * in-memory file map whose values deliberately remain `unknown` here because
89
+ * this adapter only needs to preserve ownership, not consume their contents.
90
+ */
91
+ config?: {
92
+ files?: Readonly<Record<string, unknown>>;
93
+ };
94
+ /**
95
+ * Register a callback for the start of a bundler build.
96
+ *
97
+ * Optional because `Bun.plugin()` runtime builders do not expose this hook.
98
+ */
99
+ onStart?(callback: () => void | Promise<void>): void;
70
100
  /**
71
101
  * Register a loader callback for files matching `filter`.
72
102
  *
73
- * The callback receives the absolute file path and must return the
74
- * transformed file contents plus the `loader` Bun should apply next. The
75
- * `loader` matters most for the runtime path (`Bun.plugin`), where Bun must
76
- * be told the returned contents are still TypeScript so it keeps transpiling
77
- * them before execution.
103
+ * The callback receives the file path and must return the transformed file
104
+ * contents plus the `loader` Bun should apply next. Configured in-memory
105
+ * files retain relative key spellings; ordinary disk files are normally
106
+ * absolute. The `loader` matters most for the runtime path (`Bun.plugin`),
107
+ * where Bun must be told the returned contents are still TypeScript so it
108
+ * keeps transpiling them before execution.
78
109
  */
79
110
  onLoad(
80
111
  options: { filter: RegExp },
81
112
  loader: (args: {
82
113
  path: string;
83
- }) => Promise<{ contents: string; loader: BunLoader }>,
114
+ }) => Promise<{ contents: string; loader: BunLoader } | undefined>,
84
115
  ): void;
85
116
  }
86
117
 
87
118
  /**
88
119
  * Create a ttsc plugin for Bun's bundler AND runtime.
89
120
  *
90
- * Bun does not implement the unplugin protocol, so this adapter instantiates
91
- * the raw unplugin transform and wires it to Bun's `onLoad` hook manually. The
92
- * adapter reads each matching file from disk and forwards the content to the
93
- * ttsc transform; if the transform returns no changes the original source is
94
- * passed through unchanged.
121
+ * Bun does not implement the unplugin protocol, so this adapter wires the
122
+ * shared ttsc transform core to Bun's `onLoad` hook directly. It reads each
123
+ * included file from disk and forwards the content to the transform. Under
124
+ * `Bun.build`, excluded files and no-op transforms return `undefined` so the
125
+ * next loader retains ownership. Entries supplied through `BuildConfig.files`
126
+ * also stay with Bun's in-memory loader: they are not filesystem project inputs
127
+ * and reading the same path from disk would either fail or silently replace the
128
+ * configured contents. The runtime `Bun.plugin()` API rejects an undefined
129
+ * `onLoad` result, so that path explicitly returns the original source and
130
+ * loader instead.
95
131
  *
96
132
  * The same object works for `Bun.build({ plugins: [ttsc()] })` (bundler) and
97
133
  * for `Bun.plugin(ttsc())` / a `bunfig.toml` preload (runtime) — see
98
134
  * `bun-register`. Every result carries an explicit `loader` so Bun keeps
99
- * transpiling the emitted TypeScript at runtime; `sourceFilePattern` only
100
- * matches TypeScript, so the loader is always `ts`/`tsx`.
135
+ * transpiling the emitted TypeScript at runtime; `bunSourceFilePattern` only
136
+ * matches TypeScript, so the loader is always `ts`/`tsx`. A runtime plugin
137
+ * instance is one immutable load session, like Bun's own module cache; restart
138
+ * the process after changing compiler inputs.
101
139
  */
102
140
  export default function bun(options?: TtscBunOptions): BunLikePlugin {
103
141
  return {
104
142
  name: "ttsc-unplugin",
105
143
  setup(build) {
106
- // Build the raw transform lazily on first load rather than in `setup`.
107
- // Bun runs `setup` synchronously when the plugin is registered, so a
108
- // runtime registration (bun-register) that resolves its effective options
109
- // through a provider must defer that resolution until after any explicit
110
- // `register(options)` call in the same tick. Deferring also keeps a single
111
- // transform (and its project cache) shared across every loaded module.
112
- let raw: ReturnType<typeof unplugin.raw> | undefined;
113
- build.onLoad({ filter: sourceFilePattern }, async (args) => {
114
- raw ??= unplugin.raw(
115
- resolveBunOptions(options),
116
- {} as UnpluginContextMeta,
117
- );
144
+ // Resolve options lazily on first load. Runtime registration may call
145
+ // register(options) immediately after the import-time default
146
+ // registration; the provider form must observe that last synchronous
147
+ // update without installing a second shadowing loader.
148
+ let resolved: ReturnType<typeof resolveOptions> | undefined;
149
+ const getOptions = () =>
150
+ (resolved ??= resolveOptions(resolveBunOptions(options)));
151
+ const cache = createTtscTransformCache();
152
+ const runtime = build.onStart === undefined;
153
+ const ownsInMemoryFile = createBunInMemoryFileMatcher(build);
154
+ // Bun.plugin() has no onStart callback, but one setup invocation belongs
155
+ // to exactly one runtime process and module-loading session. Mark that
156
+ // session up front so first delivery of every emitted project module is
157
+ // constant-time instead of re-reading the whole project. Bun.build()
158
+ // immediately starts the same initial scope again through onStart and
159
+ // repeats it for subsequent builds.
160
+ beginTtscTransformBuild(cache);
161
+ build.onStart?.(() => beginTtscTransformBuild(cache));
162
+ build.onLoad({ filter: bunSourceFilePattern }, async (args) => {
163
+ if (!runtime && ownsInMemoryFile(args.path)) {
164
+ return undefined;
165
+ }
166
+ if (!isTransformTarget(args.path)) {
167
+ if (!runtime) return undefined;
168
+ return {
169
+ contents: await fs.readFile(args.path, "utf8"),
170
+ loader: bunLoaderFor(args.path),
171
+ };
172
+ }
118
173
  const loader = bunLoaderFor(args.path);
119
174
  const source = await fs.readFile(args.path, "utf8");
120
- const result =
121
- typeof raw.transform === "function"
122
- ? await raw.transform.call(
123
- bunTransformContext as never,
124
- source,
125
- args.path,
126
- )
127
- : undefined;
128
- // Unpack both shorthand string and object result shapes.
129
- if (typeof result === "string") {
130
- return { contents: result, loader };
131
- }
132
- if (
133
- typeof result === "object" &&
134
- result !== null &&
135
- "code" in result &&
136
- typeof result.code === "string"
137
- ) {
175
+ const result = await transformTtsc(
176
+ args.path,
177
+ source,
178
+ getOptions(),
179
+ undefined,
180
+ cache,
181
+ bunTransformHooks,
182
+ );
183
+ if (result !== undefined) {
138
184
  return { contents: result.code, loader };
139
185
  }
140
- return { contents: source, loader };
186
+ return runtime ? { contents: source, loader } : undefined;
141
187
  });
142
188
  },
143
189
  };
144
190
  }
145
191
 
146
192
  /**
147
- * Pick the Bun loader for a matched file. `sourceFilePattern` is
193
+ * Pick the Bun loader for a matched file. `bunSourceFilePattern` is
148
194
  * `/\.[cm]?tsx?$/`, so a trailing `x` (`.tsx`/`.ctsx`/`.mtsx`) is JSX-flavored
149
195
  * TypeScript and everything else (`.ts`/`.cts`/`.mts`) is plain TypeScript.
150
196
  */
151
197
  function bunLoaderFor(filePath: string): BunLoader {
152
198
  return /x$/i.test(filePath) ? "tsx" : "ts";
153
199
  }
200
+
201
+ /**
202
+ * Create a stable ownership matcher for Bun's `BuildConfig.files` map.
203
+ *
204
+ * Bun preserves relative `files` keys in the corresponding `onLoad` path.
205
+ * Preserve relative versus absolute spelling and dot segments exactly. Windows
206
+ * normalizes separators and drive-letter case, but not component case. No path
207
+ * is resolved against cwd, so `process.chdir()` cannot change ownership.
208
+ */
209
+ function createBunInMemoryFileMatcher(
210
+ build: BunLikeBuild,
211
+ ): (file: string) => boolean {
212
+ const files = build.config?.files;
213
+ if (files === undefined) return () => false;
214
+ const identities = new Set(Object.keys(files).map(bunPathIdentityKey));
215
+ return (file) => identities.has(bunPathIdentityKey(file));
216
+ }
217
+
218
+ /**
219
+ * Normalize the path forms Bun equates for its in-memory file map.
220
+ *
221
+ * Bun normalizes Windows separators and drive-letter case, but preserves path
222
+ * component case, relative versus absolute spelling, and dot segments. A
223
+ * filesystem identity key is broader and would suppress real disk transforms.
224
+ */
225
+ function bunPathIdentityKey(file: string): string {
226
+ if (process.platform !== "win32") return file;
227
+ return file
228
+ .replace(/\\/g, "/")
229
+ .replace(
230
+ /^([a-z]):/i,
231
+ (_match, drive: string) => `${drive.toLowerCase()}:`,
232
+ );
233
+ }
package/src/core/index.ts CHANGED
@@ -1,17 +1,22 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import type { UnpluginFactory, UnpluginInstance } from "unplugin";
2
4
  import { createUnplugin } from "unplugin";
3
5
 
4
6
  import type { TtscUnpluginOptions } from "./options";
5
7
  import { resolveOptions } from "./options";
6
8
  import {
9
+ beginTtscTransformBuild,
7
10
  collectExternalInputHashes,
8
11
  collectProjectInputHashes,
9
12
  createTtscTransformCache,
10
13
  isDeclarationFile,
11
14
  isProjectWalkPath,
15
+ resetTtscTransformCache,
12
16
  stripQuery,
13
17
  transformTtsc,
14
18
  } from "./transform";
19
+ import { createViteServeMissingInputWatch } from "./viteServe";
15
20
 
16
21
  const name = "ttsc-unplugin";
17
22
  /**
@@ -34,9 +39,9 @@ const virtualModulePattern = /\0/;
34
39
  *
35
40
  * The factory resolves raw options once, creates a per-build transform cache,
36
41
  * and captures Vite alias configuration via the `vite.configResolved` hook so
37
- * that path aliases are forwarded to the generated tsconfig overlay. The cache
38
- * is cleared on every `buildStart` to avoid stale results across watch-mode
39
- * rebuilds.
42
+ * that path aliases are forwarded to the generated tsconfig overlay. Real build
43
+ * lifecycles use a per-build cache; Vite's development server keeps persistent
44
+ * validation because its one `buildStart` spans later HMR edits.
40
45
  */
41
46
  const unpluginFactory: UnpluginFactory<
42
47
  TtscUnpluginOptions | undefined,
@@ -44,7 +49,9 @@ const unpluginFactory: UnpluginFactory<
44
49
  > = (rawOptions = {}) => {
45
50
  const options = resolveOptions(rawOptions);
46
51
  const transformCache = createTtscTransformCache();
52
+ const missingInputs = createViteServeMissingInputWatch();
47
53
  let aliases: unknown;
54
+ let viteCommand: string | undefined;
48
55
 
49
56
  return {
50
57
  name,
@@ -53,11 +60,34 @@ const unpluginFactory: UnpluginFactory<
53
60
  vite: {
54
61
  configResolved(config) {
55
62
  aliases = config.resolve.alias;
63
+ // Re-read per config resolution: a plugin instance reused across a
64
+ // serve and a later build must stop routing missing inputs to the
65
+ // serve-time poll, even though the closed server stays attached
66
+ // (see the dispose note in viteServe.ts).
67
+ viteCommand = config.command;
68
+ },
69
+ // Vite serve funnels every transform-context `addWatchFile()` into the
70
+ // module's added-import graph (`_addedImports`), which import-analysis
71
+ // resolves like real imports. Capture the dev server so the transform
72
+ // hook can route watch inputs that do not exist yet — superseding
73
+ // resolution candidates above all — around that graph and still
74
+ // invalidate their importers when the path is created.
75
+ configureServer(server) {
76
+ missingInputs.attach(server);
77
+ },
78
+ // Vite calls buildEnd when the dev server (or build) closes; drop every
79
+ // poller so a stopped server leaks no watch state.
80
+ buildEnd() {
81
+ missingInputs.dispose();
56
82
  },
57
83
  },
58
84
 
59
85
  buildStart() {
60
- transformCache.clear();
86
+ if (viteCommand === "serve") {
87
+ resetTtscTransformCache(transformCache);
88
+ } else {
89
+ beginTtscTransformBuild(transformCache);
90
+ }
61
91
  },
62
92
 
63
93
  transformInclude(id) {
@@ -75,8 +105,23 @@ const unpluginFactory: UnpluginFactory<
75
105
  // unioned with the host-owned reference graph) so type-only inputs
76
106
  // invalidate this module in watch mode and persistent caches;
77
107
  // bundlers erase type-only imports from their own module graph and
78
- // would otherwise serve stale generated code.
79
- addWatchFile: (watched) => this.addWatchFile(watched),
108
+ // would otherwise serve stale generated code. Under Vite serve a
109
+ // missing input must not enter `addWatchFile`: import-analysis
110
+ // resolves added imports and 500s on a path that is absent by design
111
+ // (a superseding resolution candidate, a not-yet-generated
112
+ // dependency), so those are watched on the filesystem instead and
113
+ // invalidate this module when created.
114
+ addWatchFile: (watched) => {
115
+ if (
116
+ viteCommand === "serve" &&
117
+ missingInputs.serving() &&
118
+ !fs.existsSync(watched)
119
+ ) {
120
+ missingInputs.watch(watched, path.resolve(file));
121
+ return;
122
+ }
123
+ this.addWatchFile(watched);
124
+ },
80
125
  // A module the plugin declared volatile depends on non-file inputs,
81
126
  // which no file-dependency snapshot can represent; mark it
82
127
  // uncacheable where the bundler exposes that control.
@@ -103,10 +148,12 @@ export type {
103
148
  } from "./options";
104
149
  export type { TtscTransformHooks } from "./transform";
105
150
  export {
151
+ beginTtscTransformBuild,
106
152
  collectExternalInputHashes,
107
153
  collectProjectInputHashes,
108
154
  createTtscTransformCache,
109
155
  isProjectWalkPath,
156
+ resetTtscTransformCache,
110
157
  resolveOptions,
111
158
  transformTtsc,
112
159
  unplugin,
@@ -121,7 +168,7 @@ export default unplugin;
121
168
  * Excluded ids: virtual modules (NUL prefix), `.d.ts` declaration files, and
122
169
  * anything inside `node_modules`.
123
170
  */
124
- function isTransformTarget(id: string): boolean {
171
+ export function isTransformTarget(id: string): boolean {
125
172
  return (
126
173
  sourceFilePattern.test(id) &&
127
174
  !virtualModulePattern.test(id) &&