Skip to content

Commit 01ede75

Browse files
authored
feat(server): helpers (#805)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a suite of helper utilities for base64url encoding/decoding, cookie management, encryption, and data signing/verification. * Added new documentation pages for each helper, accessible via a new "Helpers" section in the sidebar. * Enabled import of all helpers from a single entry point. * **Documentation** * Added comprehensive guides and examples for using the new helper utilities. * Updated plugin documentation to recommend and demonstrate usage of the new cookie helpers. * **Tests** * Added thorough test coverage for base64url, cookie, encryption, and signing helpers to ensure reliability and correct behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 2746745 commit 01ede75

18 files changed

Lines changed: 997 additions & 3 deletions

apps/content/.vitepress/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,16 @@ export default withMermaid(defineConfig({
142142
{ text: 'Strict GET method', link: '/docs/plugins/strict-get-method' },
143143
],
144144
},
145+
{
146+
text: 'Helpers',
147+
collapsed: true,
148+
items: [
149+
{ text: 'Base64Url', link: '/docs/helpers/base64url' },
150+
{ text: 'Cookie', link: '/docs/helpers/cookie' },
151+
{ text: 'Encryption', link: '/docs/helpers/encryption' },
152+
{ text: 'Signing', link: '/docs/helpers/signing' },
153+
],
154+
},
145155
{
146156
text: 'Client',
147157
collapsed: true,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
title: Base64Url Helpers
3+
description: Functions to encode and decode base64url strings, a URL-safe variant of base64 encoding.
4+
---
5+
6+
# Base64Url Helpers
7+
8+
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.
9+
10+
```ts twoslash
11+
import { decodeBase64url, encodeBase64url } from '@orpc/server/helpers'
12+
13+
const originalText = 'Hello World'
14+
const textBytes = new TextEncoder().encode(originalText)
15+
const encodedData = encodeBase64url(textBytes)
16+
const decodedBytes = decodeBase64url(encodedData)
17+
const decodedText = new TextDecoder().decode(decodedBytes) // 'Hello World'
18+
```
19+
20+
::: info
21+
The `decodeBase64url` accepts `undefined` or `null` as encoded value and returns `undefined` for invalid inputs, enabling seamless handling of optional data.
22+
:::
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
title: Cookie Helpers
3+
description: Functions for managing HTTP cookies in web applications.
4+
---
5+
6+
# Cookie Helpers
7+
8+
The Cookie helpers provide functions to set and get HTTP cookies.
9+
10+
```ts twoslash
11+
import { getCookie, setCookie } from '@orpc/server/helpers'
12+
13+
const headers = new Headers()
14+
15+
setCookie(headers, 'sessionId', 'abc123', {
16+
secure: true,
17+
maxAge: 3600
18+
})
19+
20+
const sessionId = getCookie(headers, 'sessionId') // 'abc123'
21+
```
22+
23+
::: info
24+
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).
25+
:::
26+
27+
## Security with Signing and Encryption
28+
29+
Combine cookies with [signing](/docs/helpers/signing) or [encryption](/docs/helpers/encryption) for enhanced security:
30+
31+
```ts twoslash
32+
import { getCookie, setCookie, sign, unsign } from '@orpc/server/helpers'
33+
34+
const secret = 'your-secret-key'
35+
36+
const headers = new Headers()
37+
38+
setCookie(headers, 'sessionId', await sign('abc123', secret), {
39+
httpOnly: true,
40+
secure: true,
41+
maxAge: 3600
42+
})
43+
44+
const signedSessionId = await unsign(getCookie(headers, 'sessionId'), secret)
45+
// 'abc123'
46+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
title: Encryption Helpers
3+
description: Functions to encrypt and decrypt sensitive data using AES-GCM.
4+
---
5+
6+
# Encryption Helpers
7+
8+
Encryption helpers provide functions to encrypt and decrypt sensitive data using AES-GCM with PBKDF2 key derivation.
9+
10+
::: info
11+
Encryption prevents users from reading data content but is slower than [signing](/docs/helpers/signing).
12+
:::
13+
14+
```ts twoslash
15+
import { decrypt, encrypt } from '@orpc/server/helpers'
16+
17+
const secret = 'your-encryption-key'
18+
const sensitiveData = 'user-email@example.com'
19+
20+
const encryptedData = await encrypt(sensitiveData, secret)
21+
// 'Rq7wF8...' (base64url encoded, unreadable)
22+
23+
const decryptedData = await decrypt(encryptedData, secret)
24+
// 'user-email@example.com'
25+
```
26+
27+
::: info
28+
The `decrypt` helper accepts `undefined` or `null` as encrypted value and returns `undefined` for invalid inputs, enabling seamless handling of optional data.
29+
:::
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
title: Signing Helpers
3+
description: Functions to cryptographically sign and verify data using HMAC-SHA256.
4+
---
5+
6+
# Signing Helpers
7+
8+
Signing helpers provide functions to cryptographically sign and verify data using HMAC-SHA256.
9+
10+
::: info
11+
Signing is faster than [encryption](/docs/helpers/encryption) but users can view the original data.
12+
:::
13+
14+
```ts twoslash
15+
import { sign, unsign } from '@orpc/server/helpers'
16+
17+
const secret = 'your-secret-key'
18+
const userData = 'user123'
19+
20+
const signedValue = await sign(userData, secret)
21+
// 'user123.oneQsU0r5dvwQFHFEjjV1uOI_IR3gZfkYHij3TRauVA'
22+
// ↑ Original data is visible to users
23+
24+
const verifiedValue = await unsign(signedValue, secret) // 'user123'
25+
```
26+
27+
::: info
28+
The `unsign` helper accepts `undefined` or `null` as signed value and returns `undefined` for invalid inputs, enabling seamless handling of optional data.
29+
:::

apps/content/docs/plugins/request-headers.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ There's no functional difference, but this plugin provides a consistent interfac
1717
```ts twoslash
1818
import { os } from '@orpc/server'
1919
// ---cut---
20+
import { getCookie } from '@orpc/server/helpers'
2021
import { RequestHeadersPluginContext } from '@orpc/server/plugins'
2122

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

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

43+
::: tip
44+
Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie management.
45+
:::
46+
4247
## Handler Setup
4348

4449
```ts

apps/content/docs/plugins/response-headers.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The Response Headers Plugin allows you to set response headers in oRPC. It injec
1212
```ts twoslash
1313
import { os } from '@orpc/server'
1414
// ---cut---
15+
import { setCookie } from '@orpc/server/helpers'
1516
import { ResponseHeadersPluginContext } from '@orpc/server/plugins'
1617

1718
interface ORPCContext extends ResponseHeadersPluginContext {}
@@ -24,7 +25,10 @@ const example = base
2425
return next()
2526
})
2627
.handler(({ context }) => {
27-
context.resHeaders?.set('x-custom-header', 'value')
28+
setCookie(context.resHeaders, 'session_id', 'abc123', {
29+
secure: true,
30+
maxAge: 3600
31+
})
2832
})
2933
```
3034

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

40+
::: tip
41+
Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie management.
42+
:::
43+
3644
## Handler Setup
3745

3846
```ts

packages/server/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"import": "./dist/index.mjs",
2121
"default": "./dist/index.mjs"
2222
},
23+
"./helpers": {
24+
"types": "./dist/helpers/index.d.mts",
25+
"import": "./dist/helpers/index.mjs",
26+
"default": "./dist/helpers/index.mjs"
27+
},
2328
"./plugins": {
2429
"types": "./dist/plugins/index.d.mts",
2530
"import": "./dist/plugins/index.mjs",
@@ -84,6 +89,7 @@
8489
},
8590
"exports": {
8691
".": "./src/index.ts",
92+
"./helpers": "./src/helpers/index.ts",
8793
"./plugins": "./src/plugins/index.ts",
8894
"./hibernation": "./src/hibernation/index.ts",
8995
"./standard": "./src/adapters/standard/index.ts",
@@ -125,7 +131,8 @@
125131
"@orpc/standard-server-aws-lambda": "workspace:*",
126132
"@orpc/standard-server-fetch": "workspace:*",
127133
"@orpc/standard-server-node": "workspace:*",
128-
"@orpc/standard-server-peer": "workspace:*"
134+
"@orpc/standard-server-peer": "workspace:*",
135+
"cookie": "^1.0.2"
129136
},
130137
"devDependencies": {
131138
"@types/ws": "^8.18.1",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { decodeBase64url, encodeBase64url } from './base64url'
2+
3+
describe('encodeBase64url / decodeBase64url', () => {
4+
it('should encode and decode Uint8Array correctly', () => {
5+
const original = new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100])
6+
const encoded = encodeBase64url(original)
7+
const decoded = decodeBase64url(encoded)
8+
9+
expect(decoded).toEqual(original)
10+
})
11+
12+
it('should produce URL-safe output without padding', () => {
13+
const data = new Uint8Array([255, 254, 253]) // Will produce +/= characters in regular base64
14+
const encoded = encodeBase64url(data)
15+
16+
expect(encoded).not.toMatch(/[+/=]/)
17+
expect(encoded).toMatch(/^[\w-]+$/)
18+
})
19+
20+
it('should handle empty data', () => {
21+
const empty = new Uint8Array([])
22+
const encoded = encodeBase64url(empty)
23+
const decoded = decodeBase64url(encoded)
24+
25+
expect(encoded).toBe('')
26+
expect(decoded).toEqual(empty)
27+
})
28+
29+
it('should work with TextEncoder/TextDecoder', () => {
30+
const text = 'Hello World'
31+
const encoded = encodeBase64url(new TextEncoder().encode(text))
32+
const decoded = decodeBase64url(encoded)
33+
34+
expect(new TextDecoder().decode(decoded)).toEqual(text)
35+
})
36+
37+
it('should handle large data without call stack overflow', () => {
38+
// Create a large Uint8Array (100KB)
39+
const largeData = new Uint8Array(100 * 1024)
40+
for (let i = 0; i < largeData.length; i++) {
41+
largeData[i] = i % 256
42+
}
43+
44+
const encoded = encodeBase64url(largeData)
45+
const decoded = decodeBase64url(encoded)
46+
47+
expect(decoded).toEqual(largeData)
48+
expect(encoded).not.toMatch(/[+/=]/) // Should still be URL-safe
49+
})
50+
51+
it('should handle invalid input gracefully', () => {
52+
expect(decodeBase64url(null)).toBeUndefined()
53+
expect(decodeBase64url(undefined)).toBeUndefined()
54+
expect(decodeBase64url('invalid base64!')).toBeUndefined()
55+
})
56+
})
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Encodes a Uint8Array to base64url format
3+
* Base64url is URL-safe and doesn't use padding
4+
*
5+
* @example
6+
* ```ts
7+
* const text = "Hello World"
8+
* const encoded = encodeBase64url(new TextEncoder().encode(text))
9+
* const decoded = decodeBase64url(encoded)
10+
* expect(new TextDecoder().decode(decoded)).toEqual(text)
11+
* ```
12+
*/
13+
export function encodeBase64url(data: Uint8Array): string {
14+
const chunkSize = 8192 // 8KB chunks to stay well below call stack limits
15+
let binaryString = ''
16+
17+
for (let i = 0; i < data.length; i += chunkSize) {
18+
const chunk = data.subarray(i, i + chunkSize)
19+
binaryString += String.fromCharCode(...chunk)
20+
}
21+
22+
const base64 = btoa(binaryString)
23+
return base64
24+
.replace(/\+/g, '-')
25+
.replace(/\//g, '_')
26+
.replace(/=/g, '')
27+
}
28+
29+
/**
30+
* Decodes a base64url string to Uint8Array
31+
* Returns undefined if the input is invalid
32+
*
33+
* @example
34+
* ```ts
35+
* const text = "Hello World"
36+
* const encoded = encodeBase64url(new TextEncoder().encode(text))
37+
* const decoded = decodeBase64url(encoded)
38+
* expect(new TextDecoder().decode(decoded)).toEqual(text)
39+
* ```
40+
*/
41+
export function decodeBase64url(base64url: string | undefined | null): Uint8Array | undefined {
42+
try {
43+
if (typeof base64url !== 'string') {
44+
return undefined
45+
}
46+
47+
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
48+
49+
while (base64.length % 4) {
50+
base64 += '='
51+
}
52+
53+
const binaryString = atob(base64)
54+
55+
const bytes = new Uint8Array(binaryString.length)
56+
for (let i = 0; i < binaryString.length; i++) {
57+
bytes[i] = binaryString.charCodeAt(i)
58+
}
59+
60+
return bytes
61+
}
62+
catch {
63+
return undefined
64+
}
65+
}

0 commit comments

Comments
 (0)