Skip to content

Latest commit

Β 

History

History
801 lines (623 loc) Β· 21.5 KB

File metadata and controls

801 lines (623 loc) Β· 21.5 KB

Effect Router Migration Guide

Migrate from manual async patterns to the Effect-based router for type-safe, composable request handling.

Overview

The Effect router eliminates boilerplate and prevents entire classes of bugs:

Problem Old Pattern New Pattern
Error handling Try-catch + manual state Typed errors via Effect
Timeouts Promise.race() everywhere .timeout("5s") declarative
Retries Custom exponential backoff (3+ implementations) .retry("exponential") built-in
Loading state Manual useState(isLoading) Derived from Effect execution
Streaming EventSourceParserStream + manual batching Effect.Stream + heartbeat
Validation Manual schema checks .input(Schema) automatic
Cancellation AbortController leak-prone Effect interruption safe

Effort estimate: 2-3 days for full migration. Can be done incrementally.

Before/After Comparison

Error Handling

BEFORE: Manual try-catch + state

// apps/web/src/react/use-session.ts (current pattern)
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
  setIsLoading(true);
  setError(null);

  client.session
    .get(sessionId)
    .then((session) => setSession(session))
    .catch((err) => {
      setError(err);
      toast.error("Failed to load session");
    })
    .finally(() => setIsLoading(false));
}, [sessionId]);

AFTER: Typed errors via Effect

// apps/web/src/react/use-session.ts (Effect pattern)
import * as Effect from "effect/Effect";
import { ValidationError, TimeoutError, HandlerError } from "@/core/router";

const effect = caller("session.get", { id: sessionId }).pipe(
  Effect.catchTag("ValidationError", (err) => {
    toast.error(`Invalid input: ${err.issues[0].message}`);
    return Effect.fail(err);
  }),
  Effect.catchTag("TimeoutError", (err) => {
    toast.error(`Request timed out after ${err.duration}`);
    return Effect.fail(err);
  }),
  Effect.catchTag("HandlerError", (err) => {
    toast.error("Failed to load session");
    return Effect.fail(err);
  }),
);

const result = await Effect.runPromise(effect);

Key differences:

  • Errors are typed (not unknown)
  • Error handling is composable (pipe operators)
  • No manual state management needed
  • Toast notifications are co-located with error handling

Timeout Handling

BEFORE: Manual Promise.race

// apps/web/src/app/page.tsx (current pattern)
const sessionsResponse = await Promise.race([
  client.session.list(),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), 5000),
  ),
]);

AFTER: Declarative timeout

// apps/web/src/app/page.tsx (Effect pattern)
const route = o({ timeout: "5s" })
  .input(Schema.Struct({ limit: Schema.Number }))
  .handler(async ({ input, sdk }) => sdk.session.list());

// Timeout is automatic - no manual Promise.race needed

Retry Logic

BEFORE: Custom exponential backoff (3+ implementations)

// apps/web/src/core/multi-server-sse.ts (current pattern)
const MAX_RETRIES = 10;
const BASE_DELAY = 3000;
let retries = 0;

while (retries < MAX_RETRIES) {
  try {
    const response = await fetch(url);
    if (response.ok) return response;
  } catch (error) {
    retries++;
    const delay = Math.min(BASE_DELAY * Math.pow(2, retries), 30000);
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

AFTER: Built-in retry presets

// apps/web/src/core/router/routes.ts (Effect pattern)
const route = o({ retry: "exponential" }) // 100ms base, 2x, 3 retries
  .input(Schema.Struct({ url: Schema.String }))
  .handler(async ({ input, sdk }) => fetch(input.url));

// Or custom retry:
const route = o({
  retry: {
    maxAttempts: 10,
    delay: "3s",
    backoff: 2, // exponential multiplier
  },
});

Loading State

BEFORE: Manual useState

// apps/web/src/app/session/[id]/session-layout.tsx (current pattern)
const [isLoading, setIsLoading] = useState(false)

useEffect(() => {
  setIsLoading(true)
  client.session.get(sessionId)
    .then(setSession)
    .finally(() => setIsLoading(false))
}, [sessionId])

return isLoading ? <Spinner /> : <SessionView session={session} />

AFTER: Derived from Effect execution

// apps/web/src/app/session/[id]/session-layout.tsx (Effect pattern)
const [result, setResult] = useState<Result<Session>>({ state: "loading" })

useEffect(() => {
  const effect = caller("session.get", { id: sessionId })

  Effect.runPromiseExit(effect).then(exit => {
    if (Exit.isSuccess(exit)) {
      setResult({ state: "success", data: exit.value })
    } else {
      setResult({ state: "error", error: Cause.failureOption(exit.cause).value })
    }
  })
}, [sessionId])

return result.state === "loading" ? <Spinner /> : <SessionView session={result.data} />

Streaming

BEFORE: EventSourceParserStream + manual batching

// apps/web/src/core/multi-server-sse.ts (current pattern)
const events = await client.global.event();
const parser = new EventSourceParserStream();

let eventQueue: Event[] = [];
let debounceTimer: NodeJS.Timeout;

for await (const event of parser.parse(events.stream)) {
  eventQueue.push(event);

  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    eventQueue.forEach((e) => store.handleEvent(e));
    eventQueue = [];
  }, 16); // Manual 16ms batching
}

AFTER: Effect.Stream with heartbeat

// apps/web/src/core/router/routes.ts (Effect pattern)
const route = o({
  stream: true,
  heartbeat: "30s", // Automatic heartbeat timeout
}).handler(async function* ({ sdk }) {
  const events = await sdk.global.event();

  for await (const event of events.stream) {
    yield event; // Effect.Stream handles batching automatically
  }
});

Migration Strategy

Phase 1: Define Routes (Day 1)

Create apps/web/src/core/router/routes.ts with all routes:

import { createOpencodeRoute } from "./builder";
import * as Schema from "effect/Schema";

const o = createOpencodeRoute();

export const routes = {
  "session.get": o({ timeout: "30s" })
    .input(Schema.Struct({ id: Schema.String }))
    .handler(async ({ input, sdk }) => sdk.session.get(input.id)),

  "session.list": o({ timeout: "10s" }).handler(async ({ sdk }) =>
    sdk.session.list(),
  ),

  "session.prompt": o({
    timeout: "5m",
    retry: "exponential",
    stream: true,
    heartbeat: "30s",
  })
    .input(Schema.Struct({ sessionId: Schema.String, prompt: Schema.String }))
    .handler(async function* ({ input, sdk }) {
      const response = await sdk.session.prompt({
        sessionId: input.sessionId,
        prompt: input.prompt,
      });

      for await (const part of response.stream) {
        yield part;
      }
    }),
};

Phase 2: Set Up Router (Day 1)

Create apps/web/src/core/router/routes-config.ts:

import { createRouter } from "./router";
import { routes } from "./routes";

export const router = createRouter(routes);

Phase 3: Create Adapters (Day 1)

For Next.js API routes:

// apps/web/src/app/api/router/route.ts
import { createNextHandler } from "@/core/router/adapters/next";
import { router } from "@/core/router/routes-config";
import { createClient } from "@/core/client";

const handler = createNextHandler({
  router,
  createContext: async (req) => ({
    sdk: createClient(),
  }),
});

export { handler as GET, handler as POST };

For Server Actions:

// apps/web/src/core/router/actions.ts
"use server";

import { createAction } from "@/core/router/adapters/next";
import { router } from "@/core/router/routes-config";
import { createClient } from "@/core/client";

export const callRoute = createAction({
  router,
  createContext: async () => ({
    sdk: createClient(),
  }),
});

For Server Components (RSC):

// apps/web/src/app/session/[id]/page.tsx
import { createCaller } from "@/core/router/adapters/direct"
import { router } from "@/core/router/routes-config"
import { createClient } from "@/core/client"

export default async function SessionPage({ params }) {
  const caller = createCaller(router, {
    sdk: createClient(params.directory),
  })

  const session = await caller("session.get", { id: params.id })

  return <SessionView session={session} />
}

Phase 4: Migrate Hooks (Day 2)

Migrate useSession, useMessages, useSendMessage to use the router:

// apps/web/src/react/use-session.ts (AFTER)
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Cause from "effect/Cause";
import { useCallback, useEffect, useState } from "react";
import { useOpenCode } from "./use-provider";

export function useSession(sessionId: string) {
  const { caller } = useOpenCode();
  const [session, setSession] = useState<Session | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    setIsLoading(true);
    setError(null);

    const effect = caller("session.get", { id: sessionId });

    Effect.runPromiseExit(effect).then((exit) => {
      if (Exit.isSuccess(exit)) {
        setSession(exit.value);
      } else {
        const err = Cause.failureOption(exit.cause).value;
        setError(err instanceof Error ? err : new Error(String(err)));
      }
      setIsLoading(false);
    });
  }, [sessionId, caller]);

  return { session, error, isLoading };
}

Phase 5: Remove Old Patterns (Day 2-3)

Delete:

  • Manual Promise.race timeout logic
  • Custom exponential backoff implementations
  • Manual loading state management
  • Try-catch error handling boilerplate

Pattern Mapping

Error Handling: try-catch β†’ Typed Errors

Old Pattern New Pattern Benefit
try { ... } catch (e) { ... } Effect.catchTag("ErrorType", ...) Type-safe, composable
error: unknown error: ValidationError | TimeoutError | ... Exhaustive error handling
Manual error state Derived from Effect Exit No state sync bugs

Example:

// OLD
try {
  const session = await client.session.get(id);
  setSession(session);
} catch (error) {
  if (error instanceof ValidationError) {
    setError("Invalid input");
  } else if (error instanceof TimeoutError) {
    setError("Request timed out");
  } else {
    setError("Unknown error");
  }
}

// NEW
const effect = caller("session.get", { id }).pipe(
  Effect.catchTag("ValidationError", (err) => {
    console.error("Invalid input:", err.issues);
    return Effect.fail(err);
  }),
  Effect.catchTag("TimeoutError", (err) => {
    console.error("Request timed out:", err.duration);
    return Effect.fail(err);
  }),
  Effect.catchTag("HandlerError", (err) => {
    console.error("Handler failed:", err.cause);
    return Effect.fail(err);
  }),
);

const result = await Effect.runPromise(effect);

Loading State: Manual β†’ Derived

Old Pattern New Pattern Benefit
useState(isLoading) Exit.isSuccess(exit) Single source of truth
Manual try-finally Effect execution Guaranteed cleanup
Race conditions Effect guarantees No stale state

Example:

// OLD
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);

useEffect(() => {
  setIsLoading(true);
  client
    .fetch(id)
    .then(setData)
    .catch(setError)
    .finally(() => setIsLoading(false));
}, [id]);

// NEW
const [result, setResult] = useState<Exit<Data, Error>>();

useEffect(() => {
  const effect = caller("fetch", { id });
  Effect.runPromiseExit(effect).then(setResult);
}, [id]);

const isLoading = result === undefined;
const data = Exit.isSuccess(result) ? result.value : null;
const error = Exit.isFailure(result)
  ? Cause.failureOption(result.cause).value
  : null;

Retry: Manual β†’ Built-in

Old Pattern New Pattern Benefit
Custom backoff loop .retry("exponential") Standardized, tested
Manual delay calculation Duration parsing Type-safe, readable
Retry count tracking Built-in schedule No off-by-one errors

Example:

// OLD
const MAX_RETRIES = 3;
const BASE_DELAY = 100;

async function fetchWithRetry(url: string) {
  for (let i = 0; i < MAX_RETRIES; i++) {
    try {
      return await fetch(url);
    } catch (error) {
      if (i === MAX_RETRIES - 1) throw error;
      const delay = BASE_DELAY * Math.pow(2, i);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

// NEW
const route = o({
  retry: {
    maxAttempts: 3,
    delay: "100ms",
    backoff: 2,
  },
}).handler(async ({ input }) => fetch(input.url));

Timeout: Promise.race β†’ Declarative

Old Pattern New Pattern Benefit
Promise.race([request, timeout]) .timeout("5s") Readable, composable
Manual timeout error Typed TimeoutError Exhaustive handling
Timeout per request Route-level config Consistent across calls

Example:

// OLD
const result = await Promise.race([
  client.session.get(id),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), 5000),
  ),
]);

// NEW
const route = o({ timeout: "5s" })
  .input(Schema.Struct({ id: Schema.String }))
  .handler(async ({ input, sdk }) => sdk.session.get(input.id));

Streaming: EventSourceParserStream β†’ Effect.Stream

Old Pattern New Pattern Benefit
EventSourceParserStream Effect.Stream Backpressure handling
Manual event batching Built-in batching Correct by default
Manual heartbeat .heartbeat("30s") Prevents stale connections

Example:

// OLD
const events = await client.global.event();
const parser = new EventSourceParserStream();

let eventQueue: Event[] = [];
let debounceTimer: NodeJS.Timeout;

for await (const event of parser.parse(events.stream)) {
  eventQueue.push(event);

  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    eventQueue.forEach((e) => store.handleEvent(e));
    eventQueue = [];
  }, 16);
}

// NEW
const route = o({
  stream: true,
  heartbeat: "30s",
}).handler(async function* ({ sdk }) {
  const events = await sdk.global.event();

  for await (const event of events.stream) {
    yield event; // Effect.Stream handles batching + heartbeat
  }
});

Testing

Testing Routes

Use Effect's test utilities:

import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import { describe, it, expect } from "bun:test";
import { createCaller } from "@/core/router/adapters/direct";
import { router } from "@/core/router/routes-config";

describe("session.get route", () => {
  it("returns session on success", async () => {
    const mockSdk = {
      session: {
        get: async (id: string) => ({ id, title: "Test" }),
      },
    };

    const caller = createCaller(router, { sdk: mockSdk });
    const result = await caller("session.get", { id: "ses_123" });

    expect(result).toEqual({ id: "ses_123", title: "Test" });
  });

  it("handles validation errors", async () => {
    const mockSdk = {
      /* ... */
    };
    const caller = createCaller(router, { sdk: mockSdk });

    const exit = await Effect.runPromiseExit(
      caller("session.get", { id: 123 }), // Wrong type
    );

    expect(Exit.isFailure(exit)).toBe(true);
    const error = Cause.failureOption(exit.cause).value;
    expect(error).toBeInstanceOf(ValidationError);
  });

  it("respects timeout", async () => {
    const mockSdk = {
      session: {
        get: async () => new Promise((resolve) => setTimeout(resolve, 10000)),
      },
    };

    const caller = createCaller(router, { sdk: mockSdk });
    const exit = await Effect.runPromiseExit(
      caller("session.get", { id: "ses_123" }),
    );

    expect(Exit.isFailure(exit)).toBe(true);
    const error = Cause.failureOption(exit.cause).value;
    expect(error).toBeInstanceOf(TimeoutError);
  });
});

Testing Hooks

import { renderHook, waitFor } from "@testing-library/react";
import { useSession } from "@/react/use-session";

describe("useSession hook", () => {
  it("loads session on mount", async () => {
    const { result } = renderHook(() => useSession("ses_123"));

    expect(result.current.isLoading).toBe(true);

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.session).toBeDefined();
  });

  it("handles errors", async () => {
    const { result } = renderHook(() => useSession("invalid"));

    await waitFor(() => {
      expect(result.current.error).toBeDefined();
    });
  });
});

Gotchas

1. Effect.runPromise vs Effect.runPromiseExit

Wrong:

// This throws on error - no error handling
const result = await Effect.runPromise(effect);

Right:

// This returns Exit - safe error handling
const exit = await Effect.runPromiseExit(effect);
if (Exit.isSuccess(exit)) {
  // handle success
} else {
  // handle error
}

2. Middleware Execution Order

Middleware executes left to right in the chain:

const route = o()
  .middleware(authMiddleware) // Runs first
  .middleware(loggingMiddleware) // Runs second
  .handler(async ({ ctx }) => {
    // ctx has both auth and logging context
  });

3. Streaming Routes Return AsyncIterable

Streaming routes don't return the data directly - they return an async iterable:

// WRONG
const parts = await caller("session.prompt", { ... })
console.log(parts) // AsyncIterable, not array

// RIGHT
const parts = await caller("session.prompt", { ... })
for await (const part of parts) {
  console.log(part)
}

4. Schema Validation is Strict

Effect Schema is stricter than Zod by default:

// This will fail validation - extra properties not allowed
const input = { id: "123", extra: "field" };
await caller("session.get", input); // ValidationError

// Use Schema.Struct with optional fields
const schema = Schema.Struct({
  id: Schema.String,
  extra: Schema.optional(Schema.String),
});

5. AbortSignal Cleanup

Routes automatically handle AbortSignal, but cleanup is your responsibility:

const route = o().handler(async ({ signal, sdk }) => {
  const controller = new AbortController();
  signal.addEventListener("abort", () => controller.abort());

  // Now controller.signal will abort when route is cancelled
  return fetch(url, { signal: controller.signal });
});

6. Heartbeat Timeout is Strict

If a streaming route doesn't yield within the heartbeat interval, it fails:

const route = o({
  stream: true,
  heartbeat: "30s", // Must yield every 30 seconds
})
  .handler(async function* ({ sdk }) {
    // This will timeout if no yield for 30s
    const result = await sdk.session.prompt({ ... })

    for await (const part of result.stream) {
      yield part // Resets heartbeat timer
    }
  })

Rollback Plan

If you need to revert:

  1. Keep old patterns in parallel - Don't delete old code immediately
  2. Feature flag routes - Use environment variable to switch between old/new
  3. Gradual migration - Migrate one route at a time, not all at once

Example feature flag:

// apps/web/src/core/router/routes-config.ts
const useEffectRouter = process.env.NEXT_PUBLIC_USE_EFFECT_ROUTER === "true";

export const router = useEffectRouter
  ? createRouter(effectRoutes)
  : createRouter(legacyRoutes);

Migration Checklist

  • Create apps/web/src/core/router/routes.ts with all routes
  • Create apps/web/src/core/router/routes-config.ts with router instance
  • Create apps/web/src/app/api/router/route.ts (Next.js adapter)
  • Create apps/web/src/core/router/actions.ts (Server Actions)
  • Migrate useSession hook
  • Migrate useMessages hook
  • Migrate useSendMessage hook
  • Update Server Components to use createCaller
  • Delete old Promise.race timeout logic
  • Delete custom exponential backoff implementations
  • Delete manual loading state management
  • Run full test suite
  • Deploy with feature flag enabled
  • Monitor error rates for 24 hours
  • Remove feature flag and old code

References