Skip to content
Merged
Show file tree
Hide file tree
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
13 changes: 8 additions & 5 deletions packages/client/src/adapters/standard/rpc-link-codec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { Promisable, Public, Value } from '@orpc/shared'
import { isAsyncIteratorObject, pathToHttpPath, stringifyJSON, value } from '@orpc/shared'
import {
isAsyncIteratorObject,
mergeHttpPath,
pathToHttpPath,
stringifyJSON,
value,
} from '@orpc/shared'
import type {
StandardHeaders,
StandardLazyResponse,
Expand Down Expand Up @@ -74,8 +80,6 @@ export interface RPCLinkCodecOptions<T extends ClientContext> {
serializer?: Public<RPCSerializer> | undefined
}

const END_SLASH_REGEX = /\/$/

export class RPCLinkCodec<T extends ClientContext> implements StandardLinkCodec<T> {
private readonly baseUrl: Exclude<RPCLinkCodecOptions<T>['url'], undefined>
private readonly maxUrlLength: Exclude<RPCLinkCodecOptions<T>['maxUrlLength'], undefined>
Expand Down Expand Up @@ -107,8 +111,7 @@ export class RPCLinkCodec<T extends ClientContext> implements StandardLinkCodec<
const baseUrl = await value(this.baseUrl, options, path, input)

const [pathname, search, hash] = parseStandardUrl(baseUrl)
const newPathname =
`${pathname.replace(END_SLASH_REGEX, '')}${pathToHttpPath(path)}` as StandardUrl
const newPathname: StandardUrl = mergeHttpPath(pathname, pathToHttpPath(path))
const serialized = this.serializer.serialize(input)

if (
Expand Down
27 changes: 9 additions & 18 deletions packages/client/src/plugins/dedupe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
defer,
disposableAllAbortSignal,
isAsyncIteratorObject,
listenAbort,
replicateAsyncIterator,
replicateReadableStream,
runWithSignal,
Expand Down Expand Up @@ -318,21 +319,15 @@ function closeAsyncIteratorOnAbort<T, TReturn, TNext>(
return replica
}

const abort = () => replica.return().catch(() => {})

if (signal.aborted) {
void abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
const removeAbortListener = listenAbort(signal, () => void replica.return().catch(() => {}))

return wrapAsyncIterator(replica, {
mapResult(result) {
// Ending the replica reports its reads as done rather than failed.
throwIfAborted(signal)
return result
},
onFinish: () => signal.removeEventListener('abort', abort),
onFinish: removeAbortListener,
})
}

Expand All @@ -349,13 +344,9 @@ function closeReadableStreamOnAbort<T>(
}

const reader = replica.getReader()
const abort = () => reader.cancel(signal.reason).catch(() => {})

if (signal.aborted) {
void abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
const removeAbortListener = listenAbort(signal, (reason) => {
reader.cancel(reason).catch(() => {})
})

return new ReadableStream<T>(
{
Expand All @@ -366,18 +357,18 @@ function closeReadableStreamOnAbort<T>(
throwIfAborted(signal)

if (result.done) {
signal.removeEventListener('abort', abort)
removeAbortListener()
controller.close()
} else {
controller.enqueue(result.value)
}
} catch (error) {
signal.removeEventListener('abort', abort)
removeAbortListener()
throw error
}
},
async cancel(reason) {
signal.removeEventListener('abort', abort)
removeAbortListener()
await reader.cancel(reason)
},
},
Expand Down
48 changes: 14 additions & 34 deletions packages/client/src/plugins/request-compression.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { isAsyncIteratorObject, isCompressibleContentType, toArray } from '@orpc/shared'
import type { CompressionEncoding } from '@orpc/shared'
import {
estimateUtf8ByteLength,
estimateCompressibleFormDataSize,
isAsyncIteratorObject,
isCompressibleContentType,
toArray,
} from '@orpc/shared'
import type { StandardBodyHint, StandardHeaders } from '@standard-server/core'
import { flattenStandardHeader, generateContentDisposition } from '@standard-server/core'
import { toFetchBody } from '@standard-server/fetch'
Expand All @@ -11,17 +18,13 @@ import type {
} from '../adapters/standard/index.ts'
import type { ClientContext } from '../types.ts'

// Rough UTF-8 estimate. Mostly ASCII text stays close to 1 byte/char;
// occasional multi-byte characters increase the average.
const AVG_BYTES_PER_CHAR = 1.2

export interface RequestCompressionLinkPluginOptions<T extends ClientContext> {
/**
* The compression scheme to use for request compression.
*
* @default 'gzip'
*/
encoding?: 'gzip' | 'deflate' | 'deflate-raw' | undefined
encoding?: CompressionEncoding | undefined

/**
* The minimum request size in bytes required to trigger compression.
Expand Down Expand Up @@ -151,32 +154,9 @@ export class RequestCompressionLinkPlugin<
})
}
} else if (request.body instanceof FormData) {
const PART_OVERHEAD = 64 // approx bytes for boundary + Content-Disposition/Content-Type headers per part

let contentLength = 0
for (const [key, value] of request.body) {
contentLength += PART_OVERHEAD + key.length

if (value instanceof Blob) {
if (!Number.isFinite(value.size)) {
// Bun-s3 can use NaN for size
if (!this.isCompressibleContentType(value.type, interceptorOptions)) {
// Unknown non-compressible part size makes the estimate unreliable
contentLength = -Infinity
break
}

// Unknown size for compressible content - still apply compression
contentLength = Infinity
} else {
contentLength += this.isCompressibleContentType(value.type, interceptorOptions)
? value.size
: -value.size
}
} else {
contentLength += value.length * AVG_BYTES_PER_CHAR
}
}
const contentLength = estimateCompressibleFormDataSize(request.body, (contentType) =>
this.isCompressibleContentType(contentType, interceptorOptions),
)

if (contentLength >= this.threshold) {
const res = new Response(request.body)
Expand All @@ -199,7 +179,7 @@ export class RequestCompressionLinkPlugin<
}
} else if (request.body instanceof URLSearchParams) {
const string = request.body.toString()
if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) {
if (estimateUtf8ByteLength(string) >= this.threshold) {
const compressedStream = new Blob([string])
.stream()
.pipeThrough(new CompressionStream(this.encoding))
Expand All @@ -225,7 +205,7 @@ export class RequestCompressionLinkPlugin<
string,
StandardHeaders,
]
if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) {
if (estimateUtf8ByteLength(string) >= this.threshold) {
const compressedStream = new Blob([string])
.stream()
.pipeThrough(new CompressionStream(this.encoding))
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/plugins/response-compression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,22 @@ describe('responseCompressionLinkPlugin', () => {
await expect(link.call(['test'], undefined, { context: {} })).resolves.toEqual('OK')
})

it('ignores empty content-encoding list elements', async () => {
const compressed = await compressAsync(JSON.stringify({ json: 'OK' }), 'gzip')

const { link } = createLink({
fetchImpl: async () =>
new Response(compressed, {
headers: {
'content-type': 'application/json',
'content-encoding': 'gzip, ',
},
}),
})

await expect(link.call(['test'], undefined, { context: {} })).resolves.toEqual('OK')
})

it('decompresses response body when 5 content-encodings are applied', async () => {
let body: Uint8Array<ArrayBuffer> = await compressAsync(JSON.stringify({ json: 'OK' }), 'gzip')
for (let i = 1; i < 5; i++) {
Expand Down
27 changes: 4 additions & 23 deletions packages/client/src/plugins/response-compression.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { toArray } from '@orpc/shared'
import type { CompressionEncoding } from '@orpc/shared'
import { isCompressionEncoding, parseContentEncodings, toArray } from '@orpc/shared'
Comment thread
dinwwwh marked this conversation as resolved.
Resolved
import { flattenStandardHeader } from '@standard-server/core'
import { toFetchHeaders, toStandardBody } from '@standard-server/fetch'

Expand All @@ -22,7 +23,7 @@ export interface ResponseCompressionLinkPluginOptions<_T extends ClientContext>
*
* @default ['gzip', 'deflate']
*/
encodings?: readonly ('gzip' | 'deflate' | 'deflate-raw')[] | undefined
encodings?: readonly CompressionEncoding[] | undefined
}

/**
Expand Down Expand Up @@ -73,7 +74,7 @@ export class ResponseCompressionLinkPlugin<
flattenStandardHeader(response.headers['content-encoding']),
)

if (encodings.length === 0 || !encodings.every(isSupportedEncoding)) {
if (encodings.length === 0 || !encodings.every(isCompressionEncoding)) {
return response
}

Expand Down Expand Up @@ -120,23 +121,3 @@ export class ResponseCompressionLinkPlugin<
}
}
}

const SUPPORTED_ENCODINGS = ['gzip', 'deflate', 'deflate-raw'] as const
type SupportedEncoding = (typeof SUPPORTED_ENCODINGS)[number]

function isSupportedEncoding(encoding: string): encoding is SupportedEncoding {
return (SUPPORTED_ENCODINGS as readonly string[]).includes(encoding)
}

/**
* Parse Content-Encoding into ordered codings (order applied).
*
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-content-encoding
*/
function parseContentEncodings(header: string | undefined): string[] {
if (header === undefined) {
return []
}

return header.split(',').map((part) => part.trim().toLowerCase())
}
13 changes: 13 additions & 0 deletions packages/client/src/rpc-json-serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,19 @@ describe('rpcJsonSerializer: custom handlers', () => {
expect(roundTripThroughWire(serializer, { value: date })).toEqual({ value: date })
})

it('serializes like the defaults when no custom handler is set', () => {
const serializer = new RPCJsonSerializer({
handlers: {
unused: undefined,
},
})

const date = new Date('2023-01-01')
const data = { value: date, list: [undefined], text: 'a' }
expect(serializer.serializeJson(data)).toEqual(new RPCJsonSerializer().serializeJson(data))
expect(roundTripThroughWire(serializer, data)).toEqual(data)
})

it('supports disabling default handlers', () => {
const serializer = new RPCJsonSerializer({
handlers: {
Expand Down
9 changes: 3 additions & 6 deletions packages/client/src/rpc-json-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
copyOnWrite,
DEFAULT_MAX_BIGINT_DIGITS,
isPlainObject,
isPlainObjectOrArray,
NullProtoObj,
parseBigIntString,
} from '@orpc/shared'
Expand Down Expand Up @@ -50,14 +51,10 @@ function assertSerializedType(ok: boolean, type: string, expected: string): void
}
}

function isWalkable(value: unknown): boolean {
return Array.isArray(value) || isPlainObject(value)
}

function walkInto(parent: any, key: Segment, original: unknown, restored?: Set<unknown>): any {
const value = parent[key]

if (!isWalkable(value) || restored?.has(value)) {
if (!isPlainObjectOrArray(value) || restored?.has(value)) {
throw invalidSerializedData(`segment "${key}" cannot be walked into.`)
}

Expand Down Expand Up @@ -562,7 +559,7 @@ export class RPCJsonSerializer {
const value = handler.deserialize(currentRef[preSegment])
currentRef[preSegment] = value

if (isWalkable(value)) {
if (isPlainObjectOrArray(value)) {
;(restored ??= new Set()).add(value)
}
}
Expand Down
11 changes: 7 additions & 4 deletions packages/client/src/rpc-serializer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { isAsyncIteratorObject, isPlainObject, stringifyJSON } from '@orpc/shared'
import {
isAsyncIteratorObject,
isPlainObject,
NON_NEGATIVE_INTEGER_REGEX,
stringifyJSON,
} from '@orpc/shared'
import type { StandardBody } from '@standard-server/core'
import { EventStreamErrorEvent } from '@standard-server/core'

Expand Down Expand Up @@ -137,7 +142,7 @@ export class RPCSerializer extends RPCJsonSerializer {
for (const [key, value] of data) {
if (key === 'data' && raw === undefined && typeof value === 'string') {
raw = value
} else if (value instanceof Blob && BLOB_KEY_REGEX.test(key)) {
} else if (value instanceof Blob && NON_NEGATIVE_INTEGER_REGEX.test(key)) {
blobs[Number(key)] = value
hasBlobs = true
} else {
Expand All @@ -156,8 +161,6 @@ export class RPCSerializer extends RPCJsonSerializer {
}
}

const BLOB_KEY_REGEX = /^(?:0|[1-9]\d*)$/

function invalidFormat(): TypeError {
return new TypeError('Invalid RPC serialized data format.')
}
Expand Down
3 changes: 3 additions & 0 deletions packages/json-schema/src/coercer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ describe('jsonSchemaCoercer', () => {
expect(coerce(DATE_SCHEMA, '2020-02-30T06:15:00Z')).toBe('2020-02-30T06:15:00Z')
expect(coerce(DATE_SCHEMA, '2020-02-30 06:15')).toBe('2020-02-30 06:15')
expect(coerce(DATE_SCHEMA, '0050-02-29')).toBe('0050-02-29')
// century years are leap years only when divisible by 400
expect(coerce(DATE_SCHEMA, '1900-02-29')).toBe('1900-02-29')
expect(coerce(DATE_SCHEMA, '2000-02-29')).toEqual(new Date('2000-02-29'))

// real days stay coerced, including a leap day and years below 100
expect(coerce(DATE_SCHEMA, '2020-02-29')).toEqual(new Date('2020-02-29'))
Expand Down
9 changes: 5 additions & 4 deletions packages/json-schema/src/coercer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
DEFAULT_MAX_BIGINT_DIGITS,
getOwn,
isPlainObject,
INTEGER_REGEX,
isNonPrimitive,
isPlainObject,
isPlainObjectOrArray,
parseBigIntString,
toArray,
tryOrUndefined,
Expand Down Expand Up @@ -553,7 +555,7 @@ function countValues(input: unknown, limit: number): number {
while (stack.length) {
const value = stack.pop()

if ((Array.isArray(value) || isPlainObject(value)) && !walked.has(value)) {
if (isPlainObjectOrArray(value) && !walked.has(value)) {
walked.add(value)

const items = Array.isArray(value) ? value : Object.values(value)
Expand Down Expand Up @@ -591,9 +593,8 @@ function stringToNumber(value: string): number | string {
return num
}

const INTEGER_PATTERN = /^-?(?:0|[1-9]\d*)$/
function stringToInteger(value: string): number | string {
if (!INTEGER_PATTERN.test(value)) {
if (!INTEGER_REGEX.test(value)) {
return value
}

Expand Down
Loading
Loading