@opsydyn/astro-foldkit
v0.2.1
Published
Astro integration and renderer for FoldKit.
Maintainers
Readme
@opsydyn/astro-foldkit
Astro integration and renderer for FoldKit.
FoldKit is an Elm Architecture runtime built on Effect. This package registers FoldKit as an Astro renderer so you can drop any FoldKit app into a .astro page as a component and hydrate it with client:load.
Installation
npm install @opsydyn/astro-foldkit
# peer deps
npm install astro foldkitSetup
Add the integration to astro.config.ts:
import { defineConfig } from 'astro/config'
import foldkit from '@opsydyn/astro-foldkit'
export default defineConfig({
integrations: [foldkit()],
})Defining an app
Use defineApp to register a FoldKit app for lazy loading. The loader returns your main.ts module which must export a value that satisfies AppConfig.
// src/apps/counter/app.ts
import { defineApp } from '@opsydyn/astro-foldkit/define-app'
export default defineApp(() => import('./main'))// src/apps/counter/main.ts
export const Model = null
export const init = () => [0, []] as const
export const update = (model: number, message: 'Inc' | 'Dec') =>
[message === 'Inc' ? model + 1 : model - 1, []] as const
export const view = (model: number) => ({ /* foldkit view tree */ })Use the app in an Astro page:
---
import Counter from '../apps/counter/app'
---
<Counter client:load />Passing props
defineApp accepts a type parameter for the props your FoldKit app expects. This makes the component callable with typed attributes in .astro files.
// src/apps/greeting/app.ts
import { defineApp } from '@opsydyn/astro-foldkit/define-app'
import type { Name } from './model'
export default defineApp<{ name: Name }>(() => import('./main'))Props are forwarded from Astro's <astro-island> serialisation into your init function. Declare init to accept props: unknown and validate at the boundary with Effect Schema:
// src/apps/greeting/model.ts
import { Schema } from 'effect'
export const Name = Schema.String.pipe(Schema.brand('Name'))
export type Name = typeof Name.Type
const Props = Schema.Struct({ name: Name })
export const init = (props: unknown): readonly [Model, readonly []] => {
const { name } = Schema.decodeUnknownSync(Props)(props)
return [name, []]
}Pass the branded value from the Astro page:
---
import { Schema } from 'effect'
import GreetingApp from '../apps/greeting/app'
import { Name } from '../apps/greeting/model'
const name = Schema.decodeSync(Name)(Astro.url.searchParams.get('name') ?? 'World')
---
<GreetingApp client:load name={name} />The TypeScript types flow end-to-end: the Name brand is required at the Astro call site, and Schema.decodeUnknownSync re-validates the serialised value at the client hydration boundary.
Built-in props
The renderer intercepts one reserved prop before forwarding anything to your app. It is never seen by init, update, or view.
noMeta
Prevents the FoldKit program from overwriting document.title. Use this whenever you embed an app as an island on a page that manages its own title — without it, the island's view tree will update the browser tab title on every render tick.
<DashboardChart client:load noMeta data={chartData} />Both noMeta and noMeta={true} are accepted. The prop is stripped before init receives the rest of your props.
Architecture
Imperative shell, functional core
The Astro page is the imperative shell: it performs side effects (HTTP fetches, query-param reads, URL parsing) and validates raw values into branded types via Schema.decodeSync. The shell hands only clean, typed values into the component.
The FoldKit app is the functional core: its init, update, and view functions are pure. They receive already-validated props, run a closed message loop, and produce a view tree — with no side effects outside of declared Commands and Subscriptions.
Data flow
flowchart TD
subgraph shell ["Astro SSR (imperative shell)"]
A[fetch / query params] --> B["Schema.decodeSync(Brand)"]
B --> C[branded prop]
end
subgraph island ["Astro serialisation"]
C --> D["<astro-island props='...'>"]
end
subgraph hydration ["Client hydration — client.ts"]
D --> E["config.init(props)"]
E --> F["Schema.decodeUnknownSync(Props)"]
F --> G[initial Model]
end
subgraph core ["FoldKit runtime (functional core)"]
G --> H[update loop]
H --> I[view]
I --> J[DOM patch]
K["Subscription\n(animationFrame, events)"] --> H
endThe double-decode is intentional: Schema.decodeSync at the Astro boundary ensures the page cannot render with invalid data; Schema.decodeUnknownSync at the FoldKit boundary re-validates after JSON round-trip through the island serialisation, so the functional core never receives unverified input.
AppConfig
The module returned by your loader must export:
| Export | Type | Description |
| :------- | :------------------------------------------------------------- | :---------------------------------------------- |
| Model | unknown | Initial model type marker |
| init | (props: unknown) => readonly [Model, ReadonlyArray<Command>] | Initial state from props and startup commands |
| update | (model, message) => readonly [Model, ReadonlyArray<Command>] | Pure state transition |
| view | (model) => Document | Render the current model to a FoldKit view tree |
Exports
| Entry point | Description |
| :---------------------------------- | :-------------------------------------- |
| @opsydyn/astro-foldkit | Default Astro integration (foldkit()) |
| @opsydyn/astro-foldkit/define-app | defineApp helper and AppConfig type |
Embedding
Runtime.embed gives you a typed handle to the running program so you can clean it up on unmount and push live data in without remounting. This integration uses embed internally — the astro:unmount event calls handle.dispose() to tear down subscriptions and animation-frame loops when Astro navigates away.
Declaring ports
Ports are typed channels declared in your app's main.ts. Pass a Schema for each port so values are validated at the boundary.
// src/apps/dashboard/main.ts
import { Port } from 'foldkit'
import { Schema } from 'effect'
export const ports = {
inbound: {
data: Port.inbound(Schema.Array(DataPointSchema)),
},
outbound: {
selection: Port.outbound(Schema.NullOr(Schema.String)),
},
}Pass the ports map to makeApplication alongside your init, update, and view:
import { Runtime } from 'foldkit'
const program = Runtime.makeApplication({
Model,
init,
update,
view,
container,
devTools: false,
ports,
})Live prop updates
Once embedded, push new data through an inbound port instead of remounting the component. This is particularly useful in Astro View Transition flows where the page shell re-runs but the island should preserve its running state:
const handle = Runtime.embed(program)
// Called when Astro soft-navigates back to this page with new data
handle.ports.data.send(nextDataPoints)Outbound subscriptions
Subscribe to outbound ports to react to events inside the program from the host page. Port names are flat on handle.ports regardless of whether they were declared under inbound or outbound:
const unsubscribe = handle.ports.selection.subscribe((id) => {
// sync selection state to the URL or another island
history.replaceState({}, '', `?selected=${id ?? ''}`)
})
element.addEventListener('astro:unmount', () => {
unsubscribe()
handle.dispose()
}, { once: true })Peer dependencies
| Package | Version |
| :-------- | :--------- |
| astro | ≥ 5.0 |
| foldkit | ≥ 0.108 |
License
MIT
