UNPKG

@macroforge/mcp-server

Version:

MCP server for Macroforge documentation and code analysis

180 lines (138 loc) • 6.46 kB
# Expanded Config Foreign-type handlers are written in `macroforge.config.ts`, but they run inside the code `@derive(Encode)` and `@derive(Decode)` generate in your own modules. Macroforge connects the two by expanding the config into `.macroforge/config/`, where every handler is a named export, and generated code imports the handlers it calls from `#macroforge/config`. ## Before and after Take a config whose handlers use a library and a helper of its own: ```typescript // macroforge.config.ts import { DateTime } from 'effect'; const isIsoString = (value: unknown): value is string => typeof value === 'string' && !Number.isNaN(Date.parse(value)); export default { foreignTypes: { 'DateTime.Utc': { from: ['effect'], encode: (v: DateTime.Utc) => DateTime.formatIso(v), decode: (raw: unknown) => DateTime.unsafeFromDate(new Date(String(raw))), default: () => DateTime.unsafeNow(), hasShape: isIsoString } } }; ``` and a type that uses it: ```typescript // event.ts import type { DateTime } from 'effect'; /** @derive(Encode, Default) */ export interface Event { name: string; startsAt: DateTime.Utc; } ``` ### Before: handler bodies were copied Each handler's source text was pasted into every generated module, and the names it read were patched up with synthetic imports: ```typescript // event.ts, expanded import { DateTime as __mf_DateTime } from 'effect'; export function eventEncodeWithContext(value: Event, ctx: __mf_EncodeContext) { // ... result.startsAt = ((v) => __mf_DateTime.formatIso(v))(value.startsAt); } export function eventDefaultValue(): Event { return { name: '', startsAt: (() => __mf_DateTime.unsafeNow())() }; } ``` That only works while a handler reads nothing but imports macroforge can guess at. `hasShape: isIsoString` copies as the bare name `isIsoString`, which the expanded module never defines, so it fails at runtime with a `ReferenceError`. The same happens to any helper, constant or local function the config declares. ### After: handlers are imported The config is expanded into `.macroforge/config/handlers.ts`, with each handler hoisted to an export named after its type: ```typescript // .macroforge/config/handlers.ts (generated) import { DateTime } from 'effect'; const isIsoString = (value: unknown): value is string => typeof value === 'string' && !Number.isNaN(Date.parse(value)); export const __foreign__dateTimeUtcEncode = (v: DateTime.Utc) => DateTime.formatIso(v); export const __foreign__dateTimeUtcDecode = (raw: unknown) => DateTime.unsafeFromDate(new Date(String(raw))); export const __foreign__dateTimeUtcDefault = () => DateTime.unsafeNow(); export default { foreignTypes: { 'DateTime.Utc': { from: ['effect'], encode: __foreign__dateTimeUtcEncode, decode: __foreign__dateTimeUtcDecode, default: __foreign__dateTimeUtcDefault, hasShape: isIsoString } } }; export { isIsoString as __foreign__dateTimeUtcHasShape }; ``` Generated code imports what it calls: ```typescript // event.ts, expanded import { __foreign__dateTimeUtcDefault, __foreign__dateTimeUtcEncode } from '#macroforge/config'; export function eventEncodeWithContext(value: Event, ctx: __mf_EncodeContext) { // ... result.startsAt = __foreign__dateTimeUtcEncode(value.startsAt); } export function eventDefaultValue(): Event { return { name: '', startsAt: __foreign__dateTimeUtcDefault() }; } ``` Every handler now runs in the module it was written in, so it can use anything that module imports or declares. Each is a separate export, so a bundler keeps only the handlers your code calls. ## Setup `#macroforge/config` is a [subpath import](https://nodejs.org/api/packages.html#subpath-imports), declared in your manifest the way SvelteKit declares `#lib`. Add it once: ```bash macroforge init ``` which writes this into `package.json` (or the `imports` map of `deno.json` when there is no `package.json`): ```json { "imports": { "#macroforge/config": { "types": "./.macroforge/config/handlers.ts", "default": "./.macroforge/config/handlers.js" } } } ``` TypeScript, bundlers, Node and Deno all resolve it natively. A project whose config declares foreign types but whose manifest does not map `#macroforge/config` fails with an error that says what to add. `macroforge init` does not rewrite a `deno.jsonc`, since that would drop its comments; add the entry by hand there. ## When it is regenerated `.macroforge/config/` is generated, like `.svelte-kit/`: don't edit it and don't commit it. It is rewritten whenever macroforge reads the project: the Vite plugin's project scan, `macroforge watch` (again on every config change), `macroforge cache` and `macroforge expand`. In CI, run `macroforge sync` before a type check to write it without expanding anything. It is written only when its contents change, and swapped in whole, so a reader never sees half of it. Both `handlers.ts` and a type-stripped `handlers.js` are written, so the entry resolves in tools that read types and in runtimes that don't. A config that extends a base config gets the base expanded beside it under `modules/`, with its handlers re-exported from `handlers.ts`. ## What the config may contain The expanded config is imported by generated code, so it runs wherever your code runs. The expansion therefore refuses a config that: - runs a statement at its top level, such as a bare function call: the top level may only declare; - uses `import.meta` or a dynamic `import()`, which read differently once the module is moved into `.macroforge/config/`; - declares a foreign type inside a function, where its handlers cannot be exported; - gives two foreign types the same export name. Relative imports in the config are rewritten to reach the same files from `.macroforge/config/`. Calls that initialise a top-level binding or the default export are marked `/*#__PURE__*/`, so a bundler can still drop what is unused. ## Packaged libraries A published package does not ship `.macroforge/`. When `macroforge svelte-package` packages a library, it copies the expanded config into the output as `__macroforge/config/` and rewrites each emitted `#macroforge/config` import to a relative path to that copy, so the package needs no manifest entry of its own.