Fresh 2 + Effect-TS. A fork of @fresh/core that adds first-class Effect integration for typed services, structured errors, and full-stack RPC.
| Package | Description |
|---|---|
@fresh/core |
Fresh 2 framework (forked) |
@fresh/core/effect |
Effect integration — createEffectApp, HTTP API, RPC, atom hydration, client hooks |
@fresh/plugin-effect |
Re-export shim (backward compat — prefer @fresh/core/effect) |
@fresh/plugin-tailwindcss |
Tailwind CSS v4 plugin |
# Clone and run the example app
git clone https://github.com/type-driven/freak
cd freak
deno task --cwd packages/examples/core/effect-integration dev
This repo now includes Gitea Actions workflows in
.gitea/workflows/:
ci.yml: format, lint, type-check, test, build package artifact, runnpm packrelease.yml: onv*tags, validates version, builds package, publishes to Gitea npm registry
Required secret for release workflow:
GITEA_PACKAGE_TOKEN(token with package write access)
Optional environment overrides in the workflow runner:
GITEA_PACKAGE_OWNER(defaults to repository owner)GITEA_REGISTRY_URL(defaults to current Gitea server URL)
Local package build:
deno task build:registry-package
deno task pack:registry-package
Tag-based release flow:
# 1) bump packages/fresh/deno.json version
# 2) push commit
# 3) create matching tag (v<version>)
git tag v2.2.1
git push origin v2.2.1
Consumers can use the published package from Gitea with:
{
"imports": {
"fresh": "npm:@type-driven/freak@2.2.1",
"fresh/": "npm:@type-driven/freak@2.2.1/"
}
}
// main.ts
import { createEffectApp } from "@fresh/core/effect";
import { staticFiles } from "@fresh/core";
import { AppLayer } from "./layers.ts";
const app = createEffectApp({ layer: AppLayer });
export const appInstance = app
.use(staticFiles())
.fsRoutes();
createEffectApp({ layer }) creates a ManagedRuntime from your Layer and:
- Registers an
EffectRunnerso any handler can return anEffectdirectly - Wires atom hydration automatically (no manual
setAtomHydrationHookneeded) - Registers SIGINT/SIGTERM signal handlers for clean disposal
// routes/todos.ts
import { createEffectDefine } from "@fresh/core/effect";
import { Effect } from "effect";
import { TodoService } from "../services/TodoService.ts";
const define = createEffectDefine<unknown, TodoService>();
export const handlers = define.handlers({
GET: (ctx) =>
Effect.gen(function* () {
const svc = yield* TodoService;
const todos = yield* svc.list();
return Response.json(todos);
}),
});
Plain handlers (Response, PageResponse, Promise) continue to work
unchanged — Effect support is additive.
import {
HttpApi,
HttpApiEndpoint,
HttpApiGroup,
} from "effect/unstable/httpapi";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { Schema } from "effect";
const Api = HttpApi.make("todos").add(
HttpApiGroup.make("todos").prefix("/todos").add(
HttpApiEndpoint.get("list", "/", { success: Schema.Array(TodoSchema) }),
HttpApiEndpoint.post("create", "/", {
payload: Schema.Struct({ text: Schema.String }),
success: TodoSchema,
}),
),
);
const TodosLive = HttpApiBuilder.group(Api, "todos", (h) =>
h
.handle("list", () =>
Effect.gen(function* () {
return yield* (yield* TodoService).list();
}))
.handle("create", ({ payload }) =>
Effect.gen(function* () {
return yield* (yield* TodoService).create(payload.text);
})));
app.httpApi("/api", Api, Layer.provide(TodosLive, AppLayer));
Define procedures once; get typed client hooks automatically.
Server:
// services/rpc.ts
import { Rpc, RpcGroup, RpcSchema } from "effect/unstable/rpc";
import { Effect, Schedule, Schema, Stream } from "effect";
const ListTodos = Rpc.make("ListTodos", { success: Schema.Array(TodoSchema) });
const CreateTodo = Rpc.make("CreateTodo", {
payload: Schema.Struct({ text: Schema.String }),
success: TodoSchema,
});
const WatchTodos = Rpc.make("WatchTodos", {
success: RpcSchema.Stream(Schema.Array(TodoSchema), Schema.Never),
});
export const TodoRpc = RpcGroup.make(ListTodos, CreateTodo, WatchTodos);
export const TodoRpcHandlers = TodoRpc.toLayer({
ListTodos: () => Effect.flatMap(TodoService, (s) => s.list()),
CreateTodo: ({ text }) => Effect.flatMap(TodoService, (s) => s.create(text)),
WatchTodos: () =>
Stream.fromEffectSchedule(
Effect.flatMap(TodoService, (s) => s.list()),
Schedule.spaced("2 seconds"),
),
});
Mount on the app:
// main.ts
const RpcWithDeps = Layer.provide(TodoRpcHandlers, AppLayer);
app.rpc({
group: TodoRpc,
path: "/rpc/todos",
protocol: "http",
handlerLayer: RpcWithDeps,
});
app.rpc({
group: TodoRpc,
path: "/rpc/todos/ws",
protocol: "websocket",
handlerLayer: RpcWithDeps,
});
app.rpc({
group: TodoRpc,
path: "/rpc/todos/sse",
protocol: "sse",
handlerLayer: RpcWithDeps,
});
Client (island):
// islands/TodoApp.tsx
import { useRpcResult, useRpcStream } from "@fresh/core/effect/island";
import { TodoRpc } from "../services/rpc.ts";
export default function TodoApp() {
// Request/response — returns [state, client]
const [state, client] = useRpcResult(TodoRpc, { url: "/rpc/todos" });
// Server-push stream — relative paths resolve against window.location
const stream = useRpcStream(TodoRpc, {
url: "/rpc/todos/ws",
procedure: "WatchTodos",
});
return (
<div>
<button onClick={() => client.ListTodos()}>Load</button>
{state._tag === "ok" && (
<ul>{state.value.map((t) => <li>{t.text}</li>)}</ul>
)}
{stream._tag === "connected" && stream.latest && (
<p>Live count: {stream.latest.length}</p>
)}
</div>
);
}
Four transports, identical client-side interface (RpcStreamState):
| Protocol | Mount option | Client hook | Best for |
|---|---|---|---|
| HTTP request/response | protocol: "http" |
useRpcResult, useRpcQuery |
CRUD operations |
| WebSocket streaming | protocol: "websocket" |
useRpcStream |
Low-latency push, full-duplex |
| HTTP NDJSON streaming | protocol: "http-stream" |
useRpcHttpStream |
Streaming without WS upgrade |
| Server-Sent Events | protocol: "sse" |
useRpcSse |
Auto-reconnect, proxies |
import {
getCacheData, // Read cached value
invalidateQuery, // Trigger refetch for a cache key
setCacheData, // Optimistic writes
useMutation, // Mutations with optimistic update support
useQuery, // Data fetching with cache + deduplication
useRpcHttpStream, // HTTP NDJSON streaming
useRpcPolled, // Polling on a schedule
useRpcQuery, // Typed RPC fetching built on useQuery
useRpcResult, // Request/response RPC — returns [state, client proxy]
useRpcSse, // SSE streaming
useRpcStream, // WebSocket streaming
} from "@fresh/core/effect/island";
All hooks share a module-level ManagedRuntime backed by FetchHttpClient.
Per-URL runtimes share the same memoMap so services are built once per page.
Seed client-side state from the server without prop drilling. No setup required
— createEffectApp wires the hydration hook automatically.
// routes/index.tsx (server)
import { setAtom } from "@fresh/core/effect";
import { todoListAtom } from "../atoms.ts";
export const handlers = define.handlers({
GET: (ctx) =>
Effect.gen(function* () {
const svc = yield* TodoService;
const todos = yield* svc.list();
yield* setAtom(ctx, todoListAtom, todos); // serialized into HTML
return page();
}),
});
// islands/TodoApp.tsx (client)
import { useAtom } from "@fresh/core/effect/island";
import { todoListAtom } from "../atoms.ts";
export default function TodoApp() {
const [todos, setTodos] = useAtom(todoListAtom); // hydrated from SSR
// ...
}
Core architecture:
@fresh/coreknows nothing about Effect. It defines anEffectLikestructural type (duck-typed on"~effect/Effect") and anEffectRunnercallback slot perAppinstance.createEffectApp({ layer })builds aManagedRuntime, creates a resolver, and registers it viasetEffectRunner. Any handler returning an Effect-like value is automatically run through the runtime.EffectAppwrapsApp<State>(composition, not inheritance) and proxies all builder methods with Effect-compatible types. The.appgetter exposes the innerApp<State>required byBuilder.listen().
WebSocket isolation:
Each WebSocket connection gets a fresh ManagedRuntime with
Layer.fresh(RpcServer.layerProtocolSocketServer) — bypassing the shared
memoMap to prevent stale protocol state across reconnections. Shared services
(e.g., database pools) remain memoized and reused.
Built on Effect v4 beta. The core integration patterns are stable and tested, but the upstream Effect API is still evolving. Not recommended for production until Effect v4 reaches a stable release.
MIT