import { parse } from 'svelte/compiler'; import type { AST } from 'svelte/compiler'; import MagicString from 'magic-string'; import path from 'node:path'; import { walk } from 'zimmerframe'; import { ALSO_HYDRATE_ENVELOPE_KEY, type AlsoHydrateMode } from '../types'; import { FRAMEWORK_COMPONENTS_SPECIFIER, resolveFrameworkComponent } from './frameworkComponents'; import { encodeSourcePath } from './manifestPaths'; /** Svelte's AST nodes all have start/end, but estree types don't declare them. */ interface Positioned { start: number; end: number; } export interface HydratableComponent { /** Unique identity key (`_`, see `islandIdentity`) — the registry map key, `component-name` attribute, and placeholder key. */ name: string; /** The bare local import identifier, for human-facing messages only (never an identity key). */ displayName: string; resolvedPath: string; /** Export the component was imported from: `'default'` or a named export of the resolved module. */ exportName: string; } export interface ServerIslandComponent { /** Unique identity key (`_`, see `islandIdentity`) — the registry map key, `component-name` attribute, and props AAD. */ name: string; /** The bare local import identifier, for human-facing messages only (never an identity key). */ displayName: string; resolvedPath: string; /** Export the component was imported from: `'default'` or a named export of the resolved module. */ exportName: string; } /** * A `mochi:*` directive the preprocessor could not honour. Surfaced as a * compile error (via `ComponentRegistry.getErrors()`) rather than thrown, so * the dev error page can render it and the dev watcher can clear it on fix — * silently skipping would leave an inert component with no signal to the author. */ export interface PreprocessIslandError { /** Bare component name as written in the template. */ component: string; /** The directive that required resolution, e.g. `mochi:hydrate:visible`. */ directive: string; /** Absolute path of the file containing the directive. */ filePath: string; /** Import specifier when one exists but is unsupported (bare package, namespace, non-svelte source); null when no import matched at all. */ importSource: string | null; } export interface PreprocessResult { transformed: string; hydratables: HydratableComponent[]; serverIslands: ServerIslandComponent[]; errors: PreprocessIslandError[]; } /** * Rewrites `mochi:*` directives on child components into island wrappers, matching through Svelte's own parser * so the AST decides rather than a regex. * * - `mochi:hydrate` / `mochi:hydrate:visible` → `` * - `mochi:defer` / `mochi:defer:visible` → `` with encrypted * props; `:visible` adds `defer-on="visible"` so the client waits for * IntersectionObserver before fetching * - Combined `mochi:defer*` + `mochi:hydrate*` → server island with `also-hydrate`, * registered in both lists * - `mochi:clientOnly` → ``, which skips the * server entirely, turns any children into SSR placeholder markup, and mounts on * the client */ export function preprocessHydratable(source: string, filePath: string): PreprocessResult { if (!source.includes('mochi:hydrate') && !source.includes('mochi:defer') && !source.includes('mochi:clientOnly')) { return { transformed: source, hydratables: [], serverIslands: [], errors: [] }; } const ast = parse(source, { modern: true }); const s = new MagicString(source); // Svelte allows one `$props.id()` per component (`props_duplicate`), so an author's existing declaration has to be // reused rather than shadowed. Only top-level instance-script declarations are scanned; a `$props.id()` nested in a // function or snippet slips through and collides, which stays unhandled until someone hits it. const importMap = new Map(); // Local names bound by imports the island pipeline can't handle (bare package specifiers, namespace imports, // non-svelte sources), kept so a directive on one of them can name the offending specifier in its compile error. const unsupportedImports = new Map(); let pidVar: string | null = null; if (ast.instance) { for (const node of ast.instance.content.body) { if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') { const importSource = node.source.value; // Resolving the framework's own public components to their on-disk `.svelte` lets a directive sit straight on // the package import (``). Only named imports resolve; the rest fall through to // the usual unresolved-island error. if (importSource === FRAMEWORK_COMPONENTS_SPECIFIER) { for (const spec of node.specifiers ?? []) { const framework = spec.type === 'ImportSpecifier' && spec.imported.type === 'Identifier' ? resolveFrameworkComponent(spec.imported.name) : null; if (framework) { importMap.set(spec.local.name, { source: framework.resolvedPath, exportName: framework.exportName }); } else { unsupportedImports.set(spec.local.name, importSource); } } continue; } const supported = /\.(svelte|md|svx)$/.test(importSource) && // TODO: Needs to be configurable to support arbitrary extensions (importSource.startsWith('./') || importSource.startsWith('../') || path.isAbsolute(importSource)); for (const spec of node.specifiers ?? []) { if (supported && spec.type === 'ImportDefaultSpecifier') { importMap.set(spec.local.name, { source: importSource, exportName: 'default' }); } else if (supported && spec.type === 'ImportSpecifier') { const exportName = spec.imported.type === 'Identifier' ? spec.imported.name : String(spec.imported.value); importMap.set(spec.local.name, { source: importSource, exportName }); } else { unsupportedImports.set(spec.local.name, importSource); } } } else if (node.type === 'VariableDeclaration') { for (const decl of node.declarations) { if ( decl.id.type === 'Identifier' && decl.init?.type === 'CallExpression' && decl.init.callee.type === 'MemberExpression' && decl.init.callee.object.type === 'Identifier' && decl.init.callee.object.name === '$props' && decl.init.callee.property.type === 'Identifier' && decl.init.callee.property.name === 'id' ) { pidVar = decl.id.name; } } } } } const pid = pidVar ?? '__mochi_pid__'; const hydratables: HydratableComponent[] = []; const serverIslands: ServerIslandComponent[] = []; const errors: PreprocessIslandError[] = []; const seen = new Set(); const seenServer = new Set(); // Set by the hydrate/visible branch alone, since only those islands SSR in-page and so need the context boundary imported below. let needsBoundary = false; walk(ast.fragment as AST.SvelteNode, null, { Component(comp, { next }) { const directives = findMochiDirectives(comp.attributes); if (!directives.server && !directives.hydrate && !directives.clientOnly) { next(); return; } // `islandId` is reserved on every directive alike, so a component can move between them without a prop // silently changing meaning; on `mochi:defer` it is the transport key inside the signed envelope. const islandDirective = directives.clientOnly ?? directives.server ?? directives.hydrate!; const entry = importMap.get(comp.name); if (!entry) { // For dotted names (``), the base identifier's import is the one worth naming in the compile error. const base = comp.name.split('.')[0]!; errors.push({ component: comp.name, directive: islandDirective.name, filePath, importSource: unsupportedImports.get(comp.name) ?? unsupportedImports.get(base) ?? importMap.get(base)?.source ?? null, }); next(); return; } const resolved = path.resolve(path.dirname(filePath), entry.source); const exportName = entry.exportName; const dedupKey = `${resolved}\0${exportName}`; // Per-component-file identity keying the `component-name` attribute, the server-island endpoint path, the // props-encryption AAD, the `__MOCHI_*____` placeholders, and the registry maps — see `islandIdentity` // for why the bare `comp.name` below, still used for the Svelte tag and error text, can't serve as the key. const islandKey = islandIdentity(comp.name, resolved, exportName); for (const attr of comp.attributes) { if (attr.type === 'Attribute' && attr.name === 'islandId') { throw new Error( `\`islandId\` is a reserved framework name and cannot be passed as a prop to a \`${islandDirective.name}\` island. ` + `For a unique id inside ${comp.name}, use Svelte's \`$props.id()\`.`, ); } } if (directives.clientOnly) { // --- CLIENT ONLY --- if (!seen.has(dedupKey)) { seen.add(dedupKey); hydratables.push({ name: islandKey, displayName: comp.name, resolvedPath: resolved, exportName }); } // Children are the optional SSR fallback, emitted as placeholder markup and removed once the client mounts. // Static markup only: nested `mochi:*` islands stay untransformed and get wiped on mount. const fallback = comp.fragment.nodes.map((n) => source.slice(n.start, n.end)).join(''); // Nothing renders server-side, so there is no hydration id to carry and the payload dedups on serialized props alone. const propsExpr = buildPropsFromAst(source, comp.attributes); let attrs = `component-name="${islandKey}" component-url="__MOCHI_COMPONENT_URL__${islandKey}__" client-only`; if (propsExpr !== '{}') { attrs += ` props-ref={__mochi_emit_props__(${propsExpr})}`; } // `mochi:clientOnly:visible` defers `mount()` until the wrapper enters the viewport, reusing the hydratable visible path. const isVisible = directives.clientOnly.name === 'mochi:clientOnly:visible'; if (isVisible) { let visibleOptionsExpr: string | null = null; if (directives.clientOnly.value !== true && !Array.isArray(directives.clientOnly.value)) { const expr = directives.clientOnly.value.expression as unknown as Positioned; visibleOptionsExpr = source.slice(expr.start, expr.end); } attrs += ` hydrate-on="visible" css-url="__MOCHI_CSS_URL__${islandKey}__"`; if (visibleOptionsExpr) { attrs += ` hydrate-options={JSON.stringify(${visibleOptionsExpr})}`; } } // The component skips SSR entirely, leaving no throw to catch and no hydration marker to force, so no ``. const replacement = `${fallback}`; s.overwrite(comp.start, comp.end, replacement); } else if (directives.server) { if (!seenServer.has(dedupKey)) { seenServer.add(dedupKey); serverIslands.push({ name: islandKey, displayName: comp.name, resolvedPath: resolved, exportName }); } // The authored also-hydrate mode rides inside the encrypted envelope (`__mochi_ah`, transport-only, stripped before // render) and the endpoint reads it from the decrypted payload: were it trusted from a `?hydrate=` query param, an // attacker could append `hydrate=eager` to any sealed token and have the endpoint echo the props back in plaintext. const alsoHydrateMode: AlsoHydrateMode | null = directives.hydrate ? (directives.hydrate.name === 'mochi:hydrate:visible' ? 'visible' : 'eager') : null; const autoEntries = directives.hydrate ? [`islandId: __mochi_iid`, `${ALSO_HYDRATE_ENVELOPE_KEY}: ${JSON.stringify(alsoHydrateMode)}`] : [`islandId: __mochi_iid`]; const propsExpr = buildPropsFromAst(source, comp.attributes, autoEntries); // Server islands always emit signed-props, since islandId is always injected and every prop must be encrypted // against reads and tampering via query parameters. The component name is bound as AAD, so a token sealed for // one component can't be replayed against another. let attrs = `component-name="${islandKey}" signed-props={__mochi_encrypt_props__(__mochi_stringify__(${propsExpr}), ${JSON.stringify(islandKey)})} css-url="__MOCHI_SERVER_CSS_URL__${islandKey}__" data-asset-prefix="__MOCHI_ASSET_PREFIX__"`; // `mochi:defer:visible` defers the fetch until the wrapper enters the // viewport. `rootMargin` rides inside the existing `server-options` // JSON so the client reads one attribute for both fetch and visibility // configuration. const isServerVisible = directives.server.name === 'mochi:defer:visible'; if (isServerVisible) { attrs += ` defer-on="visible"`; } // Extract directive options (e.g. mochi:defer={{retries: 10}} or // mochi:defer:visible={{rootMargin: '200px', retries: 5}}) let serverOptionsExpr: string | null = null; if (directives.server.value !== true && !Array.isArray(directives.server.value)) { const exprTag = directives.server.value; const expr = exprTag.expression as unknown as Positioned; serverOptionsExpr = source.slice(expr.start, expr.end); } if (serverOptionsExpr) { attrs += ` server-options={JSON.stringify(${serverOptionsExpr})}`; } // Combined: mochi:defer + mochi:hydrate/mochi:hydrate:visible if (directives.hydrate) { const isVisible = directives.hydrate.name === 'mochi:hydrate:visible'; attrs += isVisible ? ` also-hydrate="visible"` : ` also-hydrate="eager"`; attrs += ` component-url="__MOCHI_COMPONENT_URL__${islandKey}__"`; if (!seen.has(dedupKey)) { seen.add(dedupKey); hydratables.push({ name: islandKey, displayName: comp.name, resolvedPath: resolved, exportName }); } } // Children become fallback content const constDecl = `{#if true}{@const __mochi_iid = \`\${${pid}}-\${__mochi_uid__++}\`}`; let replacement: string; if (comp.fragment.nodes.length > 0) { const childrenSource = comp.fragment.nodes.map((n) => source.slice(n.start, n.end)).join(''); replacement = `${constDecl}${childrenSource}{/if}`; } else { replacement = `${constDecl}{/if}`; } s.overwrite(comp.start, comp.end, replacement); } else { const mochiAttr = directives.hydrate!; if (!seen.has(dedupKey)) { seen.add(dedupKey); hydratables.push({ name: islandKey, displayName: comp.name, resolvedPath: resolved, exportName }); } const propsSource = comp.attributes .filter((a) => !(a.type === 'Attribute' && a.name.startsWith('mochi:'))) .map((a) => source.slice(a.start, a.end)) .join(' '); const isVisible = mochiAttr.name === 'mochi:hydrate:visible'; let visibleOptionsExpr: string | null = null; if (isVisible && mochiAttr.value !== true && !Array.isArray(mochiAttr.value)) { const exprTag = mochiAttr.value; const expr = exprTag.expression as unknown as Positioned; visibleOptionsExpr = source.slice(expr.start, expr.end); } const propsExpr = buildPropsFromAst(source, comp.attributes); let attrs = `component-name="${islandKey}" component-url="__MOCHI_COMPONENT_URL__${islandKey}__"`; // Skipping empty props keeps `{}` out of the HTML. `__mochi_emit_props__` registers the payload in the // per-request dedup map and returns a ref id, which ComponentRegistry's post-render HTMLRewriter pass turns // into a