wgsl-play 0.0.2 → 0.0.5

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 (31) hide show
  1. package/README.md +115 -3
  2. package/package.json +8 -5
  3. package/src/BundleLoader.ts +12 -16
  4. package/src/Config.ts +26 -0
  5. package/src/FetchingResolver.ts +146 -0
  6. package/src/HttpPackageLoader.ts +239 -0
  7. package/src/PackageLoader.ts +142 -40
  8. package/src/Renderer.ts +32 -13
  9. package/src/WgslPlay.css +2 -0
  10. package/src/WgslPlay.ts +144 -64
  11. package/src/index.ts +2 -0
  12. package/src/test/BundleHydrator.test.ts +1 -1
  13. package/src/test/WgslPlay.e2e.ts +181 -13
  14. package/src/test/WgslPlay.e2e.ts-snapshots/basic-shader-chromium-darwin.png +0 -0
  15. package/src/test/WgslPlay.e2e.ts-snapshots/conditions-after-red-chromium-darwin.png +0 -0
  16. package/src/test/WgslPlay.e2e.ts-snapshots/conditions-initial-green-chromium-darwin.png +0 -0
  17. package/src/test/WgslPlay.e2e.ts-snapshots/link-import-chromium-darwin.png +0 -0
  18. package/src/test/WgslPlay.e2e.ts-snapshots/npm-cdn-chromium-darwin.png +0 -0
  19. package/src/test/WgslPlay.e2e.ts-snapshots/shader-root-internal-chromium-darwin.png +0 -0
  20. package/src/test/WgslPlay.e2e.ts-snapshots/shader-root-src-chromium-darwin.png +0 -0
  21. package/src/test/WgslPlay.e2e.ts-snapshots/static-import-chromium-darwin.png +0 -0
  22. package/test-page/index.html +94 -11
  23. package/test-page/main.ts +87 -8
  24. package/test-page/shaders/effects/common.wesl +5 -0
  25. package/test-page/shaders/effects/main.wesl +10 -0
  26. package/test-page/shaders/utils.wesl +5 -0
  27. package/test-page/wesl.toml +3 -0
  28. package/tsconfig.json +4 -1
  29. package/vite.config.ts +19 -0
  30. package/playwright-report/index.html +0 -81
  31. package/test-results/.last-run.json +0 -4
package/README.md CHANGED
@@ -12,13 +12,44 @@ Web component for rendering WESL/WGSL fragment shaders.
12
12
 
13
13
  That's it. The component auto-fetches dependencies and starts animating.
14
14
 
15
+ ### Shader API
16
+
17
+ Write a fragment shader with entry point `fs_main`. WESL extensions are supported (imports, conditional compilation).
18
+
19
+ Standard uniforms are provided at binding 0:
20
+
21
+ ```wgsl
22
+ import test::Uniforms;
23
+
24
+ @group(0) @binding(0) var<uniform> u: Uniforms;
25
+
26
+ @fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
27
+ let uv = pos.xy / u.resolution;
28
+ return vec4f(uv, sin(u.time) * 0.5 + 0.5, 1.0);
29
+ }
30
+ ```
31
+
32
+ | Uniform | Type | Description |
33
+ |---------|------|-------------|
34
+ | `resolution` | `vec2f` | Canvas dimensions in pixels |
35
+ | `time` | `f32` | Elapsed time in seconds |
36
+ | `mouse` | `vec2f` | Mouse position (normalized 0-1) |
37
+
15
38
  ### Inline source
16
39
 
40
+ You can include shader code inline if you'd prefer. Use a `<script type="text/wgsl">` (or `<script type="text/wesl">`) tag.
41
+
17
42
  ```html
18
43
  <wgsl-play>
19
- @fragment fn fs_main() -> @location(0) vec4f {
20
- return vec4f(1.0, 0.0, 0.0, 1.0);
21
- }
44
+ <script type="text/wesl">
45
+ import test::Uniforms;
46
+ @group(0) @binding(0) var<uniform> u: Uniforms;
47
+
48
+ @fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
49
+ let uv = pos.xy / u.resolution;
50
+ return vec4f(uv, sin(u.time) * 0.5 + 0.5, 1.0);
51
+ }
52
+ </script>
22
53
  </wgsl-play>
23
54
  ```
24
55
 
@@ -32,10 +63,22 @@ player.rewind();
32
63
  player.play();
33
64
  ```
34
65
 
66
+ ### Importing shaders (Vite)
67
+
68
+ ```typescript
69
+ import shader from './examples/noise.wesl?raw';
70
+
71
+ const player = document.querySelector("wgsl-play");
72
+ player.source = shader;
73
+ ```
74
+
75
+ The `?raw` suffix imports the file as a string. This keeps shaders alongside your source files with HMR support.
76
+
35
77
  ## API
36
78
 
37
79
  ### Attributes
38
80
  - `src` - URL to .wesl/.wgsl file
81
+ - `shader-root` - Root path for internal imports (default: `/shaders`)
39
82
 
40
83
  ### Properties
41
84
  - `source: string` - Get/set shader source
@@ -69,6 +112,70 @@ wgsl-play::part(canvas) {
69
112
  }
70
113
  ```
71
114
 
115
+ ## Multi-file Shaders
116
+
117
+ For apps with multiple shader files, use `shader-root`.
118
+
119
+ ```
120
+ public/
121
+ shaders/
122
+ utils.wesl # import package::utils
123
+ effects/
124
+ main.wesl # import super::common
125
+ common.wesl
126
+ ```
127
+
128
+ ```html
129
+ <wgsl-play src="/shaders/effects/main.wesl" shader-root="/shaders"></wgsl-play>
130
+ ```
131
+
132
+ Local shader modules referenced via `package::` or `super::`
133
+ will be fetched from the web server.
134
+
135
+ ```wgsl
136
+ // effects/main.wesl
137
+ import package::utils::noise;
138
+ import super::common::tint;
139
+
140
+ @fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
141
+ return tint(noise(pos.xy));
142
+ }
143
+ ```
144
+
145
+ ## Using with wesl-plugin
146
+
147
+ For more control, use the [wesl-plugin](https://github.com/wgsl-tooling-wg/wesl-js/tree/main/tools/packages/wesl-plugin) to
148
+ assemble shaders and libraries at build time and provide
149
+ them wgsl-play in JavaScript or TypeScript.
150
+ - provides full support for Hot Module Reloading during development
151
+ - allows specifying fixed library dependency versions
152
+
153
+ ### Runtime linking (?link)
154
+
155
+ With Runtime linking, WGSL is constructed from WGSL/WESL at runtime.
156
+ Use runtime linking to enable virtual modules,
157
+ conditional transpilation, and injecting shader constants from JavaScript.
158
+
159
+ ```typescript
160
+ // vite.config.ts
161
+ import { linkBuildExtension } from "wesl-plugin";
162
+ import viteWesl from "wesl-plugin/vite";
163
+
164
+ export default {
165
+ plugins: [viteWesl({ extensions: [linkBuildExtension] })]
166
+ };
167
+
168
+ // app.ts
169
+ import shaderConfig from "./shader.wesl?link";
170
+
171
+ // wgsl-play links internally, allowing runtime conditions/constants
172
+ player.project = {
173
+ ...shaderConfig,
174
+ conditions: { MOBILE: isMobileGPU },
175
+ constants: { num_lights: 4 }
176
+ };
177
+ ```
178
+
72
179
  ## Exports
73
180
 
74
181
  ```typescript
@@ -77,4 +184,9 @@ import "wgsl-play";
77
184
 
78
185
  // Element class only (manual registration)
79
186
  import { WgslPlay } from "wgsl-play/element";
187
+
188
+ // Configuration
189
+ import { defaults } from "wgsl-play";
190
+
191
+ defaults({ shaderRoot: "/custom/shaders" });
80
192
  ```
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "wgsl-play",
3
- "version": "0.0.2",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
+ "repository": "github:wgsl-tooling-wg/wesl-js",
5
6
  "exports": {
6
7
  ".": "./src/index.ts",
7
8
  "./element": "./src/WgslPlay.ts"
@@ -10,14 +11,16 @@
10
11
  "es-module-lexer": "^1.7.0",
11
12
  "fflate": "^0.8.2",
12
13
  "nanotar": "^0.2.0",
13
- "wesl-gpu": "0.1.1",
14
- "wesl": "0.6.47"
14
+ "resolve.exports": "^2.0.3",
15
+ "wesl": "0.7.1",
16
+ "wesl-gpu": "0.1.4"
15
17
  },
16
18
  "devDependencies": {
17
- "@playwright/test": "^1.53.2"
19
+ "@playwright/test": "^1.53.2",
20
+ "wesl-plugin": "x"
18
21
  },
19
22
  "scripts": {
20
- "dev": "vite test-page",
23
+ "dev": "vite test-page --config vite.config.ts",
21
24
  "test": "vitest",
22
25
  "test:e2e": "playwright test",
23
26
  "typecheck": "tsgo"
@@ -3,6 +3,14 @@ import { type ParsedTarFileItem, parseTar } from "nanotar";
3
3
  import type { WeslBundle } from "wesl";
4
4
  import { loadBundlesFromFiles, type WeslBundleFile } from "./BundleHydrator.ts";
5
5
 
6
+ /** Fetch bundle files from an npm package (without evaluating). */
7
+ export async function fetchBundleFilesFromNpm(
8
+ packageName: string,
9
+ ): Promise<WeslBundleFile[]> {
10
+ const tgzUrl = await npmPackageToUrl(packageName);
11
+ return fetchBundleFilesFromUrl(tgzUrl);
12
+ }
13
+
6
14
  /** Load bundles from URL or npm package name, return bundles, package name, and resolved tgz URL. */
7
15
  export async function loadBundlesWithPackageName(
8
16
  input: string,
@@ -28,7 +36,8 @@ export async function loadBundlesWithPackageName(
28
36
  return { bundles, packageName, tgzUrl };
29
37
  }
30
38
 
31
- /** Load WESL bundles from a tgz URL. */
39
+ /** Load WESL bundles from a tgz URL.
40
+ * (handy for privately published packages) */
32
41
  export async function loadBundlesFromTgz(
33
42
  tgzUrl: string,
34
43
  packageName: string,
@@ -41,16 +50,11 @@ export async function loadBundlesFromTgz(
41
50
  return loadBundlesFromFiles(bundleFiles);
42
51
  }
43
52
 
44
- /** Custom tgz URL for lygia (npm package is outdated). */
45
- export const lygiaTgzUrl =
46
- "https://raw.githubusercontent.com/mighdoll/big-files/refs/heads/main/lygia-1.3.5-rc.2.tgz";
47
-
48
53
  /** Resolve input to a tgz URL (converts npm package names to registry URLs). */
49
54
  async function resolvePackageInput(input: string): Promise<string> {
50
55
  if (input.startsWith("http://") || input.startsWith("https://")) {
51
56
  return input;
52
57
  }
53
- if (input === "lygia") return lygiaTgzUrl;
54
58
  return npmPackageToUrl(input);
55
59
  }
56
60
 
@@ -77,7 +81,7 @@ async function fetchAndExtractTgz(
77
81
  }
78
82
 
79
83
  /** Fetch bundle files from a tgz URL (without evaluating). */
80
- export async function fetchBundleFilesFromUrl(
84
+ async function fetchBundleFilesFromUrl(
81
85
  tgzUrl: string,
82
86
  ): Promise<WeslBundleFile[]> {
83
87
  const entries = await fetchAndExtractTgz(tgzUrl);
@@ -91,16 +95,8 @@ export async function fetchBundleFilesFromUrl(
91
95
  return bundleFilesWithoutPkg.map(f => ({ ...f, packageName: pkgName }));
92
96
  }
93
97
 
94
- /** Fetch bundle files from an npm package (without evaluating). */
95
- export async function fetchBundleFilesFromNpm(
96
- packageName: string,
97
- ): Promise<WeslBundleFile[]> {
98
- const tgzUrl = await npmPackageToUrl(packageName);
99
- return fetchBundleFilesFromUrl(tgzUrl);
100
- }
101
-
102
98
  /** Fetch npm package tarball URL from registry metadata. */
103
- export async function npmPackageToUrl(packageName: string): Promise<string> {
99
+ async function npmPackageToUrl(packageName: string): Promise<string> {
104
100
  const metadataUrl = `https://registry.npmjs.org/${packageName}`;
105
101
  const response = await fetch(metadataUrl);
106
102
  if (!response.ok) {
package/src/Config.ts ADDED
@@ -0,0 +1,26 @@
1
+ /** Configuration for wgsl-play. */
2
+ export interface WgslPlayConfig {
3
+ /** Root path for internal imports (package::, super::). Default: "/shaders" */
4
+ shaderRoot: string;
5
+ }
6
+
7
+ const defaultConfig: WgslPlayConfig = {
8
+ shaderRoot: "/shaders",
9
+ };
10
+
11
+ let globalConfig: WgslPlayConfig = { ...defaultConfig };
12
+
13
+ /** Set global defaults for all wgsl-play instances. */
14
+ export function defaults(config: Partial<WgslPlayConfig>): void {
15
+ globalConfig = { ...globalConfig, ...config };
16
+ }
17
+
18
+ /** Get resolved config, merging element overrides with global defaults. */
19
+ export function getConfig(overrides?: Partial<WgslPlayConfig>): WgslPlayConfig {
20
+ return { ...globalConfig, ...overrides };
21
+ }
22
+
23
+ /** Reset config to defaults (useful for testing). */
24
+ export function resetConfig(): void {
25
+ globalConfig = { ...defaultConfig };
26
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * FetchingResolver - A resolver that can fetch modules from URLs.
3
+ *
4
+ * Provides both sync and async APIs:
5
+ * - Sync `resolveModule`: for compatibility with current `findUnboundIdents`
6
+ * - Async `resolveModuleAsync`: the future API with built-in HTTP fetching
7
+ *
8
+ * When wesl gets async BindIdents, the sync method and external shim loop
9
+ * can be removed, and BindIdents will call resolveModuleAsync directly.
10
+ */
11
+
12
+ import type { ModuleResolver, WeslAST } from "wesl";
13
+ import { modulePartsToRelativePath, parseSrcModule } from "wesl";
14
+
15
+ export interface FetchingResolverOptions {
16
+ /** Base URL for fetching internal modules. */
17
+ shaderRoot: string;
18
+ /** Module path of the source file (for super:: resolution). */
19
+ srcModulePath?: string;
20
+ }
21
+
22
+ /** Resolver with sync interface for findUnboundIdents and async API for fetching. */
23
+ export class FetchingResolver implements ModuleResolver {
24
+ private readonly astCache = new Map<string, WeslAST>();
25
+ readonly sources: Record<string, string>;
26
+ private readonly requested = new Set<string>();
27
+ private readonly shaderRoot: string;
28
+ private readonly srcModuleParts?: string[];
29
+
30
+ constructor(
31
+ initialSources: Record<string, string>,
32
+ options: FetchingResolverOptions,
33
+ ) {
34
+ this.sources = { ...initialSources };
35
+ const { shaderRoot, srcModulePath } = options;
36
+ this.shaderRoot = shaderRoot.replace(/\/$/, "");
37
+ this.srcModuleParts = srcModulePath
38
+ ? this.urlToModuleParts(srcModulePath)
39
+ : undefined;
40
+ }
41
+
42
+ // ── Sync API (for findUnboundIdents compatibility) ──
43
+
44
+ /** Sync lookup - returns cached AST or undefined, records misses. */
45
+ resolveModule(modulePath: string): WeslAST | undefined {
46
+ const cached = this.astCache.get(modulePath);
47
+ if (cached) return cached;
48
+
49
+ const source = this.sources[modulePath];
50
+ if (!source) {
51
+ this.requested.add(modulePath);
52
+ return undefined;
53
+ }
54
+
55
+ return this.parseAndCache(modulePath, source);
56
+ }
57
+
58
+ /** Module paths that were requested but not found. */
59
+ getUnresolved(): string[] {
60
+ return [...this.requested].filter(p => !this.astCache.has(p));
61
+ }
62
+
63
+ /** Get all parsed modules. */
64
+ allModules(): Iterable<[string, WeslAST]> {
65
+ for (const modulePath of Object.keys(this.sources)) {
66
+ if (!this.astCache.has(modulePath)) {
67
+ this.resolveModule(modulePath);
68
+ }
69
+ }
70
+ return this.astCache.entries();
71
+ }
72
+
73
+ // ── Async API (future BindIdents interface) ──
74
+
75
+ /** Async lookup - returns cached AST or fetches, parses, and caches. */
76
+ async resolveModuleAsync(modulePath: string): Promise<WeslAST | undefined> {
77
+ const cached = this.astCache.get(modulePath);
78
+ if (cached) return cached;
79
+
80
+ if (this.sources[modulePath]) {
81
+ return this.resolveModule(modulePath);
82
+ }
83
+
84
+ const source = await this.fetchInternal(modulePath);
85
+ if (!source) return undefined;
86
+
87
+ this.sources[modulePath] = source;
88
+ this.requested.delete(modulePath);
89
+ return this.parseAndCache(modulePath, source);
90
+ }
91
+
92
+ // ── Internal fetching ──
93
+
94
+ private async fetchInternal(modulePath: string): Promise<string | undefined> {
95
+ const url = this.modulePathToUrl(modulePath);
96
+ if (!url) return undefined;
97
+ return fetchWithExtensions(url);
98
+ }
99
+
100
+ private modulePathToUrl(modulePath: string): string | undefined {
101
+ const parts = modulePath.split("::");
102
+ const filePath = modulePartsToRelativePath(
103
+ parts,
104
+ "package",
105
+ this.srcModuleParts,
106
+ );
107
+ if (!filePath) {
108
+ if (parts[0] === "super" && !this.srcModuleParts) {
109
+ const msg = `Cannot resolve super:: without file context: ${modulePath}`;
110
+ throw new Error(msg);
111
+ }
112
+ return undefined; // external module
113
+ }
114
+ return `${this.shaderRoot}/${filePath}`;
115
+ }
116
+
117
+ private parseAndCache(modulePath: string, source: string): WeslAST {
118
+ const params = { modulePath, debugFilePath: modulePath, src: source };
119
+ const ast = parseSrcModule(params);
120
+ this.astCache.set(modulePath, ast);
121
+ return ast;
122
+ }
123
+
124
+ private urlToModuleParts(urlPath: string): string[] {
125
+ const path = urlPath
126
+ .replace(this.shaderRoot, "")
127
+ .replace(/^\//, "")
128
+ .replace(/\.w[eg]sl$/, "");
129
+ return ["package", ...path.split("/").filter(Boolean)];
130
+ }
131
+ }
132
+
133
+ /** Try fetching URL with .wesl then .wgsl extension. */
134
+ async function fetchWithExtensions(
135
+ baseUrl: string,
136
+ ): Promise<string | undefined> {
137
+ for (const ext of [".wesl", ".wgsl"]) {
138
+ try {
139
+ const response = await fetch(baseUrl + ext);
140
+ if (response.ok) return response.text();
141
+ } catch {
142
+ // Try next extension
143
+ }
144
+ }
145
+ return undefined;
146
+ }
@@ -0,0 +1,239 @@
1
+ import { resolve } from "resolve.exports";
2
+ import type { WeslBundle } from "wesl";
3
+ import { findUnboundIdents, RecordResolver } from "wesl";
4
+ import type { WeslBundleFile } from "./BundleHydrator.ts";
5
+ import { bundleRegistry, hydrateBundleRegistry } from "./BundleHydrator.ts";
6
+
7
+ /** Resolution mode for package loading. (Note: disconnected from main flow) */
8
+ type PackageMode = "source" | "bundle";
9
+
10
+ /** Loaded sources for source mode. */
11
+ export interface SourcePackage {
12
+ sources: Record<string, string>;
13
+ packageName: string;
14
+ }
15
+
16
+ /** Cached package.json data. */
17
+ interface PackageJson {
18
+ name: string;
19
+ exports?: Record<string, unknown>;
20
+ }
21
+
22
+ const packageJsonCache = new Map<string, PackageJson>();
23
+
24
+ /** Fetch packages from HTTP base URL in bundle or source mode. */
25
+ export async function fetchPackagesFromHttp(
26
+ pkgNames: string[],
27
+ packageBase: string,
28
+ mode: PackageMode,
29
+ ): Promise<WeslBundle[] | SourcePackage[]> {
30
+ if (mode === "source") {
31
+ return fetchSourcePackages(pkgNames, packageBase);
32
+ }
33
+ return fetchBundlePackages(pkgNames, packageBase);
34
+ }
35
+
36
+ /** Clear package.json cache (useful for testing). */
37
+ export function clearHttpCache(): void {
38
+ packageJsonCache.clear();
39
+ }
40
+
41
+ /** Fetch raw .wesl source files for source mode using findUnboundIdents. */
42
+ async function fetchSourcePackages(
43
+ pkgNames: string[],
44
+ packageBase: string,
45
+ ): Promise<SourcePackage[]> {
46
+ const results: SourcePackage[] = [];
47
+ for (const pkgName of pkgNames) {
48
+ const pkg = await fetchSourcePackage(pkgName, packageBase);
49
+ results.push(pkg);
50
+ }
51
+ return results;
52
+ }
53
+
54
+ /** Fetch a single package's source files starting from lib.wesl. */
55
+ async function fetchSourcePackage(
56
+ pkgName: string,
57
+ packageBase: string,
58
+ ): Promise<SourcePackage> {
59
+ const sources: Record<string, string> = {};
60
+ const fetched = new Set<string>();
61
+ const pending = ["lib"];
62
+
63
+ while (pending.length > 0) {
64
+ const modulePath = pending.pop()!;
65
+ if (fetched.has(modulePath)) continue;
66
+ fetched.add(modulePath);
67
+
68
+ const source = await fetchSourceFile(pkgName, modulePath, packageBase);
69
+ if (!source) continue;
70
+
71
+ const fileName = modulePath === "lib" ? "lib.wgsl" : `${modulePath}.wesl`;
72
+ sources[fileName] = source;
73
+
74
+ const internalRefs = findInternalReferences(source, pkgName);
75
+ for (const ref of internalRefs) {
76
+ if (!fetched.has(ref)) pending.push(ref);
77
+ }
78
+ }
79
+
80
+ return { sources, packageName: pkgName };
81
+ }
82
+
83
+ /** Find references to modules within the same package. */
84
+ function findInternalReferences(source: string, pkgName: string): string[] {
85
+ const resolver = new RecordResolver(
86
+ { main: source },
87
+ { packageName: pkgName },
88
+ );
89
+ const unbound = findUnboundIdents(resolver);
90
+ return unbound
91
+ .filter(path => path[0] === pkgName && path.length > 1)
92
+ .map(path => path.slice(1).join("/"));
93
+ }
94
+
95
+ /** Fetch a single source file, trying various paths and extensions. */
96
+ async function fetchSourceFile(
97
+ pkgName: string,
98
+ modulePath: string,
99
+ packageBase: string,
100
+ ): Promise<string | null> {
101
+ const basePath = modulePath === "lib" ? "lib" : modulePath;
102
+ const prefixes = ["", "shaders/", "src/"];
103
+ const extensions = ["wesl", "wgsl"];
104
+
105
+ for (const prefix of prefixes) {
106
+ for (const ext of extensions) {
107
+ const url = normalizeUrl(
108
+ `${packageBase}/${pkgName}/${prefix}${basePath}.${ext}`,
109
+ );
110
+ try {
111
+ const response = await fetch(url);
112
+ if (response.ok) {
113
+ const contentType = response.headers.get("content-type") || "";
114
+ // Skip HTML error pages (Vite returns 200 with HTML for missing files)
115
+ if (contentType.includes("text/html")) continue;
116
+ return response.text();
117
+ }
118
+ } catch {
119
+ // Try next combination
120
+ }
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /** Fetch weslBundle.js files for bundle mode. */
127
+ async function fetchBundlePackages(
128
+ pkgNames: string[],
129
+ packageBase: string,
130
+ ): Promise<WeslBundle[]> {
131
+ const loaded = new Set<string>();
132
+ const allFiles: WeslBundleFile[] = [];
133
+
134
+ for (const pkgName of pkgNames) {
135
+ const files = await fetchBundlePackage(pkgName, packageBase, loaded);
136
+ allFiles.push(...files);
137
+ }
138
+
139
+ const registry = await bundleRegistry(allFiles);
140
+ return hydrateBundleRegistry(registry, pkgId =>
141
+ fetchBundlePackage(pkgId, packageBase, loaded),
142
+ );
143
+ }
144
+
145
+ /** Fetch bundle files for a single package. */
146
+ async function fetchBundlePackage(
147
+ pkgName: string,
148
+ packageBase: string,
149
+ loaded: Set<string>,
150
+ ): Promise<WeslBundleFile[]> {
151
+ if (loaded.has(pkgName)) return [];
152
+ loaded.add(pkgName);
153
+
154
+ const pkg = await fetchPackageJson(pkgName, packageBase);
155
+ if (!pkg) {
156
+ throw new Error(`Could not fetch package.json for ${pkgName}`);
157
+ }
158
+
159
+ const bundleFiles: WeslBundleFile[] = [];
160
+
161
+ // Try root bundle first (single-bundle package)
162
+ const rootBundlePath = resolveBundlePath(pkg, ".");
163
+ if (rootBundlePath) {
164
+ const content = await fetchBundleFile(pkgName, rootBundlePath, packageBase);
165
+ if (content) {
166
+ bundleFiles.push({
167
+ packagePath: `package/${rootBundlePath}`,
168
+ content,
169
+ packageName: pkgName,
170
+ });
171
+ }
172
+ }
173
+
174
+ // For multi-bundle packages, bundles are fetched on-demand by the hydrator
175
+
176
+ return bundleFiles;
177
+ }
178
+
179
+ /** Fetch and cache package.json. */
180
+ async function fetchPackageJson(
181
+ pkgName: string,
182
+ packageBase: string,
183
+ ): Promise<PackageJson | null> {
184
+ const key = `${packageBase}/${pkgName}`;
185
+ const cached = packageJsonCache.get(key);
186
+ if (cached) return cached;
187
+
188
+ const url = normalizeUrl(`${packageBase}/${pkgName}/package.json`);
189
+ try {
190
+ const response = await fetch(url);
191
+ if (!response.ok) return null;
192
+ const pkg = await response.json();
193
+ packageJsonCache.set(key, pkg);
194
+ return pkg;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /** Resolve bundle path using package.json exports via resolve.exports library. */
201
+ function resolveBundlePath(pkg: PackageJson, subpath: string): string | null {
202
+ if (!pkg.exports) {
203
+ // No exports field - assume single bundle at dist/weslBundle.js
204
+ return "dist/weslBundle.js";
205
+ }
206
+
207
+ try {
208
+ const resolved = resolve(pkg, subpath);
209
+ if (resolved) {
210
+ const path = Array.isArray(resolved) ? resolved[0] : resolved;
211
+ return path.startsWith("./") ? path.slice(2) : path;
212
+ }
213
+ } catch {
214
+ // resolve.exports throws on no match
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ /** Fetch a single bundle file. */
221
+ async function fetchBundleFile(
222
+ pkgName: string,
223
+ bundlePath: string,
224
+ packageBase: string,
225
+ ): Promise<string | null> {
226
+ const url = normalizeUrl(`${packageBase}/${pkgName}/${bundlePath}`);
227
+ try {
228
+ const response = await fetch(url);
229
+ if (!response.ok) return null;
230
+ return response.text();
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /** Normalize URL by removing double slashes (except after protocol). */
237
+ function normalizeUrl(url: string): string {
238
+ return url.replace(/([^:])\/+/g, "$1/");
239
+ }