Skip to content

type-driven/freak

 
 

Repository files navigation

freak

Fresh 2 + Effect-TS. A fork of @fresh/core that adds first-class Effect integration for typed services, structured errors, and full-stack RPC.

Packages

Package Description
@fresh/core Fresh 2 framework (forked)
@fresh/effect Effect integration — createEffectApp, HTTP API, RPC, atom hydration, client hooks
@fresh/plugin-effect Re-export shim (backward compat — prefer @fresh/effect)
@fresh/plugin-tailwindcss Tailwind CSS v4 plugin

Getting started

# Clone and run the example app
git clone https://github.com/type-driven/freak
cd freak
deno task --cwd packages/examples/effect-integration dev

Usage

1. Create the app

// main.ts
import { createEffectApp } from "@fresh/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 EffectRunner so any handler can return an Effect directly
  • Wires atom hydration automatically (no manual setAtomHydrationHook needed)
  • Registers SIGINT/SIGTERM signal handlers for clean disposal

2. Effect-returning handlers

// routes/todos.ts
import { createEffectDefine } from "@fresh/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.

3. Mount an HttpApi

import { HttpApi, HttpApiGroup, HttpApiEndpoint } 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));

4. Mount RPC

Define procedures once; get typed client hooks automatically.

Server:

// services/rpc.ts
import { Rpc, RpcGroup, RpcSchema } from "effect/unstable/rpc";
import { Schema, Stream, Schedule, Effect } 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/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>
  );
}

RPC protocols

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

Client hooks

import {
  useQuery,         // Data fetching with cache + deduplication
  useMutation,      // Mutations with optimistic update support
  useRpcQuery,      // Typed RPC fetching built on useQuery
  useRpcResult,     // Request/response RPC — returns [state, client proxy]
  useRpcStream,     // WebSocket streaming
  useRpcHttpStream, // HTTP NDJSON streaming
  useRpcSse,        // SSE streaming
  useRpcPolled,     // Polling on a schedule
  invalidateQuery,  // Trigger refetch for a cache key
  setCacheData,     // Optimistic writes
  getCacheData,     // Read cached value
} from "@fresh/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.

Atom hydration

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/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/effect/island";
import { todoListAtom } from "../atoms.ts";

export default function TodoApp() {
  const [todos, setTodos] = useAtom(todoListAtom); // hydrated from SSR
  // ...
}

How it works

Core architecture:

  • @fresh/core knows nothing about Effect. It defines an EffectLike structural type (duck-typed on "~effect/Effect") and an EffectRunner callback slot per App instance.
  • createEffectApp({ layer }) builds a ManagedRuntime, creates a resolver, and registers it via setEffectRunner. Any handler returning an Effect-like value is automatically run through the runtime.
  • EffectApp wraps App<State> (composition, not inheritance) and proxies all builder methods with Effect-compatible types. The .app getter exposes the inner App<State> required by Builder.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.

Project status

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.

License

MIT

About

Fresh with built in Effect/Atom support

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • TypeScript 97.1%
  • CSS 2.9%