import { html } from '@hyperspan/html'; import { assetHash as assetHashFn } from '../utils'; import { join } from 'node:path'; import type { Hyperspan as HS } from '../types'; const CWD = process.cwd(); const IS_PROD = process.env.NODE_ENV === 'production'; export const JS_PUBLIC_PATH = '/_hs/js'; export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands'; export const JS_IMPORT_MAP = new Map(); const CLIENT_JS_CACHE = new Map(); const CLIENT_JS_BUILD_PROMISES = new Map>(); const EXPORT_REGEX = /export\{(.*)\}/g; /** * Build a client JS module and return a Hyperspan.ClientJSBuildResult object */ export async function buildClientJS(modulePathResolved: string): Promise { const modulePath = modulePathResolved.replace('file://', ''); const assetHash = assetHashFn(modulePath); // Cache: Avoid re-processing the same file if (!CLIENT_JS_CACHE.has(assetHash)) { const existingBuild = CLIENT_JS_BUILD_PROMISES.get(assetHash); // Await the existing build promise if it exists (this can get called in parallel from Bun traversing imports) if (existingBuild) { await existingBuild; } else { const buildPromise = (async () => { // Build the client JS module const result = await Bun.build({ entrypoints: [modulePath], outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file... naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined, external: Array.from(JS_IMPORT_MAP.keys()), minify: true, format: 'esm', target: 'browser', env: 'APP_PUBLIC_*', }); // Add output file to import map const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', ''); const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`; JS_IMPORT_MAP.set(esmName, publicPath); // Get the contents of the file to extract the exports const contents = await result.outputs[0].text(); const { exports, fnArgs } = extractExports(contents); CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath }); })(); CLIENT_JS_BUILD_PROMISES.set(assetHash, buildPromise); try { await buildPromise; } finally { CLIENT_JS_BUILD_PROMISES.delete(assetHash); } } } const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!; return { assetHash, esmName, publicPath, renderScriptTag: (loadScript) => { const t = typeof loadScript; if (t === 'string') { return html` `; } if (t === 'function') { return html` `; } return html` `; } } } /** * Extract the exports from a client JS module */ export function extractExports(contents: string): { exports: string, fnArgs: string } { const exportLine = EXPORT_REGEX.exec(contents); let exports = '{}'; let fnArgs = '{}'; if (exportLine) { const exportName = exportLine[1]; exports = '{' + exportName .split(',') .map((name) => name.trim().split(' as ')) .map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`) .join(', ') + '}'; } fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2').trim(); if (exports === '{}' && fnArgs === '{}') { exports = '* as _module' fnArgs = '_module' } return { exports, fnArgs }; } /** * Convert a function to a string (results in loss of context!) * Handles named, async, and arrow functions */ export function functionToString(fn: any) { return fn.toString().trim(); }