sandlot 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.
Files changed (47) hide show
  1. package/README.md +616 -0
  2. package/dist/bundler.d.ts +148 -0
  3. package/dist/bundler.d.ts.map +1 -0
  4. package/dist/commands.d.ts +179 -0
  5. package/dist/commands.d.ts.map +1 -0
  6. package/dist/fs.d.ts +125 -0
  7. package/dist/fs.d.ts.map +1 -0
  8. package/dist/index.d.ts +16 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +2920 -0
  11. package/dist/internal.d.ts +74 -0
  12. package/dist/internal.d.ts.map +1 -0
  13. package/dist/internal.js +1897 -0
  14. package/dist/loader.d.ts +164 -0
  15. package/dist/loader.d.ts.map +1 -0
  16. package/dist/packages.d.ts +199 -0
  17. package/dist/packages.d.ts.map +1 -0
  18. package/dist/react.d.ts +159 -0
  19. package/dist/react.d.ts.map +1 -0
  20. package/dist/react.js +149 -0
  21. package/dist/sandbox-manager.d.ts +249 -0
  22. package/dist/sandbox-manager.d.ts.map +1 -0
  23. package/dist/sandbox.d.ts +193 -0
  24. package/dist/sandbox.d.ts.map +1 -0
  25. package/dist/shared-modules.d.ts +129 -0
  26. package/dist/shared-modules.d.ts.map +1 -0
  27. package/dist/shared-resources.d.ts +105 -0
  28. package/dist/shared-resources.d.ts.map +1 -0
  29. package/dist/ts-libs.d.ts +98 -0
  30. package/dist/ts-libs.d.ts.map +1 -0
  31. package/dist/typechecker.d.ts +127 -0
  32. package/dist/typechecker.d.ts.map +1 -0
  33. package/package.json +64 -0
  34. package/src/bundler.ts +513 -0
  35. package/src/commands.ts +733 -0
  36. package/src/fs.ts +935 -0
  37. package/src/index.ts +149 -0
  38. package/src/internal.ts +116 -0
  39. package/src/loader.ts +229 -0
  40. package/src/packages.ts +936 -0
  41. package/src/react.tsx +331 -0
  42. package/src/sandbox-manager.ts +490 -0
  43. package/src/sandbox.ts +402 -0
  44. package/src/shared-modules.ts +210 -0
  45. package/src/shared-resources.ts +169 -0
  46. package/src/ts-libs.ts +320 -0
  47. package/src/typechecker.ts +635 -0
package/src/bundler.ts ADDED
@@ -0,0 +1,513 @@
1
+ import type { IFileSystem } from "just-bash/browser";
2
+ import * as esbuild from "esbuild-wasm";
3
+ import { getPackageManifest, resolveToEsmUrl } from "./packages";
4
+ import { getSharedModuleRuntimeCode } from "./shared-modules";
5
+
6
+ /**
7
+ * How to handle npm package imports (bare imports like "react").
8
+ *
9
+ * - "cdn" (default): Rewrite to esm.sh CDN URLs using installed package versions.
10
+ * Requires packages to be installed via the `install` command.
11
+ * - "external": Mark as external, don't rewrite. The consumer must handle
12
+ * module resolution (useful for SSR or custom bundling).
13
+ * - "bundle": Attempt to bundle from node_modules. Rarely useful in browser
14
+ * since node_modules typically doesn't exist in the virtual filesystem.
15
+ */
16
+ export type NpmImportsMode = "cdn" | "external" | "bundle";
17
+
18
+ /**
19
+ * Options for bundling
20
+ */
21
+ export interface BundleOptions {
22
+ /**
23
+ * The virtual filesystem to read source files from
24
+ */
25
+ fs: IFileSystem;
26
+
27
+ /**
28
+ * Entry point path (absolute path in the virtual filesystem)
29
+ */
30
+ entryPoint: string;
31
+
32
+ /**
33
+ * Module names to mark as external (won't be bundled).
34
+ * These are in addition to bare imports when using npmImports: "external".
35
+ */
36
+ external?: string[];
37
+
38
+ /**
39
+ * How to handle npm package imports (bare imports like "react").
40
+ *
41
+ * - "cdn" (default): Rewrite to esm.sh CDN URLs using installed package versions
42
+ * - "external": Mark as external, don't rewrite (consumer must handle)
43
+ * - "bundle": Attempt to bundle from node_modules (rarely useful in browser)
44
+ */
45
+ npmImports?: NpmImportsMode;
46
+
47
+ /**
48
+ * Module IDs that should be resolved from the host's SharedModuleRegistry
49
+ * instead of esm.sh CDN. The host must have registered these modules.
50
+ *
51
+ * Example: ['react', 'react-dom/client']
52
+ *
53
+ * When specified, imports of these modules will use the host's instances,
54
+ * allowing dynamic components to share React context, hooks, etc.
55
+ */
56
+ sharedModules?: string[];
57
+
58
+ /**
59
+ * Output format: 'esm' (default), 'iife', or 'cjs'
60
+ */
61
+ format?: "esm" | "iife" | "cjs";
62
+
63
+ /**
64
+ * Enable minification
65
+ * Default: false
66
+ */
67
+ minify?: boolean;
68
+
69
+ /**
70
+ * Enable source maps (inline)
71
+ * Default: false
72
+ */
73
+ sourcemap?: boolean;
74
+
75
+ /**
76
+ * Global name for IIFE format
77
+ */
78
+ globalName?: string;
79
+
80
+ /**
81
+ * Target environment(s)
82
+ * Default: ['es2020']
83
+ */
84
+ target?: string[];
85
+ }
86
+
87
+ /**
88
+ * Result of bundling
89
+ */
90
+ export interface BundleResult {
91
+ /**
92
+ * The bundled JavaScript code
93
+ */
94
+ code: string;
95
+
96
+ /**
97
+ * Any warnings from esbuild
98
+ */
99
+ warnings: esbuild.Message[];
100
+
101
+ /**
102
+ * List of files that were included in the bundle
103
+ */
104
+ includedFiles: string[];
105
+ }
106
+
107
+ // Track initialization state
108
+ let initialized = false;
109
+ let initPromise: Promise<void> | null = null;
110
+
111
+ /**
112
+ * Get the esbuild-wasm binary URL based on the installed version
113
+ */
114
+ function getWasmUrl(): string {
115
+ // Use unpkg CDN to fetch the WASM binary matching our installed version
116
+ const version = "0.27.2"; // Match installed version
117
+ return `https://unpkg.com/esbuild-wasm@${version}/esbuild.wasm`;
118
+ }
119
+
120
+ /**
121
+ * Initialize esbuild-wasm. Called automatically on first bundle.
122
+ * Can be called explicitly to pre-warm.
123
+ */
124
+ export async function initBundler(): Promise<void> {
125
+ if (initialized) return;
126
+
127
+ if (initPromise) {
128
+ await initPromise;
129
+ return;
130
+ }
131
+
132
+ initPromise = esbuild.initialize({
133
+ wasmURL: getWasmUrl(),
134
+ });
135
+
136
+ await initPromise;
137
+ initialized = true;
138
+ }
139
+
140
+ /**
141
+ * Check if a path is a bare import (not relative or absolute)
142
+ */
143
+ function isBareImport(path: string): boolean {
144
+ return !path.startsWith(".") && !path.startsWith("/");
145
+ }
146
+
147
+ /**
148
+ * Get the appropriate loader based on file extension
149
+ */
150
+ function getLoader(path: string): esbuild.Loader {
151
+ const ext = path.split(".").pop()?.toLowerCase();
152
+ switch (ext) {
153
+ case "ts":
154
+ return "ts";
155
+ case "tsx":
156
+ return "tsx";
157
+ case "jsx":
158
+ return "jsx";
159
+ case "js":
160
+ case "mjs":
161
+ return "js";
162
+ case "json":
163
+ return "json";
164
+ case "css":
165
+ return "css";
166
+ case "txt":
167
+ return "text";
168
+ default:
169
+ return "js";
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Options for the VFS plugin
175
+ */
176
+ interface VfsPluginOptions {
177
+ fs: IFileSystem;
178
+ entryPoint: string;
179
+ npmImports: NpmImportsMode;
180
+ installedPackages: Record<string, string>;
181
+ includedFiles: Set<string>;
182
+ /** Module IDs to resolve from SharedModuleRegistry */
183
+ sharedModuleIds: Set<string>;
184
+ }
185
+
186
+ /**
187
+ * Check if an import path matches a shared module ID.
188
+ * Handles both exact matches and subpath imports (e.g., 'react-dom/client').
189
+ */
190
+ function matchesSharedModule(importPath: string, sharedModuleIds: Set<string>): string | null {
191
+ // Check exact match first
192
+ if (sharedModuleIds.has(importPath)) {
193
+ return importPath;
194
+ }
195
+
196
+ // Check if any shared module is a prefix (for subpath imports)
197
+ // e.g., if 'react-dom' is registered, 'react-dom/client' should match
198
+ for (const moduleId of sharedModuleIds) {
199
+ if (importPath === moduleId || importPath.startsWith(moduleId + '/')) {
200
+ return importPath;
201
+ }
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Create an esbuild plugin that reads from a virtual filesystem
209
+ */
210
+ function createVfsPlugin(options: VfsPluginOptions): esbuild.Plugin {
211
+ const {
212
+ fs,
213
+ entryPoint,
214
+ npmImports,
215
+ installedPackages,
216
+ includedFiles,
217
+ sharedModuleIds,
218
+ } = options;
219
+
220
+ return {
221
+ name: "virtual-fs",
222
+ setup(build) {
223
+ // Resolve all imports
224
+ build.onResolve({ filter: /.*/ }, async (args) => {
225
+ // Handle the virtual entry point
226
+ if (args.kind === "entry-point") {
227
+ return { path: entryPoint, namespace: "vfs" };
228
+ }
229
+
230
+ // Handle bare imports
231
+ if (isBareImport(args.path)) {
232
+ // Check if this module should use the shared registry
233
+ const sharedMatch = matchesSharedModule(args.path, sharedModuleIds);
234
+ if (sharedMatch) {
235
+ return {
236
+ path: sharedMatch,
237
+ namespace: "sandlot-shared"
238
+ };
239
+ }
240
+
241
+ // Handle based on npmImports mode
242
+ switch (npmImports) {
243
+ case "cdn": {
244
+ // Try to rewrite to esm.sh URL if package is installed
245
+ const esmUrl = resolveToEsmUrl(args.path, installedPackages);
246
+ if (esmUrl) {
247
+ return { path: esmUrl, external: true };
248
+ }
249
+ // Fall back to external if not installed
250
+ return { path: args.path, external: true };
251
+ }
252
+
253
+ case "external":
254
+ // Mark as external, don't rewrite
255
+ return { path: args.path, external: true };
256
+
257
+ case "bundle": {
258
+ // Try to resolve from VFS node_modules
259
+ const resolved = fs.resolvePath(args.resolveDir, `node_modules/${args.path}`);
260
+ const exists = await fs.exists(resolved);
261
+ if (exists) {
262
+ return { path: resolved, namespace: "vfs" };
263
+ }
264
+ // Fall back to external if not found
265
+ return { path: args.path, external: true };
266
+ }
267
+ }
268
+ }
269
+
270
+ // Resolve relative/absolute paths
271
+ const resolved = fs.resolvePath(args.resolveDir, args.path);
272
+
273
+ // Try with extensions if no extension provided
274
+ const extensions = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".json"];
275
+ const hasExtension = extensions.some((ext) => resolved.endsWith(ext));
276
+
277
+ if (hasExtension) {
278
+ const exists = await fs.exists(resolved);
279
+ if (exists) {
280
+ return { path: resolved, namespace: "vfs" };
281
+ }
282
+ return { errors: [{ text: `File not found: ${resolved}` }] };
283
+ }
284
+
285
+ // Try adding extensions
286
+ for (const ext of extensions) {
287
+ const withExt = resolved + ext;
288
+ if (await fs.exists(withExt)) {
289
+ return { path: withExt, namespace: "vfs" };
290
+ }
291
+ }
292
+
293
+ // Try index files
294
+ for (const ext of extensions) {
295
+ const indexPath = `${resolved}/index${ext}`;
296
+ if (await fs.exists(indexPath)) {
297
+ return { path: indexPath, namespace: "vfs" };
298
+ }
299
+ }
300
+
301
+ return { errors: [{ text: `Cannot resolve: ${args.path} from ${args.resolveDir}` }] };
302
+ });
303
+
304
+ // Load shared modules from the registry
305
+ build.onLoad({ filter: /.*/, namespace: "sandlot-shared" }, (args) => {
306
+ // Generate code that looks up the module from the global registry
307
+ const contents = `export default ${getSharedModuleRuntimeCode(args.path)};
308
+ export * from ${JSON.stringify(args.path)};
309
+ // Re-export all named exports by importing from registry
310
+ const __mod__ = ${getSharedModuleRuntimeCode(args.path)};
311
+ for (const __k__ in __mod__) {
312
+ if (__k__ !== 'default') Object.defineProperty(exports, __k__, {
313
+ enumerable: true,
314
+ get: function() { return __mod__[__k__]; }
315
+ });
316
+ }`;
317
+
318
+ // For ESM format, we need a different approach
319
+ // Generate a simple module that re-exports from registry
320
+ const esmContents = `
321
+ const __sandlot_mod__ = ${getSharedModuleRuntimeCode(args.path)};
322
+ export default __sandlot_mod__.default ?? __sandlot_mod__;
323
+ ${generateNamedExports(args.path)}
324
+ `;
325
+ return {
326
+ contents: esmContents.trim(),
327
+ loader: 'js'
328
+ };
329
+ });
330
+
331
+ // Load files from VFS
332
+ build.onLoad({ filter: /.*/, namespace: "vfs" }, async (args) => {
333
+ try {
334
+ const contents = await fs.readFile(args.path);
335
+ includedFiles.add(args.path);
336
+ return {
337
+ contents,
338
+ loader: getLoader(args.path),
339
+ resolveDir: args.path.substring(0, args.path.lastIndexOf("/")),
340
+ };
341
+ } catch (err) {
342
+ return {
343
+ errors: [{ text: `Failed to read ${args.path}: ${err}` }],
344
+ };
345
+ }
346
+ });
347
+ },
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Generate named export statements for common React exports.
353
+ * This is a pragmatic approach - for full generality, we'd need to
354
+ * introspect the actual module, but that's not possible at build time.
355
+ */
356
+ function generateNamedExports(moduleId: string): string {
357
+ // Common exports for well-known modules
358
+ const knownExports: Record<string, string[]> = {
359
+ 'react': [
360
+ 'useState', 'useEffect', 'useContext', 'useReducer', 'useCallback',
361
+ 'useMemo', 'useRef', 'useImperativeHandle', 'useLayoutEffect',
362
+ 'useDebugValue', 'useDeferredValue', 'useTransition', 'useId',
363
+ 'useSyncExternalStore', 'useInsertionEffect', 'useOptimistic', 'useActionState',
364
+ 'createElement', 'cloneElement', 'createContext', 'forwardRef', 'lazy', 'memo',
365
+ 'startTransition', 'Children', 'Component', 'PureComponent', 'Fragment',
366
+ 'Profiler', 'StrictMode', 'Suspense', 'version', 'isValidElement',
367
+ ],
368
+ 'react-dom': ['createPortal', 'flushSync', 'version'],
369
+ 'react-dom/client': ['createRoot', 'hydrateRoot'],
370
+ 'react-dom/server': ['renderToString', 'renderToStaticMarkup', 'renderToPipeableStream'],
371
+ };
372
+
373
+ const exports = knownExports[moduleId];
374
+ if (!exports) {
375
+ // For unknown modules, generate a proxy that re-exports everything
376
+ return `
377
+ // Dynamic re-export for unknown module
378
+ export const __moduleProxy__ = __sandlot_mod__;
379
+ `;
380
+ }
381
+
382
+ return exports
383
+ .map(name => `export const ${name} = __sandlot_mod__.${name};`)
384
+ .join('\n');
385
+ }
386
+
387
+ /**
388
+ * Bundle TypeScript/JavaScript files from a virtual filesystem
389
+ *
390
+ * @example
391
+ * ```ts
392
+ * const fs = IndexedDbFs.createInMemory({
393
+ * initialFiles: {
394
+ * "/src/index.ts": "export const hello = 'world';",
395
+ * "/src/utils.ts": "export function add(a: number, b: number) { return a + b; }",
396
+ * }
397
+ * });
398
+ *
399
+ * const result = await bundle({
400
+ * fs,
401
+ * entryPoint: "/src/index.ts",
402
+ * });
403
+ *
404
+ * console.log(result.code);
405
+ * ```
406
+ */
407
+ export async function bundle(options: BundleOptions): Promise<BundleResult> {
408
+ await initBundler();
409
+
410
+ const {
411
+ fs,
412
+ entryPoint,
413
+ external = [],
414
+ npmImports = "cdn",
415
+ sharedModules = [],
416
+ format = "esm",
417
+ minify = false,
418
+ sourcemap = false,
419
+ globalName,
420
+ target = ["es2020"],
421
+ } = options;
422
+
423
+ // Normalize entry point
424
+ const normalizedEntry = entryPoint.startsWith("/") ? entryPoint : `/${entryPoint}`;
425
+
426
+ // Verify entry point exists
427
+ if (!(await fs.exists(normalizedEntry))) {
428
+ throw new Error(`Entry point not found: ${normalizedEntry}`);
429
+ }
430
+
431
+ // Get installed packages for ESM URL rewriting
432
+ const manifest = await getPackageManifest(fs);
433
+ const installedPackages = manifest.dependencies;
434
+
435
+ // Create set of shared module IDs for fast lookup
436
+ const sharedModuleIds = new Set(sharedModules);
437
+
438
+ const includedFiles = new Set<string>();
439
+ const plugin = createVfsPlugin({
440
+ fs,
441
+ entryPoint: normalizedEntry,
442
+ npmImports,
443
+ installedPackages,
444
+ includedFiles,
445
+ sharedModuleIds,
446
+ });
447
+
448
+ const result = await esbuild.build({
449
+ entryPoints: [normalizedEntry],
450
+ bundle: true,
451
+ write: false,
452
+ format,
453
+ minify,
454
+ sourcemap: sourcemap ? "inline" : false,
455
+ globalName,
456
+ target,
457
+ external,
458
+ plugins: [plugin],
459
+ });
460
+
461
+ const code = result.outputFiles?.[0]?.text ?? "";
462
+
463
+ return {
464
+ code,
465
+ warnings: result.warnings,
466
+ includedFiles: Array.from(includedFiles),
467
+ };
468
+ }
469
+
470
+ /**
471
+ * Bundle and return a blob URL for dynamic import
472
+ *
473
+ * @example
474
+ * ```ts
475
+ * const url = await bundleToUrl({
476
+ * fs,
477
+ * entryPoint: "/src/index.ts",
478
+ * });
479
+ *
480
+ * const module = await import(url);
481
+ * console.log(module.hello); // 'world'
482
+ *
483
+ * // Clean up when done
484
+ * URL.revokeObjectURL(url);
485
+ * ```
486
+ */
487
+ export async function bundleToUrl(options: BundleOptions): Promise<string> {
488
+ const result = await bundle(options);
489
+ const blob = new Blob([result.code], { type: "application/javascript" });
490
+ return URL.createObjectURL(blob);
491
+ }
492
+
493
+ /**
494
+ * Bundle and immediately import the module
495
+ *
496
+ * @example
497
+ * ```ts
498
+ * const module = await bundleAndImport<{ hello: string }>({
499
+ * fs,
500
+ * entryPoint: "/src/index.ts",
501
+ * });
502
+ *
503
+ * console.log(module.hello); // 'world'
504
+ * ```
505
+ */
506
+ export async function bundleAndImport<T = unknown>(options: BundleOptions): Promise<T> {
507
+ const url = await bundleToUrl(options);
508
+ try {
509
+ return await import(/* @vite-ignore */ url);
510
+ } finally {
511
+ URL.revokeObjectURL(url);
512
+ }
513
+ }