From 768c91d336a879c9f6e40382e63b96404c54ab48 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:55:00 +0000 Subject: [PATCH 1/3] fix(peer, shared): support React Native's AbortSignal polyfill React Native 0.86 and earlier install the abort-controller package as the global AbortSignal, which has no throwIfAborted. ClientPeer.request called signal.throwIfAborted(), so every peer request carrying a signal rejected with a TypeError on React Native. Add throwIfAborted(signal) to @standard-server/shared, which only relies on aborted and reason, and use it in the peer client, sleep, and the Node test harnesses. A ban/ban lint rule flags any new x.throwIfAborted() call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XyXPRKSY5pB8LgT5HirRZr --- eslint.config.js | 4 ++++ packages/peer/src/client.ts | 6 +++--- packages/shared/src/index.ts | 1 + packages/shared/src/signal.test.ts | 17 ++++++++++++++++- packages/shared/src/signal.ts | 10 ++++++++++ packages/shared/src/time.ts | 7 +++---- tests/client-server.node-http2.ts | 3 ++- tests/client-server.node-https.ts | 3 ++- 8 files changed, 41 insertions(+), 10 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 2e6d63d..ec00836 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,10 @@ export default antfu({ name: ['*', 'bytes'], message: 'Request/Blob/Response/... .bytes is not widely supported, use readAsBuffer instead', }, + { + name: ['*', 'throwIfAborted'], + message: 'React Native\'s AbortSignal polyfill has no throwIfAborted, use throwIfAborted from @standard-server/shared instead', + }, ], }, }, { diff --git a/packages/peer/src/client.ts b/packages/peer/src/client.ts index e6a4d12..b273bb1 100644 --- a/packages/peer/src/client.ts +++ b/packages/peer/src/client.ts @@ -2,7 +2,7 @@ import type { StandardBody, StandardLazyResponse, StandardRequest } from '@stand import type { Queue } from '@standard-server/shared' import type { ClientPeerSendMessage, PeerEventStreamMessage, PeerOctetStreamMessage, ServerPeerSendMessage } from './types' import { cancelStandardBody } from '@standard-server/core' -import { AbortError, hasAnyDefinedValue, isAsyncIteratorObject, SequentialIdGenerator } from '@standard-server/shared' +import { AbortError, hasAnyDefinedValue, isAsyncIteratorObject, SequentialIdGenerator, throwIfAborted } from '@standard-server/shared' import { encodeAtomicStandardBody, toStandardBody } from './body' import { EventStreamTransmitter } from './event-stream' import { OctetStreamTransmitter } from './octet-stream' @@ -32,7 +32,7 @@ export class ClientPeer { request(request: StandardRequest): Promise { return new Promise((resolve, reject) => { const signal = request.signal - signal?.throwIfAborted() + throwIfAborted(signal) const id = this.idGenerator.generate() const state: ClientPeerRequestStateInternal = { resolve, reject } @@ -69,7 +69,7 @@ export class ClientPeer { const encodedAtomicBody = await encodeAtomicStandardBody(request.body, request.headers) // signal can be aborted during encode - request.signal?.throwIfAborted() + throwIfAborted(request.signal) // the peer can be closed during encode if (this.requests.get(id) !== state) { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 268349c..e7d1f23 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export * from './object' export * from './promise' export * from './proxy' export * from './queue' +export { throwIfAborted } from './signal' export * from './time' export * from './types' export * from './uri' diff --git a/packages/shared/src/signal.test.ts b/packages/shared/src/signal.test.ts index 93feebc..07d17b4 100644 --- a/packages/shared/src/signal.test.ts +++ b/packages/shared/src/signal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { anyAbortSignal } from './signal' +import { anyAbortSignal, throwIfAborted } from './signal' /** * Helper to temporarily disable AbortSignal.any to force fallback implementation. @@ -105,3 +105,18 @@ describe('anyAbortSignal', () => { }) }) }) + +describe('throwIfAborted', () => { + it('works on a signal without throwIfAborted (React Native polyfill)', () => { + const controller = new AbortController() + Object.defineProperty(controller.signal, 'throwIfAborted', { value: undefined }) + + expect(() => throwIfAborted(undefined)).not.toThrow() + expect(() => throwIfAborted(controller.signal)).not.toThrow() + + const reason = new Error('aborted') + controller.abort(reason) + + expect(() => throwIfAborted(controller.signal)).toThrow(reason) + }) +}) diff --git a/packages/shared/src/signal.ts b/packages/shared/src/signal.ts index 5f41fc4..b9f59c3 100644 --- a/packages/shared/src/signal.ts +++ b/packages/shared/src/signal.ts @@ -43,3 +43,13 @@ export function anyAbortSignal(...signals: (AbortSignal | undefined)[]): AbortSi return controller.signal } + +/** + * Throws the signal's abort reason if it is aborted, like `signal.throwIfAborted()`. + * React Native's AbortSignal polyfill has no `throwIfAborted`, so use this instead. + */ +export function throwIfAborted(signal: AbortSignal | undefined | null): void { + if (signal?.aborted) { + throw signal.reason + } +} diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts index 7c4f197..fbfa313 100644 --- a/packages/shared/src/time.ts +++ b/packages/shared/src/time.ts @@ -1,3 +1,5 @@ +import { throwIfAborted } from './signal' + export interface SleepOptions { signal?: AbortSignal | undefined } @@ -7,10 +9,7 @@ export interface SleepOptions { */ export function sleep(ms: number, { signal }: SleepOptions = {}): Promise { return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason) - return - } + throwIfAborted(signal) let abortListener: (() => void) | null = null diff --git a/tests/client-server.node-http2.ts b/tests/client-server.node-http2.ts index de1117d..e5d00fb 100644 --- a/tests/client-server.node-http2.ts +++ b/tests/client-server.node-http2.ts @@ -5,6 +5,7 @@ import * as http2 from 'node:http2' import { Readable } from 'node:stream' import { toFetchBody, toFetchHeaders, toStandardLazyResponse } from '@standard-server/fetch' import { sendStandardResponse, toStandardLazyRequest } from '@standard-server/node' +import { throwIfAborted } from '@standard-server/shared' import { generateTlsCert } from './tls' export interface NodeHttp2ClientServerTestOptions { @@ -57,7 +58,7 @@ export function createNodeHttp2ClientServerTest(options: NodeHttp2ClientServerTe }) const request: ClientServerTest['request'] = vi.fn(async (standardRequest) => { - standardRequest.signal?.throwIfAborted() + throwIfAborted(standardRequest.signal) const { session, port } = await serverReady diff --git a/tests/client-server.node-https.ts b/tests/client-server.node-https.ts index 724662a..b57ad88 100644 --- a/tests/client-server.node-https.ts +++ b/tests/client-server.node-https.ts @@ -5,6 +5,7 @@ import * as https from 'node:https' import { Readable } from 'node:stream' import { toFetchBody, toFetchHeaders, toStandardLazyResponse } from '@standard-server/fetch' import { sendStandardResponse, toStandardLazyRequest } from '@standard-server/node' +import { throwIfAborted } from '@standard-server/shared' import { generateTlsCert } from './tls' export function createNodeHttpsClientServerTest(): ClientServerTest { @@ -39,7 +40,7 @@ export function createNodeHttpsClientServerTest(): ClientServerTest { }) const request: ClientServerTest['request'] = vi.fn(async (standardRequest) => { - standardRequest.signal?.throwIfAborted() + throwIfAborted(standardRequest.signal) const { agent, port } = await serverReady From 1bfd2fd9e3fead623d1b3ad0d9c0b06ad2e03909 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:02:07 +0000 Subject: [PATCH 2/3] fix(shared): fall back to AbortError when an aborted signal has no reason React Native's AbortSignal polyfill never sets a reason, so throwIfAborted threw undefined and sleep rejected with undefined. Both now fall back to an AbortError, matching the native default for abort() without a reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XyXPRKSY5pB8LgT5HirRZr --- packages/shared/src/signal.test.ts | 9 +++++++++ packages/shared/src/signal.ts | 7 +++++-- packages/shared/src/time.test.ts | 13 +++++++++++++ packages/shared/src/time.ts | 3 ++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/signal.test.ts b/packages/shared/src/signal.test.ts index 07d17b4..ca40c2f 100644 --- a/packages/shared/src/signal.test.ts +++ b/packages/shared/src/signal.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { AbortError } from './error' import { anyAbortSignal, throwIfAborted } from './signal' /** @@ -119,4 +120,12 @@ describe('throwIfAborted', () => { expect(() => throwIfAborted(controller.signal)).toThrow(reason) }) + + it('throws an AbortError when the signal has no reason (React Native polyfill)', () => { + const controller = new AbortController() + Object.defineProperty(controller.signal, 'reason', { value: undefined }) + controller.abort() + + expect(() => throwIfAborted(controller.signal)).toThrow(AbortError) + }) }) diff --git a/packages/shared/src/signal.ts b/packages/shared/src/signal.ts index b9f59c3..08e445d 100644 --- a/packages/shared/src/signal.ts +++ b/packages/shared/src/signal.ts @@ -1,3 +1,5 @@ +import { AbortError } from './error' + /** * Combines multiple AbortSignals using OR semantics. * Aborts when the first signal aborts and forwards its reason. @@ -46,10 +48,11 @@ export function anyAbortSignal(...signals: (AbortSignal | undefined)[]): AbortSi /** * Throws the signal's abort reason if it is aborted, like `signal.throwIfAborted()`. - * React Native's AbortSignal polyfill has no `throwIfAborted`, so use this instead. + * React Native's AbortSignal polyfill has no `throwIfAborted` and never sets a reason, + * so use this instead. A missing reason falls back to an `AbortError`. */ export function throwIfAborted(signal: AbortSignal | undefined | null): void { if (signal?.aborted) { - throw signal.reason + throw signal.reason ?? new AbortError('This operation was aborted') } } diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts index 07a6e57..5afbac3 100644 --- a/packages/shared/src/time.test.ts +++ b/packages/shared/src/time.test.ts @@ -1,3 +1,4 @@ +import { AbortError } from './error' import { sleep } from './time' describe('sleep', () => { @@ -28,4 +29,16 @@ describe('sleep', () => { await expect(sleep(100, { signal: controller.signal })).rejects.toThrow('cancelled') expect(Date.now() - start).toBeLessThan(80) }) + + it('sleep rejects with an AbortError when the signal has no reason (React Native polyfill)', async () => { + const aborted = new AbortController() + Object.defineProperty(aborted.signal, 'reason', { value: undefined }) + aborted.abort() + await expect(sleep(100, { signal: aborted.signal })).rejects.toThrow(AbortError) + + const controller = new AbortController() + Object.defineProperty(controller.signal, 'reason', { value: undefined }) + setTimeout(() => controller.abort(), 20) + await expect(sleep(100, { signal: controller.signal })).rejects.toThrow(AbortError) + }) }) diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts index fbfa313..ded925b 100644 --- a/packages/shared/src/time.ts +++ b/packages/shared/src/time.ts @@ -1,3 +1,4 @@ +import { AbortError } from './error' import { throwIfAborted } from './signal' export interface SleepOptions { @@ -24,7 +25,7 @@ export function sleep(ms: number, { signal }: SleepOptions = {}): Promise if (signal) { signal.addEventListener('abort', abortListener = () => { clearTimeout(timeout) - reject(signal?.reason) + reject(signal.reason ?? new AbortError('This operation was aborted')) }, { once: true }) } }) From c07a70148c7257dd1730b14c7abf37ad36e08832 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:03:40 +0000 Subject: [PATCH 3/3] Revert "fix(shared): fall back to AbortError when an aborted signal has no reason" This reverts commit 1bfd2fd. The fix only needs to avoid calling the missing throwIfAborted method; the abort reason stays as the signal gives it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XyXPRKSY5pB8LgT5HirRZr --- packages/shared/src/signal.test.ts | 9 --------- packages/shared/src/signal.ts | 7 ++----- packages/shared/src/time.test.ts | 13 ------------- packages/shared/src/time.ts | 3 +-- 4 files changed, 3 insertions(+), 29 deletions(-) diff --git a/packages/shared/src/signal.test.ts b/packages/shared/src/signal.test.ts index ca40c2f..07d17b4 100644 --- a/packages/shared/src/signal.test.ts +++ b/packages/shared/src/signal.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { AbortError } from './error' import { anyAbortSignal, throwIfAborted } from './signal' /** @@ -120,12 +119,4 @@ describe('throwIfAborted', () => { expect(() => throwIfAborted(controller.signal)).toThrow(reason) }) - - it('throws an AbortError when the signal has no reason (React Native polyfill)', () => { - const controller = new AbortController() - Object.defineProperty(controller.signal, 'reason', { value: undefined }) - controller.abort() - - expect(() => throwIfAborted(controller.signal)).toThrow(AbortError) - }) }) diff --git a/packages/shared/src/signal.ts b/packages/shared/src/signal.ts index 08e445d..b9f59c3 100644 --- a/packages/shared/src/signal.ts +++ b/packages/shared/src/signal.ts @@ -1,5 +1,3 @@ -import { AbortError } from './error' - /** * Combines multiple AbortSignals using OR semantics. * Aborts when the first signal aborts and forwards its reason. @@ -48,11 +46,10 @@ export function anyAbortSignal(...signals: (AbortSignal | undefined)[]): AbortSi /** * Throws the signal's abort reason if it is aborted, like `signal.throwIfAborted()`. - * React Native's AbortSignal polyfill has no `throwIfAborted` and never sets a reason, - * so use this instead. A missing reason falls back to an `AbortError`. + * React Native's AbortSignal polyfill has no `throwIfAborted`, so use this instead. */ export function throwIfAborted(signal: AbortSignal | undefined | null): void { if (signal?.aborted) { - throw signal.reason ?? new AbortError('This operation was aborted') + throw signal.reason } } diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts index 5afbac3..07a6e57 100644 --- a/packages/shared/src/time.test.ts +++ b/packages/shared/src/time.test.ts @@ -1,4 +1,3 @@ -import { AbortError } from './error' import { sleep } from './time' describe('sleep', () => { @@ -29,16 +28,4 @@ describe('sleep', () => { await expect(sleep(100, { signal: controller.signal })).rejects.toThrow('cancelled') expect(Date.now() - start).toBeLessThan(80) }) - - it('sleep rejects with an AbortError when the signal has no reason (React Native polyfill)', async () => { - const aborted = new AbortController() - Object.defineProperty(aborted.signal, 'reason', { value: undefined }) - aborted.abort() - await expect(sleep(100, { signal: aborted.signal })).rejects.toThrow(AbortError) - - const controller = new AbortController() - Object.defineProperty(controller.signal, 'reason', { value: undefined }) - setTimeout(() => controller.abort(), 20) - await expect(sleep(100, { signal: controller.signal })).rejects.toThrow(AbortError) - }) }) diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts index ded925b..fbfa313 100644 --- a/packages/shared/src/time.ts +++ b/packages/shared/src/time.ts @@ -1,4 +1,3 @@ -import { AbortError } from './error' import { throwIfAborted } from './signal' export interface SleepOptions { @@ -25,7 +24,7 @@ export function sleep(ms: number, { signal }: SleepOptions = {}): Promise if (signal) { signal.addEventListener('abort', abortListener = () => { clearTimeout(timeout) - reject(signal.reason ?? new AbortError('This operation was aborted')) + reject(signal?.reason) }, { once: true }) } })