sandlot 0.1.2 → 0.1.4

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 (57) hide show
  1. package/README.md +138 -408
  2. package/dist/build-emitter.d.ts +31 -13
  3. package/dist/build-emitter.d.ts.map +1 -1
  4. package/dist/builder.d.ts +370 -0
  5. package/dist/builder.d.ts.map +1 -0
  6. package/dist/bundler.d.ts +6 -2
  7. package/dist/bundler.d.ts.map +1 -1
  8. package/dist/commands/compile.d.ts +13 -0
  9. package/dist/commands/compile.d.ts.map +1 -0
  10. package/dist/commands/index.d.ts +17 -0
  11. package/dist/commands/index.d.ts.map +1 -0
  12. package/dist/commands/packages.d.ts +17 -0
  13. package/dist/commands/packages.d.ts.map +1 -0
  14. package/dist/commands/run.d.ts +40 -0
  15. package/dist/commands/run.d.ts.map +1 -0
  16. package/dist/commands/types.d.ts +141 -0
  17. package/dist/commands/types.d.ts.map +1 -0
  18. package/dist/fs.d.ts +53 -49
  19. package/dist/fs.d.ts.map +1 -1
  20. package/dist/index.d.ts +5 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +300 -511
  23. package/dist/internal.js +161 -171
  24. package/dist/runner.d.ts +314 -0
  25. package/dist/runner.d.ts.map +1 -0
  26. package/dist/sandbox-manager.d.ts +45 -21
  27. package/dist/sandbox-manager.d.ts.map +1 -1
  28. package/dist/sandbox.d.ts +144 -62
  29. package/dist/sandbox.d.ts.map +1 -1
  30. package/dist/shared-modules.d.ts +22 -3
  31. package/dist/shared-modules.d.ts.map +1 -1
  32. package/dist/shared-resources.d.ts +0 -3
  33. package/dist/shared-resources.d.ts.map +1 -1
  34. package/dist/ts-libs.d.ts +7 -20
  35. package/dist/ts-libs.d.ts.map +1 -1
  36. package/dist/typechecker.d.ts +1 -1
  37. package/package.json +5 -5
  38. package/src/build-emitter.ts +32 -29
  39. package/src/builder.ts +498 -0
  40. package/src/bundler.ts +76 -55
  41. package/src/commands/compile.ts +236 -0
  42. package/src/commands/index.ts +51 -0
  43. package/src/commands/packages.ts +154 -0
  44. package/src/commands/run.ts +245 -0
  45. package/src/commands/types.ts +172 -0
  46. package/src/fs.ts +82 -221
  47. package/src/index.ts +17 -12
  48. package/src/sandbox.ts +219 -149
  49. package/src/shared-modules.ts +74 -4
  50. package/src/shared-resources.ts +0 -3
  51. package/src/ts-libs.ts +19 -121
  52. package/src/typechecker.ts +1 -1
  53. package/dist/react.d.ts +0 -159
  54. package/dist/react.d.ts.map +0 -1
  55. package/dist/react.js +0 -149
  56. package/src/commands.ts +0 -733
  57. package/src/sandbox-manager.ts +0 -409
package/src/sandbox.ts CHANGED
@@ -1,18 +1,41 @@
1
- import { Bash, defineCommand } from "just-bash/browser";
2
- import { IndexedDbFs, type IndexedDbFsOptions } from "./fs";
3
- import { initBundler, type BundleResult } from "./bundler";
4
- import { createDefaultCommands, type CommandDeps } from "./commands";
1
+ import { Bash, defineCommand, type BashOptions } from "just-bash/browser";
2
+ import { Filesystem, type FilesystemOptions } from "./fs";
3
+ import { initBundler } from "./bundler";
4
+ import { createDefaultCommands, type CommandDeps, type BuildOutput, type ValidateFn } from "./commands";
5
5
  import { getDefaultResources, type SharedResources } from "./shared-resources";
6
6
  import { BuildEmitter } from "./build-emitter";
7
+ import { installPackage, parseImportPath } from "./packages";
8
+
9
+ /**
10
+ * Options that can be passed through to the just-bash Bash constructor.
11
+ * Excludes options that sandlot controls internally (fs, customCommands, files, cwd).
12
+ * The working directory is always root (/).
13
+ */
14
+ export type SandboxBashOptions = Omit<BashOptions, 'fs' | 'customCommands' | 'files' | 'cwd'>;
7
15
 
8
16
  /**
9
17
  * Options for creating a sandbox environment
10
18
  */
11
19
  export interface SandboxOptions {
12
20
  /**
13
- * Options for the IndexedDB filesystem
21
+ * Initial files to populate the filesystem with.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const sandbox = await createSandbox({
26
+ * initialFiles: {
27
+ * '/src/index.ts': 'export const x = 1;',
28
+ * '/tsconfig.json': JSON.stringify({ compilerOptions: { strict: true } }),
29
+ * },
30
+ * });
31
+ * ```
32
+ */
33
+ initialFiles?: Record<string, string>;
34
+
35
+ /**
36
+ * Maximum filesystem size in bytes (default: 50MB)
14
37
  */
15
- fsOptions?: IndexedDbFsOptions;
38
+ maxFilesystemSize?: number;
16
39
 
17
40
  /**
18
41
  * Path to tsconfig.json in the virtual filesystem.
@@ -31,10 +54,12 @@ export interface SandboxOptions {
31
54
 
32
55
  /**
33
56
  * Callback invoked when a build succeeds.
34
- * Receives the bundle result with the compiled code.
35
- * Use this to dynamically import the bundle or halt the agent.
57
+ * Receives the build output with the bundle and loaded module.
58
+ *
59
+ * For agent workflows, prefer using `createBuilder()` which handles
60
+ * build capture automatically.
36
61
  */
37
- onBuild?: (result: BundleResult) => void | Promise<void>;
62
+ onBuild?: (result: BuildOutput) => void | Promise<void>;
38
63
 
39
64
  /**
40
65
  * Additional custom commands to add to the bash environment
@@ -45,29 +70,63 @@ export interface SandboxOptions {
45
70
  * Module IDs that should be resolved from the host's SharedModuleRegistry
46
71
  * instead of esm.sh CDN. The host must have registered these modules
47
72
  * using `registerSharedModules()` before loading dynamic code.
48
- *
73
+ *
49
74
  * This solves the "multiple React instances" problem by allowing dynamic
50
75
  * components to share the same React instance as the host application.
51
- *
76
+ *
77
+ * Type definitions are automatically installed for these modules so that
78
+ * TypeScript can typecheck code that imports them.
79
+ *
52
80
  * @example
53
81
  * ```ts
54
82
  * // Host setup
55
83
  * import * as React from 'react';
56
84
  * import * as ReactDOM from 'react-dom/client';
57
85
  * import { registerSharedModules } from 'sandlot';
58
- *
86
+ *
59
87
  * registerSharedModules({
60
88
  * 'react': React,
61
89
  * 'react-dom/client': ReactDOM,
62
90
  * });
63
- *
64
- * // Create sandbox with shared modules
65
- * const sandbox = await createInMemorySandbox({
91
+ *
92
+ * // Create sandbox with shared modules (types auto-installed)
93
+ * const sandbox = await createSandbox({
66
94
  * sharedModules: ['react', 'react-dom/client'],
67
95
  * });
68
96
  * ```
69
97
  */
70
98
  sharedModules?: string[];
99
+
100
+ /**
101
+ * Options passed through to the just-bash Bash constructor.
102
+ * Use this to configure environment variables, execution limits,
103
+ * network access, logging, and other bash-level settings.
104
+ *
105
+ * Note: `fs`, `customCommands`, `files`, and `cwd` are controlled by sandlot
106
+ * and cannot be overridden here. The working directory is always root (/).
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * const sandbox = await createSandbox({
111
+ * bashOptions: {
112
+ * env: { NODE_ENV: 'development' },
113
+ * executionLimits: { maxCommandCount: 1000 },
114
+ * },
115
+ * });
116
+ * ```
117
+ */
118
+ bashOptions?: SandboxBashOptions;
119
+ }
120
+
121
+ /**
122
+ * Sandbox state that can be serialized for persistence.
123
+ */
124
+ export interface SandboxState {
125
+ /**
126
+ * All files in the filesystem as path -> content mapping.
127
+ * Can be passed as `initialFiles` when creating a new sandbox.
128
+ */
129
+ files: Record<string, string>;
71
130
  }
72
131
 
73
132
  /**
@@ -75,9 +134,9 @@ export interface SandboxOptions {
75
134
  */
76
135
  export interface Sandbox {
77
136
  /**
78
- * The virtual filesystem (IndexedDB-backed)
137
+ * The virtual filesystem
79
138
  */
80
- fs: IndexedDbFs;
139
+ fs: Filesystem;
81
140
 
82
141
  /**
83
142
  * The just-bash shell environment
@@ -85,55 +144,94 @@ export interface Sandbox {
85
144
  bash: Bash;
86
145
 
87
146
  /**
88
- * Check if there are unsaved changes in the filesystem.
89
- *
147
+ * The last successful build output, or null if no build has succeeded yet.
148
+ *
149
+ * This is updated automatically whenever a `build` command succeeds.
150
+ * Contains both the bundle and the loaded (and validated, if applicable) module.
151
+ *
90
152
  * @example
91
153
  * ```ts
92
- * await sandbox.bash.exec('echo "hello" > /test.txt');
93
- * console.log(sandbox.isDirty()); // true
94
- * await sandbox.save();
95
- * console.log(sandbox.isDirty()); // false
154
+ * // Agent loop pattern
155
+ * while (!sandbox.lastBuild) {
156
+ * const response = await agent.step();
157
+ * await sandbox.bash.exec(response.command);
158
+ * }
159
+ * // Build succeeded, sandbox.lastBuild contains bundle + module
160
+ * const App = sandbox.lastBuild.module.App;
96
161
  * ```
97
162
  */
98
- isDirty(): boolean;
163
+ lastBuild: BuildOutput | null;
99
164
 
100
165
  /**
101
- * Save the filesystem to IndexedDB.
166
+ * Get the current sandbox state for persistence.
167
+ *
168
+ * Returns a serializable object containing all files that can be
169
+ * JSON-serialized and used to restore the sandbox later.
102
170
  *
103
- * @returns true if saved, false if nothing to save or in-memory only
171
+ * @example
172
+ * ```ts
173
+ * // Save sandbox state
174
+ * const state = sandbox.getState();
175
+ * localStorage.setItem('my-project', JSON.stringify(state));
176
+ *
177
+ * // Later, restore the sandbox
178
+ * const saved = JSON.parse(localStorage.getItem('my-project'));
179
+ * const sandbox2 = await createSandbox({ initialFiles: saved.files });
180
+ * ```
104
181
  */
105
- save(): Promise<boolean>;
182
+ getState(): SandboxState;
106
183
 
107
184
  /**
108
- * Close all resources (filesystem, etc.)
185
+ * Subscribe to build events. Called whenever a build succeeds.
186
+ * Returns an unsubscribe function.
187
+ *
188
+ * For agent workflows, prefer using `createBuilder()` which handles
189
+ * build capture automatically. Use `onBuild` directly when you need
190
+ * more control over the subscription lifecycle.
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * let lastBuild: BuildOutput | null = null;
195
+ * const unsubscribe = sandbox.onBuild((result) => {
196
+ * lastBuild = result;
197
+ * });
198
+ *
199
+ * await sandbox.bash.exec('build /src/index.ts');
200
+ * unsubscribe();
201
+ *
202
+ * if (lastBuild) {
203
+ * const App = lastBuild.module.App as React.ComponentType;
204
+ * }
205
+ * ```
109
206
  */
110
- close(): void;
207
+ onBuild(callback: (result: BuildOutput) => void | Promise<void>): () => void;
111
208
 
112
209
  /**
113
- * Subscribe to build events. Called whenever a build succeeds.
114
- * Returns an unsubscribe function.
115
- *
116
- * Use this to capture the bundle result when build succeeds.
117
- * The callback receives the BundleResult with the compiled code.
118
- *
210
+ * Set a validation function for the build command.
211
+ *
212
+ * When set, the build command will run this function after loading
213
+ * the module. If validation throws, the build fails and the agent
214
+ * sees the error. If validation passes, the validated module is
215
+ * available in the build output.
216
+ *
119
217
  * @example
120
218
  * ```ts
121
- * let bundle: BundleResult | null = null;
122
- *
123
- * const sandbox = await createInMemorySandbox({
124
- * onBuild: (result) => {
125
- * bundle = result;
126
- * console.log('Built:', result.code.length, 'bytes');
127
- * },
219
+ * sandbox.setValidation((mod) => {
220
+ * if (!mod.App) throw new Error("Must export App component");
221
+ * return { App: mod.App as React.ComponentType };
128
222
  * });
129
- *
130
- * // ... agent writes files and calls build ...
131
- *
132
- * // After successful build, bundle is available
133
- * console.log(bundle?.code);
223
+ *
224
+ * // Now build will fail if App is missing
225
+ * await sandbox.bash.exec('build /src/index.ts');
134
226
  * ```
135
227
  */
136
- onBuild(callback: (result: BundleResult) => void | Promise<void>): () => void;
228
+ setValidation(fn: ValidateFn): void;
229
+
230
+ /**
231
+ * Clear the validation function.
232
+ * After calling this, builds will not perform validation.
233
+ */
234
+ clearValidation(): void;
137
235
  }
138
236
 
139
237
  /**
@@ -143,9 +241,12 @@ export interface Sandbox {
143
241
  * The sandbox provides a just-bash shell with custom commands:
144
242
  * - `tsc [entry]` - Type check the project
145
243
  * - `build [entry] [options]` - Build the project (runs typecheck first)
244
+ * - `install <pkg>` - Install npm packages
245
+ * - `uninstall <pkg>` - Remove packages
246
+ * - `list` - List installed packages
247
+ * - `run <entry>` - Run a script
146
248
  *
147
249
  * Build options:
148
- * - `--output, -o <path>` - Output path (default: /dist/bundle.js)
149
250
  * - `--format, -f <esm|iife|cjs>` - Output format (default: esm)
150
251
  * - `--minify, -m` - Enable minification
151
252
  * - `--skip-typecheck, -s` - Skip type checking
@@ -155,18 +256,14 @@ export interface Sandbox {
155
256
  * let bundleResult: BundleResult | null = null;
156
257
  *
157
258
  * const sandbox = await createSandbox({
158
- * fsOptions: {
159
- * dbName: "my-project",
160
- * initialFiles: {
161
- * "/src/index.ts": "export const hello = 'world';",
162
- * "/tsconfig.json": JSON.stringify({
163
- * compilerOptions: { target: "ES2020", strict: true }
164
- * }),
165
- * },
259
+ * initialFiles: {
260
+ * '/src/index.ts': 'export const hello = "world";',
261
+ * '/tsconfig.json': JSON.stringify({
262
+ * compilerOptions: { target: 'ES2020', strict: true }
263
+ * }),
166
264
  * },
167
265
  * onBuild: (result) => {
168
266
  * bundleResult = result;
169
- * // Could also: dynamically import, halt agent, etc.
170
267
  * },
171
268
  * });
172
269
  *
@@ -174,155 +271,128 @@ export interface Sandbox {
174
271
  * await sandbox.bash.exec('echo "console.log(1);" > /src/index.ts');
175
272
  *
176
273
  * // Type check
177
- * const tscResult = await sandbox.bash.exec("tsc");
274
+ * const tscResult = await sandbox.bash.exec('tsc /src/index.ts');
178
275
  * console.log(tscResult.stdout);
179
276
  *
180
277
  * // Build (includes typecheck, triggers onBuild callback)
181
- * const buildResult = await sandbox.bash.exec("build");
278
+ * const buildResult = await sandbox.bash.exec('build /src/index.ts');
182
279
  * console.log(buildResult.stdout);
183
280
  * console.log(bundleResult?.code); // The compiled bundle
184
281
  *
185
- * // Save to IndexedDB
186
- * await sandbox.save();
187
- *
188
- * // Clean up
189
- * sandbox.close();
282
+ * // Save state for later
283
+ * const state = sandbox.getState();
284
+ * localStorage.setItem('my-project', JSON.stringify(state));
190
285
  * ```
191
286
  */
192
287
  export async function createSandbox(options: SandboxOptions = {}): Promise<Sandbox> {
193
288
  const {
194
- fsOptions = {},
289
+ initialFiles,
290
+ maxFilesystemSize,
195
291
  tsconfigPath = "/tsconfig.json",
196
292
  resources: providedResources,
197
293
  onBuild: onBuildCallback,
198
294
  customCommands = [],
199
295
  sharedModules,
296
+ bashOptions = {},
200
297
  } = options;
201
298
 
202
- // Start parallel initialization tasks
203
- const fsPromise = IndexedDbFs.create(fsOptions);
204
-
205
- // Determine which resources to use:
206
- // 1. Provided resources (preferred)
207
- // 2. Default resources singleton
208
- const resourcesPromise = providedResources
209
- ? Promise.resolve(providedResources)
210
- : getDefaultResources();
211
-
212
- const bundlerPromise = initBundler();
213
-
214
- // Wait for all initialization
215
- const [fs, resources] = await Promise.all([fsPromise, resourcesPromise, bundlerPromise]);
216
-
217
- // Extract lib files and types cache from resources
218
- const libFiles = resources.libFiles;
219
- const typesCache = resources.typesCache;
220
-
221
- // Create build event emitter
222
- const buildEmitter = new BuildEmitter();
223
-
224
- // If a callback was provided in options, subscribe it
225
- if (onBuildCallback) {
226
- buildEmitter.on(onBuildCallback);
227
- }
228
-
229
- // Create commands using the extracted factories
230
- // Commands emit to the build emitter
231
- const commandDeps: CommandDeps = {
232
- fs,
233
- libFiles,
234
- tsconfigPath,
235
- onBuild: buildEmitter.emit,
236
- typesCache,
237
- sharedModules,
238
- };
239
- const defaultCommands = createDefaultCommands(commandDeps);
240
-
241
- // Create bash environment with the custom filesystem
242
- const bash = new Bash({
243
- fs,
244
- cwd: "/",
245
- customCommands: [...defaultCommands, ...customCommands],
246
- });
247
-
248
- return {
249
- fs,
250
- bash,
251
- isDirty: () => fs.isDirty(),
252
- save: () => fs.save(),
253
- close: () => fs.close(),
254
- onBuild: (callback) => buildEmitter.on(callback),
255
- };
256
- }
257
-
258
- /**
259
- * Create an in-memory sandbox (no IndexedDB persistence).
260
- * Useful for testing or temporary workspaces.
261
- */
262
- export async function createInMemorySandbox(
263
- options: Omit<SandboxOptions, "fsOptions"> & {
264
- initialFiles?: Record<string, string>;
265
- } = {}
266
- ): Promise<Sandbox> {
267
- const {
299
+ // Create filesystem (synchronous)
300
+ const fs = Filesystem.create({
268
301
  initialFiles,
269
- tsconfigPath = "/tsconfig.json",
270
- resources: providedResources,
271
- onBuild: onBuildCallback,
272
- customCommands = [],
273
- sharedModules,
274
- } = options;
302
+ maxSizeBytes: maxFilesystemSize,
303
+ });
275
304
 
276
- // Determine which resources to use
305
+ // Load shared resources and bundler in parallel
277
306
  const resourcesPromise = providedResources
278
307
  ? Promise.resolve(providedResources)
279
308
  : getDefaultResources();
280
309
 
281
310
  const bundlerPromise = initBundler();
282
311
 
283
- // Create in-memory filesystem synchronously
284
- const fs = IndexedDbFs.createInMemory({ initialFiles });
285
-
286
- // Wait for resources and bundler
312
+ // Wait for async initialization
287
313
  const [resources] = await Promise.all([resourcesPromise, bundlerPromise]);
288
314
 
289
315
  // Extract lib files and types cache from resources
290
316
  const libFiles = resources.libFiles;
291
317
  const typesCache = resources.typesCache;
292
318
 
319
+ // Auto-install types for shared modules so TypeScript can typecheck them
320
+ // Only install base packages, not subpath exports (e.g., "react" not "react/jsx-runtime")
321
+ // Subpath types are fetched automatically when the base package is installed
322
+ if (sharedModules && sharedModules.length > 0) {
323
+ // Extract unique base package names
324
+ const basePackages = new Set<string>();
325
+ for (const moduleId of sharedModules) {
326
+ const { packageName } = parseImportPath(moduleId);
327
+ basePackages.add(packageName);
328
+ }
329
+
330
+ await Promise.all(
331
+ Array.from(basePackages).map(async (packageName) => {
332
+ try {
333
+ // Install the package to get its type definitions
334
+ // The runtime will use the shared module, but we need types for typechecking
335
+ await installPackage(fs, packageName, { cache: typesCache });
336
+ } catch (err) {
337
+ // Log but don't fail - module might not have types available
338
+ console.warn(`[sandlot] Failed to install types for shared module "${packageName}":`, err);
339
+ }
340
+ })
341
+ );
342
+ }
343
+
293
344
  // Create build event emitter
294
345
  const buildEmitter = new BuildEmitter();
295
346
 
347
+ // Track the last successful build
348
+ let lastBuild: BuildOutput | null = null;
349
+ buildEmitter.on((result) => {
350
+ lastBuild = result;
351
+ });
352
+
296
353
  // If a callback was provided in options, subscribe it
297
354
  if (onBuildCallback) {
298
355
  buildEmitter.on(onBuildCallback);
299
356
  }
300
357
 
301
- // Create commands using the extracted factories
302
- // Commands emit to the build emitter
358
+ // Validation function (can be set/cleared dynamically)
359
+ let validationFn: ValidateFn | null = null;
360
+
361
+ // Create commands
303
362
  const commandDeps: CommandDeps = {
304
363
  fs,
305
364
  libFiles,
306
365
  tsconfigPath,
307
366
  onBuild: buildEmitter.emit,
367
+ getValidation: () => validationFn,
308
368
  typesCache,
309
369
  sharedModules,
310
370
  };
311
371
  const defaultCommands = createDefaultCommands(commandDeps);
312
372
 
313
373
  // Create bash environment with the custom filesystem
374
+ // Always start in root directory (/) for consistent behavior
314
375
  const bash = new Bash({
376
+ ...bashOptions,
377
+ cwd: '/',
315
378
  fs,
316
- cwd: "/",
317
379
  customCommands: [...defaultCommands, ...customCommands],
318
380
  });
319
381
 
320
382
  return {
321
383
  fs,
322
384
  bash,
323
- isDirty: () => fs.isDirty(),
324
- save: () => Promise.resolve(false), // No persistence for in-memory
325
- close: () => { }, // No resources to close
385
+ get lastBuild() {
386
+ return lastBuild;
387
+ },
388
+ getState: () => ({ files: fs.getFiles() }),
326
389
  onBuild: (callback) => buildEmitter.on(callback),
390
+ setValidation: (fn: ValidateFn) => {
391
+ validationFn = fn;
392
+ },
393
+ clearValidation: () => {
394
+ validationFn = null;
395
+ },
327
396
  };
328
397
  }
398
+
@@ -21,7 +21,7 @@
21
21
  * });
22
22
  *
23
23
  * // Now create sandbox with sharedModules option
24
- * const sandbox = await createInMemorySandbox({
24
+ * const sandbox = await createSandbox({
25
25
  * sharedModules: ['react', 'react-dom/client'],
26
26
  * });
27
27
  * ```
@@ -39,6 +39,7 @@ const GLOBAL_KEY = '__sandlot_shared_modules__';
39
39
  */
40
40
  export class SharedModuleRegistry {
41
41
  private modules = new Map<string, unknown>();
42
+ private exportNames = new Map<string, string[]>();
42
43
 
43
44
  constructor() {
44
45
  // Make available globally for dynamic imports
@@ -46,7 +47,8 @@ export class SharedModuleRegistry {
46
47
  }
47
48
 
48
49
  /**
49
- * Register a module to be shared with dynamic bundles
50
+ * Register a module to be shared with dynamic bundles.
51
+ * Automatically introspects the module to discover its exports.
50
52
  *
51
53
  * @param moduleId - The import specifier (e.g., 'react', 'react-dom/client')
52
54
  * @param module - The module's exports object
@@ -54,18 +56,21 @@ export class SharedModuleRegistry {
54
56
  */
55
57
  register(moduleId: string, module: unknown): this {
56
58
  this.modules.set(moduleId, module);
59
+ // Introspect the module to get its export names
60
+ this.exportNames.set(moduleId, introspectExports(module));
57
61
  return this;
58
62
  }
59
63
 
60
64
  /**
61
- * Register multiple modules at once
65
+ * Register multiple modules at once.
66
+ * Automatically introspects each module to discover its exports.
62
67
  *
63
68
  * @param modules - Object mapping module IDs to their exports
64
69
  * @returns this for chaining
65
70
  */
66
71
  registerAll(modules: Record<string, unknown>): this {
67
72
  for (const [id, mod] of Object.entries(modules)) {
68
- this.modules.set(id, mod);
73
+ this.register(id, mod);
69
74
  }
70
75
  return this;
71
76
  }
@@ -116,11 +121,23 @@ export class SharedModuleRegistry {
116
121
  return [...this.modules.keys()];
117
122
  }
118
123
 
124
+ /**
125
+ * Get the export names for a registered module.
126
+ * These were discovered by introspecting the module at registration time.
127
+ *
128
+ * @param moduleId - The import specifier
129
+ * @returns Array of export names, or empty array if not registered
130
+ */
131
+ getExportNames(moduleId: string): string[] {
132
+ return this.exportNames.get(moduleId) ?? [];
133
+ }
134
+
119
135
  /**
120
136
  * Clear all registrations
121
137
  */
122
138
  clear(): void {
123
139
  this.modules.clear();
140
+ this.exportNames.clear();
124
141
  }
125
142
 
126
143
  /**
@@ -131,6 +148,48 @@ export class SharedModuleRegistry {
131
148
  }
132
149
  }
133
150
 
151
+ /**
152
+ * Introspect a module to discover its export names.
153
+ * Filters out non-identifier keys and internal properties.
154
+ */
155
+ function introspectExports(module: unknown): string[] {
156
+ if (module === null || module === undefined) {
157
+ return [];
158
+ }
159
+
160
+ if (typeof module !== 'object' && typeof module !== 'function') {
161
+ return [];
162
+ }
163
+
164
+ const exports: string[] = [];
165
+
166
+ // Get own enumerable properties
167
+ for (const key of Object.keys(module as object)) {
168
+ // Filter out non-valid JavaScript identifiers
169
+ if (isValidIdentifier(key)) {
170
+ exports.push(key);
171
+ }
172
+ }
173
+
174
+ return exports;
175
+ }
176
+
177
+ /**
178
+ * Check if a string is a valid JavaScript identifier.
179
+ * Used to filter out keys that can't be used as named exports.
180
+ */
181
+ function isValidIdentifier(name: string): boolean {
182
+ if (name.length === 0) return false;
183
+ // Must start with letter, underscore, or $
184
+ if (!/^[a-zA-Z_$]/.test(name)) return false;
185
+ // Rest must be alphanumeric, underscore, or $
186
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) return false;
187
+ // Exclude reserved words that would cause issues
188
+ const reserved = ['default', 'class', 'function', 'var', 'let', 'const', 'import', 'export'];
189
+ if (reserved.includes(name)) return false;
190
+ return true;
191
+ }
192
+
134
193
  // Singleton instance
135
194
  let defaultRegistry: SharedModuleRegistry | null = null;
136
195
 
@@ -190,6 +249,17 @@ export function clearSharedModules(): void {
190
249
  getSharedModuleRegistry().clear();
191
250
  }
192
251
 
252
+ /**
253
+ * Get the export names for a registered shared module.
254
+ * Used by the bundler to generate proper re-export statements.
255
+ *
256
+ * @param moduleId - The import specifier
257
+ * @returns Array of export names, or empty array if not registered
258
+ */
259
+ export function getSharedModuleExports(moduleId: string): string[] {
260
+ return getSharedModuleRegistry().getExportNames(moduleId);
261
+ }
262
+
193
263
  /**
194
264
  * Generate the runtime code that dynamic bundles use to access shared modules.
195
265
  * This is injected into bundles when they import from shared modules.
@@ -6,9 +6,6 @@
6
6
  * - esbuild WASM (~10MB) - singleton bundler initialization
7
7
  * - Types cache - avoids redundant network fetches when multiple sandboxes
8
8
  * install the same packages
9
- *
10
- * This module consolidates what was previously scattered across
11
- * sandbox.ts and sandbox-manager.ts into a single source of truth.
12
9
  */
13
10
 
14
11
  import { fetchAndCacheLibs, getDefaultBrowserLibs } from "./ts-libs";