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/sandbox.ts ADDED
@@ -0,0 +1,402 @@
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";
5
+ import { getDefaultResources, type SharedResources } from "./shared-resources";
6
+
7
+ // Re-export for convenience
8
+ export type { BundleResult } from "./bundler";
9
+ export type { TypecheckResult } from "./typechecker";
10
+ export type { SharedResources, TypesCache } from "./shared-resources";
11
+ export type { PackageManifest, InstallResult } from "./packages";
12
+ export type { RunContext, RunOptions, RunResult } from "./commands";
13
+ export { installPackage, uninstallPackage, listPackages, getPackageManifest } from "./packages";
14
+ export { InMemoryTypesCache } from "./shared-resources";
15
+
16
+ // Loader utilities
17
+ export {
18
+ loadModule,
19
+ loadExport,
20
+ loadDefault,
21
+ getExportNames,
22
+ hasExport,
23
+ createModuleUrl,
24
+ revokeModuleUrl,
25
+ ModuleLoadError,
26
+ ExportNotFoundError,
27
+ } from "./loader";
28
+
29
+ /**
30
+ * Simple typed event emitter for build results.
31
+ * Caches the last result so waitFor() can be called after build completes.
32
+ */
33
+ class BuildEmitter {
34
+ private listeners = new Set<(result: BundleResult) => void | Promise<void>>();
35
+ private lastResult: BundleResult | null = null;
36
+
37
+ /**
38
+ * Emit a build result to all listeners and cache it
39
+ */
40
+ emit = async (result: BundleResult): Promise<void> => {
41
+ this.lastResult = result;
42
+ const promises: Promise<void>[] = [];
43
+ for (const listener of this.listeners) {
44
+ const ret = listener(result);
45
+ if (ret instanceof Promise) {
46
+ promises.push(ret);
47
+ }
48
+ }
49
+ await Promise.all(promises);
50
+ };
51
+
52
+ /**
53
+ * Subscribe to build events. Returns an unsubscribe function.
54
+ */
55
+ on(callback: (result: BundleResult) => void | Promise<void>): () => void {
56
+ this.listeners.add(callback);
57
+ return () => {
58
+ this.listeners.delete(callback);
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Get the last build result, or wait for the next one if none exists.
64
+ * Clears the cached result after returning, so subsequent calls wait for new builds.
65
+ */
66
+ waitFor(): Promise<BundleResult> {
67
+ if (this.lastResult) {
68
+ const result = this.lastResult;
69
+ this.lastResult = null;
70
+ return Promise.resolve(result);
71
+ }
72
+ return new Promise((resolve) => {
73
+ const unsub = this.on((result) => {
74
+ unsub();
75
+ this.lastResult = null;
76
+ resolve(result);
77
+ });
78
+ });
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Options for creating a sandbox environment
84
+ */
85
+ export interface SandboxOptions {
86
+ /**
87
+ * Options for the IndexedDB filesystem
88
+ */
89
+ fsOptions?: IndexedDbFsOptions;
90
+
91
+ /**
92
+ * Path to tsconfig.json in the virtual filesystem.
93
+ * Default: "/tsconfig.json"
94
+ */
95
+ tsconfigPath?: string;
96
+
97
+ /**
98
+ * Shared resources (lib files, bundler).
99
+ * If not provided, uses the default singleton resources.
100
+ *
101
+ * Provide this to share resources across multiple sandboxes,
102
+ * or to use custom TypeScript libs.
103
+ */
104
+ resources?: SharedResources;
105
+
106
+ /**
107
+ * Callback invoked when a build succeeds.
108
+ * Receives the bundle result with the compiled code.
109
+ * Use this to dynamically import the bundle or halt the agent.
110
+ */
111
+ onBuild?: (result: BundleResult) => void | Promise<void>;
112
+
113
+ /**
114
+ * Additional custom commands to add to the bash environment
115
+ */
116
+ customCommands?: ReturnType<typeof defineCommand>[];
117
+
118
+ /**
119
+ * Module IDs that should be resolved from the host's SharedModuleRegistry
120
+ * instead of esm.sh CDN. The host must have registered these modules
121
+ * using `registerSharedModules()` before loading dynamic code.
122
+ *
123
+ * This solves the "multiple React instances" problem by allowing dynamic
124
+ * components to share the same React instance as the host application.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * // Host setup
129
+ * import * as React from 'react';
130
+ * import * as ReactDOM from 'react-dom/client';
131
+ * import { registerSharedModules } from 'sandlot';
132
+ *
133
+ * registerSharedModules({
134
+ * 'react': React,
135
+ * 'react-dom/client': ReactDOM,
136
+ * });
137
+ *
138
+ * // Create sandbox with shared modules
139
+ * const sandbox = await createInMemorySandbox({
140
+ * sharedModules: ['react', 'react-dom/client'],
141
+ * });
142
+ * ```
143
+ */
144
+ sharedModules?: string[];
145
+ }
146
+
147
+ /**
148
+ * The sandbox environment containing the filesystem and bash shell
149
+ */
150
+ export interface Sandbox {
151
+ /**
152
+ * The virtual filesystem (IndexedDB-backed)
153
+ */
154
+ fs: IndexedDbFs;
155
+
156
+ /**
157
+ * The just-bash shell environment
158
+ */
159
+ bash: Bash;
160
+
161
+ /**
162
+ * Check if there are unsaved changes in the filesystem.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * await sandbox.bash.exec('echo "hello" > /test.txt');
167
+ * console.log(sandbox.isDirty()); // true
168
+ * await sandbox.save();
169
+ * console.log(sandbox.isDirty()); // false
170
+ * ```
171
+ */
172
+ isDirty(): boolean;
173
+
174
+ /**
175
+ * Save the filesystem to IndexedDB.
176
+ *
177
+ * @returns true if saved, false if nothing to save or in-memory only
178
+ */
179
+ save(): Promise<boolean>;
180
+
181
+ /**
182
+ * Close all resources (filesystem, etc.)
183
+ */
184
+ close(): void;
185
+
186
+ /**
187
+ * Subscribe to build events. Called whenever a build succeeds.
188
+ * Returns an unsubscribe function.
189
+ *
190
+ * Use this to capture the bundle result when build succeeds.
191
+ * The callback receives the BundleResult with the compiled code.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * let bundle: BundleResult | null = null;
196
+ *
197
+ * const sandbox = await createInMemorySandbox({
198
+ * onBuild: (result) => {
199
+ * bundle = result;
200
+ * console.log('Built:', result.code.length, 'bytes');
201
+ * },
202
+ * });
203
+ *
204
+ * // ... agent writes files and calls build ...
205
+ *
206
+ * // After successful build, bundle is available
207
+ * console.log(bundle?.code);
208
+ * ```
209
+ */
210
+ onBuild(callback: (result: BundleResult) => void | Promise<void>): () => void;
211
+ }
212
+
213
+ /**
214
+ * Create an in-browser agent sandbox with a virtual filesystem, TypeScript
215
+ * type checking, and bundling capabilities.
216
+ *
217
+ * The sandbox provides a just-bash shell with custom commands:
218
+ * - `tsc [entry]` - Type check the project
219
+ * - `build [entry] [options]` - Build the project (runs typecheck first)
220
+ *
221
+ * Build options:
222
+ * - `--output, -o <path>` - Output path (default: /dist/bundle.js)
223
+ * - `--format, -f <esm|iife|cjs>` - Output format (default: esm)
224
+ * - `--minify, -m` - Enable minification
225
+ * - `--skip-typecheck, -s` - Skip type checking
226
+ *
227
+ * @example
228
+ * ```ts
229
+ * let bundleResult: BundleResult | null = null;
230
+ *
231
+ * const sandbox = await createSandbox({
232
+ * fsOptions: {
233
+ * dbName: "my-project",
234
+ * initialFiles: {
235
+ * "/src/index.ts": "export const hello = 'world';",
236
+ * "/tsconfig.json": JSON.stringify({
237
+ * compilerOptions: { target: "ES2020", strict: true }
238
+ * }),
239
+ * },
240
+ * },
241
+ * onBuild: (result) => {
242
+ * bundleResult = result;
243
+ * // Could also: dynamically import, halt agent, etc.
244
+ * },
245
+ * });
246
+ *
247
+ * // Use bash commands
248
+ * await sandbox.bash.exec('echo "console.log(1);" > /src/index.ts');
249
+ *
250
+ * // Type check
251
+ * const tscResult = await sandbox.bash.exec("tsc");
252
+ * console.log(tscResult.stdout);
253
+ *
254
+ * // Build (includes typecheck, triggers onBuild callback)
255
+ * const buildResult = await sandbox.bash.exec("build");
256
+ * console.log(buildResult.stdout);
257
+ * console.log(bundleResult?.code); // The compiled bundle
258
+ *
259
+ * // Save to IndexedDB
260
+ * await sandbox.save();
261
+ *
262
+ * // Clean up
263
+ * sandbox.close();
264
+ * ```
265
+ */
266
+ export async function createSandbox(options: SandboxOptions = {}): Promise<Sandbox> {
267
+ const {
268
+ fsOptions = {},
269
+ tsconfigPath = "/tsconfig.json",
270
+ resources: providedResources,
271
+ onBuild: onBuildCallback,
272
+ customCommands = [],
273
+ sharedModules,
274
+ } = options;
275
+
276
+ // Start parallel initialization tasks
277
+ const fsPromise = IndexedDbFs.create(fsOptions);
278
+
279
+ // Determine which resources to use:
280
+ // 1. Provided resources (preferred)
281
+ // 2. Default resources singleton
282
+ const resourcesPromise = providedResources
283
+ ? Promise.resolve(providedResources)
284
+ : getDefaultResources();
285
+
286
+ const bundlerPromise = initBundler();
287
+
288
+ // Wait for all initialization
289
+ const [fs, resources] = await Promise.all([fsPromise, resourcesPromise, bundlerPromise]);
290
+
291
+ // Extract lib files and types cache from resources
292
+ const libFiles = resources.libFiles;
293
+ const typesCache = resources.typesCache;
294
+
295
+ // Create build event emitter
296
+ const buildEmitter = new BuildEmitter();
297
+
298
+ // If a callback was provided in options, subscribe it
299
+ if (onBuildCallback) {
300
+ buildEmitter.on(onBuildCallback);
301
+ }
302
+
303
+ // Create commands using the extracted factories
304
+ // Commands emit to the build emitter
305
+ const commandDeps: CommandDeps = {
306
+ fs,
307
+ libFiles,
308
+ tsconfigPath,
309
+ onBuild: buildEmitter.emit,
310
+ typesCache,
311
+ sharedModules,
312
+ };
313
+ const defaultCommands = createDefaultCommands(commandDeps);
314
+
315
+ // Create bash environment with the custom filesystem
316
+ const bash = new Bash({
317
+ fs,
318
+ cwd: "/",
319
+ customCommands: [...defaultCommands, ...customCommands],
320
+ });
321
+
322
+ return {
323
+ fs,
324
+ bash,
325
+ isDirty: () => fs.isDirty(),
326
+ save: () => fs.save(),
327
+ close: () => fs.close(),
328
+ onBuild: (callback) => buildEmitter.on(callback),
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Create an in-memory sandbox (no IndexedDB persistence).
334
+ * Useful for testing or temporary workspaces.
335
+ */
336
+ export async function createInMemorySandbox(
337
+ options: Omit<SandboxOptions, "fsOptions"> & {
338
+ initialFiles?: Record<string, string>;
339
+ } = {}
340
+ ): Promise<Sandbox> {
341
+ const {
342
+ initialFiles,
343
+ tsconfigPath = "/tsconfig.json",
344
+ resources: providedResources,
345
+ onBuild: onBuildCallback,
346
+ customCommands = [],
347
+ sharedModules,
348
+ } = options;
349
+
350
+ // Determine which resources to use
351
+ const resourcesPromise = providedResources
352
+ ? Promise.resolve(providedResources)
353
+ : getDefaultResources();
354
+
355
+ const bundlerPromise = initBundler();
356
+
357
+ // Create in-memory filesystem synchronously
358
+ const fs = IndexedDbFs.createInMemory({ initialFiles });
359
+
360
+ // Wait for resources and bundler
361
+ const [resources] = await Promise.all([resourcesPromise, bundlerPromise]);
362
+
363
+ // Extract lib files and types cache from resources
364
+ const libFiles = resources.libFiles;
365
+ const typesCache = resources.typesCache;
366
+
367
+ // Create build event emitter
368
+ const buildEmitter = new BuildEmitter();
369
+
370
+ // If a callback was provided in options, subscribe it
371
+ if (onBuildCallback) {
372
+ buildEmitter.on(onBuildCallback);
373
+ }
374
+
375
+ // Create commands using the extracted factories
376
+ // Commands emit to the build emitter
377
+ const commandDeps: CommandDeps = {
378
+ fs,
379
+ libFiles,
380
+ tsconfigPath,
381
+ onBuild: buildEmitter.emit,
382
+ typesCache,
383
+ sharedModules,
384
+ };
385
+ const defaultCommands = createDefaultCommands(commandDeps);
386
+
387
+ // Create bash environment with the custom filesystem
388
+ const bash = new Bash({
389
+ fs,
390
+ cwd: "/",
391
+ customCommands: [...defaultCommands, ...customCommands],
392
+ });
393
+
394
+ return {
395
+ fs,
396
+ bash,
397
+ isDirty: () => fs.isDirty(),
398
+ save: () => Promise.resolve(false), // No persistence for in-memory
399
+ close: () => { }, // No resources to close
400
+ onBuild: (callback) => buildEmitter.on(callback),
401
+ };
402
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Shared Module Registry for Sandlot
3
+ *
4
+ * Allows host applications to register their module instances (like React)
5
+ * so that dynamically bundled code can use the same instances instead of
6
+ * loading separate copies from esm.sh CDN.
7
+ *
8
+ * This solves the "multiple React instances" problem where hooks fail
9
+ * because the host and dynamic code use different React copies.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // Host application setup
14
+ * import * as React from 'react';
15
+ * import * as ReactDOM from 'react-dom/client';
16
+ * import { registerSharedModules } from 'sandlot';
17
+ *
18
+ * registerSharedModules({
19
+ * 'react': React,
20
+ * 'react-dom/client': ReactDOM,
21
+ * });
22
+ *
23
+ * // Now create sandbox with sharedModules option
24
+ * const sandbox = await createInMemorySandbox({
25
+ * sharedModules: ['react', 'react-dom/client'],
26
+ * });
27
+ * ```
28
+ */
29
+
30
+ /**
31
+ * Global key used to expose the registry for dynamic bundles
32
+ */
33
+ const GLOBAL_KEY = '__sandlot_shared_modules__';
34
+
35
+ /**
36
+ * Registry for sharing host modules with dynamic bundles.
37
+ * Modules registered here will be used instead of esm.sh CDN
38
+ * when the sandbox is configured with matching sharedModules.
39
+ */
40
+ export class SharedModuleRegistry {
41
+ private modules = new Map<string, unknown>();
42
+
43
+ constructor() {
44
+ // Make available globally for dynamic imports
45
+ (globalThis as Record<string, unknown>)[GLOBAL_KEY] = this;
46
+ }
47
+
48
+ /**
49
+ * Register a module to be shared with dynamic bundles
50
+ *
51
+ * @param moduleId - The import specifier (e.g., 'react', 'react-dom/client')
52
+ * @param module - The module's exports object
53
+ * @returns this for chaining
54
+ */
55
+ register(moduleId: string, module: unknown): this {
56
+ this.modules.set(moduleId, module);
57
+ return this;
58
+ }
59
+
60
+ /**
61
+ * Register multiple modules at once
62
+ *
63
+ * @param modules - Object mapping module IDs to their exports
64
+ * @returns this for chaining
65
+ */
66
+ registerAll(modules: Record<string, unknown>): this {
67
+ for (const [id, mod] of Object.entries(modules)) {
68
+ this.modules.set(id, mod);
69
+ }
70
+ return this;
71
+ }
72
+
73
+ /**
74
+ * Unregister a previously registered module
75
+ *
76
+ * @param moduleId - The import specifier to remove
77
+ * @returns true if the module was registered and removed
78
+ */
79
+ unregister(moduleId: string): boolean {
80
+ return this.modules.delete(moduleId);
81
+ }
82
+
83
+ /**
84
+ * Get a registered module (used by dynamic bundles at runtime)
85
+ *
86
+ * @param moduleId - The import specifier
87
+ * @returns The registered module exports
88
+ * @throws Error if the module is not registered
89
+ */
90
+ get(moduleId: string): unknown {
91
+ const mod = this.modules.get(moduleId);
92
+ if (mod === undefined && !this.modules.has(moduleId)) {
93
+ const available = this.list();
94
+ throw new Error(
95
+ `Shared module "${moduleId}" not registered. ` +
96
+ `Available: ${available.length > 0 ? available.join(', ') : '(none)'}. ` +
97
+ `Call registerSharedModules({ '${moduleId}': ... }) in your host application.`
98
+ );
99
+ }
100
+ return mod;
101
+ }
102
+
103
+ /**
104
+ * Check if a module is registered
105
+ *
106
+ * @param moduleId - The import specifier to check
107
+ */
108
+ has(moduleId: string): boolean {
109
+ return this.modules.has(moduleId);
110
+ }
111
+
112
+ /**
113
+ * Get list of all registered module IDs
114
+ */
115
+ list(): string[] {
116
+ return [...this.modules.keys()];
117
+ }
118
+
119
+ /**
120
+ * Clear all registrations
121
+ */
122
+ clear(): void {
123
+ this.modules.clear();
124
+ }
125
+
126
+ /**
127
+ * Get the number of registered modules
128
+ */
129
+ get size(): number {
130
+ return this.modules.size;
131
+ }
132
+ }
133
+
134
+ // Singleton instance
135
+ let defaultRegistry: SharedModuleRegistry | null = null;
136
+
137
+ /**
138
+ * Get the default shared module registry.
139
+ * Creates it if it doesn't exist.
140
+ */
141
+ export function getSharedModuleRegistry(): SharedModuleRegistry {
142
+ if (!defaultRegistry) {
143
+ defaultRegistry = new SharedModuleRegistry();
144
+ }
145
+ return defaultRegistry;
146
+ }
147
+
148
+ /**
149
+ * Check if a shared module registry exists on globalThis
150
+ */
151
+ export function hasSharedModuleRegistry(): boolean {
152
+ return GLOBAL_KEY in globalThis;
153
+ }
154
+
155
+ /**
156
+ * Convenience function to register modules with the default registry.
157
+ *
158
+ * @param modules - Object mapping module IDs to their exports
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * import * as React from 'react';
163
+ * import * as ReactDOM from 'react-dom/client';
164
+ * import { registerSharedModules } from 'sandlot';
165
+ *
166
+ * registerSharedModules({
167
+ * 'react': React,
168
+ * 'react-dom/client': ReactDOM,
169
+ * });
170
+ * ```
171
+ */
172
+ export function registerSharedModules(modules: Record<string, unknown>): void {
173
+ getSharedModuleRegistry().registerAll(modules);
174
+ }
175
+
176
+ /**
177
+ * Convenience function to unregister a module from the default registry.
178
+ *
179
+ * @param moduleId - The import specifier to remove
180
+ * @returns true if the module was registered and removed
181
+ */
182
+ export function unregisterSharedModule(moduleId: string): boolean {
183
+ return getSharedModuleRegistry().unregister(moduleId);
184
+ }
185
+
186
+ /**
187
+ * Clear all shared modules from the default registry.
188
+ */
189
+ export function clearSharedModules(): void {
190
+ getSharedModuleRegistry().clear();
191
+ }
192
+
193
+ /**
194
+ * Generate the runtime code that dynamic bundles use to access shared modules.
195
+ * This is injected into bundles when they import from shared modules.
196
+ */
197
+ export function getSharedModuleRuntimeCode(moduleId: string): string {
198
+ return `
199
+ (function() {
200
+ var registry = globalThis["${GLOBAL_KEY}"];
201
+ if (!registry) {
202
+ throw new Error(
203
+ 'Sandlot SharedModuleRegistry not found. ' +
204
+ 'Call registerSharedModules() in your host application before loading dynamic modules.'
205
+ );
206
+ }
207
+ return registry.get(${JSON.stringify(moduleId)});
208
+ })()
209
+ `.trim();
210
+ }