Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 59 additions & 20 deletions almostesm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ fs.mkdirSync(CACHE_DIR, { recursive: true });
// CORS for browser access
app.use((req: Request, res: Response, next: NextFunction) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Expose-Headers", "X-Externals");
res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type");
res.header("Access-Control-Expose-Headers", "X-Externals, X-Resolved-Version");
if (req.method === "OPTIONS") { res.sendStatus(204); return; }
next();
});

Expand Down Expand Up @@ -124,6 +127,21 @@ async function handlePkgRequest(res: Response, pkgName: string, version: string,
`module.exports = require("${requireSpecifier}");\n`
);

// Write a shim file for Node built-ins and build an alias map.
// RN packages sometimes import Node APIs (buffer, stream, etc.) for
// native code paths that are dead code in the browser bundle.
const shimFile = path.join(tmpDir, "__node-shim.js");
fs.writeFileSync(shimFile, "module.exports = {};");
const nodeBuiltinAliases: Record<string, string> = {};
for (const name of [
"buffer", "stream", "fs", "path", "os", "crypto", "util", "events",
"http", "https", "net", "tls", "zlib", "child_process", "worker_threads",
"url", "querystring", "string_decoder", "assert", "tty", "domain",
]) {
nodeBuiltinAliases[name] = shimFile;
nodeBuiltinAliases[`node:${name}`] = shimFile;
}

// Externalize bare package imports (e.g. "react") so shared deps are
// loaded once. For subpath imports (e.g. "css-in-js-utils/lib/foo"),
// generally inline them since they're internal implementation details.
Expand Down Expand Up @@ -173,30 +191,43 @@ async function handlePkgRequest(res: Response, pkgName: string, version: string,
} else {
pkg = args.path.split("/")[0];
}
if (!externalSet.has(pkg)) return null;

// Track installed version for the base package
if (!externalizedMap[pkg]) {
const version = getInstalledVersion(pkg);
if (version) externalizedMap[pkg] = version;
}

// Bare import: always externalize
if (args.path === pkg) {
return { path: pkg, external: true };
}

// Subpath import: for version-sensitive packages (react, react-dom,
// react-native), always externalize to avoid inlining mismatched
// versions. For other packages, try to resolve locally and inline.
if (alwaysExternalSubpaths.has(pkg)) {
return { path: args.path, external: true };
if (externalSet.has(pkg)) {
// Track installed version for the base package
if (!externalizedMap[pkg]) {
const version = getInstalledVersion(pkg);
if (version) externalizedMap[pkg] = version;
}

// Bare import: always externalize
if (args.path === pkg) {
return { path: pkg, external: true };
}

// Subpath import: for version-sensitive packages (react, react-dom,
// react-native), always externalize to avoid inlining mismatched
// versions. For other packages, try to resolve locally and inline.
if (alwaysExternalSubpaths.has(pkg)) {
return { path: args.path, external: true };
}

try {
require.resolve(args.path, { paths: [args.resolveDir] });
return null;
} catch {
return { path: args.path, external: true };
}
}

// Not in the explicit externals set — try to resolve locally.
// If resolution fails (implicit peer dep like expo-modules-core
// imported by expo-router without being listed as a dependency),
// externalize it so esbuild doesn't crash the entire build.
try {
require.resolve(args.path, { paths: [args.resolveDir] });
return null;
return null; // resolvable locally → inline it
} catch {
console.log(`[auto-external] ${args.path} (not in deps, unresolvable)`);
return { path: args.path, external: true };
}
});
Expand All @@ -212,6 +243,7 @@ async function handlePkgRequest(res: Response, pkgName: string, version: string,
outfile: outFile,
platform: "browser",
target: "es2020",
alias: nodeBuiltinAliases,
// For RN/Expo packages: prioritize .web.* extensions, handle JSX in .js,
// and inline font/image assets as data URLs.
...(isReactNative && {
Expand All @@ -237,7 +269,14 @@ async function handlePkgRequest(res: Response, pkgName: string, version: string,
});

const bundled = fs.readFileSync(outFile, "utf-8");
const wrapped = `// Bundled: ${requireSpecifier}@${version}\n// Externals: ${externals.join(", ") || "none"}\n${bundled}\nif (typeof __module !== "undefined") { module.exports = __module; }\n`;
// When esbuild bundles an ESM package as IIFE, __toCommonJS wraps the
// exports with __esModule: true and a .default getter. If we pass this
// wrapper through as-is, consuming esbuild bundles apply __toESM which
// tries to chain getters across two separate IIFE closures — this breaks
// for packages like color-convert where the .default getter never
// resolves correctly. Fix: unwrap the .default so consumers get the
// actual value directly (CJS-style), which __toESM always handles.
const wrapped = `// Bundled: ${requireSpecifier}@${version}\n// Externals: ${externals.join(", ") || "none"}\n${bundled}\nif (typeof __module !== "undefined") { module.exports = (__module && __module.__esModule && __module.default !== undefined) ? __module.default : __module; }\n`;

fs.writeFileSync(cacheFile, wrapped);
const externalsJson = JSON.stringify(externalizedMap);
Expand Down
4 changes: 3 additions & 1 deletion almostmetro/example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions almostmetro/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 31 additions & 1 deletion almostmetro/src/transforms/react-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ export function createReactRefreshTransformer(base: Transformer): Transformer {
return result;
}

// Check if module uses createContext (needs HMR identity preservation)
const usesCreateContext =
params.src.includes('createContext') || result.code.includes('createContext');

// Preamble: set up refresh hooks scoped to this module
const preamble =
let preamble =
'var _prevRefreshReg = window.$RefreshReg$;\n' +
'var _prevRefreshSig = window.$RefreshSig$;\n' +
'var _refreshModuleId = ' + JSON.stringify(params.filename) + ';\n' +
Expand All @@ -73,6 +77,26 @@ export function createReactRefreshTransformer(base: Transformer): Transformer {
' return function(type) { return type; };\n' +
'};\n';

// Context identity preservation: patch React.createContext so re-executions
// return the same context object, preventing useContext identity mismatches
if (usesCreateContext) {
preamble +=
'var _hmrCtxIdx = 0;\n' +
'var _hmrOrigCC;\n' +
'try {\n' +
' var _hmrReact = require("react");\n' +
' _hmrOrigCC = _hmrReact.createContext;\n' +
' if (!window.__HMR_CONTEXTS__) window.__HMR_CONTEXTS__ = {};\n' +
' _hmrReact.createContext = function(defaultValue) {\n' +
' var key = _refreshModuleId + ":ctx:" + (_hmrCtxIdx++);\n' +
' if (window.__HMR_CONTEXTS__[key]) return window.__HMR_CONTEXTS__[key];\n' +
' var ctx = _hmrOrigCC(defaultValue);\n' +
' window.__HMR_CONTEXTS__[key] = ctx;\n' +
' return ctx;\n' +
' };\n' +
'} catch(_e) {}\n';
}

// Postamble: register each component and accept HMR
let postamble = '\n';
for (const name of components) {
Expand All @@ -81,6 +105,12 @@ export function createReactRefreshTransformer(base: Transformer): Transformer {
' $RefreshReg$(' + name + ', ' + JSON.stringify(name) + ');\n' +
'}\n';
}
if (usesCreateContext) {
postamble +=
'if (_hmrOrigCC) {\n' +
' try { require("react").createContext = _hmrOrigCC; } catch(_e) {}\n' +
'}\n';
}
postamble +=
'window.$RefreshReg$ = _prevRefreshReg;\n' +
'window.$RefreshSig$ = _prevRefreshSig;\n' +
Expand Down