Skip to content

feat(durable-iterator): rewrite + enchance#965

Merged
dinwwwh merged 67 commits intomainfrom
feat/durable-event-iterator/rewrite-and-improve
Sep 23, 2025
Merged

feat(durable-iterator): rewrite + enchance#965
dinwwwh merged 67 commits intomainfrom
feat/durable-event-iterator/rewrite-and-improve

Conversation

@dinwwwh
Copy link
Copy Markdown
Member

@dinwwwh dinwwwh commented Sep 5, 2025

  • feat: rewrite
  • feat: refresh token without re-estimate websocket connection
  • feat: tags with publish events filter support
  • feat: support targets and exclude as callback for filter
  • fix: should run before retry-plugin to avoid conflict
  • fix: server-side client, retry-plugin not preserve extra iterator's fields (rpc methods in this case)
  • test
  • docs

Closes: https://github.com/unnoq/orpc/issues/993

Summary by CodeRabbit

  • New Features

    • Introduced Durable Iterator: end-to-end durable streaming with token-based auth, auto-reconnect, resume/replay, publishEvent, tagging/target/exclude filters, client & handler plugins, and token refresh.
    • Streaming cancellation via AbortController; added getSignedValue helper and new utilities overlayProxy and fallback.
  • Documentation

    • Renamed docs/sidebar to “Durable Iterator” and updated streaming examples to show AbortController usage.
  • Chores

    • Package rename and CI/playground updates for @orpc/experimental-durable-iterator.

@vercel
Copy link
Copy Markdown

vercel Bot commented Sep 5, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
orpc Ready Ready Preview Comment Sep 23, 2025 10:01am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Sep 5, 2025

Walkthrough

Replaces the durable-event-iterator package with a reorganized durable-iterator implementation: removes the old package and tests; adds new durable-iterator package (client, link/plugin, durable-object handler/object, resume storage, websocket adapters, schemas, contract, consts, errors); updates docs, playgrounds, shared utilities, signing helper, and CI configs.

Changes

Cohort / File(s) Summary
Docs & Guides
apps/content/.vitepress/config.ts, apps/content/docs/client/event-iterator.md, apps/content/docs/helpers/signing.md, apps/content/docs/integrations/durable-event-iterator.md, apps/content/docs/integrations/durable-iterator.md
Sidebar rename to "Durable Iterator"; client.streaming docs now show AbortController signal; signing docs add getSignedValue; old durable-event-iterator doc deleted and replaced by durable-iterator integration doc.
Remove old package (durable-event-iterator)
packages/durable-event-iterator/src/**
Entire package removed: client modules, plugin, durable-object modules (object, handler, websocket-manager, event-storage, schemas, consts, upgrade), contracts, and all tests/type-tests.
Add new package (durable-iterator)
packages/durable-iterator/{package.json,README.md,src/**}, tsconfig.json
New package added and wired: client iterator & plugin, contract, schemas (sign/verify/parse), error, consts, durable-object handler/object/object-state/resume-storage/websocket/upgrade, many tests, package.json metadata; tsconfig exclude updated to packages/durable-iterator/**.
Client: iterator & plugin
packages/durable-iterator/src/client/iterator.ts, .../plugin.ts, .../index.ts, .../*.test*
New createClientDurableIterator, getClientDurableIteratorToken, DurableIteratorLinkPlugin (WS management, token refresh/reconnect), exports and tests; index re-exports iterator and plugin.
Durable Object runtime & resume storage
packages/durable-iterator/src/durable-object/{handler.ts,object.ts,object-state.ts,resume-storage.ts,websocket.ts,upgrade.ts}, .../*.test*
New DurableIteratorObject handler/class, object-state proxy, EventResumeStorage persistence, websocket adapter, upgrade flow and extensive tests covering publish/subscribe/upgrade/rpc/token update flows.
Iterator API (authoring)
packages/durable-iterator/src/iterator.ts, packages/durable-iterator/src/contract.ts, packages/durable-iterator/src/object.ts, .../iterator.test*
New DurableIterator builder (PromiseLike client), durableIteratorContract (updateToken/subscribe/call), object/type helpers (DurableIteratorObject and RPC inference), token signing/parsing/verification utilities, and tests.
Constants & error
packages/durable-iterator/src/consts.ts, packages/durable-iterator/src/error.ts
New exported constants (token param, id param, header key/value) and DurableIteratorError class.
Shared utilities
packages/shared/src/proxy.ts, packages/shared/src/value.ts, packages/shared/src/index.ts, .../*.test*
Added overlayProxy and fallback utilities; exported retry/sleep from radash; tests added/updated.
Overlay proxy integration
packages/client/src/plugins/retry.ts, packages/client/src/plugins/retry.test.ts, packages/server/src/procedure-client.ts, packages/server/src/procedure-client.test.ts
Async iterator results are now wrapped with overlayProxy; ClientRetryPlugin.order added; tests updated to assert overlayProxy usage.
Response validation order
packages/contract/src/plugins/response-validation.ts
ResponseValidationPlugin.order lowered from 1_500_000 → 1_200_000 and a clarifying comment added.
Signing helpers
packages/server/src/helpers/signing.ts, packages/server/src/helpers/signing.test.ts, apps/content/docs/helpers/signing.md
New exported function getSignedValue(signedValue) to extract pre-signature string; tests and docs updated to include it.
RPC handler options
packages/server/src/adapters/websocket/rpc-handler.ts
Added RPCHandlerOptions extending StandardRPCHandlerOptions; RPCHandler constructor updated to accept NoInfer<RPCHandlerOptions>.
Playgrounds updates
playgrounds/cloudflare-worker/{package.json,src/lib/orpc.ts,src/components/chat-room.tsx,worker/index.ts,worker/dos/chat-room.ts,worker/routers/message.ts}
Playground migrated to new durable-iterator API: class/func renames, publishEvent usage, plugin swap to DurableIteratorLinkPlugin, and token refresh option added.
CI / tooling
.github/workflows/{ci.yaml,release.yaml,release-next.yaml}
Added actions/setup-node@v5 Node 22 step (pnpm cache) to several workflows.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant ORPCClient as ORPC Client
  participant DIPlugin as DurableIteratorLinkPlugin
  participant WS as ReconnectableWebSocket
  participant DO as DurableIteratorObject
  participant Handler as Durable Iterator Handler

  Client->>ORPCClient: call that yields durable-iterator output
  ORPCClient->>DIPlugin: interceptor detects durable-iterator response
  DIPlugin->>DIPlugin: getToken() / parse token → create id
  DIPlugin->>WS: open ws://.../?id=...&token=...
  WS->>DO: upgrade forwarded (upgradeDurableIteratorRequest)
  DO->>Handler: subscribe() → returns AsyncIterator of events
  Handler-->>WS: stream events (hibernation-encoded)
  DIPlugin->>ORPCClient: build durable client link
  ORPCClient-->>Client: return proxied AsyncIterator (overlayProxy)
Loading
sequenceDiagram
  autonumber
  participant Procedure as ProcedureClient
  participant Handler as RPC Handler
  participant It as AsyncIterator
  participant Shared as overlayProxy

  Procedure->>Handler: call -> handler returns AsyncIterator
  Handler-->>Procedure: AsyncIterator
  Procedure->>Shared: overlayProxy(AsyncIterator, AsyncIteratorClass)
  Shared-->>Procedure: proxied iterator
  Procedure-->>Caller: proxied iterator returned
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • unnoq/orpc#668 — Large refactor/rename that moved durable-event-iterator into durable-iterator and reworked many modules; strong overlap in package/module renames and API moves.
  • unnoq/orpc#968 — Related changes to async-iterator handling and retry/overlayProxy integration affecting client retry and iterator wiring.
  • unnoq/orpc#682 — Changes to durable iterator internals (ID type and related modules) that touch similar durable-iterator/hibernation code paths.

Poem

Thump-thump, I sign the token bright,
Websockets hop awake at night.
Overlay wraps the streaming trail,
Retry nudges hope when connections fail.
A rabbit cheers the durable switch — tiny hops, big sail! 🥕🐇

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Out of Scope Changes Check ⚠️ Warning Most edits are on-topic for the durable-iterator rewrite (new durable-iterator package, handler, resume-storage, API renames, overlayProxy and plugin fixes), but the PR also introduces large, seemingly unrelated additions to the Cloudflare playground typing file (worker-configuration.d.ts) and broad global-type expansions that are not required by the linked issue and increase review surface; these large typing and playground changes should be separated or justified to avoid scope creep. Remove or move the extensive playground/global typing changes into a separate PR (or add a clear justification in this PR), and keep this PR focused on the durable-iterator rewrite plus closely related fixes (overlayProxy, plugin ordering, tests and docs); this will reduce review scope and make it easier to validate the feature set against the linked issue.
Linked Issues Check ❓ Inconclusive The changes largely implement the core requests from issue #993: persistence is now opt-in via EventResumeStorage (isEnabled), an onConnect-style hook is provided via onSubscribed in the handler options, transient in-memory streaming is supported by making resume storage optional and by publishEvent supporting tags/targets/exclude and event metadata, and the token/typing DX is preserved through new token helpers and tests; however the patch does not clearly expose a way to inject or replace the storage implementation instance (I cannot find an explicit option to pass a custom storage object), so that specific requirement is only partially addressed or undocumented. If storage injection is intended, add an explicit option to DurableIteratorObjectHandlerOptions to accept a custom resume-storage instance (or document the supported extension point) and add a test demonstrating replacement of the default storage; otherwise update the PR description to state why injection was excluded and how consumers should customize storage behavior.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title is concise and directly reflects the main work in the changeset (a rewrite and enhancements to the durable-iterator package, including renames and feature additions), so it summarizes the primary change appropriately; however it contains a small typo ("enchance" → "enhance") that should be fixed for clarity.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/durable-event-iterator/rewrite-and-improve

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cdf4913 and e5488a7.

📒 Files selected for processing (1)
  • packages/durable-iterator/src/durable-object/upgrade.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/durable-iterator/src/durable-object/upgrade.test.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: publish-commit
  • GitHub Check: lint
  • GitHub Check: Analyze (javascript-typescript)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Sep 5, 2025

More templates

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@965

@orpc/client

npm i https://pkg.pr.new/@orpc/client@965

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@965

@orpc/experimental-durable-iterator

npm i https://pkg.pr.new/@orpc/experimental-durable-iterator@965

@orpc/hey-api

npm i https://pkg.pr.new/@orpc/hey-api@965

@orpc/interop

npm i https://pkg.pr.new/@orpc/interop@965

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@965

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@965

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@965

@orpc/openapi-client

npm i https://pkg.pr.new/@orpc/openapi-client@965

@orpc/otel

npm i https://pkg.pr.new/@orpc/otel@965

@orpc/react

npm i https://pkg.pr.new/@orpc/react@965

@orpc/react-query

npm i https://pkg.pr.new/@orpc/react-query@965

@orpc/experimental-react-swr

npm i https://pkg.pr.new/@orpc/experimental-react-swr@965

@orpc/server

npm i https://pkg.pr.new/@orpc/server@965

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@965

@orpc/solid-query

npm i https://pkg.pr.new/@orpc/solid-query@965

@orpc/standard-server

npm i https://pkg.pr.new/@orpc/standard-server@965

@orpc/standard-server-aws-lambda

npm i https://pkg.pr.new/@orpc/standard-server-aws-lambda@965

@orpc/standard-server-fetch

npm i https://pkg.pr.new/@orpc/standard-server-fetch@965

@orpc/standard-server-node

npm i https://pkg.pr.new/@orpc/standard-server-node@965

@orpc/standard-server-peer

npm i https://pkg.pr.new/@orpc/standard-server-peer@965

@orpc/svelte-query

npm i https://pkg.pr.new/@orpc/svelte-query@965

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@965

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@965

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@965

@orpc/vue-colada

npm i https://pkg.pr.new/@orpc/vue-colada@965

@orpc/vue-query

npm i https://pkg.pr.new/@orpc/vue-query@965

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@965

commit: e5488a7

@dinwwwh dinwwwh force-pushed the feat/durable-event-iterator/rewrite-and-improve branch from 044b6e8 to 58d94af Compare September 9, 2025 09:45
@dinwwwh dinwwwh force-pushed the feat/durable-event-iterator/rewrite-and-improve branch from e8e68d1 to cdd93bb Compare September 14, 2025 07:51
@dinwwwh dinwwwh marked this pull request as ready for review September 23, 2025 09:11
@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Sep 23, 2025
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (66)
packages/server/src/adapters/websocket/rpc-handler.ts (1)

7-7: Consider extending interface capabilities for consistency.

The RPCHandlerOptions<T> interface currently only extends StandardRPCHandlerOptions<T> without adding any WebSocket-specific options. Looking at the fetch and node adapters, they both add a strictGetMethodPluginEnabled option. While WebSocket may not need this specific option, consider whether any WebSocket-specific configuration should be exposed here for future extensibility or consistency with the adapter pattern.

packages/contract/src/plugins/response-validation.ts (1)

23-27: Clarify the comment (current wording is confusing).

Suggest rewording for precision and grammar.

   /**
-   * run before (validate after) retry plugin, because validation failed can't be retried
-   * run before (validate after) durable iterator plugin, because we expect durable iterator to validation (if user use it)
+   * Initialize before Retry and Durable Iterator plugins so that—with last-added interceptors being outermost—
+   * response validation runs after them:
+   * - Retry: validation errors are not retriable.
+   * - Durable Iterator: validate the final transformed payload (if enabled).
    */
packages/durable-iterator/tests/shared.ts (3)

22-25: Track accepted sockets so getWebSockets() returns real connections

Right now getWebSockets() always returns [], which can hide bugs in code that iterates/acts on active sockets. Record accepted sockets and return that list.

Apply this diff within the object:

-    waitUntil: vi.fn(),
-    acceptWebSocket: vi.fn(),
-    getWebSockets: vi.fn(() => []),
+    waitUntil: vi.fn(),
+    acceptWebSocket: vi.fn((ws) => { sockets.push(ws) }),
+    getWebSockets: vi.fn(() => sockets),

Add the sockets store inside createDurableObjectState (outside the selected lines):

// right after `const db = new Database(':memory:')`
const sockets: any[] = []

31-37: Let close() transition readyState to CLOSED (3)

This makes the mock behave closer to WebSocket semantics and catches misuse after closure.

-    readyState: 1,
-    send: vi.fn(),
-    close: vi.fn(),
+    readyState: 1,
+    send: vi.fn(),
+    close: vi.fn(function (this: any) { this.readyState = 3 }),

10-11: Make SQL method detection case‑insensitive

Safer for lowercase/mixed‑case queries in tests.

-          const method = query.includes('SELECT') || query.includes('RETURNING') ? 'all' : 'run'
-          const result = db.prepare(query)[method](...bindings)
+          const q = query.toUpperCase()
+          const method = q.includes('SELECT') || q.includes('RETURNING') ? 'all' : 'run'
+          const result = db.prepare(query)[method](...bindings)
packages/durable-iterator/src/client/iterator.test-d.ts (1)

7-9: Avoid DOM type dependency in type test (WebSocket).

Using the WebSocket type forces lib: ["dom"] or a custom type. Prefer unknown (or any) here since the arg type is irrelevant to the assertion.

-    sendMessage: (ws: WebSocket) => Client<object, string, string, 'any-error'>
+    sendMessage: (ws: unknown) => Client<object, string, string, 'any-error'>
packages/shared/src/proxy.ts (1)

53-83: Docstring binding semantics are misleading; bind target varies (overlay or target).

The code binds functions to whichever object provides the property, not always to the overlay. Update the comment to avoid confusion.

Apply this diff to clarify:

 /**
  * Create a proxy that overlays one object (`overlay`) on top of another (`target`).
  *
  * - Properties from `overlay` take precedence.
  * - Properties not in `overlay` fall back to `target`.
- * - Methods from either object are bound to `overlay` so `this` is consistent.
+ * - Methods are bound to the object they originate from (overlay or target) so `this` remains correct.
  *
  * Useful when you want to override or extend behavior without fully copying/merging objects.
  */
packages/client/src/plugins/retry.test.ts (4)

77-87: Reduce flakiness in timing assertions.

CI timing variance can exceed 249ms. Loosen the upper bound.

Apply this diff:

-    expect(Date.now() - start).toBeLessThanOrEqual(249)
+    expect(Date.now() - start).toBeLessThanOrEqual(400)

196-208: OK to assert overlay proxy usage (non‑retry path).

Count=1 is reasonable with current internals; if internals evolve, consider asserting ≥1 instead of exact.


210-223: Overlay proxy call count could be brittle across refactors.

Prefer asserting at least one call and argument shapes, not exact totals.

Apply this diff:

-      expect(overlayProxySpy).toHaveBeenCalledTimes(5) // handler 4, plugin 1
+      expect(overlayProxySpy.mock.calls.length).toBeGreaterThanOrEqual(5) // handler 4, plugin 1 (min)

368-382: Reduce flakiness in iterator retry timing assertions.

Same rationale as above.

Apply this diff:

-      expect(Date.now() - start).toBeLessThanOrEqual(249)
+      expect(Date.now() - start).toBeLessThanOrEqual(400)
packages/client/src/plugins/retry.ts (3)

153-196: Prevent resource leaks when switching iterators on retry

When a retry yields a new iterator, the previous iterator isn’t closed. Proactively calling return() on the old iterator avoids dangling sockets/streams.

Apply this diff:

           catch (error) {
             const meta = getEventMeta(error)
             lastEventId = meta?.id ?? lastEventId
             lastEventRetry = meta?.retry ?? lastEventRetry

-            const maybeEventIterator = await next({ error })
+            const maybeEventIterator = await next({ error })

             if (!isAsyncIteratorObject(maybeEventIterator)) {
               throw new ClientRetryPluginInvalidEventIteratorRetryResponse(
                 'RetryPlugin: Expected an Event Iterator, got a non-Event Iterator',
               )
             }

-            current = maybeEventIterator
+            // Close previous iterator to avoid leaking resources.
+            const prev = current
+            current = maybeEventIterator
+            try {
+              await prev.return?.()
+            }
+            catch {
+              // noop — best-effort cleanup
+            }

             /**
              * If iterator is aborted while retrying, we should cleanup right away
              */
             if (isIteratorAborted) {
               await current.return?.()
               throw error
             }
           }

117-124: Make retry delay abortable

Currently the wait before retry isn’t canceled by AbortSignal, causing unnecessary stalls after abort. Make the delay abortable.

Apply this diff:

-            const retryDelayMs = await value(retryDelay, attemptOptions)
-
-            await new Promise(resolve => setTimeout(resolve, retryDelayMs))
+            const retryDelayMs = await value(retryDelay, attemptOptions)
+            await new Promise<void>((resolve, reject) => {
+              const timer = setTimeout(resolve, retryDelayMs)
+              const sig = attemptOptions.signal
+              if (sig) {
+                const onAbort = () => {
+                  clearTimeout(timer)
+                  reject((sig as any).reason ?? new Error('Aborted'))
+                }
+                if (sig.aborted) onAbort()
+                else sig.addEventListener('abort', onAbort, { once: true })
+              }
+            })

59-60: Make order readonly and confirm plugin sequencing

Mark the plugin's order as readonly to prevent accidental mutation. 1_800_000 places Retry after durable-iterator (1_500_000) and before dedupe-requests (4_000_000) and batch (5_000_000).

Location: packages/client/src/plugins/retry.ts (around lines 59–60)

Apply this minimal refactor:

-  order = 1_800_000
+  readonly order = 1_800_000 as const
packages/durable-iterator/src/error.ts (1)

1-2: Set error name and pass-through options for better stack/cause metadata.

Add an explicit constructor to set name and forward ErrorOptions (cause) reliably.

-export class DurableIteratorError extends Error {
-}
+export class DurableIteratorError extends Error {
+  constructor(message?: string, options?: ErrorOptions) {
+    super(message, options)
+    this.name = 'DurableIteratorError'
+  }
+}
packages/durable-iterator/src/schemas.test.ts (1)

118-131: Fix potential flakiness: exp === now depends on wall-clock.

The implementation checks exp < now (strict). Using exp === now can intermittently pass. Make it unambiguously expired.

-  const currentTime = Math.floor(Date.now() / 1000)
+  const currentTime = Math.floor(Date.now() / 1000)

   const payload: DurableIteratorTokenPayload = {
     chn: 'test-channel',
     iat: currentTime,
-    exp: currentTime, // expires exactly now
+    exp: currentTime - 1, // ensure expired
   }
packages/durable-iterator/src/object.ts (1)

12-19: Type utility looks good. Consider a brief doc comment.

Optional: add a short JSDoc explaining the intent (pick RPC-capable members; exclude AsyncIterator methods).

packages/durable-iterator/src/client/iterator.ts (2)

49-65: Avoid parsing the token for non-RPC property access.

Parse only when needed. This prevents throws on e.g. Symbol.toStringTag reads and reduces overhead.

   const proxy = new Proxy(iterator, {
     get(target, prop) {
-      const token = options.getToken()
-      const { rpc: allowMethods } = parseDurableIteratorToken(token)
-
-      if (prop === CLIENT_DURABLE_ITERATOR_TOKEN_SYMBOL) {
-        return token
-      }
-
-      if (typeof prop === 'string' && allowMethods?.includes(prop)) {
-        return createORPCClient(link, { path: [prop] })
-      }
+      if (prop === CLIENT_DURABLE_ITERATOR_TOKEN_SYMBOL) {
+        return options.getToken()
+      }
+      if (typeof prop === 'string') {
+        const { rpc: allowMethods } = parseDurableIteratorToken(options.getToken())
+        if (allowMethods?.includes(prop)) {
+          return createORPCClient(link, { path: [prop] })
+        }
+      }
 
       const v = Reflect.get(target, prop)
       return typeof v === 'function'
         ? v.bind(target) // Require .bind itself for calling
         : v
     },
   })

72-74: Nit: update docstring to match rename (drop “Event”).

- * If return a token if the client is a Client Durable Event Iterator.
+ * Return a token if the client is a Client Durable Iterator.
packages/durable-iterator/src/client/plugin.ts (4)

86-92: Fix context merge order to avoid clobbering plugin context

options.context spread currently overwrites the plugin context symbol if present. Place the plugin context last.

-      const next = () => options.next({
-        ...options,
-        context: {
-          [this.CONTEXT_SYMBOL]: pluginContext,
-          ...options.context,
-        },
-      })
+      const next = () => options.next({
+        ...options,
+        context: {
+          ...options.context,
+          [this.CONTEXT_SYMBOL]: pluginContext,
+        },
+      })

56-58: Update docs link to new iterator naming

The JSDoc still references “durable-event-iterator”.

- * @see {@link https://orpc.unnoq.com/docs/integrations/durable-event-iterator Durable Event Iterator Integration}
+ * @see {@link https://orpc.unnoq.com/docs/integrations/durable-iterator Durable Iterator Integration}

136-180: Clamp negative refresh delay to 0 to avoid scheduling anomalies

If exp - now - beforeSeconds is negative, the timer should fire immediately but explicitly clamping is clearer and safer across runtimes.

-        const nowInSeconds = Math.floor(Date.now() / 1000)
-
-        refreshTokenBeforeExpireTimeoutId = setTimeout(async () => {
+        const nowInSeconds = Math.floor(Date.now() / 1000)
+        const delayMs = Math.max(0, (tokenAndPayload.payload.exp - nowInSeconds - beforeSeconds) * 1000)
+
+        refreshTokenBeforeExpireTimeoutId = setTimeout(async () => {
           // retry until success or finished
           const newTokenAndPayload = await retry({ times: Number.POSITIVE_INFINITY, delay: 2000 }, async (exit) => {
             try {
               const output = await next()
               return this.validateToken(output, options.path)
             }
             catch (err) {
               if (isFinished) {
                 exit(err)
               }
 
               throw err
             }
           })
@@
-        }, (tokenAndPayload.payload.exp - nowInSeconds - beforeSeconds) * 1000)
+        }, delayMs)

154-157: Make token “tags” comparison order-agnostic

JSON string equality is sensitive to array order; tags are typically sets.

-          const canProactivelyUpdateToken
-            = newTokenAndPayload.payload.chn === tokenAndPayload.payload.chn
-              && stringifyJSON(newTokenAndPayload.payload.tags) === stringifyJSON(tokenAndPayload.payload.tags)
+          const canProactivelyUpdateToken =
+            newTokenAndPayload.payload.chn === tokenAndPayload.payload.chn &&
+            (() => {
+              const a = new Set(newTokenAndPayload.payload.tags ?? [])
+              const b = new Set(tokenAndPayload.payload.tags ?? [])
+              if (a.size !== b.size) return false
+              for (const t of a) if (!b.has(t)) return false
+              return true
+            })()
packages/durable-iterator/src/durable-object/resume-storage.ts (2)

52-55: Validate/sanitize resumeTablePrefix to prevent SQL identifier injection

resumeTablePrefix is interpolated into SQL identifiers. Restrict to a safe charset to avoid accidental injection via quotes.

-    this.retentionSeconds = fallback(options.resumeRetentionSeconds, Number.NaN) // disabled by default
-    this.schemaPrefix = fallback(options.resumeTablePrefix, 'orpc:durable-iterator:resume:')
+    this.retentionSeconds = fallback(options.resumeRetentionSeconds, Number.NaN) // disabled by default
+    const rawPrefix = fallback(options.resumeTablePrefix, 'orpc:durable-iterator:resume:')
+    if (!/^[\w:-]+$/.test(rawPrefix)) {
+      throw new Error('Invalid resumeTablePrefix: only letters, digits, underscore, dash, and colon are allowed')
+    }
+    this.schemaPrefix = rawPrefix
     this.serializer = new StandardRPCJsonSerializer(options)

146-151: Tag filter short‑circuit is correct; consider documenting semantics

Events with tags only deliver to websockets sharing at least one tag; tagless websockets won’t receive tagged events.

Add a brief comment to clarify intended behavior for future maintainers.

packages/durable-iterator/src/durable-object/resume-storage.test.ts (2)

7-7: Rename suite for clarity

Use the class name to make test output clearer.

-describe('eventStreamStorage', () => {
+describe('EventResumeStorage', () => {

24-26: Avoid void new … in tests

Instantiation side‑effects can be expressed without void, which reads oddly in tests.

-    void new EventResumeStorage(ctx, { resumeRetentionSeconds: 1 })
+    new EventResumeStorage(ctx, { resumeRetentionSeconds: 1 })
packages/durable-iterator/src/index.test.ts (1)

1-3: Tighten test: assert DurableIteratorHandlerPlugin is exported from index

packages/durable-iterator/src/index.test.ts — index.ts re-exports ./plugin; make the test explicit:

-it('export something', async () => {
-  expect(Object.keys(await import('./index'))).toContain('DurableIteratorHandlerPlugin')
-})
+it('exports DurableIteratorHandlerPlugin from index', async () => {
+  const api = await import('./index')
+  expect('DurableIteratorHandlerPlugin' in api).toBe(true)
+})
packages/durable-iterator/src/client/index.test.ts (1)

1-3: Clarify test name and assert export directly

Use a precise name and assert via property presence (also type-check) for a more robust surface test.

Apply this diff:

-it('export something', async () => {
-  expect(Object.keys(await import('./index'))).toContain('DurableIteratorLinkPlugin')
-})
+it('should export DurableIteratorLinkPlugin from client index', async () => {
+  const mod = await import('./index')
+  expect(mod).toHaveProperty('DurableIteratorLinkPlugin')
+  expect(typeof mod.DurableIteratorLinkPlugin).toBe('function')
+})
playgrounds/cloudflare-worker/src/components/chat-room.tsx (1)

31-34: Prefer a single approach and handle abort errors

  • Keep one send path to reduce noise.
  • Consider catching AbortError in the effect to avoid unhandled rejections on unmount.

Apply this diff to clean up the send path:

-    // await client.message.send({ message })
-    // or ---
-    await iterator?.publishMessageRPC({ message })
+    await iterator?.publishMessageRPC({ message })

Outside the selected range, wrap the effect body to handle aborts:

useEffect(() => {
  const controller = new AbortController()

  ;(async () => {
    try {
      const iterator = await client.message.on(undefined, { signal: controller.signal })
      setIterator(iterator)
      for await (const message of iterator) {
        setMessages(messages => [...messages, message.message])
      }
    } catch (err) {
      if ((err as any)?.name !== 'AbortError') {
        console.error(err)
      }
    }
  })()

  return () => controller.abort()
}, [])
apps/content/docs/helpers/signing.md (1)

26-27: Add an explicit security warning about getSignedValue

Clarify that getSignedValue must not be used for auth/security decisions.

Apply this diff:

 // Extract value without verification
 const extractedValue = getSignedValue(signedValue) // 'user123'
+// ---
+
+::: warning
+getSignedValue does not verify authenticity. Do not use it for authentication,
+authorization, or any security-critical decisions. Use `unsign` to verify.
+:::
playgrounds/cloudflare-worker/worker/index.ts (1)

86-89: Consider sourcing signingKey from env/secrets

Hardcoding 'key' is fine for a playground, but prefer env for clarity and future-proofing.

Example:

return upgradeDurableIteratorRequest(request, {
  signingKey: env.SIGNING_KEY,
  namespace: env.CHAT_ROOM,
})
packages/server/src/helpers/signing.ts (1)

91-117: Add a misuse warning in JSDoc

Add a brief remark to prevent security misuse.

Apply this diff:

 /**
  * Extracts the value part from a signed string without verification.
  *
  * This function simply extracts the original value from a signed string
  * without performing any signature verification. It's useful when you need
  * to access the value quickly without the overhead of cryptographic verification.
+ *
+ * @remarks Do not use this for authentication/authorization or any
+ * security-sensitive logic. Use `unsign` to verify integrity.
  *
  * @example
packages/durable-iterator/src/contract.ts (2)

9-12: Use unknown over any for output type.

Prefer AsyncIteratorClass for safer typing.

-    .output(type<AsyncIteratorClass<any>>()),
+    .output(type<AsyncIteratorClass<unknown>>()),

15-19: Validate valibot tupleWithRest availability (or switch to tuple + rest).

Confirm v.tupleWithRest exists in your valibot version; otherwise use tuple with rest.

If needed, replace with valibot’s tuple + rest equivalent (adjust per your valibot version):

  • Example pattern: v.tuple([v.string()], v.rest(v.string()))
packages/durable-iterator/src/durable-object/websocket.test.ts (2)

6-8: Isolate state per test (re-create ws/proxy).

Avoid cross-test leakage of attachments/mocks by instantiating per test.

 beforeEach(() => {
   vi.clearAllMocks()
+  ws = createCloudflareWebsocket()
+  proxied = toDurableIteratorWebsocket(ws) as any
 })
 
 describe('toDurableIteratorWebsocket', () => {
-  const ws = createCloudflareWebsocket()
-  const proxied = toDurableIteratorWebsocket(ws) as any
+  let ws: any
+  let proxied: any

Also applies to: 11-13


54-69: Use fake timers; assert close code.

Eliminates flakiness; also assert the 1008 close code.

-  it('proxied and auto close if expired on send', async () => {
-    const nowInSeconds = Math.floor(Date.now() / 1000)
+  it('proxied and auto close if expired on send', async () => {
+    vi.useFakeTimers()
+    const now = new Date('2020-01-01T00:00:00.000Z')
+    vi.setSystemTime(now)
+    const nowInSeconds = Math.floor(now.getTime() / 1000)
     proxied['~orpc'].serializeTokenPayload({ id: 'some-id', exp: nowInSeconds + 1 })
 
     proxied.send('data')
     expect(ws.send).toHaveBeenCalledTimes(1)
     expect(ws.close).toHaveBeenCalledTimes(0)
 
     vi.mocked(ws.send as () => any).mockClear()
 
-    await sleep(1001)
+    vi.advanceTimersByTime(1001)
     proxied.send('data')
     expect(ws.send).toHaveBeenCalledTimes(1)
     expect(ws.close).toHaveBeenCalledTimes(1)
+    expect(ws.close).toHaveBeenCalledWith(1008, 'Token expired')
     expect(ws.close).toHaveBeenCalledBefore(ws.send)
+    vi.useRealTimers()
   })

After applying, remove the now-unused sleep import on Line 1.

packages/durable-iterator/src/iterator.test-d.ts (1)

24-24: Nit: rename test title (remove “event”).

-  it('resolve correct client durable event iterator type', async () => {
+  it('resolve correct client durable iterator type', async () => {
packages/durable-iterator/src/iterator.test.ts (3)

8-8: Avoid secrets scanner false positives.

Use an obvious dummy or env-provided value to prevent Gitleaks noise.

-  const testSigningKey = 'test-signing-key-32-chars-long-123'
+  const testSigningKey = process.env.CI_TEST_SIGNING_KEY ?? 'unit-test-secret'

If CI still flags, consider allowlisting this exact test file/key in gitleaks config.


24-48: Freeze time to stabilize iat/exp assertions.

Prevents second-boundary flakiness.

-    it('token & throw when interacting with client iterator', async () => {
-      const date = new Date()
+    it('token & throw when interacting with client iterator', async () => {
+      vi.useFakeTimers()
+      const now = new Date('2020-01-01T00:00:00.000Z')
+      vi.setSystemTime(now)
       const options = {
         tags: ['tag1', 'tag2'],
         att: { userId: 'user123' },
         rpc: ['getUser', 'sendMessage'] as any,
         signingKey: testSigningKey,
       }
       const iterator = new DurableIterator(testChannel, options) as any
       const clientIterator = await iterator
 
       const token = getClientDurableIteratorToken(clientIterator)
       expect(token).toBeDefined()
       const payload = await verifyDurableIteratorToken(testSigningKey, token!)
 
       expect(payload?.chn).toBe(testChannel)
       expect(payload?.tags).toEqual(['tag1', 'tag2'])
       expect(payload?.att).toEqual({ userId: 'user123' })
       expect(payload?.rpc).toEqual(['getUser', 'sendMessage'])
-      expect(payload?.iat).toEqual(Math.floor(date.getTime() / 1000))
-      expect(payload?.exp).toEqual(Math.floor(date.getTime() / 1000) + 60 * 60 * 24)
+      expect(payload?.iat).toEqual(Math.floor(now.getTime() / 1000))
+      expect(payload?.exp).toEqual(Math.floor(now.getTime() / 1000) + 60 * 60 * 24)
 
       await expect(clientIterator.next()).rejects.toThrow()
       await expect(clientIterator.getUser()).rejects.toThrow()
+      vi.useRealTimers()
     })

50-63: Freeze time for TTL test too.

-    it('can change token TTL', async () => {
-      const date = new Date()
+    it('can change token TTL', async () => {
+      vi.useFakeTimers()
+      const now = new Date('2020-01-01T00:00:00.000Z')
+      vi.setSystemTime(now)
       const options = {
         tokenTTLSeconds: 3600, // 1 hour
         signingKey: testSigningKey,
       }
 
       const iterator = new DurableIterator(testChannel, options) as any
       const clientIterator = await iterator
       const token = getClientDurableIteratorToken(clientIterator)
       const payload = await verifyDurableIteratorToken(testSigningKey, token!)
 
-      expect(payload?.exp).toEqual(Math.floor(date.getTime() / 1000) + 3600)
+      expect(payload?.exp).toEqual(Math.floor(now.getTime() / 1000) + 3600)
+      vi.useRealTimers()
     })
packages/durable-iterator/src/durable-object/upgrade.test.ts (1)

97-136: Optional: test last-value-wins for duplicate query params.

Since upgrade reads the last token/id via getAll(...).at(-1), add a test covering duplicates to lock behavior.

packages/durable-iterator/src/durable-object/object-state.test.ts (1)

8-10: Isolate proxied state per test.

Create ctx/proxied in beforeEach to avoid shared mutable state between tests.

-describe('toDurableIteratorObjectState', () => {
-  const ctx = createDurableObjectState()
-  const proxied = toDurableIteratorObjectState(ctx) as any
+describe('toDurableIteratorObjectState', () => {
+  let ctx: any
+  let proxied: any
+  beforeEach(() => {
+    ctx = createDurableObjectState()
+    proxied = toDurableIteratorObjectState(ctx) as any
+  })
packages/durable-iterator/src/client/iterator.test.ts (2)

21-30: Create fresh iterator per test to avoid shared state.

iterator is created once for the whole suite. Prefer per‑test setup to avoid cross‑test coupling (e.g., after a .return()).

Example:

let iterator: ReturnType<typeof createClientDurableIterator>
let next: jest.Mock, cleanup: jest.Mock, call: jest.Mock
beforeEach(() => {
  next = vi.fn(() => Promise.resolve({ value: '__next__', done: false }))
  cleanup = vi.fn(() => Promise.resolve())
  call = vi.fn(() => Promise.resolve('__call__'))
  iterator = createClientDurableIterator(new AsyncIteratorClass<any>(next, cleanup), { call }, { getToken })
})

55-56: Brittle expectation on mock call count.

Proxy.get can be triggered by engine/TS helpers; counting exact getToken calls risks flakes. Assert minimum instead.

Suggested change:

-expect(getToken).toHaveBeenCalledTimes(5)
+expect(getToken).toHaveBeenCalledTimes(expect.toBeGreaterThanOrEqual(5))
playgrounds/cloudflare-worker/worker/routers/message.ts (1)

7-12: Avoid hardcoding secrets in examples.

Use env/config for signingKey rather than a literal to prevent accidental leaks in real use.

Suggested change:

-  return new DurableIterator<ChatRoom>('some-room', {
-    signingKey: 'key',
+  return new DurableIterator<ChatRoom>('some-room', {
+    signingKey: context.env.SIGNING_KEY,
packages/durable-iterator/package.json (1)

4-4: Version 0.0.0 is unconventional for publication.

Consider 0.0.1 or 0.1.0 to avoid tooling edge cases.

apps/content/docs/client/event-iterator.md (1)

38-43: Example calls both abort and return. Use one.

Executing both can confuse readers and is unnecessary.

Apply this diff:

-// Stop the stream after 1 second
+// Stop the stream after 1 second
 setTimeout(async () => {
-  controller.abort()
-  // or
-  await iterator.return()
+  controller.abort()
+  // or:
+  // await iterator.return()
 }, 1000)
playgrounds/cloudflare-worker/worker/dos/chat-room.ts (2)

7-13: DurableObjectState generic parameter looks incorrect.

DurableObjectState<TProps>’s type parameter is for props, not env. Use DurableObjectState (or the correct props type).

Apply this diff:

-  constructor(
-    ctx: DurableObjectState<Cloudflare.Env>,
-    env: Env,
-  ) {
+  constructor(
+    ctx: DurableObjectState,
+    env: Env,
+  ) {

15-20: Use env/config instead of hardcoded signing key.

Avoid literals for secrets in examples.

-      signingKey: 'key',
+      signingKey: env.SIGNING_KEY,
apps/content/docs/integrations/durable-iterator.md (1)

152-155: Update docs to new Durable Iterator naming and import paths.

Several references still use DurableEventIterator* names or non‑experimental import paths.

Apply these diffs:

-import { withEventMeta } from '@orpc/durable-iterator'
+import { withEventMeta } from '@orpc/experimental-durable-iterator/durable-object'
-import { DurableEventIterator } from '@orpc/experimental-durable-iterator'
+import { DurableIterator } from '@orpc/experimental-durable-iterator'
...
-    return new DurableEventIterator<ChatRoom>('some-room', {
+    return new DurableIterator<ChatRoom>('some-room', {
-import { DurableEventIteratorHandlerPlugin } from '@orpc/experimental-durable-iterator'
+import { DurableIteratorHandlerPlugin } from '@orpc/experimental-durable-iterator'
...
-    new DurableEventIteratorHandlerPlugin(),
+    new DurableIteratorHandlerPlugin(),
-import { DurableEventIteratorLinkPlugin } from '@orpc/experimental-durable-iterator/client'
+import { DurableIteratorLinkPlugin } from '@orpc/experimental-durable-iterator/client'
...
-    new DurableEventIteratorLinkPlugin({
+    new DurableIteratorLinkPlugin({
-`DurableEventIteratorLinkPlugin` establishes a WebSocket connection
+`DurableIteratorLinkPlugin` establishes a WebSocket connection
-import { DurableEventIterator } from '@orpc/experimental-durable-iterator'
+import { DurableIterator } from '@orpc/experimental-durable-iterator'
...
-  return new DurableEventIterator<ChatRoom>('some-room', {
+  return new DurableIterator<ChatRoom>('some-room', {
-import type { ClientDurableEventIterator } from '@orpc/experimental-durable-iterator/client'
+import type { ClientDurableIterator } from '@orpc/experimental-durable-iterator/client'
...
-  onMessage: oc.output(type<ClientDurableEventIterator<ChatRoom, 'publishMessage'>>()),
+  onMessage: oc.output(type<ClientDurableIterator<ChatRoom, 'publishMessage'>>()),

Additionally, the first Durable Object example uses onError but doesn't import it; add:

import { onError } from '@orpc/server'

Also applies to: 168-176, 189-199, 206-219, 221-223, 310-323, 371-383

packages/durable-iterator/src/schemas.ts (1)

35-37: Use second‑precision for expiry check to avoid early invalidation.

Floor the current time and use <= to align with common exp semantics.

Apply this diff:

-    if (payload.exp < (Date.now() / 1000)) {
+    const nowInSeconds = Math.floor(Date.now() / 1000)
+    if (payload.exp <= nowInSeconds) {
       return undefined
     }
packages/durable-iterator/src/durable-object/websocket.ts (2)

60-62: Fix comment referencing outdated name.

The comment still references "Durable Event Iterator" instead of "Durable Iterator", which is inconsistent with the rename throughout the codebase.

Apply this diff to fix the naming consistency:

   /**
-   * Durable Event Iterator internal apis
+   * Durable Iterator internal apis
    */

65-65: Consider documenting the WebSocket cache behavior.

The WeakMap cache ensures reference equality preservation, which is critical for WebSocket adapter behavior. Consider adding a brief comment explaining why this cache is necessary.

+/**
+ * Cache to ensure the same WebSocket always maps to the same DurableIteratorWebsocket instance.
+ * This preserves reference equality, which is required for proper WebSocket adapter behavior.
+ */
 const websocketReferencesCache = new WeakMap<WebSocket, DurableIteratorWebsocket>()
packages/durable-iterator/src/durable-object/object.ts (2)

42-43: Remove unnecessary return statement from void method.

The publishEvent method has a void return type but returns the result of the handler call.

Apply this diff to fix the return type issue:

   publishEvent(payload: T, options: PublishEventOptions = {}): void {
-    return this['~orpc'].publishEvent(payload, options)
+    this['~orpc'].publishEvent(payload, options)
   }

46-54: Consider clarifying the @info comment about non-upgrade requests.

The comment mentions intercepting non-upgrade requests, but the method name fetch and its delegation pattern don't make this immediately clear. Consider expanding the comment to explain how non-upgrade requests would be handled.

   /**
    * Upgrades websocket connection
    *
-   * @info You can safety intercept non-upgrade requests
+   * @info You can safely intercept non-upgrade requests by overriding this method in a subclass
+   *       and handling them before calling super.fetch()
    * @warning No verification is done here, you should verify the token payload before calling this method.
    */
packages/durable-iterator/src/durable-object/upgrade.ts (1)

49-51: Verify id validation consistency.

The code checks typeof id !== 'string' but the getAll().at(-1) pattern can return undefined. Consider using a more explicit check for consistency with the token validation below.

Apply this diff for consistency:

-  if (typeof id !== 'string') {
+  if (!id) {
     return new Response('ID is required', { status: 401 })
   }
packages/durable-iterator/src/iterator.ts (2)

14-14: Fix typo in comment.

There's an extra space in "The signing key".

   /**
-   * The  signing key used to sign the token
+   * The signing key used to sign the token
    */

66-101: Consider the implications of using then on a class.

While the implementation creates a thenable object that integrates with async/await, using then on a class can be confusing as it makes the class instance behave like a Promise. This might lead to unexpected behavior in certain contexts.

Consider these alternatives:

Option 1: Rename to a more explicit method

-  then<TResult1 = ClientDurableIterator<T, RPC>, TResult2 = never>(
+  async build(): Promise<ClientDurableIterator<T, RPC>> {
-    onfulfilled?: ((value: ClientDurableIterator<T, RPC>) => TResult1 | PromiseLike<TResult1>) | null | undefined,
-    onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined,
-  ): PromiseLike<TResult1 | TResult2> {
-    return (async () => {
       const tokenTTLSeconds = this.options.tokenTTLSeconds ?? 60 * 60 * 24 // 24 hours

       const nowInSeconds = Math.floor(Date.now() / 1000)

       const token = await signDurableIteratorToken(this.options.signingKey, {
         chn: this.chn,
         tags: this.options.tags,
         att: this.options.att,
         rpc: this.options.rpc,
         iat: nowInSeconds,
         exp: nowInSeconds + tokenTTLSeconds,
       })

       const iterator = new AsyncIteratorClass<any>(
         () => Promise.reject(new DurableIteratorError('Cannot be iterated directly.')),
         () => Promise.reject(new DurableIteratorError('Cannot be cleaned up directly.')),
       )

       const link: ClientLink<object> = {
         call() {
           throw new DurableIteratorError('Cannot call methods directly.')
         },
       }

       const durableIterator = createClientDurableIterator(iterator, link, {
         getToken: () => token,
       })

       return durableIterator as ClientDurableIterator<T, RPC>
-    })().then(onfulfilled, onrejected)
   }

Option 2: Keep the thenable pattern but add clear documentation

+  /**
+   * Implements PromiseLike to allow await syntax.
+   * This method signs a token and creates a ClientDurableIterator.
+   * 
+   * @example
+   * const iterator = await new DurableIterator(channelName, options)
+   */
   then<TResult1 = ClientDurableIterator<T, RPC>, TResult2 = never>(
packages/durable-iterator/src/client/plugin.test.ts (2)

31-31: Remove unnecessary async from describe block

describe should not be async. Drop it to avoid unintended promise handling during test definition.

-describe('durableIteratorLinkPlugin', async () => {
+describe('durableIteratorLinkPlugin', () => {

307-326: Avoid registering afterEach inside an it; clean up the listener within the test

Registering afterEach inside a test is brittle and leaks listeners across tests. Clean up with process.off at the end (or use try/finally).

-      const unhandledRejectionHandler = vi.fn()
-      process.on('unhandledRejection', unhandledRejectionHandler)
-      afterEach(() => {
-        process.off('unhandledRejection', unhandledRejectionHandler)
-      })
+      const unhandledRejectionHandler = vi.fn()
+      process.on('unhandledRejection', unhandledRejectionHandler)
@@
       await output.return() // cleanup
@@
       expect(unhandledRejectionHandler.mock.calls[0]![0]).toEqual(
         new DurableIteratorError(`Expected valid token for procedure durableIterator`),
       )
+      process.off('unhandledRejection', unhandledRejectionHandler)
packages/durable-iterator/src/plugin.ts (1)

11-16: Update docs link to new Durable Iterator page

The reference still points to durable-event-iterator. Update to durable-iterator.

-/**
- * @see {@link https://orpc.unnoq.com/docs/integrations/durable-event-iterator Durable Event Iterator Integration}
- */
+/**
+ * @see {@link https://orpc.unnoq.com/docs/integrations/durable-iterator Durable Iterator Integration}
+ */
playgrounds/cloudflare-worker/worker-configuration.d.ts (4)

435-436: Empty interface is intentional; suppress Biome for this generated file

interface DurableObjectClass<...> {} is used for declaration merging; do not convert to a type alias. Suppress Biome’s noEmptyInterface for this generated d.ts.

Apply suppression inline if you must appease Biome:

+/* biome-ignore lint/suspicious/noEmptyInterface: declaration merging in generated types */
 interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> {
 }

Alternatively, exclude this file or the rule in your Biome config for generated d.ts files.


1380-1384: Fix generic parameter in Request constructor

RequestInfo is parameterized as <CfHostMetadata, Cf>, but here it’s passed <CfProperties>, which is incorrect and degrades typing.

Apply:

 declare var Request: {
     prototype: Request;
-    new <CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>>(input: RequestInfo<CfProperties> | URL, init?: RequestInit<Cf>): Request<CfHostMetadata, Cf>;
+    new <CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>>(input: RequestInfo<CfHostMetadata, Cf> | URL, init?: RequestInit<Cf>): Request<CfHostMetadata, Cf>;
 };

5425-5430: Use Omit instead of Exclude to drop object properties

Exclude<GatewayOptions, 'id'> doesn’t remove the id key from an object type. Use Omit to make id optional correctly.

Apply:

-type UniversalGatewayOptions = Exclude<GatewayOptions, 'id'> & {
+type UniversalGatewayOptions = Omit<GatewayOptions, 'id'> & {
   /**
    ** @deprecated
    */
   id?: string;
 };

5510-5511: Empty interfaces are by design for declaration merging—suppress Biome

AutoRAGNameNotSetError, Cloudflare.Env, and Cloudflare.GlobalProps are intentionally empty for interface merging. Don’t convert to type aliases; suppress Biome on these or exclude generated d.ts from linting.

Options:

  • Add inline suppressions:
    • /* biome-ignore lint/suspicious/noEmptyInterface: enables declaration merging */
  • Or ignore this file/glob in Biome config (preferred for generated artifacts).

Also applies to: 6982-6990, 7010-7011

Comment thread packages/durable-iterator/src/client/iterator.test.ts
Comment thread packages/durable-iterator/src/durable-object/handler.ts
Comment thread packages/durable-iterator/src/durable-object/object.test.ts
Comment thread packages/durable-iterator/src/iterator.test-d.ts
Comment thread packages/server/src/plugins/strict-get-method.ts
@codecov
Copy link
Copy Markdown

codecov Bot commented Sep 23, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (21)
packages/durable-iterator/README.md (2)

64-64: Add install snippet and brief experimental/migration note under the new section.

Improves DX and reduces confusion coming from the rename.

 ## `@orpc/experimental-durable-iterator`

+[!TIP]
+> Experimental: API surface may change. This is the rewrite/rename of `@orpc/durable-event-iterator`.
+> See migration notes in the docs (Durable Iterator integration guide).
+
+### Install
+
+```bash
+pnpm add @orpc/experimental-durable-iterator
+# or
+npm i @orpc/experimental-durable-iterator
+# or
+yarn add @orpc/experimental-durable-iterator
+```

5-5: Fix empty H1.

An empty top-level heading hurts readability and accessibility. Set a meaningful title.

-<h1></h1>
+<h1>oRPC</h1>
.github/workflows/release.yaml (1)

48-52: Add cache-dependency-path for reliable PNPM cache hits.

Being explicit avoids cache misses in mono-repos or non-standard lockfile paths.

Apply this diff:

-      - uses: actions/setup-node@v5
-        with:
-          node-version: 22
-          cache: pnpm
+      - uses: actions/setup-node@v5
+        with:
+          node-version: 22
+          cache: pnpm
+          cache-dependency-path: pnpm-lock.yaml

Optionally pin PNPM for reproducibility (outside this hunk):

- uses: pnpm/action-setup@v4
  with:
    version: 9.12.3 # pick your org’s pinned version
.github/workflows/release-next.yaml (3)

18-22: Move Node setup before PNPM setup.

Set up Node first to ensure the toolchain is consistent when pnpm is installed/configured.

Proposed reordering (outside the selected range):

-      - uses: pnpm/action-setup@v4
-
-      - uses: actions/setup-node@v5
+      - uses: actions/setup-node@v5
         with:
           node-version: 22
           cache: pnpm
+          cache-dependency-path: '**/pnpm-lock.yaml'
+
+      - uses: pnpm/action-setup@v4

18-22: Improve pnpm cache hit rate in monorepos.

Explicitly set cache-dependency-path to lockfiles to avoid cache misses when lockfiles aren’t at repo root.

Apply within this step:

       - uses: actions/setup-node@v5
         with:
           node-version: 22
           cache: pnpm
+          cache-dependency-path: '**/pnpm-lock.yaml'

18-22: Optional: prevent duplicate publishes on rapid pushes.

Add a concurrency group to cancel in-progress runs of this job.

Example (outside the selected range):

concurrency:
  group: release-next-${{ github.ref }}
  cancel-in-progress: true
.github/workflows/ci.yaml (3)

35-39: Consider a Node matrix for tests.

Run tests across LTS/current (e.g., 18.x, 20.x, 22.x) to catch version-specific issues, while keeping lint/publish on a single Node version.

Example (outside this hunk):

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [18.x, 20.x, 22.x]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v5
        with:
          node-version: ${{ matrix.node }}
          check-latest: true
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml
      - uses: pnpm/action-setup@v4
      - run: pnpm i
      - run: pnpm run test:coverage

55-59: Mirror the caching/versioning hardening here.

Apply the same tweaks as in the lint job:

-      - uses: actions/setup-node@v5
-        with:
-          node-version: 22
-          cache: pnpm
+      - uses: actions/setup-node@v5
+        with:
+          node-version: 22.x
+          check-latest: true
+          cache: pnpm
+          cache-dependency-path: pnpm-lock.yaml

17-21: Harden setup-node: pin to 22.x, enable check-latest, and cache pnpm via lockfile

packageManager is pnpm@10.16.1 and pnpm-lock.yaml exists — apply the diff below and mirror in other jobs.

-      - uses: actions/setup-node@v5
-        with:
-          node-version: 22
-          cache: pnpm
+      - uses: actions/setup-node@v5
+        with:
+          node-version: 22.x
+          check-latest: true
+          cache: pnpm
+          cache-dependency-path: pnpm-lock.yaml

Recommended step order for each job:

- uses: actions/checkout@v4
- uses: actions/setup-node@v5
  with:
    node-version: 22.x
    check-latest: true
    cache: pnpm
    cache-dependency-path: pnpm-lock.yaml
- uses: pnpm/action-setup@v4
- run: pnpm i
packages/durable-iterator/src/client/plugin.ts (8)

232-245: Don’t throw when plugin context is absent; fail open.

Throwing here can break unrelated calls if another interceptor drops context. Degrade gracefully by proceeding without marking the response.

-      const pluginContext = options.context[this.CONTEXT_SYMBOL] as DurableIteratorLinkPluginContext | undefined
-
-      if (!pluginContext) {
-        throw new DurableIteratorError('Plugin context has been corrupted or modified by another plugin or interceptor')
-      }
-
-      const response = await options.next()
-
-      pluginContext.isDurableIteratorResponse = response.headers[DURABLE_ITERATOR_PLUGIN_HEADER_KEY] === DURABLE_ITERATOR_PLUGIN_HEADER_VALUE
-
-      return response
+      const pluginContext = options.context[this.CONTEXT_SYMBOL] as DurableIteratorLinkPluginContext | undefined
+      const response = await options.next()
+      if (pluginContext) {
+        pluginContext.isDurableIteratorResponse =
+          response.headers[DURABLE_ITERATOR_PLUGIN_HEADER_KEY] === DURABLE_ITERATOR_PLUGIN_HEADER_VALUE
+      }
+      return response

183-187: Guard clearTimeout against undefined.

Prevents a TS type error and is safer across environments.

   const closeConnection = () => {
     isFinished = true
-    clearTimeout(refreshTokenBeforeExpireTimeoutId)
+    if (refreshTokenBeforeExpireTimeoutId !== undefined) {
+      clearTimeout(refreshTokenBeforeExpireTimeoutId)
+    }
     websocket.close()
   }

136-180: Clamp refresh delay and bail out early if finished.

  • Clamp the setTimeout delay to [0, 2_147_483_647] ms to avoid negative/overflow values.
  • Exit early after fetching a new token if the iterator was finished during refresh.
-        const nowInSeconds = Math.floor(Date.now() / 1000)
-
-        refreshTokenBeforeExpireTimeoutId = setTimeout(async () => {
+        const nowInSeconds = Math.floor(Date.now() / 1000)
+        const delayMs = Math.max(
+          0,
+          Math.min(2_147_483_647, Math.round((tokenAndPayload.payload.exp - nowInSeconds - beforeSeconds) * 1000)),
+        )
+
+        refreshTokenBeforeExpireTimeoutId = setTimeout(async () => {
           // retry until success or finished
           const newTokenAndPayload = await retry({ times: Number.POSITIVE_INFINITY, delay: 2000 }, async (exit) => {
             try {
               const output = await next()
               return this.validateToken(output, options.path)
             }
             catch (err) {
               if (isFinished) {
                 exit(err)
               }
 
               throw err
             }
           })
 
+          if (isFinished) {
+            return
+          }
+
           const canProactivelyUpdateToken
             = newTokenAndPayload.payload.chn === tokenAndPayload.payload.chn
               && stringifyJSON(newTokenAndPayload.payload.tags) === stringifyJSON(tokenAndPayload.payload.tags)
 
           tokenAndPayload = newTokenAndPayload
           await refreshTokenBeforeExpire() // recursively call
@@
-        }, (tokenAndPayload.payload.exp - nowInSeconds - beforeSeconds) * 1000)
+        }, delayMs)

110-115: Support relative WS URLs (or explicitly document absolute requirement).

new URL(...) throws on relative URLs without a base. Either clamp to absolute in docs or support relative using location.origin.

-        const url = new URL(await value(this.url, tokenAndPayload.payload, options))
+        const raw = await value(this.url, tokenAndPayload.payload, options)
+        const base = (typeof globalThis !== 'undefined' && (globalThis as any).location?.origin) || undefined
+        const url = typeof raw === 'string' ? new URL(raw, base) : new URL(raw.toString())

121-125: Avoid duplicate ClientRetryPlugin instances.

If callers already pass a ClientRetryPlugin, this adds a second instance. Dedup to prevent double handling.

-          plugins: [
-            ...toArray(this.linkOptions.plugins),
-            new ClientRetryPlugin(),
-          ],
+          plugins: [
+            ...toArray(this.linkOptions.plugins).filter(p => !(p instanceof ClientRetryPlugin)),
+            new ClientRetryPlugin(),
+          ],

72-77: Provide a robust fallback for createId when crypto.randomUUID is unavailable.

Older runtimes may lack crypto.randomUUID. Use getRandomValues when available; otherwise, fallback.

Please confirm your target runtimes (Node/browser versions). If Node < 16.17 or older browsers are in scope, apply:

   constructor({ url, refreshTokenBeforeExpireInSeconds, ...options }: DurableIteratorLinkPluginOptions<T>) {
     this.url = url
-    this.createId = fallback(options.createId, () => crypto.randomUUID())
+    this.createId = fallback(options.createId, () => {
+      const c: any = (globalThis as any).crypto
+      if (c?.randomUUID) return c.randomUUID()
+      const bytes: Uint8Array =
+        c?.getRandomValues?.(new Uint8Array(16)) ?? Uint8Array.from({ length: 16 }, () => Math.floor(Math.random() * 256))
+      bytes[6] = (bytes[6] & 0x0f) | 0x40
+      bytes[8] = (bytes[8] & 0x3f) | 0x80
+      const hex: string[] = []
+      for (const b of bytes) hex.push(b.toString(16).padStart(2, '0'))
+      return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`
+    })
     this.refreshTokenBeforeExpireInSeconds = fallback(refreshTokenBeforeExpireInSeconds, Number.NaN)
     this.linkOptions = options
   }

49-51: Doc nit: “an infinite value”.

Grammar fix in the option JSDoc.

-   * - Use a infinite value to disable refreshing
+   * - Use an infinite value to disable refreshing

140-152: Replace fixed-delay infinite retry with exponential backoff + jitter (cap)
packages/durable-iterator/src/client/plugin.ts (~line 140): currently uses retry({ times: Number.POSITIVE_INFINITY, delay: 2000 }, ...). radash.retry (re-exported via packages/shared) supports a backoff function — use exponential backoff with jitter and a max delay (and pass options.signal if available) to avoid thundering-herd (e.g. backoff: i => Math.min(30_000, Math.floor(Math.random() * (2000 * 2 ** (i - 1))))).

packages/durable-iterator/src/plugin.ts (4)

31-34: Prevent pluginContext from being overwritten by an existing context entry

Spread options.context first, then set your symbol to ensure your fresh pluginContext takes precedence.

Apply:

-        context: {
-          [this.CONTEXT_SYMBOL]: pluginContext,
-          ...options.context,
-        },
+        context: {
+          ...options.context,
+          [this.CONTEXT_SYMBOL]: pluginContext,
+        },

45-50: Avoid injecting undefined header values

Build headers conditionally to skip the key entirely when not applicable.

Apply:

         headers: {
           ...result.response.headers,
-          [DURABLE_ITERATOR_PLUGIN_HEADER_KEY]: pluginContext.isClientDurableIteratorOutput
-            ? DURABLE_ITERATOR_PLUGIN_HEADER_VALUE
-            : undefined,
+          ...(pluginContext.isClientDurableIteratorOutput
+            ? { [DURABLE_ITERATOR_PLUGIN_HEADER_KEY]: DURABLE_ITERATOR_PLUGIN_HEADER_VALUE }
+            : {}),
         },

56-61: Degrade gracefully if plugin context is absent

Throwing here can break legitimate flows where the client interceptors run outside the request pipeline. Pass-through instead.

Apply:

-      if (!pluginContext) {
-        throw new DurableIteratorError('Plugin context has been corrupted or modified by another plugin or interceptor')
-      }
-
-      const output = await options.next()
+      const output = await options.next()
+      if (!pluginContext) {
+        // Outside request context or plugin not active — pass through
+        return output
+      }

64-69: LGTM — token extraction and response shaping

Header key/value are defined in packages/durable-iterator/src/consts.ts (DURABLE_ITERATOR_PLUGIN_HEADER_KEY = 'x-orpc-dei', DURABLE_ITERATOR_PLUGIN_HEADER_VALUE = '1'); if retained for backward compatibility, add a short doc comment or changelog entry.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3f95342 and 8178e3a.

📒 Files selected for processing (14)
  • .github/workflows/ci.yaml (3 hunks)
  • .github/workflows/release-next.yaml (1 hunks)
  • .github/workflows/release.yaml (1 hunks)
  • packages/durable-iterator/README.md (2 hunks)
  • packages/durable-iterator/src/client/iterator.test.ts (1 hunks)
  • packages/durable-iterator/src/client/iterator.ts (1 hunks)
  • packages/durable-iterator/src/client/plugin.test.ts (1 hunks)
  • packages/durable-iterator/src/client/plugin.ts (1 hunks)
  • packages/durable-iterator/src/durable-object/upgrade.test.ts (1 hunks)
  • packages/durable-iterator/src/durable-object/websocket.ts (1 hunks)
  • packages/durable-iterator/src/iterator.test-d.ts (1 hunks)
  • packages/durable-iterator/src/plugin.test.ts (3 hunks)
  • packages/durable-iterator/src/plugin.ts (2 hunks)
  • tsconfig.json (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • tsconfig.json
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/durable-iterator/src/client/plugin.test.ts
  • packages/durable-iterator/src/client/iterator.ts
  • packages/durable-iterator/src/iterator.test-d.ts
  • packages/durable-iterator/src/client/iterator.test.ts
  • packages/durable-iterator/src/plugin.test.ts
  • packages/durable-iterator/src/durable-object/websocket.ts
  • packages/durable-iterator/src/durable-object/upgrade.test.ts
🧰 Additional context used
🧬 Code graph analysis (2)
packages/durable-iterator/src/client/plugin.ts (9)
packages/shared/src/value.ts (3)
  • Value (1-1)
  • fallback (17-19)
  • value (3-12)
packages/shared/src/index.ts (2)
  • Promisable (23-23)
  • retry (22-22)
packages/durable-iterator/src/schemas.ts (2)
  • DurableIteratorTokenPayload (6-6)
  • parseDurableIteratorToken (52-60)
packages/durable-iterator/src/consts.ts (4)
  • DURABLE_ITERATOR_ID_PARAM (2-2)
  • DURABLE_ITERATOR_TOKEN_PARAM (1-1)
  • DURABLE_ITERATOR_PLUGIN_HEADER_KEY (3-3)
  • DURABLE_ITERATOR_PLUGIN_HEADER_VALUE (4-4)
packages/client/src/client.ts (1)
  • createORPCClient (17-43)
packages/shared/src/array.ts (1)
  • toArray (1-3)
packages/shared/src/json.ts (1)
  • stringifyJSON (9-12)
packages/client/src/types.ts (1)
  • ClientLink (36-38)
packages/durable-iterator/src/client/iterator.ts (1)
  • createClientDurableIterator (40-69)
packages/durable-iterator/src/plugin.ts (6)
packages/server/src/context.ts (1)
  • Context (1-1)
packages/server/src/adapters/standard/plugin.ts (1)
  • StandardHandlerPlugin (5-8)
packages/server/src/adapters/standard/handler.ts (1)
  • StandardHandlerOptions (27-48)
packages/durable-iterator/src/consts.ts (2)
  • DURABLE_ITERATOR_PLUGIN_HEADER_KEY (3-3)
  • DURABLE_ITERATOR_PLUGIN_HEADER_VALUE (4-4)
packages/durable-iterator/src/error.ts (1)
  • DurableIteratorError (1-2)
packages/durable-iterator/src/client/iterator.ts (1)
  • getClientDurableIteratorToken (74-80)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: lint
  • GitHub Check: test
  • GitHub Check: publish-commit
🔇 Additional comments (7)
packages/durable-iterator/README.md (1)

11-12: NPM link/badge update looks correct; verify publish timing.

The URL encoding and package name match the new experimental package. Please ensure the package is (or will be) published under this name so the badge resolves correctly post-merge.

.github/workflows/release.yaml (1)

48-52: LGTM: Node 22 + pnpm cache step added; verify provenance on publish

package.json (root) contains:
pnpm run build:packages && pnpm --filter='./packages/*' publish -r --access=public
No explicit --provenance flag found; add --provenance to the publish command or confirm your pnpm/npm version/config will emit provenance during publish.

.github/workflows/release-next.yaml (1)

18-22: Verify Node 22 compatibility & pin pnpm in CI

Automated scan found no "engines" or "packageManager" fields in package.json files and no workflows referencing pnpm/action-setup — confirm all packages work on Node 22 and pin pnpm (via packageManager or workflow action) to avoid CI drift.

packages/durable-iterator/src/client/plugin.ts (1)

62-66: Plugin ordering looks correct.

High order to run before retry prevents iterator breakage. Good.

packages/durable-iterator/src/plugin.ts (3)

3-5: LGTM: imports updated to new API

New token getter, consts, and error import look correct.


7-9: LGTM: plugin context shape

Minimal context with a single boolean flag is appropriate.


14-16: LGTM: class rename and context symbol

Renamed plugin and per-instance symbol usage are consistent.

Comment thread packages/durable-iterator/src/plugin.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/durable-iterator/src/durable-object/upgrade.test.ts (2)

18-41: Align signing key in “missing client id” test to avoid order‑dependency

The test currently signs with 'signing-key' but passes options.signingKey = 'test-sign'. It still passes due to earlier ID check, but aligning keys removes reliance on validation order.

Minimal change:

-    const token = await signDurableIteratorToken('signing-key', {
+    const token = await signDurableIteratorToken('test-sign', {

105-111: Optional: reduce time‑based flakiness

Using Date.now() is fine here, but you can lock time with vi.setSystemTime for fully deterministic tokens.

Also applies to: 117-127

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8178e3a and cdf4913.

📒 Files selected for processing (1)
  • packages/durable-iterator/src/durable-object/upgrade.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/durable-iterator/src/durable-object/upgrade.test.ts (2)
packages/durable-iterator/src/schemas.ts (1)
  • signDurableIteratorToken (20-22)
packages/durable-iterator/src/consts.ts (2)
  • DURABLE_ITERATOR_TOKEN_PARAM (1-1)
  • DURABLE_ITERATOR_ID_PARAM (2-2)
🪛 GitHub Check: test
packages/durable-iterator/src/durable-object/upgrade.test.ts

[failure] 132-132: packages/durable-iterator/src/durable-object/upgrade.test.ts > upgradeDurableIteratorRequest > upgrades valid request
AssertionError: expected "spy" to be called with arguments: [ …(2) ]

Received:

1st spy call:

@@ -12,8 +12,8 @@
110,
101,
108,
],
{

  • "test": "test",
    
  • "locationHint": "something",
    
    },
    ]

Number of calls: 1

❯ packages/durable-iterator/src/durable-object/upgrade.test.ts:132:27

🪛 GitHub Actions: CI
packages/durable-iterator/src/durable-object/upgrade.test.ts

[error] 132-132: AssertionError: expected "spy" to be called with arguments: [ …(2) ]; Received: payload mismatch where expected { "test": "test" } but got { "locationHint": "something" } in the first call. This indicates the upgradeDurableIteratorRequest test case failed.

🔇 Additional comments (1)
packages/durable-iterator/src/durable-object/upgrade.test.ts (1)

5-16: Good coverage of rejection paths

Solid negative tests for non‑websocket, missing id, missing token, invalid token/payload. Assertions match the upgrade logic and status codes.

Also applies to: 43-58, 60-76, 78-95

Comment thread packages/durable-iterator/src/durable-object/upgrade.test.ts
@dinwwwh dinwwwh merged commit c5401b5 into main Sep 23, 2025
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Some thoughts on the durable event iterator package

1 participant