@furystack/rest-service 14.1.3 → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +26 -6
  3. package/esm/add-cors-header.d.ts.map +1 -1
  4. package/esm/add-cors-header.js +11 -0
  5. package/esm/add-cors-header.js.map +1 -1
  6. package/esm/add-cors-header.spec.js +25 -4
  7. package/esm/add-cors-header.spec.js.map +1 -1
  8. package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
  9. package/esm/endpoint-generators/create-patch-endpoint.js +2 -3
  10. package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
  11. package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
  12. package/esm/endpoint-generators/create-post-endpoint.js +2 -3
  13. package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
  14. package/esm/helpers.d.ts +18 -4
  15. package/esm/helpers.d.ts.map +1 -1
  16. package/esm/helpers.js +12 -6
  17. package/esm/helpers.js.map +1 -1
  18. package/esm/http-authentication-settings.d.ts +14 -0
  19. package/esm/http-authentication-settings.d.ts.map +1 -1
  20. package/esm/http-authentication-settings.js +2 -0
  21. package/esm/http-authentication-settings.js.map +1 -1
  22. package/esm/http-server-pool.d.ts +6 -0
  23. package/esm/http-server-pool.d.ts.map +1 -1
  24. package/esm/http-server-pool.js +19 -1
  25. package/esm/http-server-pool.js.map +1 -1
  26. package/esm/http-server-pool.spec.js +25 -0
  27. package/esm/http-server-pool.spec.js.map +1 -1
  28. package/esm/http-user-context.d.ts.map +1 -1
  29. package/esm/http-user-context.js +14 -2
  30. package/esm/http-user-context.js.map +1 -1
  31. package/esm/http-user-context.spec.js +40 -1
  32. package/esm/http-user-context.spec.js.map +1 -1
  33. package/esm/read-post-body.d.ts +14 -3
  34. package/esm/read-post-body.d.ts.map +1 -1
  35. package/esm/read-post-body.js +40 -15
  36. package/esm/read-post-body.js.map +1 -1
  37. package/esm/read-post-body.spec.d.ts +2 -0
  38. package/esm/read-post-body.spec.d.ts.map +1 -0
  39. package/esm/read-post-body.spec.js +80 -0
  40. package/esm/read-post-body.spec.js.map +1 -0
  41. package/esm/rest-api-runtime.d.ts +4 -0
  42. package/esm/rest-api-runtime.d.ts.map +1 -1
  43. package/esm/rest-api-runtime.js +55 -15
  44. package/esm/rest-api-runtime.js.map +1 -1
  45. package/esm/rest-service.integration.spec.js +74 -0
  46. package/esm/rest-service.integration.spec.js.map +1 -1
  47. package/esm/static-server-manager.spec.js +33 -0
  48. package/esm/static-server-manager.spec.js.map +1 -1
  49. package/esm/static-site-runtime.d.ts.map +1 -1
  50. package/esm/static-site-runtime.js +39 -6
  51. package/esm/static-site-runtime.js.map +1 -1
  52. package/esm/static-site-runtime.spec.js +36 -0
  53. package/esm/static-site-runtime.spec.js.map +1 -1
  54. package/package.json +15 -15
  55. package/src/add-cors-header.spec.ts +31 -4
  56. package/src/add-cors-header.ts +11 -0
  57. package/src/endpoint-generators/create-patch-endpoint.ts +3 -4
  58. package/src/endpoint-generators/create-post-endpoint.ts +2 -3
  59. package/src/helpers.ts +26 -5
  60. package/src/http-authentication-settings.ts +16 -0
  61. package/src/http-server-pool.spec.ts +27 -0
  62. package/src/http-server-pool.ts +20 -1
  63. package/src/http-user-context.spec.ts +55 -1
  64. package/src/http-user-context.ts +15 -2
  65. package/src/read-post-body.spec.ts +94 -0
  66. package/src/read-post-body.ts +52 -15
  67. package/src/rest-api-runtime.ts +60 -13
  68. package/src/rest-service.integration.spec.ts +76 -0
  69. package/src/static-server-manager.spec.ts +39 -0
  70. package/src/static-site-runtime.spec.ts +37 -0
  71. 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
- * Prerequisite: {@link UserStore} and {@link SessionStore} must be bound to
122
- * persistent implementations before calling this helper, since the Basic
123
- * and Cookie providers need to read users and sessions immediately.
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
  })
@@ -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
- return pending
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
 
@@ -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', `${this.authentication.cookieName}=${sessionId}; Path=/; HttpOnly`)
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', `${this.authentication.cookieName}=; Path=/; HttpOnly`)
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
+ })
@@ -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
- export const readPostBodyRaw = async (incomingMessage: IncomingMessage) => {
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
- let body = ''
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
- incomingMessage.on('readable', () => {
12
- const data = incomingMessage.read() as string
13
- if (data) {
14
- body += data
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
- incomingMessage.on('end', () => {
42
+ chunks.push(buffer)
43
+ }
44
+
45
+ const onEnd = () => {
46
+ cleanup()
18
47
  resolve()
19
- })
20
- incomingMessage.on('error', (err) => {
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
- return body
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
  }
@@ -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
- try {
134
- const actionResult = await action({
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: () => readPostBody<never>(req),
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(actionResult)
145
- } catch (error) {
146
- const errorActionResult = await ErrorAction({
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
- getBody: async () => error,
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
- res.sendActionResult(errorActionResult)
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', () => {