sandlot 0.2.1 → 0.2.2

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 (38) 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 +205 -9
  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/executor.d.ts.map +1 -1
  13. package/dist/core/sandlot.d.ts.map +1 -1
  14. package/dist/index.js +5 -0
  15. package/dist/node/bundler.d.ts +5 -0
  16. package/dist/node/bundler.d.ts.map +1 -1
  17. package/dist/node/index.d.ts +2 -0
  18. package/dist/node/index.d.ts.map +1 -1
  19. package/dist/node/index.js +174 -8
  20. package/dist/node/preset.d.ts +16 -1
  21. package/dist/node/preset.d.ts.map +1 -1
  22. package/dist/node/wasm-bundler.d.ts +86 -0
  23. package/dist/node/wasm-bundler.d.ts.map +1 -0
  24. package/dist/types.d.ts +25 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/browser/bundler.ts +17 -0
  28. package/src/browser/iframe-executor.ts +320 -0
  29. package/src/browser/index.ts +9 -2
  30. package/src/browser/preset.ts +30 -6
  31. package/src/core/executor.ts +8 -7
  32. package/src/core/sandlot.ts +7 -0
  33. package/src/node/bundler.ts +11 -0
  34. package/src/node/index.ts +10 -0
  35. package/src/node/preset.ts +59 -5
  36. package/src/node/wasm-bundler.ts +299 -0
  37. package/src/types.ts +27 -0
  38. /package/src/browser/{executor.ts → main-thread-executor.ts} +0 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Node/Bun/Deno bundler implementation using esbuild-wasm.
3
+ *
4
+ * This bundler uses the same WebAssembly-based esbuild as the browser bundler,
5
+ * but runs in Node.js/Bun/Deno environments. It's primarily useful for:
6
+ *
7
+ * 1. Testing consistency with the browser bundler
8
+ * 2. Ensuring identical import resolution behavior
9
+ * 3. Validating that bundled output matches between browser and server
10
+ *
11
+ * For production use, prefer EsbuildNativeBundler which is ~3-5x faster.
12
+ */
13
+
14
+ import type * as EsbuildTypes from "esbuild-wasm";
15
+ import type {
16
+ IBundler,
17
+ BundleOptions,
18
+ BundleResult,
19
+ BundleWarning,
20
+ BundleError,
21
+ } from "../types";
22
+ import {
23
+ createVfsPlugin,
24
+ isEsbuildBuildFailure,
25
+ convertEsbuildMessage,
26
+ } from "../core/bundler-utils";
27
+
28
+ // =============================================================================
29
+ // Global Singleton for esbuild-wasm initialization
30
+ // =============================================================================
31
+ // esbuild-wasm can only be initialized once per process. We track this globally
32
+ // so multiple EsbuildWasmNodeBundler instances can share the same initialization.
33
+
34
+ interface EsbuildGlobalState {
35
+ esbuild: typeof EsbuildTypes | null;
36
+ initialized: boolean;
37
+ initPromise: Promise<void> | null;
38
+ }
39
+
40
+ // Use a different key than the browser bundler to avoid conflicts if both
41
+ // are somehow loaded in the same environment (e.g., during SSR)
42
+ const GLOBAL_KEY = "__sandlot_esbuild_wasm_node__";
43
+
44
+ function getGlobalState(): EsbuildGlobalState {
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ const g = globalThis as any;
47
+ if (!g[GLOBAL_KEY]) {
48
+ g[GLOBAL_KEY] = {
49
+ esbuild: null,
50
+ initialized: false,
51
+ initPromise: null,
52
+ };
53
+ }
54
+ return g[GLOBAL_KEY];
55
+ }
56
+
57
+ export interface EsbuildWasmNodeBundlerOptions {
58
+ /**
59
+ * Base URL for CDN imports.
60
+ * npm imports like "lodash" are rewritten to "{cdnBaseUrl}/lodash@{version}".
61
+ * @default "https://esm.sh"
62
+ */
63
+ cdnBaseUrl?: string;
64
+ }
65
+
66
+ /**
67
+ * Bundler implementation using esbuild-wasm for Node.js/Bun/Deno.
68
+ *
69
+ * Uses the same WebAssembly-based esbuild as the browser bundler,
70
+ * making it ideal for testing consistency between browser and server builds.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const bundler = new EsbuildWasmNodeBundler();
75
+ * await bundler.initialize();
76
+ *
77
+ * const result = await bundler.bundle({
78
+ * fs: myFilesystem,
79
+ * entryPoint: "/src/index.ts",
80
+ * });
81
+ * ```
82
+ *
83
+ * @example Testing consistency with native bundler
84
+ * ```ts
85
+ * const native = new EsbuildNativeBundler();
86
+ * const wasm = new EsbuildWasmNodeBundler();
87
+ *
88
+ * await native.initialize();
89
+ * await wasm.initialize();
90
+ *
91
+ * const nativeResult = await native.bundle(options);
92
+ * const wasmResult = await wasm.bundle(options);
93
+ *
94
+ * // Results should be equivalent (modulo minor formatting differences)
95
+ * ```
96
+ */
97
+ export class EsbuildWasmNodeBundler implements IBundler {
98
+ private options: EsbuildWasmNodeBundlerOptions;
99
+
100
+ constructor(options: EsbuildWasmNodeBundlerOptions = {}) {
101
+ this.options = {
102
+ cdnBaseUrl: "https://esm.sh",
103
+ ...options,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Initialize the esbuild WASM module.
109
+ * Called automatically on first bundle() if not already initialized.
110
+ *
111
+ * Uses a global singleton pattern since esbuild-wasm can only be
112
+ * initialized once per process.
113
+ */
114
+ async initialize(): Promise<void> {
115
+ const state = getGlobalState();
116
+
117
+ // Already initialized globally
118
+ if (state.initialized && state.esbuild) {
119
+ return;
120
+ }
121
+
122
+ // Another instance is initializing - wait for it
123
+ if (state.initPromise) {
124
+ await state.initPromise;
125
+ return;
126
+ }
127
+
128
+ // We're the first - do the initialization
129
+ state.initPromise = this.doInitialize(state);
130
+ await state.initPromise;
131
+ }
132
+
133
+ private async doInitialize(state: EsbuildGlobalState): Promise<void> {
134
+ // Import esbuild-wasm from node_modules
135
+ const esbuild = await import("esbuild-wasm");
136
+
137
+ if (typeof esbuild?.initialize !== "function") {
138
+ throw new Error(
139
+ "Failed to load esbuild-wasm: initialize function not found"
140
+ );
141
+ }
142
+
143
+ // In Node.js/Bun/Deno, esbuild-wasm automatically loads the WASM
144
+ // from node_modules without needing a wasmURL option.
145
+ // The wasmURL option is only for browsers.
146
+ await esbuild.initialize({});
147
+
148
+ // Store in global state
149
+ state.esbuild = esbuild;
150
+ state.initialized = true;
151
+ }
152
+
153
+ /**
154
+ * Get the initialized esbuild instance.
155
+ */
156
+ private getEsbuild(): typeof EsbuildTypes {
157
+ const state = getGlobalState();
158
+ if (!state.esbuild) {
159
+ throw new Error("esbuild not initialized - call initialize() first");
160
+ }
161
+ return state.esbuild;
162
+ }
163
+
164
+ /**
165
+ * Dispose of the esbuild WASM service.
166
+ * This stops the esbuild service and allows the process to exit.
167
+ *
168
+ * Note: Since esbuild-wasm uses a global singleton, this affects all
169
+ * instances. After dispose(), you'll need to create a new bundler.
170
+ */
171
+ async dispose(): Promise<void> {
172
+ const state = getGlobalState();
173
+ if (state.esbuild) {
174
+ await state.esbuild.stop();
175
+ state.esbuild = null;
176
+ state.initialized = false;
177
+ state.initPromise = null;
178
+ }
179
+ }
180
+
181
+ async bundle(options: BundleOptions): Promise<BundleResult> {
182
+ await this.initialize();
183
+
184
+ const esbuild = this.getEsbuild();
185
+
186
+ const {
187
+ fs,
188
+ entryPoint,
189
+ installedPackages = {},
190
+ sharedModules = [],
191
+ sharedModuleRegistry,
192
+ external = [],
193
+ format = "esm",
194
+ minify = false,
195
+ sourcemap = false,
196
+ target = ["es2020"],
197
+ } = options;
198
+
199
+ // Normalize entry point to absolute path
200
+ const normalizedEntry = entryPoint.startsWith("/")
201
+ ? entryPoint
202
+ : `/${entryPoint}`;
203
+
204
+ // Verify entry point exists
205
+ if (!fs.exists(normalizedEntry)) {
206
+ return {
207
+ success: false,
208
+ errors: [{ text: `Entry point not found: ${normalizedEntry}` }],
209
+ warnings: [],
210
+ };
211
+ }
212
+
213
+ // Track files included in the bundle
214
+ const includedFiles = new Set<string>();
215
+
216
+ // Create the VFS plugin
217
+ // Note: bundleCdnImports is true for Node/Bun because they cannot
218
+ // resolve HTTP imports at runtime - esbuild will fetch and bundle them
219
+ const plugin = createVfsPlugin({
220
+ fs,
221
+ entryPoint: normalizedEntry,
222
+ installedPackages,
223
+ sharedModules: new Set(sharedModules),
224
+ sharedModuleRegistry: sharedModuleRegistry ?? null,
225
+ cdnBaseUrl: this.options.cdnBaseUrl!,
226
+ includedFiles,
227
+ bundleCdnImports: true,
228
+ });
229
+
230
+ try {
231
+ // Run esbuild
232
+ // Note: We do NOT mark http/https as external here because Node/Bun
233
+ // cannot resolve HTTP imports at runtime. Instead, bundleCdnImports: true
234
+ // tells the VFS plugin to let esbuild fetch and bundle CDN imports.
235
+ const result = await esbuild.build({
236
+ entryPoints: [normalizedEntry],
237
+ bundle: true,
238
+ write: false,
239
+ format,
240
+ minify,
241
+ sourcemap: sourcemap ? "inline" : false,
242
+ target,
243
+ external,
244
+ // Cast to esbuild's Plugin type since our minimal interface is compatible
245
+ plugins: [plugin as EsbuildTypes.Plugin],
246
+ jsx: "automatic",
247
+ });
248
+
249
+ const code = result.outputFiles?.[0]?.text ?? "";
250
+
251
+ // Convert esbuild warnings to our format
252
+ const warnings: BundleWarning[] = result.warnings.map((w) =>
253
+ convertEsbuildMessage(w)
254
+ );
255
+
256
+ return {
257
+ success: true,
258
+ code,
259
+ warnings,
260
+ includedFiles: Array.from(includedFiles),
261
+ };
262
+ } catch (err) {
263
+ // esbuild throws BuildFailure with .errors array
264
+ if (isEsbuildBuildFailure(err)) {
265
+ const errors: BundleError[] = err.errors.map((e) =>
266
+ convertEsbuildMessage(e)
267
+ );
268
+ const warnings: BundleWarning[] = err.warnings.map((w) =>
269
+ convertEsbuildMessage(w)
270
+ );
271
+ return {
272
+ success: false,
273
+ errors,
274
+ warnings,
275
+ };
276
+ }
277
+
278
+ // Unknown error - wrap it
279
+ const message = err instanceof Error ? err.message : String(err);
280
+ return {
281
+ success: false,
282
+ errors: [{ text: message }],
283
+ warnings: [],
284
+ };
285
+ }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Create an esbuild-wasm bundler for Node.js/Bun/Deno.
291
+ *
292
+ * This is primarily useful for testing consistency with the browser bundler.
293
+ * For production use, prefer createEsbuildNativeBundler() which is ~3-5x faster.
294
+ */
295
+ export function createEsbuildWasmNodeBundler(
296
+ options?: EsbuildWasmNodeBundlerOptions
297
+ ): EsbuildWasmNodeBundler {
298
+ return new EsbuildWasmNodeBundler(options);
299
+ }
package/src/types.ts CHANGED
@@ -22,6 +22,12 @@ export interface IBundler {
22
22
  * Bundle source files from a filesystem into a single output
23
23
  */
24
24
  bundle(options: BundleOptions): Promise<BundleResult>;
25
+
26
+ /**
27
+ * Dispose of resources held by the bundler (optional).
28
+ * Called by Sandlot.dispose() to clean up background services.
29
+ */
30
+ dispose?(): Promise<void>;
25
31
  }
26
32
 
27
33
  export interface BundleOptions {
@@ -669,4 +675,25 @@ export interface Sandlot {
669
675
  * The shared module registry (if shared modules were provided)
670
676
  */
671
677
  readonly sharedModules: ISharedModuleRegistry | null;
678
+
679
+ /**
680
+ * Dispose of resources held by this Sandlot instance.
681
+ *
682
+ * This should be called when you're done using Sandlot to allow
683
+ * the process to exit cleanly. It stops any background services
684
+ * like the esbuild child process.
685
+ *
686
+ * After calling dispose(), this instance should not be used.
687
+ *
688
+ * @example
689
+ * ```ts
690
+ * const sandlot = await createNodeSandlot();
691
+ * const sandbox = await sandlot.createSandbox();
692
+ *
693
+ * // ... do work ...
694
+ *
695
+ * await sandlot.dispose(); // Allow process to exit
696
+ * ```
697
+ */
698
+ dispose(): Promise<void>;
672
699
  }