@c9up/ream 0.1.32 → 0.1.33
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 +27 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +29 -0
- package/dist/Application.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +13 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/HttpKernel.d.ts +8 -0
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +35 -12
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +8 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +39 -0
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +55 -43
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +52 -0
- package/dist/bodyparser/qsParse.d.ts.map +1 -0
- package/dist/bodyparser/qsParse.js +161 -0
- package/dist/bodyparser/qsParse.js.map +1 -0
- package/dist/bootstrap/hyper-server.d.ts.map +1 -1
- package/dist/bootstrap/hyper-server.js +2 -8
- package/dist/bootstrap/hyper-server.js.map +1 -1
- package/dist/container/Container.d.ts +41 -0
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +193 -26
- package/dist/container/Container.js.map +1 -1
- package/dist/container/ContainerResolver.d.ts +46 -0
- package/dist/container/ContainerResolver.d.ts.map +1 -0
- package/dist/container/ContainerResolver.js +60 -0
- package/dist/container/ContainerResolver.js.map +1 -0
- package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
- package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
- package/dist/container/ContextualBindingsBuilder.js +31 -0
- package/dist/container/ContextualBindingsBuilder.js.map +1 -0
- package/dist/container/types.d.ts +9 -6
- package/dist/container/types.d.ts.map +1 -1
- package/dist/decorators/Service.d.ts +1 -5
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/decorators/Service.js +10 -1
- package/dist/decorators/Service.js.map +1 -1
- package/dist/events/Emitter.d.ts +66 -6
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +165 -28
- package/dist/events/Emitter.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +5 -5
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/helpers/duration.d.ts +32 -0
- package/dist/helpers/duration.d.ts.map +1 -0
- package/dist/helpers/duration.js +74 -0
- package/dist/helpers/duration.js.map +1 -0
- package/dist/helpers/napi-loader.d.ts +10 -1
- package/dist/helpers/napi-loader.d.ts.map +1 -1
- package/dist/helpers/napi-loader.js +18 -3
- package/dist/helpers/napi-loader.js.map +1 -1
- package/dist/helpers/types.d.ts +1 -1
- package/dist/helpers/types.d.ts.map +1 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +17 -5
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +16 -5
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +4 -2
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +48 -2
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +95 -14
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +128 -8
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +259 -8
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +74 -3
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +177 -4
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts +36 -7
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +66 -22
- package/dist/router/Router.js.map +1 -1
- package/dist/security/CookieSigner.d.ts +35 -4
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +99 -10
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/server/Server.d.ts +2 -3
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +30 -13
- package/dist/server/Server.js.map +1 -1
- package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
- package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
- package/dist/session/ReadOnlyValuesStore.js +45 -0
- package/dist/session/ReadOnlyValuesStore.js.map +1 -0
- package/dist/session/Session.d.ts +181 -9
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +284 -33
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +87 -33
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
- package/dist/session/drivers/DatabaseDriver.js +132 -0
- package/dist/session/drivers/DatabaseDriver.js.map +1 -0
- package/dist/session/drivers/FileDriver.d.ts +22 -0
- package/dist/session/drivers/FileDriver.d.ts.map +1 -0
- package/dist/session/drivers/FileDriver.js +66 -0
- package/dist/session/drivers/FileDriver.js.map +1 -0
- package/dist/session/drivers/MemoryDriver.d.ts +10 -2
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +70 -0
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +10 -2
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +58 -0
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/session/templateTags.d.ts +42 -0
- package/dist/session/templateTags.d.ts.map +1 -0
- package/dist/session/templateTags.js +101 -0
- package/dist/session/templateTags.js.map +1 -0
- package/dist/testing/RequestBuilder.d.ts +38 -1
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +40 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/testing/TestClient.d.ts +3 -1
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +3 -1
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/types/helpers.d.ts +1 -1
- package/dist/types/helpers.d.ts.map +1 -1
- package/dist/utils/Macroable.d.ts +14 -5
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +20 -18
- package/dist/utils/Macroable.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +39 -0
- package/src/ErrorBoundary.ts +13 -1
- package/src/HttpKernel.ts +45 -19
- package/src/Ignitor.ts +39 -0
- package/src/bodyparser/BodyParserMiddleware.ts +66 -44
- package/src/bodyparser/qsParse.ts +179 -0
- package/src/bootstrap/hyper-server.ts +2 -9
- package/src/container/Container.ts +265 -25
- package/src/container/ContainerResolver.ts +84 -0
- package/src/container/ContextualBindingsBuilder.ts +41 -0
- package/src/container/types.ts +11 -7
- package/src/decorators/Service.ts +16 -3
- package/src/events/Emitter.ts +200 -25
- package/src/graphql/GraphQLEngine.ts +7 -8
- package/src/helpers/duration.ts +71 -0
- package/src/helpers/napi-loader.ts +19 -3
- package/src/helpers/types.ts +4 -1
- package/src/http/Exception.ts +17 -5
- package/src/http/HttpContext.ts +20 -5
- package/src/http/RedirectBuilder.ts +126 -11
- package/src/http/Request.ts +283 -13
- package/src/http/Response.ts +213 -6
- package/src/index.ts +17 -3
- package/src/router/Router.ts +98 -33
- package/src/security/CookieSigner.ts +102 -10
- package/src/server/Server.ts +36 -16
- package/src/session/ReadOnlyValuesStore.ts +52 -0
- package/src/session/Session.ts +364 -37
- package/src/session/SessionMiddleware.ts +93 -30
- package/src/session/drivers/DatabaseDriver.ts +184 -0
- package/src/session/drivers/FileDriver.ts +82 -0
- package/src/session/drivers/MemoryDriver.ts +75 -2
- package/src/session/drivers/RedisDriver.ts +77 -2
- package/src/session/templateTags.ts +124 -0
- package/src/testing/RequestBuilder.ts +76 -0
- package/src/testing/TestClient.ts +10 -2
- package/src/types/helpers.ts +4 -1
- package/src/utils/Macroable.ts +23 -8
- package/dist/utils/inflect.d.ts +0 -17
- package/dist/utils/inflect.d.ts.map +0 -1
- package/dist/utils/inflect.js +0 -32
- package/dist/utils/inflect.js.map +0 -1
- package/src/utils/inflect.ts +0 -29
package/src/Application.ts
CHANGED
|
@@ -20,12 +20,21 @@ import { Container } from './container/Container.js'
|
|
|
20
20
|
import type { AppContext, ProviderContract } from './Provider.js'
|
|
21
21
|
import { callProviderPhase } from './Provider.js'
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* How far the app intends to go in its lifecycle.
|
|
25
|
+
*
|
|
26
|
+
* `run` is a real boot. `warmup` means the app is being INSPECTED — a codegen
|
|
27
|
+
* or listing command — so it will never become ready.
|
|
28
|
+
*/
|
|
29
|
+
export type ApplicationMode = 'run' | 'warmup'
|
|
30
|
+
|
|
23
31
|
export class Application implements AppContext {
|
|
24
32
|
readonly container: Container
|
|
25
33
|
readonly config: ConfigStore
|
|
26
34
|
#appRoot?: URL
|
|
27
35
|
private providers: ProviderContract[] = []
|
|
28
36
|
private _booted = false
|
|
37
|
+
#mode: ApplicationMode = 'run'
|
|
29
38
|
private _bootingHooks: Array<() => Promise<void> | void> = []
|
|
30
39
|
private _bootedHooks: Array<() => Promise<void> | void> = []
|
|
31
40
|
private _shutdownHooks: Array<() => Promise<void> | void> = []
|
|
@@ -35,6 +44,36 @@ export class Application implements AppContext {
|
|
|
35
44
|
this.config = new ConfigStore()
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The current mode (AdonisJS `getMode`).
|
|
49
|
+
*
|
|
50
|
+
* A provider reads it to skip its SIDE EFFECTS — starting queue workers,
|
|
51
|
+
* opening a connection — when the app is only being inspected. It must never
|
|
52
|
+
* change which bindings are registered: the app being inspected has to match
|
|
53
|
+
* the app that runs, or the generated types describe something else.
|
|
54
|
+
*
|
|
55
|
+
* async start() {
|
|
56
|
+
* if (this.app.getMode() !== 'run') return
|
|
57
|
+
* await startQueueWorkers()
|
|
58
|
+
* }
|
|
59
|
+
*/
|
|
60
|
+
getMode(): ApplicationMode {
|
|
61
|
+
return this.#mode
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Switch the mode (AdonisJS `setMode`). Only before boot — afterwards the
|
|
66
|
+
* side effects a provider skipped have already been skipped, and pretending
|
|
67
|
+
* otherwise would leave the app half-started.
|
|
68
|
+
*/
|
|
69
|
+
setMode(mode: ApplicationMode): this {
|
|
70
|
+
if (this._booted) {
|
|
71
|
+
throw new Error(`Cannot switch to '${mode}' mode: the application is already booted.`)
|
|
72
|
+
}
|
|
73
|
+
this.#mode = mode
|
|
74
|
+
return this
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
// ─── Paths ────────────────────────────────────────────────
|
|
39
78
|
// AdonisJS-style path helpers — resolve against the project root passed to
|
|
40
79
|
// the Ignitor (`new Ignitor(new URL('../', import.meta.url))`). App code uses
|
package/src/ErrorBoundary.ts
CHANGED
|
@@ -142,7 +142,15 @@ export class ErrorBoundary {
|
|
|
142
142
|
process.stderr.write(`[ErrorBoundary] Failed to emit: ${JSON.stringify(event)}\n`)
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
// A fatal always reaches stderr, dev or not.
|
|
146
|
+
//
|
|
147
|
+
// Installing this boundary REPLACES Node's own handling of an unhandled
|
|
148
|
+
// rejection, which prints the reason and exits. An app that registered no
|
|
149
|
+
// error listener would otherwise swallow every one of them silently — the
|
|
150
|
+
// boundary would turn each forgotten `await` from a loud crash into
|
|
151
|
+
// nothing at all, which is the opposite of what it is for. A duplicated
|
|
152
|
+
// line when a listener also logs it is a much smaller price.
|
|
153
|
+
if (this.devMode || event.type === 'system.fatal') {
|
|
146
154
|
const prefix =
|
|
147
155
|
event.type === 'system.fatal'
|
|
148
156
|
? '✗ FATAL'
|
|
@@ -152,6 +160,10 @@ export class ErrorBoundary {
|
|
|
152
160
|
? '⚠ SECURITY'
|
|
153
161
|
: '✗ SERVICE'
|
|
154
162
|
process.stderr.write(`${prefix} [${event.source}] ${event.message}\n`)
|
|
163
|
+
// The stack is the only part that says WHERE the rejection came from.
|
|
164
|
+
if (event.type === 'system.fatal' && event.originalError !== undefined) {
|
|
165
|
+
process.stderr.write(`${event.originalError}\n`)
|
|
166
|
+
}
|
|
155
167
|
}
|
|
156
168
|
}
|
|
157
169
|
}
|
package/src/HttpKernel.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
RuntimeValidator,
|
|
21
21
|
} from './middleware/Pipeline.js'
|
|
22
22
|
import { compose } from './middleware/Pipeline.js'
|
|
23
|
-
import type { RouteDefinition, Router } from './router/Router.js'
|
|
23
|
+
import type { AnyConstructor, RouteDefinition, Router } from './router/Router.js'
|
|
24
24
|
import type { CookieSigner } from './security/CookieSigner.js'
|
|
25
25
|
import type { SignedUrl } from './security/SignedUrl.js'
|
|
26
26
|
import type { Dict } from './types/helpers.js'
|
|
@@ -41,6 +41,14 @@ export interface HttpKernelConfig {
|
|
|
41
41
|
* SSE path with a clean error — used by mock servers in unit tests.
|
|
42
42
|
*/
|
|
43
43
|
streamBackend?: () => import('./http/SseStream.js').StreamBackend | undefined
|
|
44
|
+
/**
|
|
45
|
+
* Honour a `_method` form field on POST (AdonisJS `http.allowMethodSpoofing`).
|
|
46
|
+
*
|
|
47
|
+
* OFF by default, as upstream. An HTML form cannot send PUT or DELETE, so an
|
|
48
|
+
* app that relies on the workaround has to say so — otherwise any POST body
|
|
49
|
+
* could rewrite itself into a DELETE.
|
|
50
|
+
*/
|
|
51
|
+
allowMethodSpoofing?: boolean
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export interface HttpKernelResponse {
|
|
@@ -152,17 +160,22 @@ export function createHttpKernel(
|
|
|
152
160
|
}
|
|
153
161
|
: { pattern: '', middleware: [] }
|
|
154
162
|
|
|
155
|
-
// 4. Create HttpContext
|
|
156
|
-
// `
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
163
|
+
// 4. Create HttpContext with its OWN resolver (Adonis
|
|
164
|
+
// `container.createResolver()`), so agnostic middleware (Warden,
|
|
165
|
+
// Blackhole) resolve host services from the context they are handed
|
|
166
|
+
// instead of importing `@c9up/ream/services/app` at runtime — and so a
|
|
167
|
+
// value bound for this request cannot be seen by any other.
|
|
168
|
+
const resolver = config.container?.createResolver()
|
|
169
|
+
const ctx = new HttpContext(correlationId, reqData, match?.params ?? {}, routeInfo, resolver)
|
|
170
|
+
// What Adonis binds on every request's resolver: the context itself, so a
|
|
171
|
+
// controller or service can take `HttpContext` as a constructor dependency.
|
|
172
|
+
resolver?.bindValue(HttpContext, ctx)
|
|
173
|
+
if (config.allowMethodSpoofing === true) {
|
|
174
|
+
// Was never wired: the setter existed but nothing called it, so a
|
|
175
|
+
// migrated config asking for spoofing got a `_method` field that did
|
|
176
|
+
// nothing at all.
|
|
177
|
+
ctx.request.setMethodSpoofing(true)
|
|
178
|
+
}
|
|
166
179
|
ctx.setRouteUrlResolver((name, params) => config.router.urlFor(name, params))
|
|
167
180
|
// Inject the async-resolved APP_KEY services + base logger (resolved once).
|
|
168
181
|
await resolveServices()
|
|
@@ -296,7 +309,11 @@ export function createHttpKernel(
|
|
|
296
309
|
path: reqData.path,
|
|
297
310
|
status: ctx.response.getStatus(),
|
|
298
311
|
})
|
|
299
|
-
|
|
312
|
+
const serialized = await serializeResponse(ctx)
|
|
313
|
+
// The body is built; anything registered with `response.onFinish()` runs
|
|
314
|
+
// now — after the answer is ready, before we hand it back.
|
|
315
|
+
ctx.response.runFinishCallbacks()
|
|
316
|
+
return serialized
|
|
300
317
|
} catch (error) {
|
|
301
318
|
// If the handler opened an SSE stream via `response.sse()` and
|
|
302
319
|
// THEN threw, the reserved stream id is still on the response.
|
|
@@ -334,18 +351,24 @@ export function createHttpKernel(
|
|
|
334
351
|
path: reqData.path,
|
|
335
352
|
status: ctx.response.getStatus(),
|
|
336
353
|
})
|
|
337
|
-
|
|
354
|
+
const serialized = await serializeResponse(ctx)
|
|
355
|
+
// The body is built; anything registered with `response.onFinish()` runs
|
|
356
|
+
// now — after the answer is ready, before we hand it back.
|
|
357
|
+
ctx.response.runFinishCallbacks()
|
|
358
|
+
return serialized
|
|
338
359
|
}
|
|
339
360
|
}
|
|
340
361
|
}
|
|
341
362
|
|
|
342
363
|
function createControllerHandler(
|
|
343
|
-
|
|
344
|
-
controller: { target: new (...args: any[]) => any; method: string },
|
|
364
|
+
controller: { target: AnyConstructor; method: string },
|
|
345
365
|
container?: Container,
|
|
346
366
|
): (ctx: HttpContext) => Promise<void> {
|
|
347
367
|
return async (ctx: HttpContext) => {
|
|
348
|
-
|
|
368
|
+
// Through the REQUEST resolver when there is one: a controller taking
|
|
369
|
+
// `HttpContext` as a dependency must get this request's, not a stale one.
|
|
370
|
+
const resolve = ctx.containerResolver ?? container
|
|
371
|
+
const instance = resolve ? await resolve.make(controller.target) : new controller.target()
|
|
349
372
|
const method = (instance as Record<string, (ctx: HttpContext) => Promise<void> | void>)[
|
|
350
373
|
controller.method
|
|
351
374
|
]
|
|
@@ -363,7 +386,7 @@ function createControllerHandler(
|
|
|
363
386
|
* Returns empty arrays if no decorators are present.
|
|
364
387
|
*/
|
|
365
388
|
function readControllerGuardMetadata(
|
|
366
|
-
target:
|
|
389
|
+
target: AnyConstructor,
|
|
367
390
|
method: string,
|
|
368
391
|
): { guards: string[]; roles: string[]; permissions: string[] } {
|
|
369
392
|
try {
|
|
@@ -379,7 +402,10 @@ function readControllerGuardMetadata(
|
|
|
379
402
|
}
|
|
380
403
|
}
|
|
381
404
|
|
|
382
|
-
function serializeResponse(ctx: HttpContext): HttpKernelResponse {
|
|
405
|
+
async function serializeResponse(ctx: HttpContext): Promise<HttpKernelResponse> {
|
|
406
|
+
// A body still being drained by `response.stream()` — upstream returns void
|
|
407
|
+
// there, so a migrated controller does not await it.
|
|
408
|
+
await ctx.response.settle()
|
|
383
409
|
const streamId = ctx.response.getStreamId()
|
|
384
410
|
return {
|
|
385
411
|
status: ctx.response.getStatus(),
|
package/src/Ignitor.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { startHotReload } from './HotReload.js'
|
|
|
29
29
|
import type { HttpKernelRequest, HttpKernelResponse } from './HttpKernel.js'
|
|
30
30
|
import { createHttpKernel } from './HttpKernel.js'
|
|
31
31
|
import { ExceptionHandler } from './http/Exception.js'
|
|
32
|
+
import { HttpContext } from './http/HttpContext.js'
|
|
32
33
|
import type { MiddlewareFunction } from './middleware/Pipeline.js'
|
|
33
34
|
import { MiddlewareRegistry } from './middleware/Pipeline.js'
|
|
34
35
|
import type { AppContext, ProviderContract } from './Provider.js'
|
|
@@ -40,6 +41,7 @@ import { Server } from './server/Server.js'
|
|
|
40
41
|
import { clearApp, setApp } from './services/app.js'
|
|
41
42
|
import { clearRouter, setRouter } from './services/router.js'
|
|
42
43
|
import { clearServer, setServer } from './services/server.js'
|
|
44
|
+
import { registerSessionTemplateTags } from './session/templateTags.js'
|
|
43
45
|
|
|
44
46
|
/** Application environment. */
|
|
45
47
|
export type AppEnvironment = 'web' | 'console' | 'test' | 'unknown'
|
|
@@ -253,6 +255,12 @@ export class Ignitor {
|
|
|
253
255
|
this.app.container.singleton('server', () => this.server)
|
|
254
256
|
this.app.container.singleton('middleware', () => this.middleware)
|
|
255
257
|
this.app.container.singleton('app', () => this.app)
|
|
258
|
+
// The HttpContext CLASS, so an integration provider can extend it the way
|
|
259
|
+
// AdonisJS does — `HttpContext.getter('view', …)` is how the view layer
|
|
260
|
+
// attaches `ctx.view`. Exported from the barrel too, but a provider that
|
|
261
|
+
// must stay agnostic reaches it through the container instead of importing
|
|
262
|
+
// ream at runtime.
|
|
263
|
+
this.app.container.singleton('HttpContext', () => HttpContext)
|
|
256
264
|
// `appRoot` is the URL passed to `new Ignitor(new URL('../', import.meta.url))`.
|
|
257
265
|
// Providers resolve it through the container so they can interpret
|
|
258
266
|
// relative paths in config files (e.g. `pages.root: './resources/pages'`)
|
|
@@ -431,6 +439,30 @@ export class Ignitor {
|
|
|
431
439
|
loadEnvFiles(this.appRoot, { skipEnvLocal: this.environment === 'test' })
|
|
432
440
|
}
|
|
433
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Give the template engine `@error` / `@errors` / `@inputError` /
|
|
444
|
+
* `@flashMessage`, as AdonisJS's session plugin does.
|
|
445
|
+
*
|
|
446
|
+
* The engine is duck-typed out of the container so ream carries no dependency
|
|
447
|
+
* on it, and an app with no template layer simply skips this.
|
|
448
|
+
*/
|
|
449
|
+
private async registerSessionTemplateTags(): Promise<void> {
|
|
450
|
+
for (const token of ['inker', 'view']) {
|
|
451
|
+
if (!this.app.container.has(token)) continue
|
|
452
|
+
const binding: unknown = await this.app.container.resolve(token)
|
|
453
|
+
for (const candidate of [binding, Reflect.get(Object(binding), '_templates')]) {
|
|
454
|
+
const register = Reflect.get(Object(candidate), 'registerTag')
|
|
455
|
+
if (typeof register !== 'function') continue
|
|
456
|
+
registerSessionTemplateTags({
|
|
457
|
+
registerTag: (tag) => {
|
|
458
|
+
register.call(candidate, tag)
|
|
459
|
+
},
|
|
460
|
+
})
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
434
466
|
private async phaseRegister(): Promise<void> {
|
|
435
467
|
// Auto-load config/*.ts files into app.config
|
|
436
468
|
await this.autoloadConfig()
|
|
@@ -527,6 +559,8 @@ export class Ignitor {
|
|
|
527
559
|
onError: (error, ctx) => {
|
|
528
560
|
this.errorBoundary.serviceError('HttpKernel', error, ctx.id)
|
|
529
561
|
},
|
|
562
|
+
// AdonisJS `config/app.ts` → `http: { allowMethodSpoofing: true }`.
|
|
563
|
+
allowMethodSpoofing: this.app.config.get<boolean>('app.http.allowMethodSpoofing') === true,
|
|
530
564
|
streamBackend: () => {
|
|
531
565
|
const server = this._httpServer
|
|
532
566
|
if (
|
|
@@ -601,6 +635,11 @@ export class Ignitor {
|
|
|
601
635
|
// Install error boundary
|
|
602
636
|
this.errorBoundary.install()
|
|
603
637
|
|
|
638
|
+
// Publish the flash-message tags to the template engine, if one is
|
|
639
|
+
// installed. After boot(), so the engine's own provider has bound it;
|
|
640
|
+
// before ready(), so a provider that pre-renders finds the tags in place.
|
|
641
|
+
await this.registerSessionTemplateTags()
|
|
642
|
+
|
|
604
643
|
// Call ready() on providers
|
|
605
644
|
for (const provider of this.providers) {
|
|
606
645
|
await callProviderPhase(provider, 'ready')
|
|
@@ -9,25 +9,32 @@
|
|
|
9
9
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
10
10
|
import { hydrateMultipartPayload } from './MultipartFile.js'
|
|
11
11
|
import { parseSize } from './parseSize.js'
|
|
12
|
+
import { parseQueryString, type QsParseOptions } from './qsParse.js'
|
|
12
13
|
|
|
13
14
|
export interface BodyParserConfig {
|
|
14
15
|
json?: {
|
|
15
|
-
enabled?: boolean
|
|
16
16
|
limit?: string // e.g. '1mb'
|
|
17
17
|
types?: string[]
|
|
18
18
|
}
|
|
19
19
|
form?: {
|
|
20
|
-
enabled?: boolean
|
|
21
20
|
limit?: string
|
|
22
21
|
types?: string[]
|
|
22
|
+
/**
|
|
23
|
+
* Turn `''` into `null`. AdonisJS defaults this to `true`, so an empty
|
|
24
|
+
* text input arrives as `null` rather than an empty string — which is what
|
|
25
|
+
* a "nullable" validation rule expects to see.
|
|
26
|
+
*/
|
|
27
|
+
convertEmptyStringsToNull?: boolean
|
|
28
|
+
/** Trim surrounding whitespace. AdonisJS defaults this to `true`. */
|
|
29
|
+
trimWhitespaces?: boolean
|
|
30
|
+
/** Options for the bracket-notation parser (`depth`, `parameterLimit`). */
|
|
31
|
+
queryString?: QsParseOptions
|
|
23
32
|
}
|
|
24
33
|
raw?: {
|
|
25
|
-
enabled?: boolean
|
|
26
34
|
limit?: string
|
|
27
35
|
types?: string[]
|
|
28
36
|
}
|
|
29
37
|
multipart?: {
|
|
30
|
-
enabled?: boolean
|
|
31
38
|
/** Total multipart body size cap (sum of all file sizes). Default: '20mb'. */
|
|
32
39
|
limit?: string
|
|
33
40
|
/** Maximum number of files per request. Default: 20. */
|
|
@@ -47,22 +54,24 @@ interface ResolvedBodyParserConfig {
|
|
|
47
54
|
|
|
48
55
|
const DEFAULT_CONFIG: ResolvedBodyParserConfig = {
|
|
49
56
|
json: {
|
|
50
|
-
enabled: true,
|
|
51
57
|
limit: '1mb',
|
|
52
58
|
types: ['application/json', 'application/vnd.api+json'],
|
|
53
59
|
},
|
|
54
60
|
form: {
|
|
55
|
-
enabled: true,
|
|
56
61
|
limit: '1mb',
|
|
57
62
|
types: ['application/x-www-form-urlencoded'],
|
|
63
|
+
// AdonisJS ships both as `true` (define_config: form.convertEmptyStringsToNull
|
|
64
|
+
// / trimWhitespaces), so a migrated form behaves the same here.
|
|
65
|
+
convertEmptyStringsToNull: true,
|
|
66
|
+
trimWhitespaces: true,
|
|
67
|
+
queryString: {},
|
|
58
68
|
},
|
|
59
69
|
raw: {
|
|
60
|
-
|
|
70
|
+
// AdonisJS parses `text/*` out of the box (define_config: raw.types).
|
|
61
71
|
limit: '1mb',
|
|
62
|
-
types: ['text
|
|
72
|
+
types: ['text/*'],
|
|
63
73
|
},
|
|
64
74
|
multipart: {
|
|
65
|
-
enabled: true,
|
|
66
75
|
limit: '20mb',
|
|
67
76
|
maxFiles: 20,
|
|
68
77
|
maxFields: 500,
|
|
@@ -75,6 +84,7 @@ export default class BodyParserMiddleware {
|
|
|
75
84
|
#config: ResolvedBodyParserConfig
|
|
76
85
|
|
|
77
86
|
constructor(config?: BodyParserConfig) {
|
|
87
|
+
assertNoEnabledFlag(config)
|
|
78
88
|
this.#config = {
|
|
79
89
|
json: { ...DEFAULT_CONFIG.json, ...config?.json },
|
|
80
90
|
form: { ...DEFAULT_CONFIG.form, ...config?.form },
|
|
@@ -96,26 +106,32 @@ export default class BodyParserMiddleware {
|
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
// JSON
|
|
99
|
-
if (
|
|
100
|
-
// Request
|
|
109
|
+
if (matchesType(contentType, this.#config.json.types)) {
|
|
110
|
+
// `Request` lazy-parses JSON on first read; nothing to do here.
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
// Form URL-encoded
|
|
104
|
-
if (
|
|
105
|
-
ctx.request.setParsedBody(
|
|
114
|
+
if (matchesType(contentType, this.#config.form.types)) {
|
|
115
|
+
ctx.request.setParsedBody(
|
|
116
|
+
parseQueryString(rawBody, {
|
|
117
|
+
...this.#config.form.queryString,
|
|
118
|
+
convertEmptyStringsToNull: this.#config.form.convertEmptyStringsToNull,
|
|
119
|
+
trimWhitespaces: this.#config.form.trimWhitespaces,
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
// Raw text — wrap the string under `_body` so consumers can still
|
|
109
125
|
// reach it through `request.input('_body')`. This matches the fallback
|
|
110
126
|
// shape used by `Request.#ensureParsedBody` when JSON yields a non-object.
|
|
111
|
-
if (
|
|
127
|
+
if (matchesType(contentType, this.#config.raw.types)) {
|
|
112
128
|
ctx.request.setParsedBody({ _body: rawBody })
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
// Multipart — the Rust-side HyperServer parses the body server-side
|
|
116
132
|
// (multer crate) and ships the structured payload on `request.multipart`.
|
|
117
133
|
// JS just hydrates `MultipartFile` instances from the typed envelope.
|
|
118
|
-
if (
|
|
134
|
+
if (matchesType(contentType, this.#config.multipart.types)) {
|
|
119
135
|
const payload = ctx.request.multipart()
|
|
120
136
|
if (payload) {
|
|
121
137
|
if (this.#rejectMultipart(ctx, payload.files, payload.fields)) return
|
|
@@ -177,38 +193,44 @@ export default class BodyParserMiddleware {
|
|
|
177
193
|
}
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Whether a request's content-type is claimed by one of `types`.
|
|
198
|
+
*
|
|
199
|
+
* AdonisJS configures wildcards (`raw.types` defaults to `text/*`), so a bare
|
|
200
|
+
* substring test would never match `text/plain` against `text/*`. Parameters
|
|
201
|
+
* are stripped first: `application/json; charset=utf-8` is a JSON body.
|
|
202
|
+
*/
|
|
180
203
|
function matchesType(contentType: string, types: string[]): boolean {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (!pair) continue
|
|
189
|
-
const eqIdx = pair.indexOf('=')
|
|
190
|
-
if (eqIdx === -1) {
|
|
191
|
-
result[decodeFormComponent(pair)] = ''
|
|
192
|
-
} else {
|
|
193
|
-
result[decodeFormComponent(pair.slice(0, eqIdx))] = decodeFormComponent(pair.slice(eqIdx + 1))
|
|
204
|
+
const actual = (contentType.split(';')[0] ?? '').trim().toLowerCase()
|
|
205
|
+
if (!actual) return false
|
|
206
|
+
return types.some((candidate) => {
|
|
207
|
+
const pattern = candidate.trim().toLowerCase()
|
|
208
|
+
if (pattern === '*/*') return true
|
|
209
|
+
if (pattern.endsWith('/*')) {
|
|
210
|
+
return actual.startsWith(`${pattern.slice(0, -1)}`)
|
|
194
211
|
}
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
return actual === pattern
|
|
213
|
+
})
|
|
197
214
|
}
|
|
198
215
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
/**
|
|
217
|
+
* `enabled` was an atlas-era invention: AdonisJS has no such flag, and a parser
|
|
218
|
+
* is turned off by giving it no `types`. It also never worked for JSON, which
|
|
219
|
+
* `Request` lazy-parses on first read regardless.
|
|
220
|
+
*
|
|
221
|
+
* Silently ignoring a config key that used to mean something would re-enable a
|
|
222
|
+
* parser an app deliberately switched off, so it fails loudly with the
|
|
223
|
+
* replacement spelled out.
|
|
224
|
+
*/
|
|
225
|
+
function assertNoEnabledFlag(config?: BodyParserConfig): void {
|
|
226
|
+
if (!config) return
|
|
227
|
+
for (const section of ['json', 'form', 'raw', 'multipart'] as const) {
|
|
228
|
+
const value: unknown = config[section]
|
|
229
|
+
if (value !== null && typeof value === 'object' && 'enabled' in value) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`[E_BODYPARSER_CONFIG] \`${section}.enabled\` is no longer supported — AdonisJS has no such option. ` +
|
|
232
|
+
`To disable this parser, give it no types: \`${section}: { types: [] }\`.`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
213
235
|
}
|
|
214
236
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bracket-notation form parsing — what AdonisJS gets from `qs`.
|
|
3
|
+
*
|
|
4
|
+
* `@adonisjs/bodyparser` runs `qs.parse(body, options.qs)` on every
|
|
5
|
+
* `application/x-www-form-urlencoded` body, so `user[name]=ada` arrives as
|
|
6
|
+
* `{ user: { name: 'ada' } }` and a repeated `tags[]` becomes an array. Parsing
|
|
7
|
+
* flat instead does not merely lose the nesting: a repeated key OVERWRITES,
|
|
8
|
+
* so a multi-select or a checkbox group silently submits only its last value.
|
|
9
|
+
*
|
|
10
|
+
* Reimplemented rather than depending on `qs` — this package ships no runtime
|
|
11
|
+
* dependency it can write in a screenful, and the limits below are easier to
|
|
12
|
+
* defend when they are visible.
|
|
13
|
+
*
|
|
14
|
+
* Three guards, all of which `qs` also applies:
|
|
15
|
+
*
|
|
16
|
+
* - **Prototype keys are dropped.** `__proto__[x]=1` in a form body is the
|
|
17
|
+
* classic prototype-pollution vector; the key never reaches an object.
|
|
18
|
+
* - **Depth is capped** (`qs` defaults to 5). Beyond it the remainder is kept
|
|
19
|
+
* as a literal key rather than allocating unbounded nesting.
|
|
20
|
+
* - **Parameter count is capped** (`qs` defaults to 1000), so a body of a
|
|
21
|
+
* million `a[0]`, `a[1]`… cannot be turned into a million-entry object.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** `qs` defaults, reproduced so the behaviour is the same out of the box. */
|
|
25
|
+
export const DEFAULT_DEPTH = 5
|
|
26
|
+
export const DEFAULT_PARAMETER_LIMIT = 1000
|
|
27
|
+
|
|
28
|
+
export interface QsParseOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Treat `user.name` as `user[name]`. AdonisJS forces this ON for form
|
|
31
|
+
* bodies (`queryStringOptions.allowDots ??= true`), so a dotted field name
|
|
32
|
+
* nests there and must nest here.
|
|
33
|
+
*/
|
|
34
|
+
allowDots?: boolean
|
|
35
|
+
depth?: number
|
|
36
|
+
parameterLimit?: number
|
|
37
|
+
/** Turn `''` into `null`, as AdonisJS' `convertEmptyStringsToNull` does. */
|
|
38
|
+
convertEmptyStringsToNull?: boolean
|
|
39
|
+
/** Trim surrounding whitespace, as AdonisJS' `trimWhitespaces` does. */
|
|
40
|
+
trimWhitespaces?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Keys that must never be written onto an object. */
|
|
44
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Split `user[address][city]` into `['user', 'address', 'city']`, and
|
|
48
|
+
* `tags[]` into `['tags', '']` — an empty segment meaning "append".
|
|
49
|
+
* Segments past `depth` are folded back into one literal key, which is what
|
|
50
|
+
* `qs` does rather than erroring.
|
|
51
|
+
*/
|
|
52
|
+
function splitKey(key: string, depth: number, allowDots: boolean): string[] {
|
|
53
|
+
// `user.name` → `user[name]` before anything else, so both spellings take
|
|
54
|
+
// the same path. AdonisJS forces allowDots on for form bodies.
|
|
55
|
+
const key2 = allowDots && !key.startsWith('.') ? key.replace(/\.([^.[\]]+)/g, '[$1]') : key
|
|
56
|
+
const firstBracket = key2.indexOf('[')
|
|
57
|
+
if (firstBracket === -1) return [key2]
|
|
58
|
+
const root = key2.slice(0, firstBracket)
|
|
59
|
+
const segments: string[] = [root]
|
|
60
|
+
let rest = key2.slice(firstBracket)
|
|
61
|
+
while (segments.length <= depth) {
|
|
62
|
+
const match = /^\[([^\]]*)\]/.exec(rest)
|
|
63
|
+
if (!match) break
|
|
64
|
+
segments.push(match[1] ?? '')
|
|
65
|
+
rest = rest.slice(match[0].length)
|
|
66
|
+
if (rest.length === 0) return segments
|
|
67
|
+
}
|
|
68
|
+
// Anything left over (too deep, or malformed) stays literal on the last
|
|
69
|
+
// segment — no value is dropped, it is just not nested any further.
|
|
70
|
+
if (rest.length > 0) {
|
|
71
|
+
segments[segments.length - 1] = `${segments[segments.length - 1] ?? ''}${rest}`
|
|
72
|
+
}
|
|
73
|
+
return segments
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isIndex(segment: string): boolean {
|
|
77
|
+
return /^\d+$/.test(segment)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Assign `value` at `segments` inside `target`, creating containers as needed. */
|
|
81
|
+
function assign(target: Record<string, unknown>, segments: string[], value: unknown): void {
|
|
82
|
+
let cursor: Record<string, unknown> | unknown[] = target
|
|
83
|
+
for (let i = 0; i < segments.length; i++) {
|
|
84
|
+
const segment = segments[i] ?? ''
|
|
85
|
+
const last = i === segments.length - 1
|
|
86
|
+
|
|
87
|
+
if (FORBIDDEN_KEYS.has(segment)) return
|
|
88
|
+
|
|
89
|
+
if (last) {
|
|
90
|
+
if (segment === '') {
|
|
91
|
+
// `tags[]` — append.
|
|
92
|
+
if (Array.isArray(cursor)) cursor.push(value)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(cursor)) {
|
|
96
|
+
if (isIndex(segment)) cursor[Number(segment)] = value
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const existing = cursor[segment]
|
|
100
|
+
if (existing === undefined) {
|
|
101
|
+
cursor[segment] = value
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
// A key repeated WITHOUT brackets collects too, as `qs` does — losing
|
|
105
|
+
// the earlier value is the bug this whole file exists to fix.
|
|
106
|
+
if (Array.isArray(existing)) existing.push(value)
|
|
107
|
+
else cursor[segment] = [existing, value]
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const nextSegment = segments[i + 1] ?? ''
|
|
112
|
+
const wantsArray = nextSegment === '' || isIndex(nextSegment)
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(cursor)) {
|
|
115
|
+
if (!isIndex(segment)) return
|
|
116
|
+
const index = Number(segment)
|
|
117
|
+
const existing = cursor[index]
|
|
118
|
+
if (existing === undefined || typeof existing !== 'object' || existing === null) {
|
|
119
|
+
cursor[index] = wantsArray ? [] : {}
|
|
120
|
+
}
|
|
121
|
+
cursor = cursor[index] as Record<string, unknown> | unknown[]
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const existing = cursor[segment]
|
|
126
|
+
if (existing === undefined || typeof existing !== 'object' || existing === null) {
|
|
127
|
+
cursor[segment] = wantsArray ? [] : {}
|
|
128
|
+
}
|
|
129
|
+
cursor = cursor[segment] as Record<string, unknown> | unknown[]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `application/x-www-form-urlencoded` reserves `+` for space (RFC 1866 /
|
|
135
|
+
* WHATWG URL form spec), so the substitution happens BEFORE percent-decoding —
|
|
136
|
+
* otherwise a literal `+` (sent as `%2B`) would become a space too.
|
|
137
|
+
*
|
|
138
|
+
* Malformed percent-encoding makes `decodeURIComponent` throw; the
|
|
139
|
+
* substituted-but-undecoded value is kept so a bad body is a parse miss, not
|
|
140
|
+
* a 500.
|
|
141
|
+
*/
|
|
142
|
+
export function decodeFormComponent(value: string): string {
|
|
143
|
+
const spaced = value.replace(/\+/g, ' ')
|
|
144
|
+
try {
|
|
145
|
+
return decodeURIComponent(spaced)
|
|
146
|
+
} catch {
|
|
147
|
+
return spaced
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Parse a urlencoded body into a nested object. */
|
|
152
|
+
export function parseQueryString(
|
|
153
|
+
body: string,
|
|
154
|
+
options: QsParseOptions = {},
|
|
155
|
+
): Record<string, unknown> {
|
|
156
|
+
const depth = options.depth ?? DEFAULT_DEPTH
|
|
157
|
+
const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
|
|
158
|
+
const result: Record<string, unknown> = {}
|
|
159
|
+
if (!body) return result
|
|
160
|
+
|
|
161
|
+
const pairs = body.split('&')
|
|
162
|
+
let seen = 0
|
|
163
|
+
for (const pair of pairs) {
|
|
164
|
+
if (!pair) continue
|
|
165
|
+
if (++seen > parameterLimit) break
|
|
166
|
+
|
|
167
|
+
const eqIdx = pair.indexOf('=')
|
|
168
|
+
const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx)
|
|
169
|
+
const rawValue = eqIdx === -1 ? '' : pair.slice(eqIdx + 1)
|
|
170
|
+
|
|
171
|
+
const key = decodeFormComponent(rawKey)
|
|
172
|
+
let value: unknown = decodeFormComponent(rawValue)
|
|
173
|
+
if (options.trimWhitespaces && typeof value === 'string') value = value.trim()
|
|
174
|
+
if (options.convertEmptyStringsToNull && value === '') value = null
|
|
175
|
+
|
|
176
|
+
assign(result, splitKey(key, depth, options.allowDots ?? true), value)
|
|
177
|
+
}
|
|
178
|
+
return result
|
|
179
|
+
}
|