sandlot 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/browser/bundler.d.ts +8 -0
  2. package/dist/browser/bundler.d.ts.map +1 -1
  3. package/dist/browser/iframe-executor.d.ts +82 -0
  4. package/dist/browser/iframe-executor.d.ts.map +1 -0
  5. package/dist/browser/index.d.ts +4 -2
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +339 -76
  8. package/dist/browser/main-thread-executor.d.ts +46 -0
  9. package/dist/browser/main-thread-executor.d.ts.map +1 -0
  10. package/dist/browser/preset.d.ts +7 -2
  11. package/dist/browser/preset.d.ts.map +1 -1
  12. package/dist/core/bundler-utils.d.ts +43 -1
  13. package/dist/core/bundler-utils.d.ts.map +1 -1
  14. package/dist/core/executor.d.ts.map +1 -1
  15. package/dist/core/sandbox.d.ts.map +1 -1
  16. package/dist/core/sandlot.d.ts.map +1 -1
  17. package/dist/index.js +63 -0
  18. package/dist/node/bundler.d.ts +5 -0
  19. package/dist/node/bundler.d.ts.map +1 -1
  20. package/dist/node/index.d.ts +2 -0
  21. package/dist/node/index.d.ts.map +1 -1
  22. package/dist/node/index.js +243 -75
  23. package/dist/node/preset.d.ts +16 -1
  24. package/dist/node/preset.d.ts.map +1 -1
  25. package/dist/node/wasm-bundler.d.ts +86 -0
  26. package/dist/node/wasm-bundler.d.ts.map +1 -0
  27. package/dist/types.d.ts +25 -0
  28. package/dist/types.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/browser/bundler.ts +23 -106
  31. package/src/browser/iframe-executor.ts +320 -0
  32. package/src/browser/index.ts +9 -2
  33. package/src/browser/preset.ts +30 -6
  34. package/src/core/bundler-utils.ts +148 -0
  35. package/src/core/executor.ts +8 -7
  36. package/src/core/sandbox.ts +82 -0
  37. package/src/core/sandlot.ts +7 -0
  38. package/src/node/bundler.ts +17 -110
  39. package/src/node/index.ts +10 -0
  40. package/src/node/preset.ts +59 -5
  41. package/src/node/wasm-bundler.ts +195 -0
  42. package/src/types.ts +27 -0
  43. /package/src/browser/{executor.ts → main-thread-executor.ts} +0 -0
@@ -12,7 +12,11 @@ import {
12
12
  import {
13
13
  MainThreadExecutor,
14
14
  type MainThreadExecutorOptions,
15
- } from "./executor";
15
+ } from "./main-thread-executor";
16
+ import {
17
+ IframeExecutor,
18
+ type IframeExecutorOptions,
19
+ } from "./iframe-executor";
16
20
 
17
21
  export interface CreateBrowserSandlotOptions
18
22
  extends Omit<SandlotOptions, "bundler" | "typechecker" | "typesResolver" | "executor"> {
@@ -42,11 +46,17 @@ export interface CreateBrowserSandlotOptions
42
46
  /**
43
47
  * Custom executor options, or a pre-configured executor instance.
44
48
  * Set to `false` to disable execution (sandbox.run() will throw).
49
+ * Set to `"iframe"` to use IframeExecutor with default options.
45
50
  * Defaults to MainThreadExecutor.
51
+ *
52
+ * Note: IframeExecutor does NOT support shared modules. Use MainThreadExecutor
53
+ * (the default) if you need shared modules like React.
46
54
  */
47
55
  executor?:
48
56
  | MainThreadExecutorOptions
57
+ | IframeExecutorOptions
49
58
  | SandlotOptions["executor"]
59
+ | "iframe"
50
60
  | false;
51
61
  }
52
62
 
@@ -121,11 +131,15 @@ export async function createBrowserSandlot(
121
131
  const executorInstance =
122
132
  executor === false
123
133
  ? undefined
124
- : isExecutor(executor)
125
- ? executor
126
- : new MainThreadExecutor(
127
- executor as MainThreadExecutorOptions | undefined
128
- );
134
+ : executor === "iframe"
135
+ ? new IframeExecutor()
136
+ : isExecutor(executor)
137
+ ? executor
138
+ : isIframeExecutorOptions(executor)
139
+ ? new IframeExecutor(executor)
140
+ : new MainThreadExecutor(
141
+ executor as MainThreadExecutorOptions | undefined
142
+ );
129
143
 
130
144
  return createSandlot({
131
145
  ...rest,
@@ -177,3 +191,13 @@ function isExecutor(value: unknown): value is SandlotOptions["executor"] {
177
191
  typeof (value as { execute: unknown }).execute === "function"
178
192
  );
179
193
  }
194
+
195
+ function isIframeExecutorOptions(value: unknown): value is IframeExecutorOptions {
196
+ // IframeExecutorOptions has "sandbox" or "container" properties
197
+ // MainThreadExecutorOptions only has "defaultTimeout"
198
+ return (
199
+ typeof value === "object" &&
200
+ value !== null &&
201
+ ("sandbox" in value || "container" in value)
202
+ );
203
+ }
@@ -7,6 +7,8 @@
7
7
 
8
8
  import type {
9
9
  ISharedModuleRegistry,
10
+ BundleOptions,
11
+ BundleResult,
10
12
  BundleWarning,
11
13
  BundleError,
12
14
  BundleLocation,
@@ -628,3 +630,149 @@ export function generateSharedModuleCode(
628
630
 
629
631
  return code;
630
632
  }
633
+
634
+ // =============================================================================
635
+ // Shared Bundle Execution
636
+ // =============================================================================
637
+
638
+ /**
639
+ * Minimal esbuild interface needed for bundling.
640
+ * Compatible with both esbuild and esbuild-wasm.
641
+ *
642
+ * Uses a loose `Record<string, unknown>` for build options to avoid
643
+ * type conflicts between the various esbuild module signatures.
644
+ */
645
+ export interface EsbuildInstance {
646
+ build(options: Record<string, unknown>): Promise<{
647
+ outputFiles?: Array<{ text: string }>;
648
+ warnings: EsbuildMessage[];
649
+ }>;
650
+ }
651
+
652
+ /**
653
+ * Options for the shared bundle execution helper.
654
+ */
655
+ export interface ExecuteBundleOptions {
656
+ /** The esbuild instance to use */
657
+ esbuild: EsbuildInstance;
658
+ /** Bundle options from the IBundler interface */
659
+ bundleOptions: BundleOptions;
660
+ /** Base URL for CDN imports */
661
+ cdnBaseUrl: string;
662
+ /**
663
+ * Whether to bundle CDN imports inline.
664
+ * - Browser: false (external) - browser can fetch at runtime
665
+ * - Node/Bun: true (bundle) - esbuild fetches during build
666
+ */
667
+ bundleCdnImports: boolean;
668
+ }
669
+
670
+ /**
671
+ * Execute a bundle using esbuild with the VFS plugin.
672
+ *
673
+ * This is the shared implementation used by both browser and node WASM bundlers.
674
+ * It handles entry point normalization, VFS plugin creation, and error handling.
675
+ *
676
+ * @param options - Bundle execution options
677
+ * @returns Bundle result with code or errors
678
+ */
679
+ export async function executeBundleWithEsbuild(
680
+ options: ExecuteBundleOptions
681
+ ): Promise<BundleResult> {
682
+ const { esbuild, bundleOptions, cdnBaseUrl, bundleCdnImports } = options;
683
+
684
+ const {
685
+ fs,
686
+ entryPoint,
687
+ installedPackages = {},
688
+ sharedModules = [],
689
+ sharedModuleRegistry,
690
+ external = [],
691
+ format = "esm",
692
+ minify = false,
693
+ sourcemap = false,
694
+ target = ["es2020"],
695
+ } = bundleOptions;
696
+
697
+ // Normalize entry point to absolute path
698
+ const normalizedEntry = entryPoint.startsWith("/")
699
+ ? entryPoint
700
+ : `/${entryPoint}`;
701
+
702
+ // Verify entry point exists
703
+ if (!fs.exists(normalizedEntry)) {
704
+ return {
705
+ success: false,
706
+ errors: [{ text: `Entry point not found: ${normalizedEntry}` }],
707
+ warnings: [],
708
+ };
709
+ }
710
+
711
+ // Track files included in the bundle
712
+ const includedFiles = new Set<string>();
713
+
714
+ // Create the VFS plugin
715
+ const plugin = createVfsPlugin({
716
+ fs,
717
+ entryPoint: normalizedEntry,
718
+ installedPackages,
719
+ sharedModules: new Set(sharedModules),
720
+ sharedModuleRegistry: sharedModuleRegistry ?? null,
721
+ cdnBaseUrl,
722
+ includedFiles,
723
+ bundleCdnImports,
724
+ });
725
+
726
+ try {
727
+ // Run esbuild
728
+ const result = await esbuild.build({
729
+ entryPoints: [normalizedEntry],
730
+ bundle: true,
731
+ write: false,
732
+ format,
733
+ minify,
734
+ sourcemap: sourcemap ? "inline" : false,
735
+ target,
736
+ external,
737
+ plugins: [plugin],
738
+ jsx: "automatic",
739
+ });
740
+
741
+ const code = result.outputFiles?.[0]?.text ?? "";
742
+
743
+ // Convert esbuild warnings to our format
744
+ const warnings: BundleWarning[] = result.warnings.map((w) =>
745
+ convertEsbuildMessage(w)
746
+ );
747
+
748
+ return {
749
+ success: true,
750
+ code,
751
+ warnings,
752
+ includedFiles: Array.from(includedFiles),
753
+ };
754
+ } catch (err) {
755
+ // esbuild throws BuildFailure with .errors array
756
+ if (isEsbuildBuildFailure(err)) {
757
+ const errors: BundleError[] = err.errors.map((e) =>
758
+ convertEsbuildMessage(e)
759
+ );
760
+ const warnings: BundleWarning[] = err.warnings.map((w) =>
761
+ convertEsbuildMessage(w)
762
+ );
763
+ return {
764
+ success: false,
765
+ errors,
766
+ warnings,
767
+ };
768
+ }
769
+
770
+ // Unknown error - wrap it
771
+ const message = err instanceof Error ? err.message : String(err);
772
+ return {
773
+ success: false,
774
+ errors: [{ text: message }],
775
+ warnings: [],
776
+ };
777
+ }
778
+ }
@@ -68,23 +68,18 @@ export function createBasicExecutor(
68
68
 
69
69
  const captureLog = (...args: unknown[]) => {
70
70
  logs.push(formatArgs(...args));
71
- originalConsole.log.apply(console, args);
72
71
  };
73
72
  const captureWarn = (...args: unknown[]) => {
74
73
  logs.push(`[warn] ${formatArgs(...args)}`);
75
- originalConsole.warn.apply(console, args);
76
74
  };
77
75
  const captureError = (...args: unknown[]) => {
78
76
  logs.push(`[error] ${formatArgs(...args)}`);
79
- originalConsole.error.apply(console, args);
80
77
  };
81
78
  const captureInfo = (...args: unknown[]) => {
82
79
  logs.push(`[info] ${formatArgs(...args)}`);
83
- originalConsole.info.apply(console, args);
84
80
  };
85
81
  const captureDebug = (...args: unknown[]) => {
86
82
  logs.push(`[debug] ${formatArgs(...args)}`);
87
- originalConsole.debug.apply(console, args);
88
83
  };
89
84
 
90
85
  const restoreConsole = () => {
@@ -125,13 +120,19 @@ export function createBasicExecutor(
125
120
 
126
121
  // Execute with optional timeout
127
122
  if (timeout > 0) {
123
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
128
124
  const timeoutPromise = new Promise<never>((_, reject) => {
129
- setTimeout(
125
+ timeoutId = setTimeout(
130
126
  () => reject(new Error(`Execution timed out after ${timeout}ms`)),
131
127
  timeout
132
128
  );
133
129
  });
134
- await Promise.race([executeExport(), timeoutPromise]);
130
+ try {
131
+ await Promise.race([executeExport(), timeoutPromise]);
132
+ } finally {
133
+ // Clear the timeout to allow the process to exit
134
+ if (timeoutId) clearTimeout(timeoutId);
135
+ }
135
136
  } else {
136
137
  await executeExport();
137
138
  }
@@ -262,6 +262,88 @@ export async function createSandboxImpl(
262
262
  fs.writeFile(TSCONFIG_PATH, JSON.stringify(DEFAULT_TSCONFIG, null, 2));
263
263
  }
264
264
 
265
+ // ---------------------------------------------------------------------------
266
+ // Install types for shared modules (if both registry and resolver exist)
267
+ // ---------------------------------------------------------------------------
268
+
269
+ if (sharedModuleRegistry && typesResolver) {
270
+ const sharedModuleIds = sharedModuleRegistry.list();
271
+
272
+ // Resolve types for all shared modules in parallel
273
+ const typesFetches = sharedModuleIds.map(async (moduleId) => {
274
+ try {
275
+ const typeFiles = await typesResolver.resolveTypes(moduleId);
276
+ return { moduleId, typeFiles, error: null };
277
+ } catch (err) {
278
+ // Log but don't fail - types are nice to have but not required
279
+ console.warn(`[sandlot] Failed to fetch types for shared module "${moduleId}":`, err);
280
+ return { moduleId, typeFiles: {}, error: err };
281
+ }
282
+ });
283
+
284
+ const results = await Promise.all(typesFetches);
285
+
286
+ // Write type files to the filesystem
287
+ for (const { moduleId, typeFiles } of results) {
288
+ if (Object.keys(typeFiles).length === 0) continue;
289
+
290
+ // Determine the package name (strip subpath for @types resolution)
291
+ // e.g., "react-dom/client" -> "react-dom"
292
+ let packageName = moduleId;
293
+ let subpath: string | undefined;
294
+
295
+ if (moduleId.startsWith("@")) {
296
+ const parts = moduleId.split("/");
297
+ if (parts.length >= 2) {
298
+ packageName = `${parts[0]}/${parts[1]}`;
299
+ subpath = parts.length > 2 ? parts.slice(2).join("/") : undefined;
300
+ }
301
+ } else {
302
+ const slashIndex = moduleId.indexOf("/");
303
+ if (slashIndex !== -1) {
304
+ packageName = moduleId.slice(0, slashIndex);
305
+ subpath = moduleId.slice(slashIndex + 1);
306
+ }
307
+ }
308
+
309
+ const packageDir = `/node_modules/${packageName}`;
310
+ let typesEntry: string | null = null;
311
+ let fallbackEntry: string | null = null;
312
+
313
+ for (const [filePath, content] of Object.entries(typeFiles)) {
314
+ const fullPath = filePath.startsWith("/")
315
+ ? filePath
316
+ : `${packageDir}/${filePath}`;
317
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
318
+ ensureDir(fs, dir);
319
+ fs.writeFile(fullPath, content);
320
+
321
+ // Track types entry: prefer index.d.ts, fallback to first top-level .d.ts
322
+ const relativePath = fullPath.replace(`${packageDir}/`, "");
323
+ if (relativePath === "index.d.ts") {
324
+ typesEntry = "index.d.ts";
325
+ } else if (!fallbackEntry && relativePath.endsWith(".d.ts") && !relativePath.includes("/")) {
326
+ fallbackEntry = relativePath;
327
+ }
328
+ }
329
+
330
+ // Use index.d.ts if found, otherwise use fallback
331
+ const finalTypesEntry = typesEntry ?? fallbackEntry ?? "index.d.ts";
332
+
333
+ // Create package.json if it doesn't exist yet
334
+ const pkgJsonPath = `${packageDir}/package.json`;
335
+ if (!fs.exists(pkgJsonPath)) {
336
+ const pkgJson = {
337
+ name: packageName,
338
+ version: "shared",
339
+ types: finalTypesEntry,
340
+ main: finalTypesEntry.replace(/\.d\.ts$/, ".js"),
341
+ };
342
+ fs.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
343
+ }
344
+ }
345
+ }
346
+
265
347
  // ---------------------------------------------------------------------------
266
348
  // Core Methods
267
349
  // ---------------------------------------------------------------------------
@@ -73,5 +73,12 @@ export function createSandlot(options: SandlotOptions): Sandlot {
73
73
  get sharedModules(): ISharedModuleRegistry | null {
74
74
  return sharedModuleRegistry;
75
75
  },
76
+
77
+ async dispose(): Promise<void> {
78
+ // Dispose of the bundler if it has a dispose method
79
+ if (bundler.dispose) {
80
+ await bundler.dispose();
81
+ }
82
+ },
76
83
  };
77
84
  }
@@ -6,18 +6,8 @@
6
6
  */
7
7
 
8
8
  import type * as EsbuildTypes from "esbuild";
9
- import type {
10
- IBundler,
11
- BundleOptions,
12
- BundleResult,
13
- BundleWarning,
14
- BundleError,
15
- } from "../types";
16
- import {
17
- createVfsPlugin,
18
- isEsbuildBuildFailure,
19
- convertEsbuildMessage,
20
- } from "../core/bundler-utils";
9
+ import type { IBundler, BundleOptions, BundleResult } from "../types";
10
+ import { executeBundleWithEsbuild } from "../core/bundler-utils";
21
11
 
22
12
  export interface EsbuildNativeBundlerOptions {
23
13
  /**
@@ -76,111 +66,28 @@ export class EsbuildNativeBundler implements IBundler {
76
66
  return this.esbuild;
77
67
  }
78
68
 
79
- async bundle(options: BundleOptions): Promise<BundleResult> {
80
- await this.initialize();
81
-
82
- const esbuild = this.getEsbuild();
83
-
84
- const {
85
- fs,
86
- entryPoint,
87
- installedPackages = {},
88
- sharedModules = [],
89
- sharedModuleRegistry,
90
- external = [],
91
- format = "esm",
92
- minify = false,
93
- sourcemap = false,
94
- target = ["es2020"],
95
- } = options;
96
-
97
- // Normalize entry point to absolute path
98
- const normalizedEntry = entryPoint.startsWith("/")
99
- ? entryPoint
100
- : `/${entryPoint}`;
101
-
102
- // Verify entry point exists
103
- if (!fs.exists(normalizedEntry)) {
104
- return {
105
- success: false,
106
- errors: [{ text: `Entry point not found: ${normalizedEntry}` }],
107
- warnings: [],
108
- };
69
+ /**
70
+ * Dispose of the esbuild service.
71
+ * This stops the esbuild child process and allows the Node.js process to exit.
72
+ */
73
+ async dispose(): Promise<void> {
74
+ if (this.esbuild) {
75
+ await this.esbuild.stop();
76
+ this.esbuild = null;
109
77
  }
78
+ }
110
79
 
111
- // Track files included in the bundle
112
- const includedFiles = new Set<string>();
80
+ async bundle(options: BundleOptions): Promise<BundleResult> {
81
+ await this.initialize();
113
82
 
114
- // Create the VFS plugin
115
- // Note: bundleCdnImports is true for Node/Bun because they cannot
83
+ // bundleCdnImports is true for Node/Bun because they cannot
116
84
  // resolve HTTP imports at runtime - native esbuild will fetch and bundle them
117
- const plugin = createVfsPlugin({
118
- fs,
119
- entryPoint: normalizedEntry,
120
- installedPackages,
121
- sharedModules: new Set(sharedModules),
122
- sharedModuleRegistry: sharedModuleRegistry ?? null,
85
+ return executeBundleWithEsbuild({
86
+ esbuild: this.getEsbuild(),
87
+ bundleOptions: options,
123
88
  cdnBaseUrl: this.options.cdnBaseUrl!,
124
- includedFiles,
125
89
  bundleCdnImports: true,
126
90
  });
127
-
128
- try {
129
- // Run esbuild
130
- // Note: We do NOT mark http/https as external here because Node/Bun
131
- // cannot resolve HTTP imports at runtime. Instead, bundleCdnImports: true
132
- // tells the VFS plugin to let native esbuild fetch and bundle CDN imports.
133
- const result = await esbuild.build({
134
- entryPoints: [normalizedEntry],
135
- bundle: true,
136
- write: false,
137
- format,
138
- minify,
139
- sourcemap: sourcemap ? "inline" : false,
140
- target,
141
- external,
142
- // Cast to esbuild's Plugin type since our minimal interface is compatible
143
- plugins: [plugin as EsbuildTypes.Plugin],
144
- jsx: "automatic",
145
- });
146
-
147
- const code = result.outputFiles?.[0]?.text ?? "";
148
-
149
- // Convert esbuild warnings to our format
150
- const warnings: BundleWarning[] = result.warnings.map((w) =>
151
- convertEsbuildMessage(w)
152
- );
153
-
154
- return {
155
- success: true,
156
- code,
157
- warnings,
158
- includedFiles: Array.from(includedFiles),
159
- };
160
- } catch (err) {
161
- // esbuild throws BuildFailure with .errors array
162
- if (isEsbuildBuildFailure(err)) {
163
- const errors: BundleError[] = err.errors.map((e) =>
164
- convertEsbuildMessage(e)
165
- );
166
- const warnings: BundleWarning[] = err.warnings.map((w) =>
167
- convertEsbuildMessage(w)
168
- );
169
- return {
170
- success: false,
171
- errors,
172
- warnings,
173
- };
174
- }
175
-
176
- // Unknown error - wrap it
177
- const message = err instanceof Error ? err.message : String(err);
178
- return {
179
- success: false,
180
- errors: [{ text: message }],
181
- warnings: [],
182
- };
183
- }
184
91
  }
185
92
  }
186
93
 
package/src/node/index.ts CHANGED
@@ -17,6 +17,16 @@
17
17
  export { EsbuildNativeBundler, createEsbuildNativeBundler } from "./bundler";
18
18
  export type { EsbuildNativeBundlerOptions } from "./bundler";
19
19
 
20
+ // -----------------------------------------------------------------------------
21
+ // WASM Bundler (for testing consistency with browser bundler)
22
+ // -----------------------------------------------------------------------------
23
+
24
+ export {
25
+ EsbuildWasmNodeBundler,
26
+ createEsbuildWasmNodeBundler,
27
+ } from "./wasm-bundler";
28
+ export type { EsbuildWasmNodeBundlerOptions } from "./wasm-bundler";
29
+
20
30
  // -----------------------------------------------------------------------------
21
31
  // Typechecker (platform-agnostic: re-exported for convenience)
22
32
  // -----------------------------------------------------------------------------
@@ -5,6 +5,10 @@ import {
5
5
  } from "../core/esm-types-resolver";
6
6
  import type { Sandlot, SandlotOptions } from "../types";
7
7
  import { EsbuildNativeBundler, type EsbuildNativeBundlerOptions } from "./bundler";
8
+ import {
9
+ EsbuildWasmNodeBundler,
10
+ type EsbuildWasmNodeBundlerOptions,
11
+ } from "./wasm-bundler";
8
12
  import {
9
13
  Typechecker,
10
14
  type TypecheckerOptions,
@@ -18,8 +22,17 @@ export interface CreateNodeSandlotOptions
18
22
  extends Omit<SandlotOptions, "bundler" | "typechecker" | "typesResolver" | "executor"> {
19
23
  /**
20
24
  * Custom bundler options, or a pre-configured bundler instance.
25
+ *
26
+ * Set to `"wasm"` to use the WASM bundler (for testing consistency with browser).
27
+ * You can also pass `{ wasm: true, ...options }` for WASM bundler with custom options.
28
+ *
29
+ * @default EsbuildNativeBundler (fastest, uses native esbuild binary)
21
30
  */
22
- bundler?: EsbuildNativeBundlerOptions | SandlotOptions["bundler"];
31
+ bundler?:
32
+ | EsbuildNativeBundlerOptions
33
+ | (EsbuildWasmNodeBundlerOptions & { wasm: true })
34
+ | SandlotOptions["bundler"]
35
+ | "wasm";
23
36
 
24
37
  /**
25
38
  * Custom typechecker options, or a pre-configured typechecker instance.
@@ -82,6 +95,13 @@ export interface CreateNodeSandlotOptions
82
95
  * typechecker: false,
83
96
  * });
84
97
  * ```
98
+ *
99
+ * @example Use WASM bundler for testing consistency with browser
100
+ * ```ts
101
+ * const sandlot = await createNodeSandlot({
102
+ * bundler: "wasm",
103
+ * });
104
+ * ```
85
105
  */
86
106
  export async function createNodeSandlot(
87
107
  options: CreateNodeSandlotOptions = {}
@@ -89,11 +109,9 @@ export async function createNodeSandlot(
89
109
  const { bundler, typechecker, typesResolver, executor, ...rest } = options;
90
110
 
91
111
  // Create or use provided bundler
92
- const bundlerInstance = isBundler(bundler)
93
- ? bundler
94
- : new EsbuildNativeBundler(bundler as EsbuildNativeBundlerOptions | undefined);
112
+ const bundlerInstance = createBundlerInstance(bundler);
95
113
 
96
- // Initialize bundler (loads native esbuild)
114
+ // Initialize bundler (loads native esbuild or WASM)
97
115
  await bundlerInstance.initialize();
98
116
 
99
117
  // Create or use provided typechecker
@@ -135,6 +153,42 @@ export async function createNodeSandlot(
135
153
  });
136
154
  }
137
155
 
156
+ // Helper to create bundler instance based on options
157
+
158
+ function createBundlerInstance(
159
+ bundler: CreateNodeSandlotOptions["bundler"]
160
+ ): (EsbuildNativeBundler | EsbuildWasmNodeBundler) & { initialize(): Promise<void> } {
161
+ // Already a bundler instance
162
+ if (isBundler(bundler)) {
163
+ return bundler as (EsbuildNativeBundler | EsbuildWasmNodeBundler) & { initialize(): Promise<void> };
164
+ }
165
+
166
+ // String shorthand for WASM bundler
167
+ if (bundler === "wasm") {
168
+ return new EsbuildWasmNodeBundler();
169
+ }
170
+
171
+ // Object with wasm: true flag
172
+ if (isWasmBundlerOptions(bundler)) {
173
+ const { wasm: _, ...wasmOptions } = bundler;
174
+ return new EsbuildWasmNodeBundler(wasmOptions);
175
+ }
176
+
177
+ // Default: native bundler (fastest)
178
+ return new EsbuildNativeBundler(bundler as EsbuildNativeBundlerOptions | undefined);
179
+ }
180
+
181
+ function isWasmBundlerOptions(
182
+ value: unknown
183
+ ): value is EsbuildWasmNodeBundlerOptions & { wasm: true } {
184
+ return (
185
+ typeof value === "object" &&
186
+ value !== null &&
187
+ "wasm" in value &&
188
+ (value as { wasm: unknown }).wasm === true
189
+ );
190
+ }
191
+
138
192
  // Type guards for detecting pre-configured instances
139
193
 
140
194
  function isBundler(