@furystack/rest-service 14.1.2 → 14.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +26 -6
- package/esm/add-cors-header.d.ts.map +1 -1
- package/esm/add-cors-header.js +11 -0
- package/esm/add-cors-header.js.map +1 -1
- package/esm/add-cors-header.spec.js +25 -4
- package/esm/add-cors-header.spec.js.map +1 -1
- package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-patch-endpoint.js +2 -3
- package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-post-endpoint.js +2 -3
- package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
- package/esm/helpers.d.ts +18 -4
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +12 -6
- package/esm/helpers.js.map +1 -1
- package/esm/http-authentication-settings.d.ts +14 -0
- package/esm/http-authentication-settings.d.ts.map +1 -1
- package/esm/http-authentication-settings.js +2 -0
- package/esm/http-authentication-settings.js.map +1 -1
- package/esm/http-server-pool.d.ts +6 -0
- package/esm/http-server-pool.d.ts.map +1 -1
- package/esm/http-server-pool.js +19 -1
- package/esm/http-server-pool.js.map +1 -1
- package/esm/http-server-pool.spec.js +25 -0
- package/esm/http-server-pool.spec.js.map +1 -1
- package/esm/http-user-context.d.ts.map +1 -1
- package/esm/http-user-context.js +14 -2
- package/esm/http-user-context.js.map +1 -1
- package/esm/http-user-context.spec.js +40 -1
- package/esm/http-user-context.spec.js.map +1 -1
- package/esm/read-post-body.d.ts +14 -3
- package/esm/read-post-body.d.ts.map +1 -1
- package/esm/read-post-body.js +40 -15
- package/esm/read-post-body.js.map +1 -1
- package/esm/read-post-body.spec.d.ts +2 -0
- package/esm/read-post-body.spec.d.ts.map +1 -0
- package/esm/read-post-body.spec.js +80 -0
- package/esm/read-post-body.spec.js.map +1 -0
- package/esm/rest-api-runtime.d.ts +4 -0
- package/esm/rest-api-runtime.d.ts.map +1 -1
- package/esm/rest-api-runtime.js +55 -15
- package/esm/rest-api-runtime.js.map +1 -1
- package/esm/rest-service.integration.spec.js +74 -0
- package/esm/rest-service.integration.spec.js.map +1 -1
- package/esm/static-server-manager.spec.js +33 -0
- package/esm/static-server-manager.spec.js.map +1 -1
- package/esm/static-site-runtime.d.ts.map +1 -1
- package/esm/static-site-runtime.js +39 -6
- package/esm/static-site-runtime.js.map +1 -1
- package/esm/static-site-runtime.spec.js +36 -0
- package/esm/static-site-runtime.spec.js.map +1 -1
- package/package.json +15 -15
- package/src/add-cors-header.spec.ts +31 -4
- package/src/add-cors-header.ts +11 -0
- package/src/endpoint-generators/create-patch-endpoint.ts +3 -4
- package/src/endpoint-generators/create-post-endpoint.ts +2 -3
- package/src/helpers.ts +26 -5
- package/src/http-authentication-settings.ts +16 -0
- package/src/http-server-pool.spec.ts +27 -0
- package/src/http-server-pool.ts +20 -1
- package/src/http-user-context.spec.ts +55 -1
- package/src/http-user-context.ts +15 -2
- package/src/read-post-body.spec.ts +94 -0
- package/src/read-post-body.ts +52 -15
- package/src/rest-api-runtime.ts +60 -13
- package/src/rest-service.integration.spec.ts +76 -0
- package/src/static-server-manager.spec.ts +39 -0
- package/src/static-site-runtime.spec.ts +37 -0
- package/src/static-site-runtime.ts +41 -8
package/src/helpers.ts
CHANGED
|
@@ -47,13 +47,23 @@ export interface ImplementApiOptions<T extends RestApi> {
|
|
|
47
47
|
description?: string
|
|
48
48
|
/** OpenAPI version. */
|
|
49
49
|
version?: string
|
|
50
|
+
/** Maximum request body size in bytes. Defaults to 1 MiB. */
|
|
51
|
+
maxBodyBytes?: number
|
|
52
|
+
/** Maximum time in ms for handling a request action. When exceeded, responds with 504. Does not abort in-progress body reads. */
|
|
53
|
+
actionTimeoutMs?: number
|
|
54
|
+
/** Socket inactivity timeout in ms for the underlying HTTP server (shared per listening port). */
|
|
55
|
+
requestTimeout?: number
|
|
56
|
+
/** Time allowed for receiving request headers in ms (shared per listening port). */
|
|
57
|
+
headersTimeout?: number
|
|
58
|
+
/** Keep-alive idle timeout in ms (shared per listening port). */
|
|
59
|
+
keepAliveTimeout?: number
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
/**
|
|
53
63
|
* Acquires a pooled HTTP server (creating one if needed), compiles `api`
|
|
54
64
|
* and attaches a {@link ServerApi} that dispatches matching requests.
|
|
55
65
|
* Multiple `useRestService` calls on the same `port` + `hostName` share a
|
|
56
|
-
* single underlying HTTP server.
|
|
66
|
+
* single underlying HTTP server. Server timeout options apply to that shared server.
|
|
57
67
|
*/
|
|
58
68
|
export const useRestService = async <T extends RestApi>(options: ImplementApiOptions<T>): Promise<ServerApi> => {
|
|
59
69
|
const {
|
|
@@ -68,6 +78,11 @@ export const useRestService = async <T extends RestApi>(options: ImplementApiOpt
|
|
|
68
78
|
name,
|
|
69
79
|
description,
|
|
70
80
|
version,
|
|
81
|
+
maxBodyBytes,
|
|
82
|
+
actionTimeoutMs,
|
|
83
|
+
requestTimeout,
|
|
84
|
+
headersTimeout,
|
|
85
|
+
keepAliveTimeout,
|
|
71
86
|
} = options
|
|
72
87
|
|
|
73
88
|
const extendedApi: typeof api = enableGetSchema
|
|
@@ -104,11 +119,13 @@ export const useRestService = async <T extends RestApi>(options: ImplementApiOpt
|
|
|
104
119
|
injector,
|
|
105
120
|
hostName,
|
|
106
121
|
deserializeQueryParams,
|
|
122
|
+
maxBodyBytes,
|
|
123
|
+
actionTimeoutMs,
|
|
107
124
|
}),
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
const pool = injector.get(HttpServerPoolToken)
|
|
111
|
-
const record = await pool.acquire({ port, hostName })
|
|
128
|
+
const record = await pool.acquire({ port, hostName, requestTimeout, headersTimeout, keepAliveTimeout })
|
|
112
129
|
record.apis.push(serverApi)
|
|
113
130
|
return serverApi
|
|
114
131
|
}
|
|
@@ -118,9 +135,13 @@ export const useRestService = async <T extends RestApi>(options: ImplementApiOpt
|
|
|
118
135
|
* chain (Basic + Cookie by default), appends any caller-provided providers
|
|
119
136
|
* and rebinds {@link HttpAuthenticationSettings}.
|
|
120
137
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
138
|
+
* Prerequisites:
|
|
139
|
+
* - Bind {@link UserStore} and {@link SessionStore} to persistent store implementations first.
|
|
140
|
+
* - Ensure the backing user/session stores and datasets are registered on the injector.
|
|
141
|
+
* - Provide `overrides.userDataSet` (or configure a valid `HttpAuthenticationSettings.userDataSet`)
|
|
142
|
+
* so user lookups can be resolved.
|
|
143
|
+
*
|
|
144
|
+
* The Basic and Cookie providers read users/sessions during setup, so missing bindings will fail early.
|
|
124
145
|
*/
|
|
125
146
|
export const useHttpAuthentication = (injector: Injector, overrides?: Partial<HttpAuthenticationSettings>): void => {
|
|
126
147
|
const mergedSettings: HttpAuthenticationSettings = {
|
|
@@ -52,6 +52,20 @@ export interface HttpAuthenticationSettings {
|
|
|
52
52
|
* {@link DEFAULT_USER_CACHE_CAPACITY} is used by `UserResolutionCache`.
|
|
53
53
|
*/
|
|
54
54
|
userCacheCapacity?: number
|
|
55
|
+
/**
|
|
56
|
+
* SameSite attribute for the session cookie. Defaults to `'Lax'`.
|
|
57
|
+
*/
|
|
58
|
+
cookieSameSite?: 'Lax' | 'Strict' | 'None'
|
|
59
|
+
/**
|
|
60
|
+
* When `true`, adds the `Secure` attribute so browsers only send the cookie
|
|
61
|
+
* over HTTPS. Defaults to `false` to allow plain HTTP development setups.
|
|
62
|
+
*/
|
|
63
|
+
cookieSecure?: boolean
|
|
64
|
+
/**
|
|
65
|
+
* Max-Age in seconds for the session cookie. Omit to create a session cookie
|
|
66
|
+
* without an explicit lifetime attribute.
|
|
67
|
+
*/
|
|
68
|
+
cookieMaxAgeSeconds?: number
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
/** Default TTL applied to the user-resolution cache (30 seconds). */
|
|
@@ -72,6 +86,8 @@ export const defaultHttpAuthenticationSettings = (): HttpAuthenticationSettings
|
|
|
72
86
|
authenticationProviders: [],
|
|
73
87
|
userCacheTtlMs: DEFAULT_USER_CACHE_TTL_MS,
|
|
74
88
|
userCacheCapacity: DEFAULT_USER_CACHE_CAPACITY,
|
|
89
|
+
cookieSameSite: 'Lax',
|
|
90
|
+
cookieSecure: false,
|
|
75
91
|
})
|
|
76
92
|
|
|
77
93
|
/**
|
|
@@ -217,4 +217,31 @@ describe('HttpServerPool', () => {
|
|
|
217
217
|
await injector[Symbol.asyncDispose]()
|
|
218
218
|
}
|
|
219
219
|
})
|
|
220
|
+
|
|
221
|
+
it('Should apply explicit server timeout options', async () => {
|
|
222
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
223
|
+
const pool = injector.get(HttpServerPoolToken)
|
|
224
|
+
const port = getPort()
|
|
225
|
+
const record = await pool.acquire({
|
|
226
|
+
port,
|
|
227
|
+
requestTimeout: 12_345,
|
|
228
|
+
headersTimeout: 23_456,
|
|
229
|
+
keepAliveTimeout: 3_456,
|
|
230
|
+
})
|
|
231
|
+
expect(record.server.requestTimeout).toBe(12_345)
|
|
232
|
+
expect(record.server.headersTimeout).toBe(23_456)
|
|
233
|
+
expect(record.server.keepAliveTimeout).toBe(3_456)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('Should reapply timeout options when acquiring an existing server', async () => {
|
|
238
|
+
await usingAsync(createInjector(), async (injector) => {
|
|
239
|
+
const pool = injector.get(HttpServerPoolToken)
|
|
240
|
+
const port = getPort()
|
|
241
|
+
await pool.acquire({ port, requestTimeout: 10_000 })
|
|
242
|
+
const record = await pool.acquire({ port, requestTimeout: 20_000, headersTimeout: 30_000 })
|
|
243
|
+
expect(record.server.requestTimeout).toBe(20_000)
|
|
244
|
+
expect(record.server.headersTimeout).toBe(30_000)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
220
247
|
})
|
package/src/http-server-pool.ts
CHANGED
|
@@ -11,6 +11,12 @@ import { ServerTelemetryToken } from './server-telemetry.js'
|
|
|
11
11
|
export interface ServerOptions {
|
|
12
12
|
hostName?: string
|
|
13
13
|
port: number
|
|
14
|
+
/** Socket inactivity timeout in ms. Defaults to Node's `server.requestTimeout`. */
|
|
15
|
+
requestTimeout?: number
|
|
16
|
+
/** Time allowed for receiving request headers in ms. Defaults to Node's `server.headersTimeout`. */
|
|
17
|
+
headersTimeout?: number
|
|
18
|
+
/** Keep-alive idle timeout in ms. Defaults to Node's `server.keepAliveTimeout`. */
|
|
19
|
+
keepAliveTimeout?: number
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
/** Payload passed to every {@link ServerApi.onRequest} invocation. */
|
|
@@ -99,6 +105,9 @@ export const HttpServerPoolToken: Token<HttpServerPool, 'scoped'> = defineServic
|
|
|
99
105
|
}
|
|
100
106
|
res.destroy()
|
|
101
107
|
})
|
|
108
|
+
if (options.requestTimeout !== undefined) server.requestTimeout = options.requestTimeout
|
|
109
|
+
if (options.headersTimeout !== undefined) server.headersTimeout = options.headersTimeout
|
|
110
|
+
if (options.keepAliveTimeout !== undefined) server.keepAliveTimeout = options.keepAliveTimeout
|
|
102
111
|
server.on('upgrade', (req, socket, head) => {
|
|
103
112
|
const apiMatch = apis.find((api) => api.shouldExec({ req, res: {} as ServerResponse }))
|
|
104
113
|
if (apiMatch?.onUpgrade) {
|
|
@@ -149,16 +158,26 @@ export const HttpServerPoolToken: Token<HttpServerPool, 'scoped'> = defineServic
|
|
|
149
158
|
servers.clear()
|
|
150
159
|
})
|
|
151
160
|
|
|
161
|
+
const applyServerTimeouts = (server: Server, options: ServerOptions): void => {
|
|
162
|
+
// shortcut: timeout values are per listening server (shared by port); later explicit values override
|
|
163
|
+
if (options.requestTimeout !== undefined) server.requestTimeout = options.requestTimeout
|
|
164
|
+
if (options.headersTimeout !== undefined) server.headersTimeout = options.headersTimeout
|
|
165
|
+
if (options.keepAliveTimeout !== undefined) server.keepAliveTimeout = options.keepAliveTimeout
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
return {
|
|
153
169
|
acquire: async (options) => {
|
|
154
170
|
const url = buildHostUrl(options)
|
|
155
171
|
const existing = servers.get(url)
|
|
156
172
|
if (existing) {
|
|
173
|
+
applyServerTimeouts(existing.server, options)
|
|
157
174
|
return existing
|
|
158
175
|
}
|
|
159
176
|
const pending = pendingCreates.get(url)
|
|
160
177
|
if (pending) {
|
|
161
|
-
|
|
178
|
+
const record = await pending
|
|
179
|
+
applyServerTimeouts(record.server, options)
|
|
180
|
+
return record
|
|
162
181
|
}
|
|
163
182
|
const createPromise = createRecord(url, options)
|
|
164
183
|
pendingCreates.set(url, createPromise)
|
|
@@ -370,6 +370,56 @@ describe('HttpUserContext', () => {
|
|
|
370
370
|
})
|
|
371
371
|
|
|
372
372
|
describe('cookieLogin / cookieLogout', () => {
|
|
373
|
+
it('sets SameSite=Lax and HttpOnly on login and can add Secure and Max-Age', async () => {
|
|
374
|
+
await usingAsync(createInjector(), async (i) => {
|
|
375
|
+
prepareInjector(i)
|
|
376
|
+
const ctx = i.get(HttpUserContext)
|
|
377
|
+
ctx.authentication.cookieSecure = true
|
|
378
|
+
ctx.authentication.cookieMaxAgeSeconds = 3600
|
|
379
|
+
const setHeader = vi.fn()
|
|
380
|
+
|
|
381
|
+
await ctx.cookieLogin(testUser, { setHeader })
|
|
382
|
+
expect(setHeader).toHaveBeenCalledWith(
|
|
383
|
+
'Set-Cookie',
|
|
384
|
+
expect.stringContaining('Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=3600'),
|
|
385
|
+
)
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('uses settings-driven Max-Age and a custom cookie name on login', async () => {
|
|
390
|
+
await usingAsync(createInjector(), async (i) => {
|
|
391
|
+
prepareInjector(i)
|
|
392
|
+
const ctx = i.get(HttpUserContext)
|
|
393
|
+
ctx.authentication.cookieName = 'sid'
|
|
394
|
+
ctx.authentication.cookieMaxAgeSeconds = 1800
|
|
395
|
+
const setHeader = vi.fn()
|
|
396
|
+
|
|
397
|
+
await ctx.cookieLogin(testUser, { setHeader })
|
|
398
|
+
expect(setHeader).toHaveBeenCalledWith(
|
|
399
|
+
'Set-Cookie',
|
|
400
|
+
expect.stringMatching(/^sid=[^;]+; Path=\/; HttpOnly; SameSite=Lax; Max-Age=1800$/),
|
|
401
|
+
)
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('supports SameSite=Strict and forces Secure for SameSite=None', async () => {
|
|
406
|
+
await usingAsync(createInjector(), async (i) => {
|
|
407
|
+
prepareInjector(i)
|
|
408
|
+
const ctx = i.get(HttpUserContext)
|
|
409
|
+
const strictHeader = vi.fn()
|
|
410
|
+
ctx.authentication.cookieSameSite = 'Strict'
|
|
411
|
+
await ctx.cookieLogin(testUser, { setHeader: strictHeader })
|
|
412
|
+
expect(strictHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('; SameSite=Strict'))
|
|
413
|
+
expect(strictHeader).toHaveBeenCalledWith('Set-Cookie', expect.not.stringContaining('; Secure'))
|
|
414
|
+
|
|
415
|
+
const noneHeader = vi.fn()
|
|
416
|
+
ctx.authentication.cookieSameSite = 'None'
|
|
417
|
+
ctx.authentication.cookieSecure = false
|
|
418
|
+
await ctx.cookieLogin(testUser, { setHeader: noneHeader })
|
|
419
|
+
expect(noneHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringContaining('; SameSite=None; Secure'))
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
373
423
|
it('persists the session, sets the cookie and emits onLogin', async () => {
|
|
374
424
|
await usingAsync(createInjector(), async (i) => {
|
|
375
425
|
prepareInjector(i)
|
|
@@ -381,6 +431,10 @@ describe('HttpUserContext', () => {
|
|
|
381
431
|
const result = await ctx.cookieLogin(testUser, { setHeader })
|
|
382
432
|
expect(result).toBe(testUser)
|
|
383
433
|
expect(setHeader).toHaveBeenCalledTimes(1)
|
|
434
|
+
expect(setHeader).toHaveBeenCalledWith(
|
|
435
|
+
'Set-Cookie',
|
|
436
|
+
expect.stringMatching(/^fss=[^;]+; Path=\/; HttpOnly; SameSite=Lax$/),
|
|
437
|
+
)
|
|
384
438
|
expect(onLogin).toHaveBeenCalledWith({ user: testUser })
|
|
385
439
|
|
|
386
440
|
await usingAsync(useSystemIdentityContext({ injector: i, username: 'spec' }), async (systemScope) => {
|
|
@@ -411,7 +465,7 @@ describe('HttpUserContext', () => {
|
|
|
411
465
|
)
|
|
412
466
|
expect(logoutSetHeader).toHaveBeenCalledWith(
|
|
413
467
|
'Set-Cookie',
|
|
414
|
-
`${ctx.authentication.cookieName}=; Path=/; HttpOnly`,
|
|
468
|
+
`${ctx.authentication.cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
|
415
469
|
)
|
|
416
470
|
expect(onLogout).toHaveBeenCalledWith(undefined)
|
|
417
471
|
|
package/src/http-user-context.ts
CHANGED
|
@@ -137,6 +137,19 @@ class HttpUserContextImpl extends EventHub<HttpUserContextEvents> implements Htt
|
|
|
137
137
|
throw new UnauthenticatedError()
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
private buildCookie(value: string, maxAgeSeconds?: number): string {
|
|
141
|
+
const { cookieName, cookieSameSite = 'Lax', cookieSecure, cookieMaxAgeSeconds } = this.authentication
|
|
142
|
+
const parts = [`${cookieName}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${cookieSameSite}`]
|
|
143
|
+
if (cookieSecure || cookieSameSite === 'None') {
|
|
144
|
+
parts.push('Secure')
|
|
145
|
+
}
|
|
146
|
+
const age = maxAgeSeconds ?? cookieMaxAgeSeconds
|
|
147
|
+
if (age !== undefined) {
|
|
148
|
+
parts.push(`Max-Age=${age}`)
|
|
149
|
+
}
|
|
150
|
+
return parts.join('; ')
|
|
151
|
+
}
|
|
152
|
+
|
|
140
153
|
public async cookieLogin(
|
|
141
154
|
user: User,
|
|
142
155
|
response: { setHeader: (header: string, value: string) => void },
|
|
@@ -144,7 +157,7 @@ class HttpUserContextImpl extends EventHub<HttpUserContextEvents> implements Htt
|
|
|
144
157
|
const sessionId = randomBytes(32).toString('hex')
|
|
145
158
|
const sessionDataSet = this.systemInjector.get(this.authentication.sessionDataSet)
|
|
146
159
|
await sessionDataSet.add(this.systemInjector, { sessionId, username: user.username })
|
|
147
|
-
response.setHeader('Set-Cookie',
|
|
160
|
+
response.setHeader('Set-Cookie', this.buildCookie(sessionId))
|
|
148
161
|
this.emit('onLogin', { user })
|
|
149
162
|
return user
|
|
150
163
|
}
|
|
@@ -154,7 +167,7 @@ class HttpUserContextImpl extends EventHub<HttpUserContextEvents> implements Htt
|
|
|
154
167
|
response: { setHeader: (header: string, value: string) => void },
|
|
155
168
|
): Promise<void> {
|
|
156
169
|
const sessionId = this.getSessionIdFromRequest(request)
|
|
157
|
-
response.setHeader('Set-Cookie',
|
|
170
|
+
response.setHeader('Set-Cookie', this.buildCookie('', 0))
|
|
158
171
|
|
|
159
172
|
if (sessionId) {
|
|
160
173
|
const sessionDataSet = this.systemInjector.get(this.authentication.sessionDataSet)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getPort } from '@furystack/core/port-generator'
|
|
2
|
+
import { createInjector } from '@furystack/inject'
|
|
3
|
+
import type { IncomingMessage } from 'http'
|
|
4
|
+
import { RequestError } from '@furystack/rest'
|
|
5
|
+
import { usingAsync } from '@furystack/utils'
|
|
6
|
+
import { Readable } from 'stream'
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { JsonResult } from './request-action-implementation.js'
|
|
9
|
+
import { useRestService } from './helpers.js'
|
|
10
|
+
import { readPostBody } from './read-post-body.js'
|
|
11
|
+
|
|
12
|
+
const toRequest = (payload: string | Buffer, maxBytes?: number) => {
|
|
13
|
+
const body = typeof payload === 'string' ? Buffer.from(payload) : payload
|
|
14
|
+
return {
|
|
15
|
+
req: Object.assign(Readable.from([body]), {
|
|
16
|
+
headers: {},
|
|
17
|
+
readable: true,
|
|
18
|
+
}) as unknown as IncomingMessage,
|
|
19
|
+
options: maxBytes === undefined ? undefined : { maxBytes },
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('readPostBody', () => {
|
|
24
|
+
it('should parse a normal small JSON body', async () => {
|
|
25
|
+
const { req } = toRequest(JSON.stringify({ ok: true }))
|
|
26
|
+
await expect(readPostBody<{ ok: boolean }>(req)).resolves.toEqual({ ok: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should accept a body exactly at the size limit', async () => {
|
|
30
|
+
const payload = 'x'.repeat(10)
|
|
31
|
+
const { req, options } = toRequest(payload, 10)
|
|
32
|
+
await expect(readPostBody(req, options)).rejects.toBeInstanceOf(SyntaxError)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should accept a JSON body exactly at the custom byte limit', async () => {
|
|
36
|
+
const payload = JSON.stringify({ a: 'b' })
|
|
37
|
+
const { req, options } = toRequest(payload, Buffer.byteLength(payload))
|
|
38
|
+
await expect(readPostBody<{ a: string }>(req, options)).resolves.toEqual({ a: 'b' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should reject bodies exceeding the default size limit with 413', async () => {
|
|
42
|
+
const payload = JSON.stringify({ data: 'a'.repeat(1024 * 1024 + 1) })
|
|
43
|
+
const { req } = toRequest(payload)
|
|
44
|
+
await expect(readPostBody(req)).rejects.toSatisfy((error: unknown) => {
|
|
45
|
+
return error instanceof RequestError && error.responseCode === 413
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should reject bodies exceeding a custom size limit with 413', async () => {
|
|
50
|
+
const payload = JSON.stringify({ data: 'too-large' })
|
|
51
|
+
const { req, options } = toRequest(payload, 10)
|
|
52
|
+
await expect(readPostBody(req, options)).rejects.toSatisfy((error: unknown) => {
|
|
53
|
+
return error instanceof RequestError && error.responseCode === 413
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should accept bodies under a custom size limit', async () => {
|
|
58
|
+
const payload = JSON.stringify({ data: 'ok' })
|
|
59
|
+
const { req, options } = toRequest(payload, 1024)
|
|
60
|
+
await expect(readPostBody<{ data: string }>(req, options)).resolves.toEqual({ data: 'ok' })
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('useRestService maxBodyBytes', () => {
|
|
65
|
+
it('should return 413 when the request body exceeds the configured limit', async () => {
|
|
66
|
+
await usingAsync(createInjector(), async (i) => {
|
|
67
|
+
const port = getPort()
|
|
68
|
+
await useRestService<{
|
|
69
|
+
POST: {
|
|
70
|
+
'/echo': { body: { value: string }; result: { value: string } }
|
|
71
|
+
}
|
|
72
|
+
}>({
|
|
73
|
+
injector: i,
|
|
74
|
+
root: '/api',
|
|
75
|
+
port,
|
|
76
|
+
maxBodyBytes: 16,
|
|
77
|
+
api: {
|
|
78
|
+
POST: {
|
|
79
|
+
'/echo': async ({ getBody }) => JsonResult(await getBody()),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/echo`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
body: JSON.stringify({ value: 'this body is definitely too large' }),
|
|
87
|
+
headers: { 'content-type': 'application/json' },
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
expect(response.status).toBe(413)
|
|
91
|
+
await expect(response.json()).resolves.toMatchObject({ message: 'Payload Too Large' })
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
package/src/read-post-body.ts
CHANGED
|
@@ -1,36 +1,73 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'http'
|
|
2
|
+
import { RequestError } from '@furystack/rest'
|
|
2
3
|
import './incoming-message-extensions.js'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/** Default maximum accepted request body size (1 MiB). */
|
|
6
|
+
export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
|
7
|
+
|
|
8
|
+
export type ReadPostBodyOptions = {
|
|
9
|
+
/** Maximum allowed body size in bytes. Defaults to {@link DEFAULT_MAX_BODY_BYTES}. */
|
|
10
|
+
maxBytes?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads the full request body into a Buffer and returns it as a UTF-8 string.
|
|
15
|
+
* Throws `RequestError(413)` when the body exceeds `options.maxBytes`.
|
|
16
|
+
*/
|
|
17
|
+
export const readPostBodyRaw = async (incomingMessage: IncomingMessage, options?: ReadPostBodyOptions) => {
|
|
5
18
|
if (!incomingMessage.readable) {
|
|
6
19
|
throw Error('Incoming message is not readable')
|
|
7
20
|
}
|
|
8
21
|
|
|
9
|
-
|
|
22
|
+
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
23
|
+
const chunks: Buffer[] = []
|
|
24
|
+
let received = 0
|
|
25
|
+
|
|
10
26
|
await new Promise<void>((resolve, reject) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
const cleanup = () => {
|
|
28
|
+
incomingMessage.removeListener('data', onData)
|
|
29
|
+
incomingMessage.removeListener('end', onEnd)
|
|
30
|
+
incomingMessage.removeListener('error', onError)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const onData = (chunk: Buffer | string) => {
|
|
34
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
35
|
+
received += buffer.length
|
|
36
|
+
if (received > maxBytes) {
|
|
37
|
+
cleanup()
|
|
38
|
+
incomingMessage.resume()
|
|
39
|
+
reject(new RequestError('Payload Too Large', 413))
|
|
40
|
+
return
|
|
15
41
|
}
|
|
16
|
-
|
|
17
|
-
|
|
42
|
+
chunks.push(buffer)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const onEnd = () => {
|
|
46
|
+
cleanup()
|
|
18
47
|
resolve()
|
|
19
|
-
}
|
|
20
|
-
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const onError = (err: Error) => {
|
|
51
|
+
cleanup()
|
|
21
52
|
reject(err)
|
|
22
|
-
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
incomingMessage.on('data', onData)
|
|
56
|
+
incomingMessage.on('end', onEnd)
|
|
57
|
+
incomingMessage.on('error', onError)
|
|
23
58
|
})
|
|
24
|
-
|
|
59
|
+
|
|
60
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
25
61
|
}
|
|
26
62
|
|
|
27
63
|
/**
|
|
28
|
-
* Reads the post's body and returns a promise with a parsed value
|
|
64
|
+
* Reads the post's body and returns a promise with a parsed value.
|
|
29
65
|
* @param incomingMessage The incoming message instance
|
|
66
|
+
* @param options Optional size limit overrides
|
|
30
67
|
* @returns the parsed object from the post body
|
|
31
68
|
*/
|
|
32
|
-
export const readPostBody = async <T>(incomingMessage: IncomingMessage): Promise<T> => {
|
|
33
|
-
const body = (incomingMessage.postBody as T) || (JSON.parse(await readPostBodyRaw(incomingMessage)) as T)
|
|
69
|
+
export const readPostBody = async <T>(incomingMessage: IncomingMessage, options?: ReadPostBodyOptions): Promise<T> => {
|
|
70
|
+
const body = (incomingMessage.postBody as T) || (JSON.parse(await readPostBodyRaw(incomingMessage, options)) as T)
|
|
34
71
|
incomingMessage.postBody = body
|
|
35
72
|
return body
|
|
36
73
|
}
|
package/src/rest-api-runtime.ts
CHANGED
|
@@ -57,6 +57,10 @@ export type OnRestApiMessageOptions = OnRequest & {
|
|
|
57
57
|
cors?: CorsOptions
|
|
58
58
|
supportedMethods: string[]
|
|
59
59
|
deserializeQueryParams?: (param: string) => Record<string, unknown>
|
|
60
|
+
/** Maximum request body size in bytes. Defaults to 1 MiB. */
|
|
61
|
+
maxBodyBytes?: number
|
|
62
|
+
/** Maximum time in ms for handling a request action. When exceeded, responds with 504. */
|
|
63
|
+
actionTimeoutMs?: number
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
@@ -122,7 +126,7 @@ const executeAction = async (
|
|
|
122
126
|
params: unknown
|
|
123
127
|
},
|
|
124
128
|
): Promise<void> => {
|
|
125
|
-
const { injector, req, res, fullUrl, action, deserializeQueryParams, params } = options
|
|
129
|
+
const { injector, req, res, fullUrl, action, deserializeQueryParams, params, maxBodyBytes, actionTimeoutMs } = options
|
|
126
130
|
await usingAsync(injector.createScope({ owner: action }), async (scope) => {
|
|
127
131
|
const httpUserContext = scope.get(HttpUserContext)
|
|
128
132
|
scope.bind(IdentityContext, () => ({
|
|
@@ -130,27 +134,70 @@ const executeAction = async (
|
|
|
130
134
|
isAuthorized: (...roles) => httpUserContext.isAuthorized(req, ...roles),
|
|
131
135
|
isAuthenticated: () => httpUserContext.isAuthenticated(req),
|
|
132
136
|
}))
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
|
|
138
|
+
const canRespond = () => !res.writableEnded && !res.headersSent
|
|
139
|
+
const sendResult = (actionResult: Awaited<ReturnType<typeof action>>) => {
|
|
140
|
+
if (canRespond()) res.sendActionResult(actionResult)
|
|
141
|
+
}
|
|
142
|
+
const sendError = async (error: unknown) => {
|
|
143
|
+
if (!canRespond()) return
|
|
144
|
+
const errorActionResult = await ErrorAction({
|
|
135
145
|
request: req,
|
|
136
146
|
response: res,
|
|
137
147
|
injector: scope,
|
|
138
|
-
getBody: () =>
|
|
139
|
-
headers: req.headers,
|
|
140
|
-
getQuery: () =>
|
|
141
|
-
deserializeQueryParams ? deserializeQueryParams(fullUrl.search) : deserializeQueryString(fullUrl.search),
|
|
142
|
-
getUrlParams: () => params as object,
|
|
148
|
+
getBody: async () => error,
|
|
143
149
|
})
|
|
144
|
-
res.sendActionResult(
|
|
145
|
-
}
|
|
146
|
-
|
|
150
|
+
if (canRespond()) res.sendActionResult(errorActionResult)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const runAction = async () =>
|
|
154
|
+
action({
|
|
147
155
|
request: req,
|
|
148
156
|
response: res,
|
|
149
157
|
injector: scope,
|
|
150
|
-
|
|
158
|
+
// shortcut: actionTimeoutMs does not abort in-progress body reads; use server/request timeouts for slow clients
|
|
159
|
+
getBody: () => readPostBody<never>(req, maxBodyBytes === undefined ? undefined : { maxBytes: maxBodyBytes }),
|
|
160
|
+
headers: req.headers,
|
|
161
|
+
getQuery: () =>
|
|
162
|
+
deserializeQueryParams ? deserializeQueryParams(fullUrl.search) : deserializeQueryString(fullUrl.search),
|
|
163
|
+
getUrlParams: () => params as object,
|
|
151
164
|
})
|
|
152
|
-
|
|
165
|
+
|
|
166
|
+
if (!actionTimeoutMs || actionTimeoutMs <= 0) {
|
|
167
|
+
try {
|
|
168
|
+
sendResult(await runAction())
|
|
169
|
+
} catch (error) {
|
|
170
|
+
await sendError(error)
|
|
171
|
+
}
|
|
172
|
+
return
|
|
153
173
|
}
|
|
174
|
+
|
|
175
|
+
let timedOut = false
|
|
176
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
177
|
+
// shortcut: timed-out action may keep running (no AbortSignal) and scope may dispose before it settles; late writes/errors are suppressed
|
|
178
|
+
const actionPromise = runAction().then(
|
|
179
|
+
async (actionResult) => {
|
|
180
|
+
if (timedOut) return
|
|
181
|
+
sendResult(actionResult)
|
|
182
|
+
},
|
|
183
|
+
async (error) => {
|
|
184
|
+
if (timedOut) return
|
|
185
|
+
await sendError(error)
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
await new Promise<void>((resolve) => {
|
|
190
|
+
timer = setTimeout(() => {
|
|
191
|
+
timedOut = true
|
|
192
|
+
void sendError(new RequestError('Request Timeout', 504)).finally(resolve)
|
|
193
|
+
}, actionTimeoutMs)
|
|
194
|
+
void actionPromise.finally(() => {
|
|
195
|
+
if (!timedOut) {
|
|
196
|
+
if (timer) clearTimeout(timer)
|
|
197
|
+
resolve()
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
})
|
|
154
201
|
})
|
|
155
202
|
}
|
|
156
203
|
|
|
@@ -179,6 +179,82 @@ describe('@furystack/rest-service integration tests', () => {
|
|
|
179
179
|
expect(result.message).toBe('Failed to decode URL path parameter')
|
|
180
180
|
})
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
it('returns 504 when an action exceeds actionTimeoutMs', async () => {
|
|
184
|
+
await usingAsync(createInjector(), async (i) => {
|
|
185
|
+
const port = getPort()
|
|
186
|
+
await useRestService<{ GET: { '/slow': { result: { ok: boolean } } } }>({
|
|
187
|
+
injector: i,
|
|
188
|
+
root: '/api',
|
|
189
|
+
port,
|
|
190
|
+
actionTimeoutMs: 50,
|
|
191
|
+
api: {
|
|
192
|
+
GET: {
|
|
193
|
+
'/slow': async () => new Promise<never>(() => {}),
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/slow`)
|
|
198
|
+
expect(response.status).toBe(504)
|
|
199
|
+
await expect(response.json()).resolves.toMatchObject({ message: 'Request Timeout' })
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('returns a single 504 when an action resolves after timeout', async () => {
|
|
204
|
+
await usingAsync(createInjector(), async (i) => {
|
|
205
|
+
const port = getPort()
|
|
206
|
+
await useRestService<{ GET: { '/late-ok': { result: { ok: boolean } } } }>({
|
|
207
|
+
injector: i,
|
|
208
|
+
root: '/api',
|
|
209
|
+
port,
|
|
210
|
+
actionTimeoutMs: 30,
|
|
211
|
+
api: {
|
|
212
|
+
GET: {
|
|
213
|
+
'/late-ok': async () => {
|
|
214
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
215
|
+
return JsonResult({ ok: true })
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/late-ok`)
|
|
221
|
+
expect(response.status).toBe(504)
|
|
222
|
+
await expect(response.json()).resolves.toMatchObject({ message: 'Request Timeout' })
|
|
223
|
+
await new Promise((r) => setTimeout(r, 120))
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('returns a single 504 without unhandled rejection when an action rejects after timeout', async () => {
|
|
228
|
+
const unhandled: unknown[] = []
|
|
229
|
+
const onUnhandled = (reason: unknown) => unhandled.push(reason)
|
|
230
|
+
process.on('unhandledRejection', onUnhandled)
|
|
231
|
+
try {
|
|
232
|
+
await usingAsync(createInjector(), async (i) => {
|
|
233
|
+
const port = getPort()
|
|
234
|
+
await useRestService<{ GET: { '/late-fail': { result: { ok: boolean } } } }>({
|
|
235
|
+
injector: i,
|
|
236
|
+
root: '/api',
|
|
237
|
+
port,
|
|
238
|
+
actionTimeoutMs: 30,
|
|
239
|
+
api: {
|
|
240
|
+
GET: {
|
|
241
|
+
'/late-fail': async () => {
|
|
242
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
243
|
+
throw new Error('late boom')
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
})
|
|
248
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/late-fail`)
|
|
249
|
+
expect(response.status).toBe(504)
|
|
250
|
+
await expect(response.json()).resolves.toMatchObject({ message: 'Request Timeout' })
|
|
251
|
+
await new Promise((r) => setTimeout(r, 120))
|
|
252
|
+
})
|
|
253
|
+
expect(unhandled).toEqual([])
|
|
254
|
+
} finally {
|
|
255
|
+
process.off('unhandledRejection', onUnhandled)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
182
258
|
})
|
|
183
259
|
|
|
184
260
|
describe('HttpServerPool telemetry', () => {
|