browser-metro 1.0.5 → 1.0.7
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.
- package/README.md +173 -0
- package/dist/bundler.d.ts +3 -0
- package/dist/bundler.js +62 -10
- package/dist/incremental-bundler.d.ts +3 -0
- package/dist/incremental-bundler.js +59 -10
- package/dist/utils.d.ts +15 -0
- package/dist/utils.js +62 -0
- package/package.json +19 -2
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# browser-metro
|
|
2
|
+
|
|
3
|
+
A browser-based JavaScript/TypeScript bundler inspired by [Metro](https://metrobundler.dev/) (React Native's bundler). It runs entirely client-side in a Web Worker with HMR, React Refresh, Expo Router, and source map support.
|
|
4
|
+
|
|
5
|
+
Part of [reactnative.run](https://reactnative.run) - try the [playground](https://reactnative.run/playground).
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **VirtualFS** - in-memory filesystem, no real FS needed
|
|
10
|
+
- **Module resolution** - Node.js-style with configurable extensions
|
|
11
|
+
- **Sucrase transforms** - fast TypeScript/JSX compilation
|
|
12
|
+
- **Plugin system** - pre/post transform hooks, module aliases, shims
|
|
13
|
+
- **HMR** - hot module replacement with React Refresh
|
|
14
|
+
- **Expo Router** - file-based routing with dynamic route HMR
|
|
15
|
+
- **API Routes** - in-browser `+api.ts` via fetch interception
|
|
16
|
+
- **Source maps** - inline combined source maps for accurate errors
|
|
17
|
+
- **npm packages** - on-demand bundling via ESM server
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install browser-metro
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import {
|
|
29
|
+
Bundler, VirtualFS, typescriptTransformer
|
|
30
|
+
} from "browser-metro";
|
|
31
|
+
import type { BundlerConfig, FileMap } from "browser-metro";
|
|
32
|
+
|
|
33
|
+
const files: FileMap = {
|
|
34
|
+
"/index.ts": 'import { greet } from "./utils";\nconsole.log(greet("World"));',
|
|
35
|
+
"/utils.ts": 'export function greet(name: string) { return "Hello, " + name; }',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const bundler = new Bundler(new VirtualFS(files), {
|
|
39
|
+
resolver: { sourceExts: ["ts", "tsx", "js", "jsx"] },
|
|
40
|
+
transformer: typescriptTransformer,
|
|
41
|
+
server: { packageServerUrl: "https://esm.reactnative.run" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const code = await bundler.bundle("/index.ts");
|
|
45
|
+
// code is a self-executing bundle with inline source map
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## HMR with React Refresh
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import {
|
|
52
|
+
IncrementalBundler, VirtualFS, reactRefreshTransformer
|
|
53
|
+
} from "browser-metro";
|
|
54
|
+
|
|
55
|
+
const bundler = new IncrementalBundler(new VirtualFS(files), {
|
|
56
|
+
resolver: { sourceExts: ["ts", "tsx", "js", "jsx"] },
|
|
57
|
+
transformer: reactRefreshTransformer,
|
|
58
|
+
server: { packageServerUrl: "https://esm.reactnative.run" },
|
|
59
|
+
hmr: { enabled: true, reactRefresh: true },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Initial build
|
|
63
|
+
const initial = await bundler.build("/index.tsx");
|
|
64
|
+
|
|
65
|
+
// On file change - only re-transforms changed files
|
|
66
|
+
const result = await bundler.rebuild([
|
|
67
|
+
{ path: "/App.tsx", type: "update" }
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
if (result.hmrUpdate && !result.hmrUpdate.requiresReload) {
|
|
71
|
+
// Send to iframe for hot patching
|
|
72
|
+
iframe.postMessage({
|
|
73
|
+
type: "hmr-update",
|
|
74
|
+
updatedModules: result.hmrUpdate.updatedModules,
|
|
75
|
+
removedModules: result.hmrUpdate.removedModules,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
### Bundler
|
|
83
|
+
|
|
84
|
+
One-shot bundler. Creates a single bundle from an entry file.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
const bundler = new Bundler(vfs, config);
|
|
88
|
+
const code = await bundler.bundle("/index.ts");
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### IncrementalBundler
|
|
92
|
+
|
|
93
|
+
Watch-mode bundler with HMR. Maintains dependency graph and module cache across rebuilds.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
const bundler = new IncrementalBundler(vfs, config);
|
|
97
|
+
const initial = await bundler.build("/index.tsx");
|
|
98
|
+
const update = await bundler.rebuild([{ path: "/App.tsx", type: "update" }]);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### VirtualFS
|
|
102
|
+
|
|
103
|
+
In-memory filesystem.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
const vfs = new VirtualFS(files);
|
|
107
|
+
vfs.read("/index.ts"); // string | undefined
|
|
108
|
+
vfs.write("/new.ts", code); // create or overwrite
|
|
109
|
+
vfs.exists("/index.ts"); // boolean
|
|
110
|
+
vfs.list(); // string[]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### BundlerConfig
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
interface BundlerConfig {
|
|
117
|
+
resolver: { sourceExts: string[] };
|
|
118
|
+
transformer: Transformer;
|
|
119
|
+
server: { packageServerUrl: string };
|
|
120
|
+
hmr?: { enabled: boolean; reactRefresh?: boolean };
|
|
121
|
+
plugins?: BundlerPlugin[];
|
|
122
|
+
env?: Record<string, string>;
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Transformers
|
|
127
|
+
|
|
128
|
+
- `typescriptTransformer` - TS/JSX via Sucrase
|
|
129
|
+
- `reactRefreshTransformer` - adds React Refresh + `module.hot.accept()`
|
|
130
|
+
- `createReactRefreshTransformer(base)` - wrap a custom transformer with React Refresh
|
|
131
|
+
|
|
132
|
+
### Plugins
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
interface BundlerPlugin {
|
|
136
|
+
name: string;
|
|
137
|
+
transformSource?(params): { src: string } | null; // before Sucrase
|
|
138
|
+
transformOutput?(params): { code: string } | null; // after Sucrase
|
|
139
|
+
resolveRequest?(context, name): string | null; // custom resolution
|
|
140
|
+
moduleAliases?(): Record<string, string>; // redirect requires
|
|
141
|
+
shimModules?(): Record<string, string>; // inline replacements
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## ESM Package Server
|
|
146
|
+
|
|
147
|
+
browser-metro fetches npm packages from an ESM server that bundles them on-demand with esbuild:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
https://esm.reactnative.run/pkg/lodash@4.17.21
|
|
151
|
+
https://esm.reactnative.run/pkg/react-dom@19/client
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Packages are cached after first request. All dependencies are externalized for shared runtime instances. Version pinning via `// @externals` metadata prevents transitive dependency mismatches.
|
|
155
|
+
|
|
156
|
+
## Documentation
|
|
157
|
+
|
|
158
|
+
Full docs at [reactnative.run/docs](https://reactnative.run/docs):
|
|
159
|
+
|
|
160
|
+
- [Architecture](https://reactnative.run/docs/architecture)
|
|
161
|
+
- [HMR & React Refresh](https://reactnative.run/docs/hmr)
|
|
162
|
+
- [Expo Router](https://reactnative.run/docs/expo-router)
|
|
163
|
+
- [API Routes](https://reactnative.run/docs/api-routes)
|
|
164
|
+
- [Plugin System](https://reactnative.run/docs/api/plugins)
|
|
165
|
+
- [Full API Reference](https://reactnative.run/docs/api/bundler)
|
|
166
|
+
|
|
167
|
+
## Author
|
|
168
|
+
|
|
169
|
+
Built by [Sanket Sahu](https://github.com/sanketsahu) at [RapidNative](https://rapidnative.com).
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
package/dist/bundler.d.ts
CHANGED
|
@@ -5,7 +5,10 @@ export declare class Bundler {
|
|
|
5
5
|
private resolver;
|
|
6
6
|
private config;
|
|
7
7
|
private plugins;
|
|
8
|
+
private prefetchedPackages;
|
|
8
9
|
constructor(fs: VirtualFS, config: BundlerConfig);
|
|
10
|
+
/** Prefetch all dependencies in a single batch request */
|
|
11
|
+
private prefetchDependencies;
|
|
9
12
|
/** Read tsconfig.json "compilerOptions.paths" from the VirtualFS */
|
|
10
13
|
private static readTsconfigPaths;
|
|
11
14
|
/** Run the full pre-transform -> Sucrase -> post-transform pipeline */
|
package/dist/bundler.js
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
1
1
|
import { Resolver } from "./resolver.js";
|
|
2
2
|
import { buildCombinedSourceMap, countNewlines, inlineSourceMap, shiftSourceMapOrigLines, } from "./source-map.js";
|
|
3
|
-
import { findRequires, rewriteRequires, buildBundlePreamble } from "./utils.js";
|
|
3
|
+
import { findRequires, rewriteRequires, buildBundlePreamble, parseExternalsFromBody, hashDeps, parseDepBundle } from "./utils.js";
|
|
4
4
|
export class Bundler {
|
|
5
5
|
constructor(fs, config) {
|
|
6
|
+
this.prefetchedPackages = {};
|
|
6
7
|
this.fs = fs;
|
|
7
8
|
this.config = config;
|
|
8
9
|
const paths = Bundler.readTsconfigPaths(fs);
|
|
9
10
|
this.resolver = new Resolver(fs, { ...config.resolver, ...(paths && { paths }) });
|
|
10
11
|
this.plugins = config.plugins ?? [];
|
|
11
12
|
}
|
|
13
|
+
/** Prefetch all dependencies in a single batch request */
|
|
14
|
+
async prefetchDependencies() {
|
|
15
|
+
const versions = this.getPackageVersions();
|
|
16
|
+
if (Object.keys(versions).length === 0)
|
|
17
|
+
return;
|
|
18
|
+
// Remove aliased and shimmed packages - they're handled client-side
|
|
19
|
+
const aliases = this.getModuleAliases();
|
|
20
|
+
const shims = this.getShimModules();
|
|
21
|
+
for (const name of Object.keys(aliases))
|
|
22
|
+
delete versions[name];
|
|
23
|
+
for (const name of Object.keys(shims))
|
|
24
|
+
delete versions[name];
|
|
25
|
+
// Also remove alias targets that are already in versions (e.g. react-native-web is fetched via alias)
|
|
26
|
+
if (Object.keys(versions).length === 0)
|
|
27
|
+
return;
|
|
28
|
+
const hash = await hashDeps(versions);
|
|
29
|
+
const baseUrl = this.config.server.packageServerUrl;
|
|
30
|
+
try {
|
|
31
|
+
// Try GET first (CDN cacheable)
|
|
32
|
+
const getRes = await fetch(`${baseUrl}/bundle-deps/${hash}`);
|
|
33
|
+
if (getRes.ok) {
|
|
34
|
+
const { packages } = parseDepBundle(await getRes.text());
|
|
35
|
+
this.prefetchedPackages = packages;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// POST to build
|
|
39
|
+
const postRes = await fetch(`${baseUrl}/bundle-deps`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ hash, dependencies: versions }),
|
|
43
|
+
});
|
|
44
|
+
if (postRes.ok) {
|
|
45
|
+
const { packages } = parseDepBundle(await postRes.text());
|
|
46
|
+
this.prefetchedPackages = packages;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// Silently fall back to individual fetches
|
|
51
|
+
console.warn("[prefetch] Failed, falling back to individual fetches:", err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
12
54
|
/** Read tsconfig.json "compilerOptions.paths" from the VirtualFS */
|
|
13
55
|
static readTsconfigPaths(fs) {
|
|
14
56
|
const raw = fs.read("/tsconfig.json");
|
|
@@ -146,6 +188,21 @@ export class Bundler {
|
|
|
146
188
|
}
|
|
147
189
|
/** Fetch a pre-bundled npm package from the package server */
|
|
148
190
|
async fetchPackage(specifier) {
|
|
191
|
+
// Check prefetched registry first (extract base package name from versioned specifier)
|
|
192
|
+
// specifier is like "react@19.1.0" or "react-dom@19.1.0/client"
|
|
193
|
+
let baseName = specifier;
|
|
194
|
+
const atIdx = specifier.indexOf("@", specifier.startsWith("@") ? 1 : 0);
|
|
195
|
+
if (atIdx > 0) {
|
|
196
|
+
baseName = specifier.slice(0, atIdx);
|
|
197
|
+
const afterVersion = specifier.indexOf("/", atIdx + 1);
|
|
198
|
+
if (afterVersion > 0) {
|
|
199
|
+
baseName = specifier.slice(0, atIdx) + specifier.slice(afterVersion);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (this.prefetchedPackages[baseName]) {
|
|
203
|
+
return { code: this.prefetchedPackages[baseName], externals: {} };
|
|
204
|
+
}
|
|
205
|
+
// Fallback to individual fetch
|
|
149
206
|
const url = this.config.server.packageServerUrl + "/pkg/" + specifier;
|
|
150
207
|
const res = await fetch(url);
|
|
151
208
|
if (!res.ok) {
|
|
@@ -153,18 +210,13 @@ export class Bundler {
|
|
|
153
210
|
throw new Error("Failed to fetch package '" + specifier + "' (HTTP " + res.status + ")" + (body ? ": " + body.slice(0, 200) : ""));
|
|
154
211
|
}
|
|
155
212
|
const code = await res.text();
|
|
156
|
-
|
|
157
|
-
const externalsHeader = res.headers.get("X-Externals");
|
|
158
|
-
if (externalsHeader) {
|
|
159
|
-
try {
|
|
160
|
-
externals = JSON.parse(externalsHeader);
|
|
161
|
-
}
|
|
162
|
-
catch { }
|
|
163
|
-
}
|
|
213
|
+
const externals = parseExternalsFromBody(code);
|
|
164
214
|
return { code, externals };
|
|
165
215
|
}
|
|
166
216
|
/** Build the module map by walking the dependency graph */
|
|
167
217
|
async buildModuleMap(entryFile) {
|
|
218
|
+
// Prefetch all deps in a single batch request
|
|
219
|
+
await this.prefetchDependencies();
|
|
168
220
|
const moduleMap = {};
|
|
169
221
|
const sourceMapMap = {};
|
|
170
222
|
const visited = {};
|
|
@@ -187,7 +239,7 @@ export class Bundler {
|
|
|
187
239
|
return;
|
|
188
240
|
}
|
|
189
241
|
const source = this.fs.read(filePath);
|
|
190
|
-
if (
|
|
242
|
+
if (source === undefined) {
|
|
191
243
|
throw new Error("File not found: " + filePath);
|
|
192
244
|
}
|
|
193
245
|
// Transform the file (TS -> JS, JSX -> JS, etc.)
|
|
@@ -14,6 +14,7 @@ export declare class IncrementalBundler {
|
|
|
14
14
|
private entryFile;
|
|
15
15
|
private packageVersions;
|
|
16
16
|
private transitiveDepsVersions;
|
|
17
|
+
private prefetchedPackages;
|
|
17
18
|
constructor(fs: VirtualFS, config: BundlerConfig);
|
|
18
19
|
/** Read tsconfig.json "compilerOptions.paths" from the VirtualFS */
|
|
19
20
|
private static readTsconfigPaths;
|
|
@@ -34,6 +35,8 @@ export declare class IncrementalBundler {
|
|
|
34
35
|
/** Resolve an npm specifier to a versioned form.
|
|
35
36
|
* Priority: user's package.json > transitive dep versions from manifests > bare name */
|
|
36
37
|
private resolveNpmSpecifier;
|
|
38
|
+
/** Prefetch all dependencies in a single batch request */
|
|
39
|
+
private prefetchDependencies;
|
|
37
40
|
/** Fetch a pre-bundled npm package from the package server */
|
|
38
41
|
private fetchPackage;
|
|
39
42
|
/**
|
|
@@ -3,7 +3,7 @@ import { DependencyGraph } from "./dependency-graph.js";
|
|
|
3
3
|
import { ModuleCache } from "./module-cache.js";
|
|
4
4
|
import { emitHmrBundle, HMR_RUNTIME_TEMPLATE } from "./hmr-runtime.js";
|
|
5
5
|
import { buildCombinedSourceMap, countNewlines, inlineSourceMap, shiftSourceMapOrigLines, } from "./source-map.js";
|
|
6
|
-
import { findRequires, rewriteRequires, hashString, buildBundlePreamble } from "./utils.js";
|
|
6
|
+
import { findRequires, rewriteRequires, hashString, buildBundlePreamble, parseExternalsFromBody, hashDeps, parseDepBundle } from "./utils.js";
|
|
7
7
|
export class IncrementalBundler {
|
|
8
8
|
constructor(fs, config) {
|
|
9
9
|
this.graph = new DependencyGraph();
|
|
@@ -13,6 +13,7 @@ export class IncrementalBundler {
|
|
|
13
13
|
this.entryFile = null;
|
|
14
14
|
this.packageVersions = {};
|
|
15
15
|
this.transitiveDepsVersions = {};
|
|
16
|
+
this.prefetchedPackages = {};
|
|
16
17
|
this.fs = fs;
|
|
17
18
|
this.config = config;
|
|
18
19
|
const paths = IncrementalBundler.readTsconfigPaths(fs);
|
|
@@ -162,8 +163,61 @@ export class IncrementalBundler {
|
|
|
162
163
|
const subpath = specifier.slice(baseName.length);
|
|
163
164
|
return baseName + "@" + version + subpath;
|
|
164
165
|
}
|
|
166
|
+
/** Prefetch all dependencies in a single batch request */
|
|
167
|
+
async prefetchDependencies() {
|
|
168
|
+
if (Object.keys(this.prefetchedPackages).length > 0)
|
|
169
|
+
return; // already prefetched
|
|
170
|
+
const versions = { ...this.packageVersions };
|
|
171
|
+
if (Object.keys(versions).length === 0)
|
|
172
|
+
return;
|
|
173
|
+
// Remove aliased and shimmed packages - they're handled client-side
|
|
174
|
+
const aliases = this.getModuleAliases();
|
|
175
|
+
const shims = this.getShimModules();
|
|
176
|
+
for (const name of Object.keys(aliases))
|
|
177
|
+
delete versions[name];
|
|
178
|
+
for (const name of Object.keys(shims))
|
|
179
|
+
delete versions[name];
|
|
180
|
+
if (Object.keys(versions).length === 0)
|
|
181
|
+
return;
|
|
182
|
+
const hash = await hashDeps(versions);
|
|
183
|
+
const baseUrl = this.config.server.packageServerUrl;
|
|
184
|
+
try {
|
|
185
|
+
const getRes = await fetch(`${baseUrl}/bundle-deps/${hash}`);
|
|
186
|
+
if (getRes.ok) {
|
|
187
|
+
const { packages } = parseDepBundle(await getRes.text());
|
|
188
|
+
this.prefetchedPackages = packages;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const postRes = await fetch(`${baseUrl}/bundle-deps`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: { "Content-Type": "application/json" },
|
|
194
|
+
body: JSON.stringify({ hash, dependencies: versions }),
|
|
195
|
+
});
|
|
196
|
+
if (postRes.ok) {
|
|
197
|
+
const { packages } = parseDepBundle(await postRes.text());
|
|
198
|
+
this.prefetchedPackages = packages;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
console.warn("[prefetch] Failed, falling back to individual fetches:", err);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
165
205
|
/** Fetch a pre-bundled npm package from the package server */
|
|
166
206
|
async fetchPackage(specifier) {
|
|
207
|
+
// Check prefetched registry first
|
|
208
|
+
let baseName = specifier;
|
|
209
|
+
const atIdx = specifier.indexOf("@", specifier.startsWith("@") ? 1 : 0);
|
|
210
|
+
if (atIdx > 0) {
|
|
211
|
+
baseName = specifier.slice(0, atIdx);
|
|
212
|
+
const afterVersion = specifier.indexOf("/", atIdx + 1);
|
|
213
|
+
if (afterVersion > 0) {
|
|
214
|
+
baseName = specifier.slice(0, atIdx) + specifier.slice(afterVersion);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (this.prefetchedPackages[baseName]) {
|
|
218
|
+
return { code: this.prefetchedPackages[baseName], externals: {} };
|
|
219
|
+
}
|
|
220
|
+
// Fallback to individual fetch
|
|
167
221
|
const url = this.config.server.packageServerUrl + "/pkg/" + specifier;
|
|
168
222
|
const res = await fetch(url);
|
|
169
223
|
if (!res.ok) {
|
|
@@ -171,14 +225,7 @@ export class IncrementalBundler {
|
|
|
171
225
|
throw new Error("Failed to fetch package '" + specifier + "' (HTTP " + res.status + ")" + (body ? ": " + body.slice(0, 200) : ""));
|
|
172
226
|
}
|
|
173
227
|
const code = await res.text();
|
|
174
|
-
|
|
175
|
-
const externalsHeader = res.headers.get("X-Externals");
|
|
176
|
-
if (externalsHeader) {
|
|
177
|
-
try {
|
|
178
|
-
externals = JSON.parse(externalsHeader);
|
|
179
|
-
}
|
|
180
|
-
catch { }
|
|
181
|
-
}
|
|
228
|
+
const externals = parseExternalsFromBody(code);
|
|
182
229
|
return { code, externals };
|
|
183
230
|
}
|
|
184
231
|
/**
|
|
@@ -198,7 +245,7 @@ export class IncrementalBundler {
|
|
|
198
245
|
return { localDeps: [], npmDeps: [] };
|
|
199
246
|
}
|
|
200
247
|
const source = this.fs.read(filePath);
|
|
201
|
-
if (
|
|
248
|
+
if (source === undefined) {
|
|
202
249
|
throw new Error("File not found: " + filePath);
|
|
203
250
|
}
|
|
204
251
|
const sourceHash = hashString(source);
|
|
@@ -394,6 +441,8 @@ export class IncrementalBundler {
|
|
|
394
441
|
this.moduleMap = {};
|
|
395
442
|
this.sourceMapMap = {};
|
|
396
443
|
this.packageVersions = this.getPackageVersions();
|
|
444
|
+
// Prefetch all deps in a single batch request
|
|
445
|
+
await this.prefetchDependencies();
|
|
397
446
|
const npmPackagesNeeded = new Set();
|
|
398
447
|
this.walkDeps(entryFile, npmPackagesNeeded);
|
|
399
448
|
// Process module aliases: swap sources for targets in the fetch list
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Parse externals metadata from a package bundle body.
|
|
2
|
+
* Looks for a `// @externals {...}` comment line near the top.
|
|
3
|
+
* Falls back to empty object if not found. */
|
|
4
|
+
export declare function parseExternalsFromBody(code: string): Record<string, string>;
|
|
1
5
|
/** Extract all require('...') call targets from source */
|
|
2
6
|
export declare function findRequires(source: string): string[];
|
|
3
7
|
/**
|
|
@@ -29,3 +33,14 @@ export declare function buildBundlePreamble(env?: Record<string, string>, router
|
|
|
29
33
|
export declare function buildRouterShim(): string;
|
|
30
34
|
/** Fast djb2 hash for cache invalidation */
|
|
31
35
|
export declare function hashString(str: string): string;
|
|
36
|
+
/** Hash a dependencies object to a stable cache key.
|
|
37
|
+
* Uses SHA-256 (via Web Crypto or Node crypto) truncated to 16 hex chars
|
|
38
|
+
* for collision resistance while keeping URLs short.
|
|
39
|
+
* Includes a version prefix so cache is invalidated when bundling logic changes. */
|
|
40
|
+
export declare function hashDeps(deps: Record<string, string>): Promise<string>;
|
|
41
|
+
/** Parse a dep bundle response into individual package code chunks.
|
|
42
|
+
* Format: `// @dep-start <name>\n..code..\n// @dep-end <name>` */
|
|
43
|
+
export declare function parseDepBundle(code: string): {
|
|
44
|
+
manifest: Record<string, string>;
|
|
45
|
+
packages: Record<string, string>;
|
|
46
|
+
};
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
const REQUIRE_RE = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
2
2
|
// Matches lines that are single-line comments or JSDoc/block comment continuations
|
|
3
3
|
const COMMENT_LINE_RE = /^\s*(?:\/\/|\/?\*)/;
|
|
4
|
+
const EXTERNALS_RE = /^\/\/ @externals (.+)$/m;
|
|
5
|
+
const DEP_MANIFEST_RE = /^\/\/ @dep-manifest (.+)$/m;
|
|
6
|
+
const DEP_START_RE = /^\/\/ @dep-start (.+)$/gm;
|
|
7
|
+
const DEP_END_RE = /^\/\/ @dep-end (.+)$/gm;
|
|
8
|
+
/** Parse externals metadata from a package bundle body.
|
|
9
|
+
* Looks for a `// @externals {...}` comment line near the top.
|
|
10
|
+
* Falls back to empty object if not found. */
|
|
11
|
+
export function parseExternalsFromBody(code) {
|
|
12
|
+
const match = EXTERNALS_RE.exec(code);
|
|
13
|
+
if (!match)
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(match[1]);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
4
22
|
/** Extract all require('...') call targets from source */
|
|
5
23
|
export function findRequires(source) {
|
|
6
24
|
if (!source)
|
|
@@ -206,3 +224,47 @@ export function hashString(str) {
|
|
|
206
224
|
}
|
|
207
225
|
return (hash >>> 0).toString(36);
|
|
208
226
|
}
|
|
227
|
+
// Must match SERVER_VERSION in reactnative-esm/src/index.ts
|
|
228
|
+
const DEPS_HASH_VERSION = "2";
|
|
229
|
+
/** Hash a dependencies object to a stable cache key.
|
|
230
|
+
* Uses SHA-256 (via Web Crypto or Node crypto) truncated to 16 hex chars
|
|
231
|
+
* for collision resistance while keeping URLs short.
|
|
232
|
+
* Includes a version prefix so cache is invalidated when bundling logic changes. */
|
|
233
|
+
export async function hashDeps(deps) {
|
|
234
|
+
const sorted = Object.keys(deps).sort().map(k => `${k}@${deps[k]}`).join(",");
|
|
235
|
+
const input = `v${DEPS_HASH_VERSION}:${sorted}`;
|
|
236
|
+
// Web Crypto API (works in browsers and workers)
|
|
237
|
+
if (typeof globalThis.crypto?.subtle?.digest === "function") {
|
|
238
|
+
const data = new TextEncoder().encode(input);
|
|
239
|
+
const buf = await crypto.subtle.digest("SHA-256", data);
|
|
240
|
+
const arr = Array.from(new Uint8Array(buf));
|
|
241
|
+
return arr.map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
|
|
242
|
+
}
|
|
243
|
+
// Fallback: djb2 (Node.js without crypto, shouldn't normally happen)
|
|
244
|
+
return hashString(input);
|
|
245
|
+
}
|
|
246
|
+
/** Parse a dep bundle response into individual package code chunks.
|
|
247
|
+
* Format: `// @dep-start <name>\n..code..\n// @dep-end <name>` */
|
|
248
|
+
export function parseDepBundle(code) {
|
|
249
|
+
let manifest = {};
|
|
250
|
+
const manifestMatch = DEP_MANIFEST_RE.exec(code);
|
|
251
|
+
if (manifestMatch) {
|
|
252
|
+
try {
|
|
253
|
+
manifest = JSON.parse(manifestMatch[1]);
|
|
254
|
+
}
|
|
255
|
+
catch { }
|
|
256
|
+
}
|
|
257
|
+
const packages = {};
|
|
258
|
+
const startRe = /^\/\/ @dep-start (.+)$/gm;
|
|
259
|
+
let match;
|
|
260
|
+
while ((match = startRe.exec(code)) !== null) {
|
|
261
|
+
const name = match[1];
|
|
262
|
+
const startIdx = match.index + match[0].length + 1; // skip newline
|
|
263
|
+
const endMarker = `// @dep-end ${name}`;
|
|
264
|
+
const endIdx = code.indexOf(endMarker, startIdx);
|
|
265
|
+
if (endIdx !== -1) {
|
|
266
|
+
packages[name] = code.slice(startIdx, endIdx).trimEnd();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { manifest, packages };
|
|
270
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-metro",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "A browser-based JavaScript/TypeScript bundler with HMR support",
|
|
3
|
+
"version": "1.0.7",
|
|
4
|
+
"description": "A browser-based JavaScript/TypeScript bundler with HMR support, inspired by Metro. Runs entirely client-side.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist"
|
|
9
9
|
],
|
|
10
|
+
"keywords": [
|
|
11
|
+
"bundler",
|
|
12
|
+
"react-native",
|
|
13
|
+
"metro",
|
|
14
|
+
"hmr",
|
|
15
|
+
"typescript",
|
|
16
|
+
"jsx",
|
|
17
|
+
"browser",
|
|
18
|
+
"virtual-fs",
|
|
19
|
+
"expo-router"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://reactnative.run",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/RapidNative/reactnative-run"
|
|
25
|
+
},
|
|
26
|
+
"author": "Sanket Sahu <sanket@rapidnative.com> (https://github.com/sanketsahu)",
|
|
10
27
|
"scripts": {
|
|
11
28
|
"build": "tsc",
|
|
12
29
|
"dev": "tsc --watch",
|