diff --git a/almostesm/src/index.ts b/almostesm/src/index.ts index 497b950..1e289d8 100644 --- a/almostesm/src/index.ts +++ b/almostesm/src/index.ts @@ -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(); }); @@ -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 = {}; + 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. @@ -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 }; } }); @@ -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 && { @@ -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); diff --git a/almostmetro/example/package-lock.json b/almostmetro/example/package-lock.json index d460f5d..37fa234 100644 --- a/almostmetro/example/package-lock.json +++ b/almostmetro/example/package-lock.json @@ -23,7 +23,9 @@ } }, "..": { - "version": "1.0.0", + "name": "@shaper-studio/almostmetro", + "version": "1.0.3", + "license": "MIT", "dependencies": { "sucrase": "^3.35.0" }, diff --git a/almostmetro/package-lock.json b/almostmetro/package-lock.json index 304f90b..c24f06d 100644 --- a/almostmetro/package-lock.json +++ b/almostmetro/package-lock.json @@ -1,12 +1,13 @@ { - "name": "almostmetro", - "version": "1.0.1", + "name": "@shaper-studio/almostmetro", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "almostmetro", - "version": "1.0.1", + "name": "@shaper-studio/almostmetro", + "version": "1.0.3", + "license": "MIT", "dependencies": { "sucrase": "^3.35.0" }, diff --git a/almostmetro/src/transforms/react-refresh.ts b/almostmetro/src/transforms/react-refresh.ts index 877551b..fcf38a7 100644 --- a/almostmetro/src/transforms/react-refresh.ts +++ b/almostmetro/src/transforms/react-refresh.ts @@ -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' + @@ -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) { @@ -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' +