# Free-Plan DO Pattern: `stub.fetch()` Routing

## Context

Cloudflare Workers Free plan does NOT support `stub.method()` RPC calls on Durable Objects. Calling `stub.createSession()` or any method directly on the stub returns **error 1101** silently. The workaround: route ALL DO interactions through `stub.fetch(request)` — the DO implements a single `fetch()` handler that dispatches based on URL path.

## Architecture

```
Worker fetch() ──route match──→ stub.fetch(request) ──→ DO fetch() ──path dispatch──→ handler
```

## Implementation Pattern

### Worker entry point (`src/index.ts`)

```typescript
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Route: route the full request to the DO's fetch()
    const match = url.pathname.match(/^\/api\/resource\/([a-z0-9]+)$/);
    if (match && request.method === "GET") {
      const code = match[1];
      const id = env.MY_DO.idFromName(code);
      const stub = env.MY_DO.get(id);
      // Forward the request to the DO — the DO's fetch() handles it
      return stub.fetch(new Request(
        new URL(`http://do/resource/status?code=${code}`),
        { method: "GET", headers: request.headers }
      ));
    }

    // For POST with body, forward body + headers:
    if (url.pathname === "/api/resource/create" && request.method === "POST") {
      const code = createCode();
      const id = env.MY_DO.idFromName(code);
      const stub = env.MY_DO.get(id);
      return stub.fetch(new Request(
        new URL(`http://do/resource/create?code=${code}`),
        { method: "POST", body: await request.clone().text(), headers: request.headers }
      ));
    }
  }
};
```

### Durable Object (`src/do.ts`)

```typescript
export class AuthSessionDO implements DurableObject {
  private storage: DurableObjectStorage;

  constructor(ctx: DurableObjectState, _env: unknown) {
    this.storage = ctx.storage;
  }

  // ── Single fetch() handler dispatches all operations ──

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    try {
      if (path === "/resource/create") {
        return this.handleCreate(url);
      }
      if (path === "/resource/status") {
        return this.handleStatus();
      }
      // ... more routes

      return json({ error: "Not found" }, 404);
    } catch (e) {
      return json({ error: (e as Error).message }, 500);
    }
  }

  private async handleCreate(url: URL): Promise<Response> {
    const code = url.searchParams.get("code") || "";
    const session = { code, status: "pending", createdAt: Date.now() };
    await this.storage.put("session", session);
    await this.storage.setAlarm(Date.now() + 15 * 60 * 1000);
    return json(session, 201);
  }

  private async handleStatus(): Promise<Response> {
    const s = await this.storage.get<any>("session");
    if (!s) return json({ error: "Not found" }, 404);
    // Auto-expire
    if (Date.now() > s.expiresAt && s.status !== "completed") {
      s.status = "cancelled";
      await this.storage.put("session", s);
    }
    return json(s);
  }

  async alarm(): Promise<void> {
    const s = await this.storage.get<any>("session");
    if (s && (s.status === "pending" || s.status === "active")) {
      s.status = "cancelled";
      await this.storage.put("session", s);
    }
  }
}
```

## wrangler.toml

```toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2026-05-31"
workers_dev = true

[[durable_objects.bindings]]
name = "MY_DO"
class_name = "AuthSessionDO"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["AuthSessionDO"]
```

## Common Mistakes

| Mistake | Symptom | Fix |
|---------|---------|-----|
| `stub.createSession()` | Error 1101 | Use `stub.fetch()` |
| `new_classes` in migration | Error 10097: "Free plan requires new_sqlite_classes" | Use `new_sqlite_classes` |
| Changing migration tag type | Deployment failure, DO namespace missing | Use new tag number or delete & recreate worker |
| Multiple DO classes in one worker | Migration tag conflicts | Split into separate workers |

## Reference: Real-World Auth Worker

The `addon-auth-worker` (May 2026) uses this pattern with ~7 route types:

- `/debrid/oauth/start?service=realdebrid` — starts Real-Debrid OAuth device code flow
- `/debrid/key/start?service=torbox` — creates API key entry session
- `/debrid/auth/submit` (POST) — submits API key, stores in DO
- `/debrid/auth/poll` — polls OAuth token endpoint for completion
- `/addon/url/create` — creates addon URL entry session
- `/addon/url/submit` (POST) — validates manifest URL, stores result
- All GETs to `/debrid/auth/` or `/addon/url/` — return stored session state

Each is dispatched by the DO's `fetch()` handler based on `url.pathname`.
