import { parse } from '@babel/parser' import type { ObjectProperty, StringLiteral } from '@babel/types' import type { BunPlugin } from 'bun' import tailwind from 'bun-plugin-tailwind' import { realpathSync } from 'node:fs' import { basename, dirname, join, relative, sep } from 'path' import { APP_ICON_IDS, isAppIconId } from '@/lib/app-icons' import type { AppletKind, ViewConfig, WidgetConfig } from '@/lib/types' import { scopeAppletCss } from './applet-css' // An **applet** is any custom UI unit embedded in a workspace; `widget` and // `view` are its kinds (more may follow). All compile through this pipeline — // `kind` only diverges at the edges: which `config` schema is parsed, the // synthetic-CSS filename, and the CSS scope/registry namespace. The type is // canonical in lib/types; re-exported here for this pipeline's consumers. export type { AppletKind } // Baked into the bundle wherever a runtime URL needs the workspace's API base // (RPC + workspace files). The serve route string-replaces it with the real // `/api/workspaces/` in every `.js` it returns, so the on-disk bundle stays // workspace-agnostic. Survives the build because we never minify — it lives as // a plain string literal. Assets don't use it: they self-locate via // `import.meta.url` (see the asset loader below). export const APPLET_API_BASE_SENTINEL = '%%MOI_APPLET_API_BASE%%' // Extensions an applet may `import` as a bundled asset. Each is emitted as a // content-hashed sibling of `index.js` and the import resolves to its URL via // `import.meta.url`. Deliberately images + fonts only: large media (video/audio) // belongs in the workspace and should stream via `fileUrl()`, not bloat the // bundle dir. const ASSET_EXTENSIONS = /\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf)$/i const EXTERNAL_MODULES = [ 'react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom', 'react-dom/client' ] type ServerModule = { name: string exports: string[] } // One emitted file in an applet's served directory. `code` files (entry + // chunks) are sentinel-swapped and served as JS; `asset` files (images/fonts) // stream raw. export type AppletFile = { name: string data: string | Uint8Array kind: 'code' | 'asset' } export type AppletArtifact = { // The entry (`index.js`) source, post CSS-injection — a convenience alias for // the `code` file named `index.js` in `files`. js: string // Everything to write into `.build///`: the entry, any code // chunks, and bundled assets. files: AppletFile[] serverModules: ServerModule[] config: WidgetConfig | ViewConfig | null } const DEFAULT_CONFIG: WidgetConfig = { rowSpan: 1, colSpan: 2 } const VALID_SPANS = [1, 2, 3, 4] as const // The properties of an exported `const config = { … }` object literal, or null // when the file declares no such export. Shared by the widget and view config // extractors — they each interpret the properties under their own schema. // Parsed with @babel/parser: it handles TS + JSX with no peer dependencies, so // it always resolves from moi's own tree. A parser that peer-depends on // `typescript` breaks under bun's shared global tree, where another globally // installed package controls which `typescript` sits at the hoisted root. function findConfigProperties(source: string) { const ast = parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] }) for (const node of ast.program.body) { if (node.type !== 'ExportNamedDeclaration') continue if (node.declaration?.type !== 'VariableDeclaration') continue const decl = node.declaration.declarations.find( d => d.id.type === 'Identifier' && d.id.name === 'config' ) if (!decl) continue const rawInit = decl.init // Unwrap `as const` — AST wraps the object in TSAsExpression const init = rawInit?.type === 'TSAsExpression' ? rawInit.expression : rawInit if (init?.type !== 'ObjectExpression') return null return init.properties } return null } // `requiredEnv`: an array of string literals naming env vars the bundle needs. // Advisory only — surfaced in the env UI, never enforced at build/load. function readRequiredEnv(propValue: ObjectProperty['value']): string[] | undefined { if (propValue.type !== 'ArrayExpression') return undefined const names = propValue.elements .filter((el): el is StringLiteral => el?.type === 'StringLiteral') .map(el => el.value) return names.length ? names : undefined } export async function extractWidgetConfig(srcPath: string): Promise { const source = await Bun.file(srcPath).text() const widgetName = basename(srcPath).replace(/\.tsx?$/, '') const properties = findConfigProperties(source) if (!properties) return null const result: Partial = {} for (const prop of properties) { if (prop.type !== 'ObjectProperty' || prop.key.type !== 'Identifier') continue const key = prop.key.name if (key === 'requiredEnv') { const names = readRequiredEnv(prop.value) if (names) result.requiredEnv = names continue } if (key !== 'rowSpan' && key !== 'colSpan') continue if (prop.value.type !== 'NumericLiteral') continue const val = prop.value.value if (!(VALID_SPANS as readonly number[]).includes(val)) { console.warn(`[mei] "${widgetName}": config.${key}=${val} is out of 1–4 range, using default`) continue } result[key] = val as 1 | 2 | 3 | 4 } return { ...DEFAULT_CONFIG, ...result } } // A view's config: `title` + app icon registry id + advisory `requiredEnv`. // No sizing — views are full-screen. Returns null when no `config` export is present. export async function extractViewConfig(srcPath: string): Promise { const source = await Bun.file(srcPath).text() const properties = findConfigProperties(source) if (!properties) return null const result: ViewConfig = {} for (const prop of properties) { if (prop.type !== 'ObjectProperty' || prop.key.type !== 'Identifier') continue const key = prop.key.name if (key === 'title') { if (prop.value.type === 'StringLiteral') { result.title = prop.value.value } continue } if (key === 'icon') { if (prop.value.type === 'StringLiteral') { const icon = prop.value.value if (!isAppIconId(icon)) { throw new Error(`Unknown view icon id "${icon}". Use one of: ${APP_ICON_IDS.join(', ')}`) } result.icon = icon } continue } if (key === 'requiredEnv') { const names = readRequiredEnv(prop.value) if (names) result.requiredEnv = names } } return result } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } async function validateServerExports(filePath: string): Promise { const source = await Bun.file(filePath).text() const transpiler = new Bun.Transpiler({ loader: 'ts' }) const { exports } = transpiler.scan(source) const runtimeExports = exports.filter(name => { const escaped = escapeRegex(name) const typePattern = new RegExp(`export\\s+(type|interface)\\s+${escaped}\\b`) return !typePattern.test(source) }) for (const name of runtimeExports) { const escaped = escapeRegex(name) const asyncFnPattern = new RegExp( `export\\s+async\\s+function\\*?\\s+${escaped}\\b` + '|' + `export\\s+const\\s+${escaped}\\s*=\\s*async\\s*[\\(]` ) if (!asyncFnPattern.test(source)) { throw new Error( `"${name}" in ${basename(filePath)} is not an async function. ` + `.server.ts files can only export async functions.` ) } } return runtimeExports } // The mei:rpc virtual module — contains the RPC call logic with devalue // serialization. Bundled into the applet output once, shared by all server // function stubs. The base is the sentinel the serve route rewrites to // `/api/workspaces/`, so a bundle carries no workspace id of its own. const RPC_MODULE_SOURCE = ` import { stringify, parse } from "devalue"; const BASE = ${JSON.stringify(APPLET_API_BASE_SENTINEL)}; export function rpc(module, name) { return async (...args) => { const res = await fetch(BASE + "/rpc/" + module + "/" + name, { method: "POST", headers: { "Content-Type": "application/json" }, body: stringify(args), }); if (!res.ok) throw new Error(await res.text()); return parse(await res.text()); }; } ` // The `moi` virtual module — the applet-facing runtime API. // // `fileUrl(path)` maps a workspace-relative path to its streaming URL // (`/api/workspaces//fs/`). Same sentinel base as RPC; the path is // per-segment URL-encoded so spaces / unicode in filenames survive. A leading // slash is stripped so both `clips/a.mp4` and `/clips/a.mp4` work. // // `focusTab(tab, params?)` and `sendChatMessage(message, context?)` forward to // this bundle's host-attached bridge — client-local replace-navigation to a // workspace tab (params delivered to the target view via navigation state), // and a chat message sent to the workspace's active chat as if the user had // typed it. This virtual module is INLINED PER BUNDLE, // so `bridge` is private to one applet: the host attaches it right after the // dynamic import and neuters it on invalidation (see // client/features/applets/applet-runtime.ts). Optional-chained so calls no-op // before attach and outside the moi host. The `__` exports are host wiring, // surfaced from the bundle entry below — they are deliberately NOT part of the // author-facing `declare module 'moi'` ambient types (server/moi-scaffold.ts). const MOI_MODULE_SOURCE = ` const BASE = ${JSON.stringify(APPLET_API_BASE_SENTINEL)}; let bridge = null; export function __attachBridge(next) { bridge = next; } export function __getBridge() { return bridge; } export function fileUrl(path) { const clean = String(path).replace(/^\\/+/, ""); return BASE + "/fs/" + clean.split("/").map(encodeURIComponent).join("/"); } export function focusTab(tab, params) { bridge?.focusTab(tab, params); } export function sendChatMessage(message, context) { bridge?.sendChatMessage(message, context); } ` // Server modules are keyed by their path relative to the moi root // (`.moi/widgets/hello.server.ts` → `"widgets/hello"`), posix-normalized so // keys are stable across platforms. Throws when the file escapes the root. // Both sides are canonicalized first: Bun's resolver returns realpaths // (e.g. `/tmp` → `/private/tmp` on macOS), and a symlinked root must not // look like an escape. function realpathOr(path: string): string { try { return realpathSync(path) } catch { return path } } function serverModuleKey(serverPath: string, moiRoot: string): string { const key = relative(realpathOr(moiRoot), realpathOr(serverPath)) .replace(/\.server\.ts$/, '') .split(sep) .join('/') if (key === '..' || key.startsWith('../')) { throw new Error(`Server file "${serverPath}" escapes the moi root "${moiRoot}"`) } return key } // The applet runtime plugin wires the three I/O transports into the bundle: // • `.server` imports → RPC stubs (via the `mei:rpc` virtual module) // • `moi` import → the `fileUrl` runtime // • asset imports → content-hashed sibling files, referenced by URL // It returns the collected server modules (for hot-reload + env aggregation) // and the asset files the caller must emit next to `index.js`. function appletRuntimePlugin( sourceDir: string, moiRoot: string ): { plugin: BunPlugin serverModules: ServerModule[] assets: AppletFile[] } { const serverModules: ServerModule[] = [] const assets: AppletFile[] = [] const plugin: BunPlugin = { name: 'applet-runtime', setup(build) { // Resolve mei:rpc virtual module build.onResolve({ filter: /^mei:rpc$/ }, () => ({ path: 'mei:rpc', namespace: 'mei-rpc' })) build.onLoad({ filter: /.*/, namespace: 'mei-rpc' }, () => ({ contents: RPC_MODULE_SOURCE, loader: 'js' })) // `devalue` is moi's OWN dependency, injected into every applet bundle via // the mei:rpc virtual module above. A virtual module has no on-disk // location, so Bun resolves the bare `devalue` specifier against the // process cwd's node_modules — which breaks when the server runs from a // neutral cwd (a prebuilt/global install; see serverCwd in cli.ts). A // resolveDir on the onLoad is ignored for bare specifiers in Bun 1.3, so // pin it to an absolute path inside moi's OWN package (this file's dir // walks up to moi's node_modules), making the applet build cwd-independent. build.onResolve({ filter: /^devalue$/ }, () => ({ path: Bun.resolveSync('devalue', import.meta.dir) })) // The `moi` runtime module (fileUrl). A bare specifier, so match it exactly. build.onResolve({ filter: /^moi$/ }, () => ({ path: 'moi', namespace: 'moi-runtime' })) build.onLoad({ filter: /.*/, namespace: 'moi-runtime' }, () => ({ contents: MOI_MODULE_SOURCE, loader: 'js' })) // Asset imports (images/fonts): emit a content-hashed sibling and resolve // the import to its module-relative URL. Self-locating via import.meta.url, // so it needs no API base — the asset sits next to the served entry. build.onLoad({ filter: ASSET_EXTENSIONS }, async args => { const bytes = new Uint8Array(await Bun.file(args.path).arrayBuffer()) const hash = Bun.hash(bytes).toString(16).slice(0, 8) const dot = args.path.lastIndexOf('.') const ext = args.path.slice(dot + 1).toLowerCase() const stem = basename(args.path.slice(0, dot)).replace(/[^a-zA-Z0-9_-]/g, '-') const name = `${stem}-${hash}.${ext}` if (!assets.some(a => a.name === name)) assets.push({ name, data: bytes, kind: 'asset' }) return { contents: `export default new URL(${JSON.stringify('./' + name)}, import.meta.url).href`, loader: 'js' } }) // Intercept .server imports (relative only). Resolved against the // importing file's directory — server files may live anywhere under // the moi root, e.g. `../lib/db.server`. build.onResolve({ filter: /\.server(\.ts)?$/ }, args => { if (!args.path.startsWith('.')) return const baseDir = args.importer && args.importer.includes(sep) ? dirname(args.importer) : sourceDir return { path: join(baseDir, args.path.replace(/\.server(\.ts)?$/, '.server.ts')), namespace: 'server-proxy' } }) // Generate proxy stubs using mei:rpc build.onLoad({ filter: /.*/, namespace: 'server-proxy' }, async args => { const exports = await validateServerExports(args.path) const moduleName = serverModuleKey(args.path, moiRoot) serverModules.push({ name: moduleName, exports }) const lines = [ `import { rpc } from "mei:rpc";`, ...exports.map( name => `export const ${name} = rpc(${JSON.stringify(moduleName)}, ${JSON.stringify(name)});` ) ] return { contents: lines.join('\n'), loader: 'js' } }) } } return { plugin, serverModules, assets } } // Shared Tailwind input for applet bundles. It maps semantic tokens and // provides the same texture utilities available to host components. const HOST_THEME_PATH = join(import.meta.dir, '..', '..', 'client', 'theme.css') // Three things matter for applet styling: // 1. The umbrella `@import 'tailwindcss'` brings in @layer theme + base + // utilities — which is what spacing/color utilities like `left-2.5`, // `gap-1.5`, `text-amber-500` need to resolve. The split // `theme`/`utilities` imports skip the `@layer theme` wrapper and theme // variables aren't in scope at compile time. // 2. The host's `@theme inline` block (inlined from theme.css) teaches // Tailwind about host-owned tokens (`--color-background`, // `--color-foreground`, etc.) so widgets compile classes like // `bg-foreground/20`, `text-muted`, `rounded-xl`. The `inline` keyword // means the widget's CSS does NOT redefine the underlying // `--background`/`--foreground` variables — those stay host-owned and // the widget picks them up from `:root` at runtime. // The same shared file also defines texture utilities for both surfaces. // 3. Tailwind's auto-detection treats `.moi/` as a hidden directory // and skips it. An explicit `@source` bypasses the dot-dir + gitignore // filters. // // Important: the synthetic CSS must live on disk in the `file` namespace // because `bun-plugin-tailwind`'s `onLoad` only matches that namespace — // custom-namespace CSS is treated as raw text and our `@theme inline` block // is left unprocessed. We materialize it inside `/.build/` and // have the entry import it by absolute path. async function writeSyntheticTailwindCss( widgetPath: string, moiRoot: string, kind: AppletKind ): Promise { const sourceDir = dirname(widgetPath) const buildDir = join(moiRoot, '.build') // Per-kind filename: widgets (`@source .moi/widgets`) and views // (`@source .moi/views`) build concurrently in one `moi bundle`; a shared // file would race and point Tailwind at the wrong source dir. const cssPath = join(buildDir, `${kind}-tailwind.css`) const contents = [ `@import 'tailwindcss';`, await Bun.file(HOST_THEME_PATH).text(), // Mirror the host's class-based dark mode (client/index.css) so an applet's // `dark:` variants flip with the app theme. Without this Tailwind falls // back to `@media (prefers-color-scheme: dark)`, which diverges from the // host and injects OS-preference media queries into the page. `@custom-variant dark (&:is(.dark *));`, // Dark themes and vivid surfaces use the stronger texture treatment. `@custom-variant dark-or-vivid (&:is(.dark *, [data-vivid], [data-vivid] *));`, `@source "${sourceDir}";` ].join('\n') await Bun.write(cssPath, contents) return cssPath } function widgetEntryPlugin(widgetPath: string, syntheticCssPath: string): BunPlugin { return { name: 'widget-entry', setup(build) { build.onResolve({ filter: /^__widget-entry$/ }, () => ({ path: '__widget-entry', namespace: 'widget-entry' })) build.onLoad({ filter: /.*/, namespace: 'widget-entry' }, () => ({ contents: [ `import ${JSON.stringify(syntheticCssPath)};`, `export { default } from ${JSON.stringify(widgetPath)};`, // Surface the bridge wiring on every bundle's `index.js` so the host // can attach after dynamic import. Bun dedupes the `moi` virtual // module within a bundle, so this re-export and the applet's own // `import { focusTab } from 'moi'` share one module instance — the // attached bridge is the one focusTab reads. `export { __attachBridge, __getBridge } from "moi";` ].join('\n'), loader: 'js' })) } } } // Hand the bundle's (scoped) CSS to the host instead of mutating the DOM. The // module side effect only registers the text under a stable key — the path of // the bundle dir, derived from import.meta.url so it self-locates per workspace // (`/api/workspaces///`), matching `appletStyleKey` on the // client. The host mounts a