Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 63 additions & 31 deletions packages/durable-event-iterator/src/client/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,48 +32,94 @@ export interface DurableEventIteratorLinkPluginOptions extends Omit<RPCLinkOptio
/**
* @see {@link https://orpc.unnoq.com/docs/integrations/durable-event-iterator Durable Event Iterator Integration}
*/
export class DurableEventIteratorLinkPlugin<T extends ClientContext> implements StandardLinkPlugin<T> {
export class DurableEventIteratorLinkPlugin<T extends ClientContext>
implements StandardLinkPlugin<T> {
readonly CONTEXT_SYMBOL = Symbol('ORPC_DURABLE_EVENT_ITERATOR_LINK_PLUGIN_CONTEXT')

order = 2_100_000 // make sure execute before the batch plugin
order = 2_100_000

private readonly url: DurableEventIteratorLinkPluginOptions['url']
private readonly WebSocket: DurableEventIteratorLinkPluginOptions['WebSocket']
private readonly linkOptions: Omit<RPCLinkOptions<object>, 'websocket'>

constructor({ url, WebSocket, ...options }: DurableEventIteratorLinkPluginOptions) {
constructor(opts: DurableEventIteratorLinkPluginOptions) {
const { url, WebSocket, ...rest } = opts
this.url = url
this.WebSocket = WebSocket
this.linkOptions = options
this.linkOptions = rest
}

init(options: StandardLinkOptions<T>): void {
options.interceptors ??= []
options.clientInterceptors ??= []

options.interceptors.push(async (options) => {
const pluginContext: DurableEventIteratorLinkPluginContext = {}
// Mark responses that carry a DEI token
options.clientInterceptors.push(async (clientOptions) => {
const ctx = clientOptions.context[this.CONTEXT_SYMBOL] as DurableEventIteratorLinkPluginContext | undefined
if (!ctx)
throw new TypeError('[DurableEventIteratorLinkPlugin] Plugin context has been corrupted or modified by another plugin or interceptor')
Comment on lines +58 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

For improved code clarity and to prevent potential bugs, it's a good practice to always use curly braces for if statements, even for single-line blocks. This ensures that the code's intent is clear and reduces the risk of errors if the block is expanded in the future.

Suggested change
if (!ctx)
throw new TypeError('[DurableEventIteratorLinkPlugin] Plugin context has been corrupted or modified by another plugin or interceptor')
if (!ctx) {
throw new TypeError('[DurableEventIteratorLinkPlugin] Plugin context has been corrupted or modified by another plugin or interceptor')
}


const output = await options.next({
...options,
const res = await clientOptions.next()
ctx.isDurableEventIteratorResponse = res.headers[DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_KEY] === DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_VALUE
return res
})
Comment on lines +55 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Do not throw when plugin context is absent; pass-through instead (and normalize header lookup).

This client interceptor runs for all requests. Throwing when ctx is missing will break non-DEI calls. Make it a no-op when context isn’t present and perform a case-insensitive header read.

-    options.clientInterceptors.push(async (clientOptions) => {
-      const ctx = clientOptions.context[this.CONTEXT_SYMBOL] as DurableEventIteratorLinkPluginContext | undefined
-      if (!ctx)
-        throw new TypeError('[DurableEventIteratorLinkPlugin] Plugin context has been corrupted or modified by another plugin or interceptor')
-
-      const res = await clientOptions.next()
-      ctx.isDurableEventIteratorResponse = res.headers[DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_KEY] === DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_VALUE
-      return res
-    })
+    options.clientInterceptors.push(async (clientOptions) => {
+      const ctx = clientOptions.context[this.CONTEXT_SYMBOL] as DurableEventIteratorLinkPluginContext | undefined
+      const res = await clientOptions.next()
+      if (ctx) {
+        const headers = res.headers as Record<string, string> | undefined
+        const headerValue =
+          headers?.[DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_KEY] ??
+          headers?.[String(DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_KEY).toLowerCase()]
+        ctx.isDurableEventIteratorResponse = headerValue === DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_VALUE
+      }
+      return res
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Mark responses that carry a DEI token
options.clientInterceptors.push(async (clientOptions) => {
const ctx = clientOptions.context[this.CONTEXT_SYMBOL] as DurableEventIteratorLinkPluginContext | undefined
if (!ctx)
throw new TypeError('[DurableEventIteratorLinkPlugin] Plugin context has been corrupted or modified by another plugin or interceptor')
const output = await options.next({
...options,
const res = await clientOptions.next()
ctx.isDurableEventIteratorResponse = res.headers[DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_KEY] === DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_VALUE
return res
})
// Mark responses that carry a DEI token
options.clientInterceptors.push(async (clientOptions) => {
const ctx = clientOptions.context[this.CONTEXT_SYMBOL] as DurableEventIteratorLinkPluginContext | undefined
const res = await clientOptions.next()
if (ctx) {
const headers = res.headers as Record<string, string> | undefined
const headerValue =
headers?.[DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_KEY] ??
headers?.[String(DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_KEY).toLowerCase()]
ctx.isDurableEventIteratorResponse = headerValue === DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_VALUE
}
return res
})
🤖 Prompt for AI Agents
In packages/durable-event-iterator/src/client/plugin.ts around lines 55 to 64,
the client interceptor currently throws when the plugin context is missing and
checks headers case-sensitively; change it to be a no-op when ctx is undefined
(simply await and return clientOptions.next() without throwing or modifying ctx)
so it doesn’t break non-DEI requests, and when reading the response header
perform a case-insensitive lookup (e.g., normalize header keys or compare
lowercased header names/values) before setting
ctx.isDurableEventIteratorResponse.


// Turn the token into a resilient iterator (PartySocket-powered)
options.interceptors.push(async (interceptorOptions) => {
const pluginContext: DurableEventIteratorLinkPluginContext = {}
const output = await interceptorOptions.next({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can group first call .next inside refetchToken (maybe rename refetchToken too)

...interceptorOptions,
context: {
[this.CONTEXT_SYMBOL]: pluginContext,
...options.context,
...interceptorOptions.context,
},
})

if (!pluginContext.isDurableEventIteratorResponse) {
return output
}

const token = output as string
const url = new URL(await value(this.url))
url.searchParams.append(DURABLE_EVENT_ITERATOR_TOKEN_PARAM, token)
// Token returned from this call (use once for the first connect)
let initialToken = output as string

// Save a snapshot of this exact call so we can re-fetch fresh tokens later
const upstreamNext = interceptorOptions.next
const snapshot = {
path: interceptorOptions.path,
input: interceptorOptions.input,
context: { [this.CONTEXT_SYMBOL]: pluginContext, ...interceptorOptions.context },
signal: interceptorOptions.signal,
lastEventId: interceptorOptions.lastEventId,
}

const durableWs = new ReconnectableWebSocket(url.toString(), undefined, {
WebSocket: this.WebSocket,
})
const refetchToken = async (): Promise<string> => {
const fresh = await upstreamNext(snapshot)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just call interceptorOptions.next() here no need snapshot I believe

// Server sets the header + returns the token string again.
return fresh as string
}

Comment on lines +84 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid reusing the original AbortSignal; also dedupe concurrent refreshes.

Reusing interceptorOptions.signal can cause immediate aborts on refresh. Build fresh options per call. Also, coalesce parallel refresh attempts.

-      const upstreamNext = interceptorOptions.next
-      const snapshot = {
-        path: interceptorOptions.path,
-        input: interceptorOptions.input,
-        context: { [this.CONTEXT_SYMBOL]: pluginContext, ...interceptorOptions.context },
-        signal: interceptorOptions.signal,
-        lastEventId: interceptorOptions.lastEventId,
-      }
-
-      const refetchToken = async (): Promise<string> => {
-        const fresh = await upstreamNext(snapshot)
-        // Server sets the header + returns the token string again.
-        return fresh as string
-      }
+      const upstreamNext = interceptorOptions.next
+      const makeSnapshot = () => ({
+        path: interceptorOptions.path,
+        input: interceptorOptions.input,
+        // Fresh context on each refresh; do not reuse potentially-aborted signals.
+        context: { [this.CONTEXT_SYMBOL]: pluginContext, ...interceptorOptions.context },
+      })
+
+      let inflightToken: Promise<string> | null = null
+      const refetchToken = async (): Promise<string> => {
+        if (!inflightToken) {
+          inflightToken = upstreamNext(makeSnapshot()) as Promise<string>
+          try {
+            const fresh = await inflightToken
+            return fresh
+          } finally {
+            inflightToken = null
+          }
+        }
+        return inflightToken
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Save a snapshot of this exact call so we can re-fetch fresh tokens later
const upstreamNext = interceptorOptions.next
const snapshot = {
path: interceptorOptions.path,
input: interceptorOptions.input,
context: { [this.CONTEXT_SYMBOL]: pluginContext, ...interceptorOptions.context },
signal: interceptorOptions.signal,
lastEventId: interceptorOptions.lastEventId,
}
const durableWs = new ReconnectableWebSocket(url.toString(), undefined, {
WebSocket: this.WebSocket,
})
const refetchToken = async (): Promise<string> => {
const fresh = await upstreamNext(snapshot)
// Server sets the header + returns the token string again.
return fresh as string
}
// Save a snapshot of this exact call so we can re-fetch fresh tokens later
const upstreamNext = interceptorOptions.next
const makeSnapshot = () => ({
path: interceptorOptions.path,
input: interceptorOptions.input,
// Fresh context on each refresh; do not reuse potentially-aborted signals.
context: { [this.CONTEXT_SYMBOL]: pluginContext, ...interceptorOptions.context },
})
let inflightToken: Promise<string> | null = null
const refetchToken = async (): Promise<string> => {
if (!inflightToken) {
inflightToken = upstreamNext(makeSnapshot()) as Promise<string>
try {
const fresh = await inflightToken
return fresh
} finally {
inflightToken = null
}
}
return inflightToken
}
🤖 Prompt for AI Agents
In packages/durable-event-iterator/src/client/plugin.ts around lines 84 to 99,
the refetchToken function reuses interceptorOptions.signal and replays the
original options object, which can cause immediate aborts and duplicate
concurrent refreshes; fix by creating fresh options for each refetch call (clone
path, input, context, lastEventId but set signal to a new
AbortController().signal or undefined) and call upstreamNext with that fresh
options object, and implement a simple dedupe/coalesce so parallel refetchToken
invocations share a single in-flight Promise (store the Promise on the plugin
instance or closure, return it if present, and clear it once resolved or
rejected).

const buildUrl = async (token: string): Promise<string> => {
const u = new URL(await value(this.url))
u.searchParams.set(DURABLE_EVENT_ITERATOR_TOKEN_PARAM, token)
return u.toString()
}

// One PartySocket drives everything; its URL provider pulls tokens.
let first = true
const durableWs = new ReconnectableWebSocket(
async () => {
if (first) {
first = false
return buildUrl(initialToken)
}
const nextToken = await refetchToken()
initialToken = nextToken // keep latest for visibility
return buildUrl(nextToken)
},
undefined,
{
WebSocket: this.WebSocket,
},
)
const durableLink = new RPCLink<ClientRetryPluginContext>({
...this.linkOptions,
websocket: durableWs,
Expand Down Expand Up @@ -101,24 +147,10 @@ export class DurableEventIteratorLinkPlugin<T extends ClientContext> implements
}

const durableIterator = createClientDurableEventIterator(iterator, link, {
token,
token: initialToken,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We require change createClientDurableEventIterator to reflect exactly what is current token.

})
Comment on lines 149 to 151

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The durableIterator is created here with the initialToken string. This token value is passed by value and will become stale after the first token refresh on WebSocket reconnect. Consequently, any call to getClientDurableEventIteratorToken(durableIterator) will return the original, stale token.

While the core RPC functionality remains correct because ReconnectableWebSocket handles token refreshes independently, this behavior could be misleading for consumers of getClientDurableEventIteratorToken.

The comment on line 115 (// keep latest for visibility) suggests an intent to keep the token updated. If this is the case, createClientDurableEventIterator would need to be adjusted to accept a token provider function (e.g., () => initialToken) instead of a static string. This would allow it to always access the latest token but would require changes in packages/durable-event-iterator/src/client/event-iterator.ts.

If returning the initial token is the intended and acceptable behavior, consider clarifying the comment on line 115 to something like // keep latest for subsequent reconnects to better reflect its purpose and avoid confusion.


return durableIterator
})

options.clientInterceptors.push(async (options) => {
const pluginContext = options.context[this.CONTEXT_SYMBOL] as DurableEventIteratorLinkPluginContext | undefined

if (!pluginContext) {
throw new TypeError('[DurableEventIteratorLinkPlugin] Plugin context has been corrupted or modified by another plugin or interceptor')
}

const response = await options.next()

pluginContext.isDurableEventIteratorResponse = response.headers[DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_KEY] === DURABLE_EVENT_ITERATOR_PLUGIN_HEADER_VALUE

return response
})
}
}
Loading