@c9up/ream 0.1.7 → 0.1.9
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/dist/Application.d.ts +9 -1
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +35 -5
- package/dist/Application.js.map +1 -1
- package/dist/GracefulShutdown.d.ts.map +1 -1
- package/dist/GracefulShutdown.js +13 -7
- package/dist/GracefulShutdown.js.map +1 -1
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +43 -14
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +10 -10
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +104 -32
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +3 -0
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +32 -16
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/parseSize.d.ts +2 -1
- package/dist/bodyparser/parseSize.d.ts.map +1 -1
- package/dist/bodyparser/parseSize.js +2 -1
- package/dist/bodyparser/parseSize.js.map +1 -1
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +39 -9
- package/dist/container/Container.js.map +1 -1
- package/dist/decorators/Service.d.ts +4 -6
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/decorators/Service.js +0 -1
- package/dist/decorators/Service.js.map +1 -1
- package/dist/events/Emitter.d.ts +16 -6
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +32 -25
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/EventsProvider.d.ts +1 -0
- package/dist/events/EventsProvider.d.ts.map +1 -1
- package/dist/events/EventsProvider.js +6 -4
- package/dist/events/EventsProvider.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts +16 -0
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +122 -4
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
- package/dist/graphql/GraphQLProvider.js +13 -2
- package/dist/graphql/GraphQLProvider.js.map +1 -1
- package/dist/http/HttpContext.d.ts +27 -1
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +20 -1
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +5 -1
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +26 -3
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +13 -0
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +18 -0
- package/dist/http/Request.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/middleware/Pipeline.d.ts +13 -0
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +43 -7
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/router/Router.d.ts +21 -2
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +53 -8
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +11 -2
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +11 -3
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +92 -8
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
- package/dist/scheduler/ScheduleProvider.js +10 -0
- package/dist/scheduler/ScheduleProvider.js.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts +0 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.js +0 -1
- package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
- package/dist/services/app.d.ts +6 -0
- package/dist/services/app.d.ts.map +1 -1
- package/dist/services/app.js +9 -0
- package/dist/services/app.js.map +1 -1
- package/dist/services/router.d.ts +5 -0
- package/dist/services/router.d.ts.map +1 -1
- package/dist/services/router.js +8 -0
- package/dist/services/router.js.map +1 -1
- package/dist/services/server.d.ts +5 -0
- package/dist/services/server.d.ts.map +1 -1
- package/dist/services/server.js +8 -0
- package/dist/services/server.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +4 -0
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +14 -6
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/testing/TestClient.d.ts +11 -5
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +9 -4
- package/dist/testing/TestClient.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +36 -5
- package/src/GracefulShutdown.ts +15 -7
- package/src/HttpKernel.ts +52 -17
- package/src/Ignitor.ts +106 -34
- package/src/bodyparser/BodyParserMiddleware.ts +43 -16
- package/src/bodyparser/parseSize.ts +2 -1
- package/src/container/Container.ts +42 -12
- package/src/decorators/Service.ts +4 -6
- package/src/events/Emitter.ts +36 -28
- package/src/events/EventsProvider.ts +6 -4
- package/src/graphql/GraphQLEngine.ts +139 -3
- package/src/graphql/GraphQLProvider.ts +17 -2
- package/src/http/HttpContext.ts +38 -1
- package/src/http/RedirectBuilder.ts +23 -3
- package/src/http/Request.ts +20 -0
- package/src/index.ts +1 -1
- package/src/middleware/Pipeline.ts +61 -8
- package/src/router/Router.ts +53 -8
- package/src/rpc/RpcProvider.ts +14 -2
- package/src/rpc/RpcRouter.ts +115 -11
- package/src/scheduler/ScheduleProvider.ts +10 -0
- package/src/scheduler/locks/MemoryLockBackend.ts +0 -2
- package/src/services/app.ts +9 -0
- package/src/services/router.ts +8 -0
- package/src/services/server.ts +8 -0
- package/src/session/SessionMiddleware.ts +6 -0
- package/src/session/drivers/MemoryDriver.ts +14 -6
- package/src/testing/TestClient.ts +18 -7
- package/dist/server/HyperServer.d.ts +0 -64
- package/dist/server/HyperServer.d.ts.map +0 -1
- package/dist/server/HyperServer.js +0 -54
- package/dist/server/HyperServer.js.map +0 -1
- package/dist/storage/S3Driver.d.ts +0 -27
- package/dist/storage/S3Driver.d.ts.map +0 -1
- package/dist/storage/S3Driver.js +0 -135
- package/dist/storage/S3Driver.js.map +0 -1
- package/dist/storage/StorageManager.d.ts +0 -31
- package/dist/storage/StorageManager.d.ts.map +0 -1
- package/dist/storage/StorageManager.js +0 -120
- package/dist/storage/StorageManager.js.map +0 -1
- package/src/server/HyperServer.ts +0 -121
- package/src/storage/S3Driver.ts +0 -171
- package/src/storage/StorageManager.ts +0 -142
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
10
10
|
import { hydrateMultipartPayload } from './MultipartFile.js'
|
|
11
|
+
import { parseSize } from './parseSize.js'
|
|
11
12
|
|
|
12
13
|
export interface BodyParserConfig {
|
|
13
14
|
json?: {
|
|
@@ -27,7 +28,10 @@ export interface BodyParserConfig {
|
|
|
27
28
|
}
|
|
28
29
|
multipart?: {
|
|
29
30
|
enabled?: boolean
|
|
31
|
+
/** Total multipart body size cap (sum of all file sizes). Default: '20mb'. */
|
|
30
32
|
limit?: string
|
|
33
|
+
/** Maximum number of files per request. Default: 20. */
|
|
34
|
+
maxFiles?: number
|
|
31
35
|
maxFields?: number
|
|
32
36
|
tmpDir?: string
|
|
33
37
|
types?: string[]
|
|
@@ -60,6 +64,7 @@ const DEFAULT_CONFIG: ResolvedBodyParserConfig = {
|
|
|
60
64
|
multipart: {
|
|
61
65
|
enabled: true,
|
|
62
66
|
limit: '20mb',
|
|
67
|
+
maxFiles: 20,
|
|
63
68
|
maxFields: 500,
|
|
64
69
|
tmpDir: '/tmp',
|
|
65
70
|
types: ['multipart/form-data'],
|
|
@@ -113,6 +118,7 @@ export default class BodyParserMiddleware {
|
|
|
113
118
|
if (this.#config.multipart.enabled && matchesType(contentType, this.#config.multipart.types)) {
|
|
114
119
|
const payload = ctx.request.multipart()
|
|
115
120
|
if (payload) {
|
|
121
|
+
if (this.#rejectMultipart(ctx, payload.files, payload.fields)) return
|
|
116
122
|
const { fields, files } = hydrateMultipartPayload(payload)
|
|
117
123
|
ctx.request.setParsedBody(fields)
|
|
118
124
|
ctx.request.setFiles(files)
|
|
@@ -128,28 +134,49 @@ export default class BodyParserMiddleware {
|
|
|
128
134
|
if (matchesType(contentType, this.#config.multipart.types)) return this.#config.multipart.limit
|
|
129
135
|
return this.#config.raw.limit
|
|
130
136
|
}
|
|
137
|
+
|
|
138
|
+
/** Returns true (and writes a 413/400) when the multipart payload exceeds configured limits. */
|
|
139
|
+
#rejectMultipart(
|
|
140
|
+
ctx: HttpContext,
|
|
141
|
+
files: Array<{ size: number }>,
|
|
142
|
+
fields: Array<unknown>,
|
|
143
|
+
): boolean {
|
|
144
|
+
const cfg = this.#config.multipart
|
|
145
|
+
const maxBytes = parseSize(cfg.limit)
|
|
146
|
+
|
|
147
|
+
if (fields.length > cfg.maxFields) {
|
|
148
|
+
ctx.response.status(400).json({
|
|
149
|
+
error: {
|
|
150
|
+
code: 'E_TOO_MANY_FIELDS',
|
|
151
|
+
message: `Upload exceeds maxFields (${cfg.maxFields})`,
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (files.length > cfg.maxFiles) {
|
|
158
|
+
ctx.response.status(400).json({
|
|
159
|
+
error: { code: 'E_TOO_MANY_FILES', message: `Upload exceeds maxFiles (${cfg.maxFiles})` },
|
|
160
|
+
})
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
|
|
165
|
+
if (totalBytes > maxBytes) {
|
|
166
|
+
ctx.response.status(413).json({
|
|
167
|
+
error: { code: 'E_REQUEST_ENTITY_TOO_LARGE', message: 'Upload exceeds size limit' },
|
|
168
|
+
})
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
131
174
|
}
|
|
132
175
|
|
|
133
176
|
function matchesType(contentType: string, types: string[]): boolean {
|
|
134
177
|
return types.some((t) => contentType.includes(t))
|
|
135
178
|
}
|
|
136
179
|
|
|
137
|
-
function parseSize(size: string): number {
|
|
138
|
-
const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
|
|
139
|
-
if (!match) return 1024 * 1024 // default 1mb
|
|
140
|
-
const num = parseInt(match[1], 10)
|
|
141
|
-
switch (match[2]?.toLowerCase()) {
|
|
142
|
-
case 'kb':
|
|
143
|
-
return num * 1024
|
|
144
|
-
case 'mb':
|
|
145
|
-
return num * 1024 * 1024
|
|
146
|
-
case 'gb':
|
|
147
|
-
return num * 1024 * 1024 * 1024
|
|
148
|
-
default:
|
|
149
|
-
return num
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
180
|
function parseFormUrlEncoded(body: string): Record<string, unknown> {
|
|
154
181
|
const result: Record<string, unknown> = {}
|
|
155
182
|
if (!body) return result
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parse a human-readable size string (e.g. '1mb', '512kb', '2gb') to bytes.
|
|
3
|
-
* Single source of truth
|
|
3
|
+
* Single source of truth, imported by BodyParserMiddleware (which previously
|
|
4
|
+
* carried an identical inline copy — consolidated 2026-06-15).
|
|
4
5
|
*/
|
|
5
6
|
export function parseSize(size: string): number {
|
|
6
7
|
const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
|
|
@@ -158,15 +158,12 @@ export class Container {
|
|
|
158
158
|
const paramTypes: unknown[] =
|
|
159
159
|
Reflect.getMetadata('design:paramtypes', target.prototype, method) ?? []
|
|
160
160
|
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (isClassConstructor(type)) {
|
|
168
|
-
return this.resolve(type)
|
|
169
|
-
}
|
|
161
|
+
const len = Math.max(paramTypes.length, runtimeValues?.length ?? 0)
|
|
162
|
+
const args = Array.from({ length: len }, (_, index) => {
|
|
163
|
+
// Runtime values take precedence (and fill slots beyond paramTypes)
|
|
164
|
+
if (runtimeValues && index < runtimeValues.length) return runtimeValues[index]
|
|
165
|
+
const type = paramTypes[index]
|
|
166
|
+
if (isClassConstructor(type)) return this.resolve(type)
|
|
170
167
|
return undefined
|
|
171
168
|
})
|
|
172
169
|
|
|
@@ -281,15 +278,48 @@ export class Container {
|
|
|
281
278
|
const paramTypes: unknown[] =
|
|
282
279
|
explicitDeps ?? Reflect.getMetadata('design:paramtypes', target) ?? []
|
|
283
280
|
|
|
281
|
+
const injectTokens = getInjectTokens(target)
|
|
282
|
+
const lazyIndices = getLazyParams(target)
|
|
283
|
+
|
|
284
|
+
// No `design:paramtypes` (a dev transpiler may not emit decorator metadata —
|
|
285
|
+
// esbuild / tsx don't). Recover what we can, and NEVER construct with
|
|
286
|
+
// silently-undefined deps (that masked a whole DI outage in dev):
|
|
284
287
|
if (paramTypes.length === 0) {
|
|
288
|
+
// (a) `@Inject(token)` records its tokens INDEPENDENTLY of decorator
|
|
289
|
+
// metadata, so even without `design:paramtypes` we can resolve the
|
|
290
|
+
// constructor from that map alone.
|
|
291
|
+
if (injectTokens.size > 0) {
|
|
292
|
+
const maxIndex = Math.max(...injectTokens.keys())
|
|
293
|
+
const deps = Array.from({ length: maxIndex + 1 }, (_value, index) => {
|
|
294
|
+
const depToken = injectTokens.get(index)
|
|
295
|
+
if (!depToken) return undefined
|
|
296
|
+
if (lazyIndices.includes(index)) {
|
|
297
|
+
return createLazyProxy(() => this.resolve<object>(depToken))
|
|
298
|
+
}
|
|
299
|
+
return this.resolve(depToken)
|
|
300
|
+
})
|
|
301
|
+
const instance = new target(...deps)
|
|
302
|
+
if (scope === 'singleton') this.#singletons.set(key, instance)
|
|
303
|
+
return instance
|
|
304
|
+
}
|
|
305
|
+
// (b) The constructor declares parameters but we have no way to resolve
|
|
306
|
+
// them (no metadata, no @Inject, no containerInjections). Fail LOUDLY
|
|
307
|
+
// instead of `new target()` with undefined deps.
|
|
308
|
+
if (target.length > 0) {
|
|
309
|
+
throw new ReamError(
|
|
310
|
+
'CONTAINER_MISSING_METADATA',
|
|
311
|
+
`Cannot auto-construct ${target.name}: it declares ${target.length} constructor parameter(s) but no dependency metadata was found.`,
|
|
312
|
+
{
|
|
313
|
+
hint: 'Enable decorator metadata (swc/tsc emitDecoratorMetadata), annotate constructor params with @Inject(token), or declare static containerInjections.',
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
// (c) Genuine zero-argument class.
|
|
285
318
|
const instance = new target()
|
|
286
319
|
if (scope === 'singleton') this.#singletons.set(key, instance)
|
|
287
320
|
return instance
|
|
288
321
|
}
|
|
289
322
|
|
|
290
|
-
const injectTokens = getInjectTokens(target)
|
|
291
|
-
const lazyIndices = getLazyParams(target)
|
|
292
|
-
|
|
293
323
|
const deps = paramTypes.map((type, index) => {
|
|
294
324
|
const namedToken = injectTokens.get(index)
|
|
295
325
|
// After the typeof check, type is a constructor function and valid as ServiceToken
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import 'reflect-metadata'
|
|
8
|
-
import type { ServiceMetadata, ServiceScope } from '../container/types.js'
|
|
8
|
+
import type { ServiceMetadata, ServiceScope, ServiceToken } from '../container/types.js'
|
|
9
9
|
|
|
10
10
|
const SERVICE_METADATA_KEY = Symbol('ream:service')
|
|
11
11
|
const INJECT_METADATA_KEY = Symbol('ream:inject')
|
|
@@ -50,9 +50,9 @@ export function Service(options: { scope?: ServiceScope; as?: string } = {}): Cl
|
|
|
50
50
|
*
|
|
51
51
|
* @implements FR16
|
|
52
52
|
*/
|
|
53
|
-
export function Inject(token:
|
|
53
|
+
export function Inject(token: ServiceToken): ParameterDecorator {
|
|
54
54
|
return (target, _propertyKey, parameterIndex) => {
|
|
55
|
-
const existingTokens: Map<number,
|
|
55
|
+
const existingTokens: Map<number, ServiceToken> =
|
|
56
56
|
Reflect.getOwnMetadata(INJECT_METADATA_KEY, target) ?? new Map()
|
|
57
57
|
existingTokens.set(parameterIndex, token)
|
|
58
58
|
Reflect.defineMetadata(INJECT_METADATA_KEY, existingTokens, target)
|
|
@@ -62,7 +62,7 @@ export function Inject(token: string): ParameterDecorator {
|
|
|
62
62
|
/**
|
|
63
63
|
* Get named injection tokens for a class constructor.
|
|
64
64
|
*/
|
|
65
|
-
export function getInjectTokens(target: AnyConstructor): Map<number,
|
|
65
|
+
export function getInjectTokens(target: AnyConstructor): Map<number, ServiceToken> {
|
|
66
66
|
return Reflect.getOwnMetadata(INJECT_METADATA_KEY, target) ?? new Map()
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -81,5 +81,3 @@ export function getInjectTokens(target: AnyConstructor): Map<number, string> {
|
|
|
81
81
|
export function inject(): ClassDecorator {
|
|
82
82
|
return Service({ scope: 'transient' })
|
|
83
83
|
}
|
|
84
|
-
|
|
85
|
-
export { INJECT_METADATA_KEY, SERVICE_METADATA_KEY }
|
package/src/events/Emitter.ts
CHANGED
|
@@ -12,8 +12,12 @@
|
|
|
12
12
|
* Listeners support @inject() for DI when a container resolver is provided.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
15
16
|
import type { EventBus } from './native.js'
|
|
16
17
|
|
|
18
|
+
/** Per-request correlation ID stored in async context so concurrent requests don't corrupt each other. */
|
|
19
|
+
const correlationStorage = new AsyncLocalStorage<string | undefined>()
|
|
20
|
+
|
|
17
21
|
/**
|
|
18
22
|
* Listener class interface — must have a handle() method.
|
|
19
23
|
*/
|
|
@@ -56,8 +60,9 @@ export class Emitter {
|
|
|
56
60
|
/**
|
|
57
61
|
* Listen for a string-based event.
|
|
58
62
|
* emitter.on('user:registered', (user) => { ... })
|
|
63
|
+
* emitter.on<User>('user:registered', (user) => { ... }) // typed payload
|
|
59
64
|
*/
|
|
60
|
-
on(event: string, listener: ListenerFn): void
|
|
65
|
+
on<T>(event: string, listener: ListenerFn<T>): void
|
|
61
66
|
on(event: EventConstructor | string, listener: Listener): void {
|
|
62
67
|
if (typeof event === 'string') {
|
|
63
68
|
const list = this.stringListeners.get(event) ?? []
|
|
@@ -229,38 +234,36 @@ export class Emitter {
|
|
|
229
234
|
|
|
230
235
|
// ─── Correlation context ──────────────────────────────────
|
|
231
236
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Set the correlation ID for subsequent events in the current async context.
|
|
239
|
+
* Uses AsyncLocalStorage so concurrent requests each have their own trace ID —
|
|
240
|
+
* a shared mutable field would let concurrent requests corrupt each other.
|
|
241
|
+
*/
|
|
236
242
|
setCorrelationId(id: string): void {
|
|
237
|
-
|
|
243
|
+
correlationStorage.enterWith(id)
|
|
238
244
|
}
|
|
239
245
|
|
|
240
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* Clear the correlation ID for the current async context. `enterWith` does
|
|
248
|
+
* not auto-unset, so a long-lived/reused context (and, notably, sequential
|
|
249
|
+
* tests sharing a worker) would leak the last set id — call this to reset.
|
|
250
|
+
*/
|
|
251
|
+
clearCorrelationId(): void {
|
|
252
|
+
correlationStorage.enterWith(undefined)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Get the correlation ID for the current async context. */
|
|
241
256
|
getCorrelationId(): string | undefined {
|
|
242
|
-
return
|
|
257
|
+
return correlationStorage.getStore()
|
|
243
258
|
}
|
|
244
259
|
|
|
245
|
-
/**
|
|
246
|
-
* Inject the active correlation ID at the top level of the payload so
|
|
247
|
-
* the bus's Event envelope (`{ id, name, data, correlationId, ... }`)
|
|
248
|
-
* surfaces it on the subscriber side — both the Rust `ream-events`
|
|
249
|
-
* envelope serde and `FakeBus.parseEmitData()` already read
|
|
250
|
-
* `correlationId` from the parsed `data` string. Without this injection
|
|
251
|
-
* `setCorrelationId()` was a no-op functional API. Returns `data`
|
|
252
|
-
* unchanged when no correlation ID is set OR when `data` is not a plain
|
|
253
|
-
* object (primitives / arrays / null can't carry a sibling field).
|
|
254
|
-
*/
|
|
260
|
+
/** Inject the active correlation ID at the top level of the payload. */
|
|
255
261
|
#wrapForBus(data: unknown): unknown {
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// User-supplied correlationId wins — don't clobber an explicit value.
|
|
262
|
-
if (typeof obj.correlationId === 'string') return data
|
|
263
|
-
return { ...obj, correlationId: this._correlationId }
|
|
262
|
+
const correlationId = correlationStorage.getStore()
|
|
263
|
+
if (correlationId === undefined) return data
|
|
264
|
+
if (!isPlainObject(data)) return data
|
|
265
|
+
if (typeof data.correlationId === 'string') return data
|
|
266
|
+
return { ...data, correlationId }
|
|
264
267
|
}
|
|
265
268
|
|
|
266
269
|
// ─── Introspection ────────────────────────────────────────
|
|
@@ -304,8 +307,9 @@ export class BaseEvent {
|
|
|
304
307
|
BaseEvent._emitter = emitter
|
|
305
308
|
}
|
|
306
309
|
|
|
307
|
-
|
|
308
|
-
|
|
310
|
+
/** @internal Ownership-guarded reset — only clears if `emitter` is still the active one. */
|
|
311
|
+
static resetEmitter(emitter: Emitter): void {
|
|
312
|
+
if (BaseEvent._emitter === emitter) BaseEvent._emitter = undefined
|
|
309
313
|
}
|
|
310
314
|
|
|
311
315
|
/**
|
|
@@ -331,3 +335,7 @@ function classToEventName(cls: EventConstructor): string {
|
|
|
331
335
|
function isListenerClass(listener: Listener): boolean {
|
|
332
336
|
return typeof listener === 'function' && typeof listener.prototype?.handle === 'function'
|
|
333
337
|
}
|
|
338
|
+
|
|
339
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
340
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
341
|
+
}
|
|
@@ -23,6 +23,8 @@ export interface EventsAppContext {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export default class EventsProvider {
|
|
26
|
+
#emitter: Emitter | undefined
|
|
27
|
+
|
|
26
28
|
constructor(protected app: EventsAppContext) {}
|
|
27
29
|
|
|
28
30
|
register() {
|
|
@@ -47,12 +49,12 @@ export default class EventsProvider {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
async boot() {
|
|
50
|
-
|
|
51
|
-
BaseEvent.useEmitter(emitter)
|
|
52
|
-
setEmitter(emitter)
|
|
52
|
+
this.#emitter = this.app.container.resolve<Emitter>(Emitter)
|
|
53
|
+
BaseEvent.useEmitter(this.#emitter)
|
|
54
|
+
setEmitter(this.#emitter)
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
async shutdown() {
|
|
56
|
-
BaseEvent.resetEmitter()
|
|
58
|
+
if (this.#emitter) BaseEvent.resetEmitter(this.#emitter)
|
|
57
59
|
}
|
|
58
60
|
}
|
|
@@ -40,6 +40,43 @@ interface RustParseResult {
|
|
|
40
40
|
}
|
|
41
41
|
interface GraphqlNative {
|
|
42
42
|
graphqlParse(query: string): string
|
|
43
|
+
/** `{ "Type.field": { "argName": "ScalarType" } }` extracted from the SDL. */
|
|
44
|
+
graphqlSchemaArgTypes(sdl: string): string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Coerce an argument value to its schema-declared scalar type. GraphQL input
|
|
49
|
+
* coercion: a string literal `"5"` for an `Int` arg becomes the number `5`, a
|
|
50
|
+
* number for an `ID` becomes its string form, etc. Arrays coerce element-wise.
|
|
51
|
+
* Unknown / custom / enum types pass through untouched.
|
|
52
|
+
*/
|
|
53
|
+
function coerceScalar(value: unknown, type: string): unknown {
|
|
54
|
+
if (value === null || value === undefined) return value
|
|
55
|
+
if (Array.isArray(value)) return value.map((v) => coerceScalar(v, type))
|
|
56
|
+
switch (type) {
|
|
57
|
+
case 'Int':
|
|
58
|
+
if (typeof value === 'number') return value
|
|
59
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isInteger(Number(value))) {
|
|
60
|
+
return Number(value)
|
|
61
|
+
}
|
|
62
|
+
return value
|
|
63
|
+
case 'Float':
|
|
64
|
+
if (typeof value === 'number') return value
|
|
65
|
+
if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {
|
|
66
|
+
return Number(value)
|
|
67
|
+
}
|
|
68
|
+
return value
|
|
69
|
+
case 'Boolean':
|
|
70
|
+
if (typeof value === 'boolean') return value
|
|
71
|
+
if (value === 'true') return true
|
|
72
|
+
if (value === 'false') return false
|
|
73
|
+
return value
|
|
74
|
+
case 'ID':
|
|
75
|
+
return typeof value === 'number' ? String(value) : value
|
|
76
|
+
default:
|
|
77
|
+
// String, custom scalars, enums, input objects — left as the client sent.
|
|
78
|
+
return value
|
|
79
|
+
}
|
|
43
80
|
}
|
|
44
81
|
|
|
45
82
|
let graphqlNativeCache: GraphqlNative | undefined
|
|
@@ -65,6 +102,39 @@ const OPERATION_TYPE: Record<string, string> = {
|
|
|
65
102
|
subscription: 'Subscription',
|
|
66
103
|
}
|
|
67
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Bound a parsed selection tree's depth and total field count. Iterative (explicit
|
|
107
|
+
* stack) so a pathological deeply-nested query can't overflow the JS call stack
|
|
108
|
+
* during the check itself, and short-circuits as soon as a limit is crossed.
|
|
109
|
+
* Returns an error message, or `null` when the query is within limits.
|
|
110
|
+
*/
|
|
111
|
+
function checkSelectionLimits(
|
|
112
|
+
fields: SelectionField[],
|
|
113
|
+
maxDepth: number,
|
|
114
|
+
maxComplexity: number,
|
|
115
|
+
): string | null {
|
|
116
|
+
let count = 0
|
|
117
|
+
const stack: Array<{ field: SelectionField; depth: number }> = fields.map((field) => ({
|
|
118
|
+
field,
|
|
119
|
+
depth: 1,
|
|
120
|
+
}))
|
|
121
|
+
while (stack.length > 0) {
|
|
122
|
+
const top = stack.pop()
|
|
123
|
+
if (!top) break
|
|
124
|
+
if (top.depth > maxDepth) {
|
|
125
|
+
return `Query is too deep (max depth ${maxDepth})`
|
|
126
|
+
}
|
|
127
|
+
count += 1
|
|
128
|
+
if (count > maxComplexity) {
|
|
129
|
+
return `Query is too complex (max ${maxComplexity} fields)`
|
|
130
|
+
}
|
|
131
|
+
for (const child of top.field.selection) {
|
|
132
|
+
stack.push({ field: child, depth: top.depth + 1 })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
68
138
|
/** Map a Rust `ParsedField` (sub_fields) onto the engine's `SelectionField` (selection). */
|
|
69
139
|
function mapRustField(f: RustParsedField): SelectionField {
|
|
70
140
|
return {
|
|
@@ -82,6 +152,22 @@ export interface GraphQLConfig {
|
|
|
82
152
|
path?: string
|
|
83
153
|
/** Enable playground/introspection in dev (default: true when NODE_ENV !== 'production'). */
|
|
84
154
|
playground?: boolean
|
|
155
|
+
/**
|
|
156
|
+
* Reject a query string longer than this many characters BEFORE parsing —
|
|
157
|
+
* caps the work the parser does on a hostile payload (default: 100_000).
|
|
158
|
+
*/
|
|
159
|
+
maxQueryBytes?: number
|
|
160
|
+
/**
|
|
161
|
+
* Reject a query whose selection set nests deeper than this — bounds
|
|
162
|
+
* resolver recursion / amplification (default: 12).
|
|
163
|
+
*/
|
|
164
|
+
maxDepth?: number
|
|
165
|
+
/**
|
|
166
|
+
* Reject a query selecting more than this many fields in total (aliases
|
|
167
|
+
* count) — bounds fan-out where a client repeats an expensive resolver
|
|
168
|
+
* (default: 1000).
|
|
169
|
+
*/
|
|
170
|
+
maxComplexity?: number
|
|
85
171
|
}
|
|
86
172
|
|
|
87
173
|
export interface ResolverOptions {
|
|
@@ -120,6 +206,24 @@ export class GraphQLEngine {
|
|
|
120
206
|
#container?: { make<T>(target: new (...args: unknown[]) => T): T }
|
|
121
207
|
readonly path: string
|
|
122
208
|
#playground: boolean
|
|
209
|
+
#maxQueryBytes: number
|
|
210
|
+
#maxDepth: number
|
|
211
|
+
#maxComplexity: number
|
|
212
|
+
/** Lazily-parsed `Type.field → argName → scalarType` map for input coercion. */
|
|
213
|
+
#argTypes?: Record<string, Record<string, string>>
|
|
214
|
+
|
|
215
|
+
/** Parse (once) the schema's argument types from the SDL for coercion. */
|
|
216
|
+
#schemaArgTypes(): Record<string, Record<string, string>> {
|
|
217
|
+
if (this.#argTypes === undefined) {
|
|
218
|
+
try {
|
|
219
|
+
this.#argTypes = JSON.parse(graphqlNative().graphqlSchemaArgTypes(this.#schemaSource))
|
|
220
|
+
} catch {
|
|
221
|
+
// Native unavailable or schema unparseable → coercion becomes a no-op.
|
|
222
|
+
this.#argTypes = {}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return this.#argTypes ?? {}
|
|
226
|
+
}
|
|
123
227
|
|
|
124
228
|
constructor(config: GraphQLConfig) {
|
|
125
229
|
if (!fs.existsSync(config.schemaPath)) {
|
|
@@ -128,6 +232,9 @@ export class GraphQLEngine {
|
|
|
128
232
|
this.#schemaSource = fs.readFileSync(config.schemaPath, 'utf8')
|
|
129
233
|
this.path = config.path ?? '/graphql'
|
|
130
234
|
this.#playground = config.playground ?? process.env.NODE_ENV !== 'production'
|
|
235
|
+
this.#maxQueryBytes = config.maxQueryBytes ?? 100_000
|
|
236
|
+
this.#maxDepth = config.maxDepth ?? 12
|
|
237
|
+
this.#maxComplexity = config.maxComplexity ?? 1000
|
|
131
238
|
}
|
|
132
239
|
|
|
133
240
|
/** Set IoC container for resolver instantiation. */
|
|
@@ -196,12 +303,28 @@ export class GraphQLEngine {
|
|
|
196
303
|
data?: Record<string, unknown>
|
|
197
304
|
errors?: Array<{ message: string; path?: string[] }>
|
|
198
305
|
}> {
|
|
306
|
+
// DoS guard 1 — cap the raw query size BEFORE handing it to the parser, so a
|
|
307
|
+
// hostile multi-MB payload can't tie up the native parser.
|
|
308
|
+
if (request.query.length > this.#maxQueryBytes) {
|
|
309
|
+
return {
|
|
310
|
+
errors: [{ message: `Query exceeds the maximum size of ${this.#maxQueryBytes} bytes` }],
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
199
314
|
// Parse the query to extract operation type and fields
|
|
200
315
|
const parsed = this.#parseQuery(request.query)
|
|
201
316
|
if (parsed.errors) {
|
|
202
317
|
return { errors: parsed.errors }
|
|
203
318
|
}
|
|
204
319
|
|
|
320
|
+
// DoS guard 2 — bound the selection tree's depth and total field count
|
|
321
|
+
// (aliases included) before resolving anything. Iterative walk so the check
|
|
322
|
+
// itself can't blow the stack on a deeply nested query.
|
|
323
|
+
const limitError = checkSelectionLimits(parsed.fields, this.#maxDepth, this.#maxComplexity)
|
|
324
|
+
if (limitError) {
|
|
325
|
+
return { errors: [{ message: limitError }] }
|
|
326
|
+
}
|
|
327
|
+
|
|
205
328
|
const data: Record<string, unknown> = {}
|
|
206
329
|
const errors: Array<{ message: string; path?: string[] }> = []
|
|
207
330
|
|
|
@@ -260,7 +383,7 @@ export class GraphQLEngine {
|
|
|
260
383
|
}
|
|
261
384
|
}
|
|
262
385
|
|
|
263
|
-
const args = this.#buildArgs(field, request.variables)
|
|
386
|
+
const args = this.#buildArgs(field, request.variables, typeName)
|
|
264
387
|
const result = await handler.call(instance, null, args, ctx)
|
|
265
388
|
// Prune to the client's selection set: a resolver returning a rich
|
|
266
389
|
// object (ORM entity, etc.) must not leak fields the client did not
|
|
@@ -279,6 +402,7 @@ export class GraphQLEngine {
|
|
|
279
402
|
#buildArgs(
|
|
280
403
|
field: SelectionField,
|
|
281
404
|
variables: Record<string, unknown> | undefined,
|
|
405
|
+
typeName: string,
|
|
282
406
|
): Record<string, unknown> {
|
|
283
407
|
const args: Record<string, unknown> = {}
|
|
284
408
|
for (const [k, v] of Object.entries(field.args)) {
|
|
@@ -293,6 +417,15 @@ export class GraphQLEngine {
|
|
|
293
417
|
if (!(k in args)) args[k] = v
|
|
294
418
|
}
|
|
295
419
|
}
|
|
420
|
+
// Coerce each argument to its schema-declared scalar type (Int/Float/Boolean/
|
|
421
|
+
// ID). A literal `task(id: "5")` or a string variable thus reaches the
|
|
422
|
+
// resolver as the right runtime type instead of being silently mistyped.
|
|
423
|
+
const argTypes = this.#schemaArgTypes()[`${typeName}.${field.name}`]
|
|
424
|
+
if (argTypes) {
|
|
425
|
+
for (const [k, declaredType] of Object.entries(argTypes)) {
|
|
426
|
+
if (k in args) args[k] = coerceScalar(args[k], declaredType)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
296
429
|
return args
|
|
297
430
|
}
|
|
298
431
|
|
|
@@ -307,15 +440,18 @@ export class GraphQLEngine {
|
|
|
307
440
|
return 'Unauthorized'
|
|
308
441
|
}
|
|
309
442
|
|
|
443
|
+
// Read roles/permissions from the top level OR nested under `user` — the
|
|
444
|
+
// auth provider (e.g. warden) sets `ctx.auth.user.roles`, not the top level.
|
|
445
|
+
// Mirrors the HTTP guard middleware fix.
|
|
310
446
|
if (roles.length > 0) {
|
|
311
|
-
const userRoles = ctx.auth?.roles ?? []
|
|
447
|
+
const userRoles = ctx.auth?.roles ?? ctx.auth?.user?.roles ?? []
|
|
312
448
|
if (!roles.some((r) => userRoles.includes(r))) {
|
|
313
449
|
return 'Insufficient role'
|
|
314
450
|
}
|
|
315
451
|
}
|
|
316
452
|
|
|
317
453
|
if (permissions.length > 0) {
|
|
318
|
-
const userPerms = ctx.auth?.permissions ?? []
|
|
454
|
+
const userPerms = ctx.auth?.permissions ?? ctx.auth?.user?.permissions ?? []
|
|
319
455
|
if (!permissions.every((p) => userPerms.includes(p))) {
|
|
320
456
|
return 'Insufficient permissions'
|
|
321
457
|
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* @implements Story 24.5
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
18
19
|
import type { AppContext } from '../Provider.js'
|
|
19
20
|
import { Provider } from '../Provider.js'
|
|
20
21
|
import type { Router } from '../router/Router.js'
|
|
@@ -42,9 +43,23 @@ export class GraphQLProvider extends Provider {
|
|
|
42
43
|
const config = this.app.config.get<GraphQLConfig>('graphql')
|
|
43
44
|
const engine = this.#engine ?? (config?.schemaPath ? new GraphQLEngine(config) : undefined)
|
|
44
45
|
if (!engine) return // GraphQL not configured — opt-out.
|
|
45
|
-
|
|
46
|
+
const container = this.app.container
|
|
47
|
+
if (container.has('graphql')) {
|
|
48
|
+
if (container.resolve('graphql') === engine) {
|
|
49
|
+
this.#engine = engine
|
|
50
|
+
return // re-register is idempotent
|
|
51
|
+
}
|
|
52
|
+
throw new ReamError(
|
|
53
|
+
'GRAPHQL_PROVIDER_ALREADY_REGISTERED',
|
|
54
|
+
"Container token 'graphql' is already bound to a different instance",
|
|
55
|
+
{
|
|
56
|
+
hint: 'Only one GraphQLProvider can own the graphql binding. Remove the duplicate provider from your reamrc.ts.',
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
engine.useContainer(container)
|
|
46
61
|
this.#engine = engine
|
|
47
|
-
|
|
62
|
+
container.singleton('graphql', () => engine)
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
override async boot(): Promise<void> {
|
package/src/http/HttpContext.ts
CHANGED
|
@@ -27,6 +27,23 @@ export interface AuthState {
|
|
|
27
27
|
permissions?: string[]
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Per-request authorization entry point — populated by an authorization
|
|
32
|
+
* middleware (e.g. `@c9up/warden`'s Bouncer initializer). A structural
|
|
33
|
+
* contract only: Ream stays agnostic of any concrete authorization package,
|
|
34
|
+
* exactly as `auth` is a slot that Warden fills. A handler authorizes via
|
|
35
|
+
* `await ctx.bouncer?.authorize('post.edit', post)` (throws on denial, mapped
|
|
36
|
+
* to 403 by the ExceptionHandler) or branches on `ctx.bouncer?.allows(...)`.
|
|
37
|
+
*/
|
|
38
|
+
export interface Authorizer {
|
|
39
|
+
/** True iff the action is authorized. Never throws on denial. */
|
|
40
|
+
allows(ability: string, ...args: unknown[]): Promise<boolean>
|
|
41
|
+
/** Boolean negation of {@link allows}. Never throws on denial. */
|
|
42
|
+
denies(ability: string, ...args: unknown[]): Promise<boolean>
|
|
43
|
+
/** Resolves on allow; throws an authorization failure (status 403) on deny. */
|
|
44
|
+
authorize(ability: string, ...args: unknown[]): Promise<void>
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
export interface RouteInfo {
|
|
31
48
|
pattern: string
|
|
32
49
|
name?: string
|
|
@@ -62,7 +79,11 @@ export class HttpContext {
|
|
|
62
79
|
/** Authentication state — populated by auth middleware. */
|
|
63
80
|
auth: AuthState = { authenticated: false }
|
|
64
81
|
|
|
65
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Request locale — the primary subtag of the first `Accept-Language` entry
|
|
84
|
+
* (e.g. `fr-CH,fr;q=0.9` → `fr`), defaulting to `en`. Middleware (or i18n)
|
|
85
|
+
* may override it; it's a plain mutable field.
|
|
86
|
+
*/
|
|
66
87
|
locale = 'en'
|
|
67
88
|
|
|
68
89
|
/** Per-request key-value store (for middleware to pass data downstream). */
|
|
@@ -75,6 +96,13 @@ export class HttpContext {
|
|
|
75
96
|
*/
|
|
76
97
|
events?: Emitter
|
|
77
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Per-request authorization entry point, when an authorization middleware
|
|
101
|
+
* is wired (e.g. `@c9up/warden`'s Bouncer initializer). Undefined when no
|
|
102
|
+
* such middleware ran. See {@link Authorizer}.
|
|
103
|
+
*/
|
|
104
|
+
bouncer?: Authorizer
|
|
105
|
+
|
|
78
106
|
/** Route URL resolver for redirect().toRoute(). */
|
|
79
107
|
#routeUrlResolver?: RouteUrlResolver
|
|
80
108
|
|
|
@@ -89,6 +117,7 @@ export class HttpContext {
|
|
|
89
117
|
this.route = route
|
|
90
118
|
this.request = new Request(rawRequest, params)
|
|
91
119
|
this.response = new Response()
|
|
120
|
+
this.locale = parseAcceptLanguage(this.request.header('accept-language')) ?? 'en'
|
|
92
121
|
|
|
93
122
|
// Wire redirect builder with request context
|
|
94
123
|
this.response.setRedirectFactory(
|
|
@@ -106,3 +135,11 @@ export class HttpContext {
|
|
|
106
135
|
this.#routeUrlResolver = resolver
|
|
107
136
|
}
|
|
108
137
|
}
|
|
138
|
+
|
|
139
|
+
/** Primary subtag of the first Accept-Language entry: `fr-CH,fr;q=0.9` → `fr`. */
|
|
140
|
+
function parseAcceptLanguage(header: string | undefined): string | undefined {
|
|
141
|
+
if (!header) return undefined
|
|
142
|
+
const first = header.split(',')[0]?.trim().split(';')[0]?.trim()
|
|
143
|
+
const primary = first?.split('-')[0]?.toLowerCase()
|
|
144
|
+
return primary || undefined
|
|
145
|
+
}
|