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
10 changes: 10 additions & 0 deletions apps/content/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ export default withMermaid(defineConfig({
{ text: 'Strict GET method', link: '/docs/plugins/strict-get-method' },
],
},
{
text: 'Helpers',
collapsed: true,
items: [
{ text: 'Base64Url', link: '/docs/helpers/base64url' },
{ text: 'Cookie', link: '/docs/helpers/cookie' },
{ text: 'Encryption', link: '/docs/helpers/encryption' },
{ text: 'Signing', link: '/docs/helpers/signing' },
],
},
{
text: 'Client',
collapsed: true,
Expand Down
22 changes: 22 additions & 0 deletions apps/content/docs/helpers/base64url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: Base64Url Helpers
description: Functions to encode and decode base64url strings, a URL-safe variant of base64 encoding.
---

# Base64Url Helpers

Base64Url helpers provide functions to encode and decode base64url strings, a URL-safe variant of base64 encoding used in web tokens, data serialization, and APIs.

```ts twoslash
import { decodeBase64url, encodeBase64url } from '@orpc/server/helpers'

const originalText = 'Hello World'
const textBytes = new TextEncoder().encode(originalText)
const encodedData = encodeBase64url(textBytes)
const decodedBytes = decodeBase64url(encodedData)
const decodedText = new TextDecoder().decode(decodedBytes) // 'Hello World'
```

::: info
The `decodeBase64url` accepts `undefined` or `null` as encoded value and returns `undefined` for invalid inputs, enabling seamless handling of optional data.
:::
46 changes: 46 additions & 0 deletions apps/content/docs/helpers/cookie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: Cookie Helpers
description: Functions for managing HTTP cookies in web applications.
---

# Cookie Helpers

The Cookie helpers provide functions to set and get HTTP cookies.

```ts twoslash
import { getCookie, setCookie } from '@orpc/server/helpers'

const headers = new Headers()

setCookie(headers, 'sessionId', 'abc123', {
secure: true,
maxAge: 3600
})

const sessionId = getCookie(headers, 'sessionId') // 'abc123'
```

::: info
Both helpers accept `undefined` as headers for seamless integration with plugins like [Request Headers](/docs/plugins/request-headers) or [Response Headers](/docs/plugins/response-headers).
:::

## Security with Signing and Encryption

Combine cookies with [signing](/docs/helpers/signing) or [encryption](/docs/helpers/encryption) for enhanced security:

```ts twoslash
import { getCookie, setCookie, sign, unsign } from '@orpc/server/helpers'

const secret = 'your-secret-key'

const headers = new Headers()

setCookie(headers, 'sessionId', await sign('abc123', secret), {
httpOnly: true,
secure: true,
maxAge: 3600
})

const signedSessionId = await unsign(getCookie(headers, 'sessionId'), secret)
// 'abc123'
```
29 changes: 29 additions & 0 deletions apps/content/docs/helpers/encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
title: Encryption Helpers
description: Functions to encrypt and decrypt sensitive data using AES-GCM.
---

# Encryption Helpers

Encryption helpers provide functions to encrypt and decrypt sensitive data using AES-GCM with PBKDF2 key derivation.

::: info
Encryption prevents users from reading data content but is slower than [signing](/docs/helpers/signing).
:::

```ts twoslash
import { decrypt, encrypt } from '@orpc/server/helpers'

const secret = 'your-encryption-key'
const sensitiveData = 'user-email@example.com'

const encryptedData = await encrypt(sensitiveData, secret)
// 'Rq7wF8...' (base64url encoded, unreadable)

const decryptedData = await decrypt(encryptedData, secret)
// 'user-email@example.com'
```

::: info
The `decrypt` helper accepts `undefined` or `null` as encrypted value and returns `undefined` for invalid inputs, enabling seamless handling of optional data.
:::
29 changes: 29 additions & 0 deletions apps/content/docs/helpers/signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
title: Signing Helpers
description: Functions to cryptographically sign and verify data using HMAC-SHA256.
---

# Signing Helpers

Signing helpers provide functions to cryptographically sign and verify data using HMAC-SHA256.

::: info
Signing is faster than [encryption](/docs/helpers/encryption) but users can view the original data.
:::

```ts twoslash
import { sign, unsign } from '@orpc/server/helpers'

const secret = 'your-secret-key'
const userData = 'user123'

const signedValue = await sign(userData, secret)
// 'user123.oneQsU0r5dvwQFHFEjjV1uOI_IR3gZfkYHij3TRauVA'
// ↑ Original data is visible to users

const verifiedValue = await unsign(signedValue, secret) // 'user123'
```

::: info
The `unsign` helper accepts `undefined` or `null` as signed value and returns `undefined` for invalid inputs, enabling seamless handling of optional data.
:::
7 changes: 6 additions & 1 deletion apps/content/docs/plugins/request-headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ There's no functional difference, but this plugin provides a consistent interfac
```ts twoslash
import { os } from '@orpc/server'
// ---cut---
import { getCookie } from '@orpc/server/helpers'
import { RequestHeadersPluginContext } from '@orpc/server/plugins'

interface ORPCContext extends RequestHeadersPluginContext {}
Expand All @@ -25,7 +26,7 @@ const base = os.$context<ORPCContext>()

const example = base
.use(({ context, next }) => {
const authHeader = context.reqHeaders?.get('authorization')
const sessionId = getCookie(context.reqHeaders, 'session_id')
return next()
})
.handler(({ context }) => {
Expand All @@ -39,6 +40,10 @@ const example = base
This allows procedures to run safely even when `RequestHeadersPlugin` is not used, such as in direct calls.
:::

::: tip
Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie management.
:::

## Handler Setup

```ts
Expand Down
10 changes: 9 additions & 1 deletion apps/content/docs/plugins/response-headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The Response Headers Plugin allows you to set response headers in oRPC. It injec
```ts twoslash
import { os } from '@orpc/server'
// ---cut---
import { setCookie } from '@orpc/server/helpers'
import { ResponseHeadersPluginContext } from '@orpc/server/plugins'

interface ORPCContext extends ResponseHeadersPluginContext {}
Expand All @@ -24,7 +25,10 @@ const example = base
return next()
})
.handler(({ context }) => {
context.resHeaders?.set('x-custom-header', 'value')
setCookie(context.resHeaders, 'session_id', 'abc123', {
secure: true,
maxAge: 3600
})
})
```

Expand All @@ -33,6 +37,10 @@ const example = base
This allows procedures to run safely even when `ResponseHeadersPlugin` is not used, such as in direct calls.
:::

::: tip
Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie management.
:::

## Handler Setup

```ts
Expand Down
9 changes: 8 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./helpers": {
"types": "./dist/helpers/index.d.mts",
"import": "./dist/helpers/index.mjs",
"default": "./dist/helpers/index.mjs"
},
"./plugins": {
"types": "./dist/plugins/index.d.mts",
"import": "./dist/plugins/index.mjs",
Expand Down Expand Up @@ -84,6 +89,7 @@
},
"exports": {
".": "./src/index.ts",
"./helpers": "./src/helpers/index.ts",
"./plugins": "./src/plugins/index.ts",
"./hibernation": "./src/hibernation/index.ts",
"./standard": "./src/adapters/standard/index.ts",
Expand Down Expand Up @@ -125,7 +131,8 @@
"@orpc/standard-server-aws-lambda": "workspace:*",
"@orpc/standard-server-fetch": "workspace:*",
"@orpc/standard-server-node": "workspace:*",
"@orpc/standard-server-peer": "workspace:*"
"@orpc/standard-server-peer": "workspace:*",
"cookie": "^1.0.2"
},
"devDependencies": {
"@types/ws": "^8.18.1",
Expand Down
56 changes: 56 additions & 0 deletions packages/server/src/helpers/base64url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { decodeBase64url, encodeBase64url } from './base64url'

describe('encodeBase64url / decodeBase64url', () => {
it('should encode and decode Uint8Array correctly', () => {
const original = new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100])
const encoded = encodeBase64url(original)
const decoded = decodeBase64url(encoded)

expect(decoded).toEqual(original)
})

it('should produce URL-safe output without padding', () => {
const data = new Uint8Array([255, 254, 253]) // Will produce +/= characters in regular base64
const encoded = encodeBase64url(data)

expect(encoded).not.toMatch(/[+/=]/)
expect(encoded).toMatch(/^[\w-]+$/)
})

it('should handle empty data', () => {
const empty = new Uint8Array([])
const encoded = encodeBase64url(empty)
const decoded = decodeBase64url(encoded)

expect(encoded).toBe('')
expect(decoded).toEqual(empty)
})

it('should work with TextEncoder/TextDecoder', () => {
const text = 'Hello World'
const encoded = encodeBase64url(new TextEncoder().encode(text))
const decoded = decodeBase64url(encoded)

expect(new TextDecoder().decode(decoded)).toEqual(text)
})

it('should handle large data without call stack overflow', () => {
// Create a large Uint8Array (100KB)
const largeData = new Uint8Array(100 * 1024)
for (let i = 0; i < largeData.length; i++) {
largeData[i] = i % 256
}

const encoded = encodeBase64url(largeData)
const decoded = decodeBase64url(encoded)

expect(decoded).toEqual(largeData)
expect(encoded).not.toMatch(/[+/=]/) // Should still be URL-safe
})

it('should handle invalid input gracefully', () => {
expect(decodeBase64url(null)).toBeUndefined()
expect(decodeBase64url(undefined)).toBeUndefined()
expect(decodeBase64url('invalid base64!')).toBeUndefined()
})
})
65 changes: 65 additions & 0 deletions packages/server/src/helpers/base64url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Encodes a Uint8Array to base64url format
* Base64url is URL-safe and doesn't use padding
*
* @example
* ```ts
* const text = "Hello World"
* const encoded = encodeBase64url(new TextEncoder().encode(text))
* const decoded = decodeBase64url(encoded)
* expect(new TextDecoder().decode(decoded)).toEqual(text)
* ```
*/
export function encodeBase64url(data: Uint8Array): string {
const chunkSize = 8192 // 8KB chunks to stay well below call stack limits
Comment thread
dinwwwh marked this conversation as resolved.
let binaryString = ''

for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.subarray(i, i + chunkSize)
binaryString += String.fromCharCode(...chunk)
}

const base64 = btoa(binaryString)
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}

/**
* Decodes a base64url string to Uint8Array
* Returns undefined if the input is invalid
*
* @example
* ```ts
* const text = "Hello World"
* const encoded = encodeBase64url(new TextEncoder().encode(text))
* const decoded = decodeBase64url(encoded)
* expect(new TextDecoder().decode(decoded)).toEqual(text)
* ```
*/
export function decodeBase64url(base64url: string | undefined | null): Uint8Array | undefined {
try {
if (typeof base64url !== 'string') {
return undefined
}

let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')

while (base64.length % 4) {
base64 += '='
}

const binaryString = atob(base64)

const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}

return bytes
}
catch {
return undefined
}
}
Loading