@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/http/Response.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { readFileSync } from 'node:fs'
|
|
|
11
11
|
import { basename } from 'node:path'
|
|
12
12
|
import etag from 'etag'
|
|
13
13
|
import { contentType } from 'mime-types'
|
|
14
|
+
import { durationToSeconds } from '../helpers/duration.js'
|
|
14
15
|
import type { CookieSigner } from '../security/CookieSigner.js'
|
|
15
16
|
import { Macroable } from '../utils/Macroable.js'
|
|
16
17
|
import { E_HTTP_REQUEST_ABORTED } from './Exception.js'
|
|
@@ -36,6 +37,28 @@ function assertNoCRLF(name: string, value: string): void {
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Build a `Content-Disposition` value for a download (RFC 6266).
|
|
42
|
+
*
|
|
43
|
+
* The filename sits in a quoted-string, so a `"` inside it would close the
|
|
44
|
+
* field early and the rest would be read as further parameters. Backslash and
|
|
45
|
+
* quote are escaped, which is what a quoted-string allows.
|
|
46
|
+
*
|
|
47
|
+
* A non-ASCII name cannot go in that field at all — the header is Latin-1 — so
|
|
48
|
+
* it also gets `filename*=UTF-8''…`, and the plain `filename=` keeps an ASCII
|
|
49
|
+
* approximation for clients that ignore the extended form. Without it an
|
|
50
|
+
* accented name reached the browser as mojibake, or was dropped.
|
|
51
|
+
*/
|
|
52
|
+
function contentDisposition(disposition: string, filename: string): string {
|
|
53
|
+
const quoted = filename.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
54
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control
|
|
55
|
+
// characters is the point — they cannot travel in a header field.
|
|
56
|
+
const ascii = quoted.replace(/[^\x20-\x7e]/g, '_')
|
|
57
|
+
const base = `${disposition}; filename="${ascii}"`
|
|
58
|
+
if (ascii === quoted) return base
|
|
59
|
+
return `${base}; filename*=UTF-8''${encodeURIComponent(filename)}`
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
/**
|
|
40
63
|
* JSON.stringify with AdonisJS-parity safety: `BigInt` values are emitted as
|
|
41
64
|
* strings (native `JSON.stringify` throws on them) and circular references are
|
|
@@ -54,19 +77,68 @@ function safeStringify(value: unknown): string {
|
|
|
54
77
|
})
|
|
55
78
|
}
|
|
56
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Pack a cookie value the way AdonisJS does: a JSON envelope, base64url.
|
|
82
|
+
*
|
|
83
|
+
* The envelope is what carries the TYPE — without it every cookie comes back a
|
|
84
|
+
* string, and `plainCookie('count', 3)` would read as `"3"` next request.
|
|
85
|
+
*/
|
|
86
|
+
function packCookieValue(value: unknown): string {
|
|
87
|
+
return Buffer.from(JSON.stringify({ message: value }), 'utf8').toString('base64url')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Unpack a value written by {@link packCookieValue}.
|
|
92
|
+
*
|
|
93
|
+
* Anything that is not one of our envelopes comes back untouched — a cookie set
|
|
94
|
+
* by something else, or written with `encode: false`, is still readable.
|
|
95
|
+
*/
|
|
96
|
+
export function unpackCookieValue(raw: string): unknown {
|
|
97
|
+
try {
|
|
98
|
+
const decoded = Buffer.from(raw, 'base64url').toString('utf8')
|
|
99
|
+
const parsed: unknown = JSON.parse(decoded)
|
|
100
|
+
if (typeof parsed === 'object' && parsed !== null && 'message' in parsed) {
|
|
101
|
+
return (parsed as { message: unknown }).message
|
|
102
|
+
}
|
|
103
|
+
return raw
|
|
104
|
+
} catch {
|
|
105
|
+
return raw
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
57
109
|
export interface CookieOptions {
|
|
58
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Cookie lifetime. A NUMBER is seconds; a STRING is parsed the way Adonis
|
|
112
|
+
* parses it (`'2 hours'`, `'30 mins'`, `'7 days'`, `'2h'`…), including its
|
|
113
|
+
* rule that a unitless string is milliseconds — see `helpers/duration.ts`.
|
|
114
|
+
*/
|
|
115
|
+
maxAge?: number | string
|
|
59
116
|
path?: string
|
|
60
117
|
httpOnly?: boolean
|
|
61
118
|
secure?: boolean
|
|
62
|
-
|
|
119
|
+
/**
|
|
120
|
+
* `false` omits the attribute entirely (AdonisJS accepts the boolean form);
|
|
121
|
+
* `true` is not a valid attribute value and is rejected.
|
|
122
|
+
*/
|
|
123
|
+
sameSite?: boolean | 'lax' | 'strict' | 'none'
|
|
124
|
+
/** Domain the cookie is valid for — what makes it readable across subdomains. */
|
|
125
|
+
domain?: string
|
|
126
|
+
/** Absolute expiry, or a function returning one (AdonisJS `expires`). */
|
|
127
|
+
expires?: Date | (() => Date)
|
|
128
|
+
/** CHIPS partitioned cookie: keyed to the top-level site as well as the domain. */
|
|
129
|
+
partitioned?: boolean
|
|
130
|
+
/** Eviction priority hint (Chromium). */
|
|
131
|
+
priority?: 'low' | 'medium' | 'high'
|
|
63
132
|
}
|
|
64
133
|
|
|
65
134
|
export class Response extends Macroable {
|
|
66
135
|
#status = 200
|
|
136
|
+
/** A body still being drained by {@link stream}; awaited by {@link settle}. */
|
|
137
|
+
#pendingStream: Promise<void> | undefined
|
|
67
138
|
#headers: Record<string, string> = {}
|
|
68
139
|
#cookies: string[] = []
|
|
69
140
|
#body = ''
|
|
141
|
+
readonly #finishCallbacks: Array<(err: Error | null) => void> = []
|
|
70
142
|
#finished = false
|
|
71
143
|
#redirectBuilderFactory?: () => RedirectBuilder
|
|
72
144
|
#streamBackend?: StreamBackend
|
|
@@ -572,6 +644,19 @@ export class Response extends Macroable {
|
|
|
572
644
|
throw new E_HTTP_REQUEST_ABORTED(body, status)
|
|
573
645
|
}
|
|
574
646
|
|
|
647
|
+
/**
|
|
648
|
+
* Abort unless `condition` is truthy (AdonisJS `abortUnless`) — the guard
|
|
649
|
+
* clause form: `response.abortUnless(user, 'Not found', 404)` narrows `user`
|
|
650
|
+
* for everything after it.
|
|
651
|
+
*/
|
|
652
|
+
abortUnless<T>(
|
|
653
|
+
condition: T,
|
|
654
|
+
body: unknown,
|
|
655
|
+
status = 400,
|
|
656
|
+
): asserts condition is Exclude<T, undefined | null | false> {
|
|
657
|
+
if (!condition) this.abort(body, status)
|
|
658
|
+
}
|
|
659
|
+
|
|
575
660
|
/** Abort only when `condition` is truthy (AdonisJS `abortIf`). */
|
|
576
661
|
abortIf(
|
|
577
662
|
condition: unknown,
|
|
@@ -581,6 +666,17 @@ export class Response extends Macroable {
|
|
|
581
666
|
if (condition) this.abort(body, status)
|
|
582
667
|
}
|
|
583
668
|
|
|
669
|
+
/**
|
|
670
|
+
* @internal Wait for a body still being produced (see {@link stream}). The
|
|
671
|
+
* kernel calls this before serializing; everything else resolves at once.
|
|
672
|
+
*/
|
|
673
|
+
async settle(): Promise<void> {
|
|
674
|
+
const pending = this.#pendingStream
|
|
675
|
+
if (pending === undefined) return
|
|
676
|
+
this.#pendingStream = undefined
|
|
677
|
+
await pending
|
|
678
|
+
}
|
|
679
|
+
|
|
584
680
|
/**
|
|
585
681
|
* Send a file to the client (AdonisJS `download`) — reads the file, infers the
|
|
586
682
|
* content-type from its extension, and sends it as a binary body. On a read
|
|
@@ -620,10 +716,86 @@ export class Response extends Macroable {
|
|
|
620
716
|
generateEtag = false,
|
|
621
717
|
): void {
|
|
622
718
|
const filename = name ?? basename(filePath)
|
|
623
|
-
this.header('Content-Disposition',
|
|
719
|
+
this.header('Content-Disposition', contentDisposition(disposition, filename))
|
|
624
720
|
this.download(filePath, generateEtag)
|
|
625
721
|
}
|
|
626
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Run `callback` once the response has been sent (AdonisJS `onFinish`).
|
|
725
|
+
*
|
|
726
|
+
* Where a temp file gets deleted, a timer stopped, a metric recorded — work
|
|
727
|
+
* that must not delay the reply but must still happen. Callbacks run after
|
|
728
|
+
* the body is handed to the server; one that throws is reported and does not
|
|
729
|
+
* stop the others, since by then the client already has its answer.
|
|
730
|
+
*
|
|
731
|
+
* NAMED DEVIATION: AdonisJS hands the Node `ServerResponse` to the callback.
|
|
732
|
+
* Ream's response crosses a NAPI boundary and there is no such object, so the
|
|
733
|
+
* callback receives only the error slot.
|
|
734
|
+
*/
|
|
735
|
+
onFinish(callback: (err: Error | null) => void): void {
|
|
736
|
+
this.#finishCallbacks.push(callback)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** @internal Drain the finish callbacks. Called by HttpKernel once sent. */
|
|
740
|
+
runFinishCallbacks(err: Error | null = null): void {
|
|
741
|
+
const callbacks = this.#finishCallbacks.splice(0)
|
|
742
|
+
for (const callback of callbacks) {
|
|
743
|
+
try {
|
|
744
|
+
callback(err)
|
|
745
|
+
} catch {
|
|
746
|
+
// The client already has its answer; one failing hook must not take
|
|
747
|
+
// the others with it.
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Send a readable stream as the response body (AdonisJS `stream`).
|
|
754
|
+
*
|
|
755
|
+
* NAMED DEVIATION: the stream is CONSUMED into the body rather than piped.
|
|
756
|
+
* Ream hands a complete response across the NAPI boundary, so there is no
|
|
757
|
+
* socket to pipe into — the same reason `download()` reads a file rather
|
|
758
|
+
* than streaming it. For a large payload, prefer `sse()`, which does stream.
|
|
759
|
+
*
|
|
760
|
+
* `errorCallback` maps a read failure to `[message, status]`, as upstream;
|
|
761
|
+
* without one, a failure answers 500 with a generic message rather than
|
|
762
|
+
* leaking the filesystem error to the client.
|
|
763
|
+
*
|
|
764
|
+
* Upstream's `stream()` returns void and a controller never awaits it, so a
|
|
765
|
+
* migrated controller does not either. The drain is therefore REGISTERED as
|
|
766
|
+
* well as returned: the kernel awaits it before serializing, and a caller
|
|
767
|
+
* that ignores the promise still sends the whole body instead of an empty
|
|
768
|
+
* one. Awaiting it keeps working.
|
|
769
|
+
*/
|
|
770
|
+
stream(
|
|
771
|
+
body: NodeJS.ReadableStream,
|
|
772
|
+
errorCallback?: (error: NodeJS.ErrnoException) => [string, number?],
|
|
773
|
+
): Promise<void> {
|
|
774
|
+
const draining = this.#drain(body, errorCallback)
|
|
775
|
+
this.#pendingStream = draining
|
|
776
|
+
return draining
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async #drain(
|
|
780
|
+
body: NodeJS.ReadableStream,
|
|
781
|
+
errorCallback?: (error: NodeJS.ErrnoException) => [string, number?],
|
|
782
|
+
): Promise<void> {
|
|
783
|
+
const chunks: Buffer[] = []
|
|
784
|
+
try {
|
|
785
|
+
for await (const chunk of body) {
|
|
786
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
|
787
|
+
}
|
|
788
|
+
} catch (err) {
|
|
789
|
+
const [message, status] = errorCallback
|
|
790
|
+
? errorCallback(err as NodeJS.ErrnoException)
|
|
791
|
+
: ['Cannot process the request', 500]
|
|
792
|
+
this.status(status ?? 500)
|
|
793
|
+
this.send(message)
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
this.sendBuffer(Buffer.concat(chunks))
|
|
797
|
+
}
|
|
798
|
+
|
|
627
799
|
setRedirectFactory(factory: () => RedirectBuilder): void {
|
|
628
800
|
this.#redirectBuilderFactory = factory
|
|
629
801
|
}
|
|
@@ -695,8 +867,13 @@ export class Response extends Macroable {
|
|
|
695
867
|
* for values you already protect (encrypted session ids, CSRF tokens) or that
|
|
696
868
|
* a client-side script must read.
|
|
697
869
|
*/
|
|
698
|
-
plainCookie(name: string, value:
|
|
699
|
-
|
|
870
|
+
plainCookie(name: string, value: unknown, options?: CookieOptions & { encode?: boolean }): this {
|
|
871
|
+
// AdonisJS packs the value into a base64url JSON envelope, which is what
|
|
872
|
+
// lets `plainCookie('prefs', { theme: 'dark' })` round-trip as an object.
|
|
873
|
+
// `encode: false` writes the string as-is — for a value a browser script
|
|
874
|
+
// has to read, or one that is already protected (a signed CSRF token).
|
|
875
|
+
const encoded = options?.encode === false ? String(value) : packCookieValue(value)
|
|
876
|
+
return this.#writeCookie(name, encoded, options)
|
|
700
877
|
}
|
|
701
878
|
|
|
702
879
|
/**
|
|
@@ -718,6 +895,11 @@ export class Response extends Macroable {
|
|
|
718
895
|
// (Chrome 80+, Firefox, Safari) silently reject the cookie otherwise, so
|
|
719
896
|
// a missing Secure here turns OAuth callbacks / iframe sessions into a
|
|
720
897
|
// "works in dev (localhost relaxes Secure), breaks in prod" foot-gun.
|
|
898
|
+
if (options?.sameSite === true) {
|
|
899
|
+
throw new Error(
|
|
900
|
+
`Cookie '${name}': sameSite: true is not an attribute value — use 'lax', 'strict', 'none', or false to omit it.`,
|
|
901
|
+
)
|
|
902
|
+
}
|
|
721
903
|
if (options?.sameSite === 'none' && !options.secure) {
|
|
722
904
|
throw new Error(
|
|
723
905
|
`Cookie '${name}': SameSite=None requires Secure: true (browsers reject SameSite=None cookies without Secure).`,
|
|
@@ -726,16 +908,41 @@ export class Response extends Macroable {
|
|
|
726
908
|
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]
|
|
727
909
|
// `maxAge: 0` is the RFC 6265 "delete-now" signal used by logout flows. A
|
|
728
910
|
// truthiness check would skip it; explicit `!== undefined` covers 0 too.
|
|
729
|
-
if (options?.maxAge !== undefined)
|
|
911
|
+
if (options?.maxAge !== undefined) {
|
|
912
|
+
parts.push(`Max-Age=${durationToSeconds(options.maxAge, 'a cookie maxAge')}`)
|
|
913
|
+
}
|
|
730
914
|
if (options?.path) {
|
|
731
915
|
// `Path` is concatenated raw (encodeURIComponent would mangle `/`), so it
|
|
732
916
|
// needs the same CRLF/NUL guard as header() (response-splitting).
|
|
733
917
|
assertNoCRLF(`Set-Cookie path for '${name}'`, options.path)
|
|
734
918
|
parts.push(`Path=${options.path}`)
|
|
735
919
|
}
|
|
920
|
+
if (options?.domain) {
|
|
921
|
+
// Concatenated raw like `Path`, so it carries the same splitting guard.
|
|
922
|
+
assertNoCRLF(`Set-Cookie domain for '${name}'`, options.domain)
|
|
923
|
+
parts.push(`Domain=${options.domain}`)
|
|
924
|
+
}
|
|
925
|
+
if (options?.expires !== undefined) {
|
|
926
|
+
const at = typeof options.expires === 'function' ? options.expires() : options.expires
|
|
927
|
+
if (Number.isNaN(at.getTime())) {
|
|
928
|
+
throw new Error(`Cookie '${name}': expires is an invalid Date.`)
|
|
929
|
+
}
|
|
930
|
+
parts.push(`Expires=${at.toUTCString()}`)
|
|
931
|
+
}
|
|
736
932
|
if (options?.httpOnly !== false) parts.push('HttpOnly')
|
|
737
933
|
if (options?.secure) parts.push('Secure')
|
|
738
934
|
if (options?.sameSite) parts.push(`SameSite=${options.sameSite}`)
|
|
935
|
+
// Partitioned only means anything on a Secure cookie, and browsers drop it
|
|
936
|
+
// otherwise — say so rather than emit an attribute that will be ignored.
|
|
937
|
+
if (options?.partitioned) {
|
|
938
|
+
if (!options.secure) {
|
|
939
|
+
throw new Error(
|
|
940
|
+
`Cookie '${name}': partitioned requires Secure: true (browsers ignore partitioned cookies without it).`,
|
|
941
|
+
)
|
|
942
|
+
}
|
|
943
|
+
parts.push('Partitioned')
|
|
944
|
+
}
|
|
945
|
+
if (options?.priority) parts.push(`Priority=${options.priority}`)
|
|
739
946
|
this.#cookies.push(parts.join('; '))
|
|
740
947
|
return this
|
|
741
948
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import 'reflect-metadata'
|
|
|
11
11
|
export { Application } from './Application.js'
|
|
12
12
|
export { ConfigStore, configProvider, defineModuleConfig, env } from './ConfigLoader.js'
|
|
13
13
|
export { Container } from './container/Container.js'
|
|
14
|
+
export { ContainerResolver } from './container/ContainerResolver.js'
|
|
15
|
+
export { ContextualBindingsBuilder } from './container/ContextualBindingsBuilder.js'
|
|
14
16
|
export type { Binding, ServiceFactory, ServiceScope, ServiceToken } from './container/types.js'
|
|
15
17
|
export { Env, EnvValidationException } from './env/Env.js'
|
|
16
18
|
export { loadEnvFiles } from './env/loadEnvFiles.js'
|
|
@@ -53,6 +55,13 @@ export { type GetterFn, Macroable, type MacroFn } from './utils/Macroable.js'
|
|
|
53
55
|
// ─── Session ────────────────────────────────────────────────
|
|
54
56
|
|
|
55
57
|
export { CookieDriver as SessionCookieDriver } from './session/drivers/CookieDriver.js'
|
|
58
|
+
export type {
|
|
59
|
+
DatabaseDriverOptions as SessionDatabaseDriverOptions,
|
|
60
|
+
SessionDbConnection,
|
|
61
|
+
} from './session/drivers/DatabaseDriver.js'
|
|
62
|
+
export { DatabaseDriver as SessionDatabaseDriver } from './session/drivers/DatabaseDriver.js'
|
|
63
|
+
export type { FileDriverOptions as SessionFileDriverOptions } from './session/drivers/FileDriver.js'
|
|
64
|
+
export { FileDriver as SessionFileDriver } from './session/drivers/FileDriver.js'
|
|
56
65
|
export { MemoryDriver as SessionMemoryDriver } from './session/drivers/MemoryDriver.js'
|
|
57
66
|
export type {
|
|
58
67
|
RedisSessionOptions,
|
|
@@ -60,8 +69,13 @@ export type {
|
|
|
60
69
|
SessionRedisClientSource,
|
|
61
70
|
} from './session/drivers/RedisDriver.js'
|
|
62
71
|
export { RedisDriver as SessionRedisDriver } from './session/drivers/RedisDriver.js'
|
|
63
|
-
export type {
|
|
64
|
-
|
|
72
|
+
export type {
|
|
73
|
+
SessionConfig,
|
|
74
|
+
SessionDriver,
|
|
75
|
+
SessionDriverWithTagging,
|
|
76
|
+
TaggedSession,
|
|
77
|
+
} from './session/Session.js'
|
|
78
|
+
export { Session, supportsTagging as sessionStoreSupportsTagging } from './session/Session.js'
|
|
65
79
|
export { default as SessionMiddleware } from './session/SessionMiddleware.js'
|
|
66
80
|
|
|
67
81
|
// ─── Body Parser ────────────────────────────────────────────
|
|
@@ -278,8 +292,8 @@ export type { EventsConfig } from './events/config.js'
|
|
|
278
292
|
export { defineConfig as defineEventsConfig } from './events/config.js'
|
|
279
293
|
export {
|
|
280
294
|
BaseEvent,
|
|
281
|
-
type ContainerResolver,
|
|
282
295
|
Emitter,
|
|
296
|
+
type EmitterResolver,
|
|
283
297
|
type ListenerClass,
|
|
284
298
|
} from './events/Emitter.js'
|
|
285
299
|
export type {
|
package/src/router/Router.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* @implements FR24, FR25, FR26, FR27, FR28
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { singular, snakeCase } from '../helpers/string.js'
|
|
7
8
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
8
9
|
import { safeDecodeURIComponent } from '../http/urlDecode.js'
|
|
9
10
|
import type { MiddlewareFunction } from '../middleware/Pipeline.js'
|
|
10
11
|
import type { SignedUrl } from '../security/SignedUrl.js'
|
|
11
|
-
import type { MiddlewareEntry } from '../server/Server.js'
|
|
12
|
+
import type { LazyImport, MiddlewareClassConstructor, MiddlewareEntry } from '../server/Server.js'
|
|
12
13
|
import { resolveMiddlewareEntry, resolveParametrizedMiddlewareEntry } from '../server/Server.js'
|
|
13
|
-
import { singular, snakeCase } from '../utils/inflect.js'
|
|
14
14
|
import { Macroable } from '../utils/Macroable.js'
|
|
15
15
|
|
|
16
16
|
/** Options for {@link Router.makeSignedUrl}. */
|
|
@@ -31,9 +31,15 @@ export type RouteHandlerFunction = (ctx: HttpContext) => Promise<void> | void
|
|
|
31
31
|
* Controller tuple: [ControllerClass, 'methodName'].
|
|
32
32
|
* Constructor params are resolved by the IoC container, not by TypeScript —
|
|
33
33
|
* same pattern as AdonisJS (@poppinss/utils Constructor type).
|
|
34
|
+
*
|
|
35
|
+
* `never[]` rather than `any[]`: parameters are contravariant, so a rest of
|
|
36
|
+
* `never` accepts EVERY concrete parameter list. The claim that this needed
|
|
37
|
+
* `any` was simply untrue.
|
|
34
38
|
*/
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
export type ControllerAction = [target: AnyConstructor, method: string]
|
|
40
|
+
|
|
41
|
+
/** Any class, whatever its constructor takes. */
|
|
42
|
+
export type AnyConstructor = new (...args: never[]) => unknown
|
|
37
43
|
|
|
38
44
|
/** Any controller class (derived from {@link ControllerAction} — no fresh `any`). */
|
|
39
45
|
export type ControllerConstructor = ControllerAction[0]
|
|
@@ -58,8 +64,7 @@ export interface RouteDefinition {
|
|
|
58
64
|
method: string
|
|
59
65
|
path: string
|
|
60
66
|
handler: RouteHandlerFunction | null
|
|
61
|
-
|
|
62
|
-
controller?: { target: new (...args: any[]) => any; method: string }
|
|
67
|
+
controller?: { target: AnyConstructor; method: string }
|
|
63
68
|
middleware: string[]
|
|
64
69
|
inlineMiddleware: MiddlewareFunction[]
|
|
65
70
|
guards: string[]
|
|
@@ -144,9 +149,18 @@ function makeDefinition(method: string, path: string, handler: RouteHandler): Ro
|
|
|
144
149
|
return def
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Matched route parameters.
|
|
154
|
+
*
|
|
155
|
+
* A named `:param` is a string; the catch-all `*` is the ARRAY of segments it
|
|
156
|
+
* swallowed, as AdonisJS hands it (`@poppinss/matchit`, and the route types it
|
|
157
|
+
* generates say `'*': ParamValue[]`).
|
|
158
|
+
*/
|
|
159
|
+
export type MatchedParams = Record<string, string | string[]>
|
|
160
|
+
|
|
147
161
|
export interface MatchResult {
|
|
148
162
|
route: RouteDefinition
|
|
149
|
-
params:
|
|
163
|
+
params: MatchedParams
|
|
150
164
|
}
|
|
151
165
|
|
|
152
166
|
// ─── Matchers ───────────────────────────────────────────────
|
|
@@ -648,15 +662,25 @@ export class OnRouteBuilder {
|
|
|
648
662
|
this.#path = path
|
|
649
663
|
}
|
|
650
664
|
|
|
651
|
-
/**
|
|
665
|
+
/**
|
|
666
|
+
* Render a template with no handler of your own (AdonisJS brisk `render`).
|
|
667
|
+
*
|
|
668
|
+
* Reads the request's `ctx.view` — the per-request renderer the template
|
|
669
|
+
* provider installs. It used to look only in `ctx.store` under `view`, which
|
|
670
|
+
* nothing has ever populated, so this route always threw; the message even
|
|
671
|
+
* named the wrong package. `ctx.store` is still honoured for a host that
|
|
672
|
+
* seeds its own engine there.
|
|
673
|
+
*/
|
|
652
674
|
render(view: string, data?: Record<string, unknown>): RouteBuilder {
|
|
653
675
|
return this.#router.get(this.#path, async (ctx) => {
|
|
654
|
-
const
|
|
655
|
-
if (!isViewEngine(
|
|
656
|
-
throw new Error(
|
|
676
|
+
const candidate = Reflect.get(Object(ctx), 'view') ?? ctx.store.get('view')
|
|
677
|
+
if (!isViewEngine(candidate)) {
|
|
678
|
+
throw new Error(
|
|
679
|
+
`[E_NO_VIEW_ENGINE] router.on('${this.#path}').render('${view}') needs a template engine. ` +
|
|
680
|
+
'Register one (its provider installs `ctx.view`), or seed `ctx.store` with a `view` that has a render() method.',
|
|
681
|
+
)
|
|
657
682
|
}
|
|
658
|
-
const
|
|
659
|
-
const html = await viewEngine.render(view, data)
|
|
683
|
+
const html = await candidate.render(view, data)
|
|
660
684
|
ctx.response.type('text/html; charset=utf-8').send(html)
|
|
661
685
|
})
|
|
662
686
|
}
|
|
@@ -709,9 +733,16 @@ export class Router extends Macroable {
|
|
|
709
733
|
|
|
710
734
|
/**
|
|
711
735
|
* Register router-level middleware (runs on requests with a matched route).
|
|
712
|
-
*
|
|
736
|
+
*
|
|
737
|
+
* router.use([() => import('#middleware/auth_middleware')])
|
|
738
|
+
*
|
|
739
|
+
* Imports of middleware CLASSES only, as in AdonisJS — its `use()` runs
|
|
740
|
+
* `moduleImporter(one, 'handle')` over every entry, so a closure has no
|
|
741
|
+
* place here. An inline function goes on the route or group instead
|
|
742
|
+
* (`router.get(...).use(fn)`), which is also what removes the ambiguity
|
|
743
|
+
* between a zero-arity closure and an import factory.
|
|
713
744
|
*/
|
|
714
|
-
use(middleware:
|
|
745
|
+
use(middleware: LazyImport<MiddlewareClassConstructor>[]): this {
|
|
715
746
|
for (const mw of middleware) {
|
|
716
747
|
this.#routerMiddleware.push(resolveMiddlewareEntry(mw))
|
|
717
748
|
}
|
|
@@ -980,8 +1011,22 @@ export class Router extends Macroable {
|
|
|
980
1011
|
}
|
|
981
1012
|
route.lazyController = { loader, method: route.stringRef.method }
|
|
982
1013
|
}
|
|
983
|
-
// Named route index
|
|
1014
|
+
// Named route index. A duplicate name is refused, as AdonisJS does:
|
|
1015
|
+
// `urlFor(name)` can only mean one route, and silently keeping the last
|
|
1016
|
+
// one makes every link built from that name point somewhere unintended.
|
|
984
1017
|
if (route.name) {
|
|
1018
|
+
const existing = this.#nameIndex.get(route.name)
|
|
1019
|
+
// Same name on the same PATH is one logical route split across verbs —
|
|
1020
|
+
// `route(['PUT','PATCH'], …)` and `resource()`'s `update` both do it,
|
|
1021
|
+
// and AdonisJS models those as a single route object. Only a name
|
|
1022
|
+
// reused for a DIFFERENT path is the ambiguity worth refusing.
|
|
1023
|
+
if (existing && existing.path !== route.path) {
|
|
1024
|
+
throw new Error(
|
|
1025
|
+
`[E_DUPLICATE_ROUTE_NAME] A route with name "${route.name}" already exists ` +
|
|
1026
|
+
`(${existing.method} ${existing.path}); ${route.method} ${route.path} reuses it. ` +
|
|
1027
|
+
`It may happen when two routes share a controller — give each an explicit name.`,
|
|
1028
|
+
)
|
|
1029
|
+
}
|
|
985
1030
|
this.#nameIndex.set(route.name, route)
|
|
986
1031
|
}
|
|
987
1032
|
|
|
@@ -990,6 +1035,14 @@ export class Router extends Macroable {
|
|
|
990
1035
|
if (!isParam && !route.domain) {
|
|
991
1036
|
// Static route — O(1) exact match
|
|
992
1037
|
const key = route.method === '*' ? `*:${route.path}` : `${route.method}:${route.path}`
|
|
1038
|
+
// Refused rather than overwritten, as AdonisJS does. A silent overwrite
|
|
1039
|
+
// is how an authenticated endpoint gets shadowed by a later, unguarded
|
|
1040
|
+
// one that happens to share its path — with nothing in the logs.
|
|
1041
|
+
if (this.#staticIndex.has(key)) {
|
|
1042
|
+
throw new Error(
|
|
1043
|
+
`[E_DUPLICATE_ROUTE] Duplicate route found. "${route.method}: ${route.path}" route already exists.`,
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
993
1046
|
this.#staticIndex.set(key, route)
|
|
994
1047
|
} else {
|
|
995
1048
|
// Parametric route — indexed by method
|
|
@@ -1038,8 +1091,19 @@ export class Router extends Macroable {
|
|
|
1038
1091
|
const params = matchPath(route.path, candidatePath)
|
|
1039
1092
|
if (params === null) continue
|
|
1040
1093
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1094
|
+
// A route's own matcher REPLACES the global one for that param, as
|
|
1095
|
+
// AdonisJS merges them (`{ ...global, ...local }`). Applying both as
|
|
1096
|
+
// separate gates meant the global always had the final say, so a route
|
|
1097
|
+
// could never loosen it — a global `id: /^[0-9]+$/` plus a route-level
|
|
1098
|
+
// slug matcher 404'd instead of matching.
|
|
1099
|
+
if (
|
|
1100
|
+
!this.#validateMatchers(params, {
|
|
1101
|
+
...this.#globalMatchers,
|
|
1102
|
+
...route.matchers,
|
|
1103
|
+
})
|
|
1104
|
+
) {
|
|
1105
|
+
continue
|
|
1106
|
+
}
|
|
1043
1107
|
|
|
1044
1108
|
return { route, params }
|
|
1045
1109
|
}
|
|
@@ -1231,19 +1295,21 @@ export class Router extends Macroable {
|
|
|
1231
1295
|
return makeDefinition(method, path, handler)
|
|
1232
1296
|
}
|
|
1233
1297
|
|
|
1234
|
-
#validateMatchers(
|
|
1235
|
-
params: Record<string, string>,
|
|
1236
|
-
matcherMap: Record<string, ParamMatcher>,
|
|
1237
|
-
): boolean {
|
|
1298
|
+
#validateMatchers(params: MatchedParams, matcherMap: Record<string, ParamMatcher>): boolean {
|
|
1238
1299
|
for (const [param, value] of Object.entries(params)) {
|
|
1239
1300
|
const matcher = matcherMap[param]
|
|
1240
1301
|
if (!matcher) continue
|
|
1302
|
+
// The wildcard holds several segments; every one has to satisfy the
|
|
1303
|
+
// matcher, or a `/assets/*` guard would pass on its first segment alone.
|
|
1304
|
+
const values = Array.isArray(value) ? value : [value]
|
|
1241
1305
|
const regex = matcher instanceof RegExp ? matcher : matcher.pattern
|
|
1242
1306
|
// A user-supplied /g or /y regex is stateful (`lastIndex` advances on
|
|
1243
1307
|
// match) — without a reset, the same URL alternates match/no-match
|
|
1244
1308
|
// across requests. Reset before every test.
|
|
1245
|
-
|
|
1246
|
-
|
|
1309
|
+
for (const single of values) {
|
|
1310
|
+
if (regex.global || regex.sticky) regex.lastIndex = 0
|
|
1311
|
+
if (!regex.test(single)) return false
|
|
1312
|
+
}
|
|
1247
1313
|
}
|
|
1248
1314
|
return true
|
|
1249
1315
|
}
|
|
@@ -1252,7 +1318,7 @@ export class Router extends Macroable {
|
|
|
1252
1318
|
// ─── Path matching ──────────────────────────────────────────
|
|
1253
1319
|
|
|
1254
1320
|
/** Match path pattern against actual path, extracting params. Returns null on no match. */
|
|
1255
|
-
function matchPath(pattern: string, actual: string):
|
|
1321
|
+
function matchPath(pattern: string, actual: string): MatchedParams | null {
|
|
1256
1322
|
const patternParts = pattern.split('/')
|
|
1257
1323
|
const actualParts = actual.split('/')
|
|
1258
1324
|
|
|
@@ -1263,7 +1329,7 @@ function matchPath(pattern: string, actual: string): Record<string, string> | nu
|
|
|
1263
1329
|
if (wildcardIdx !== -1) {
|
|
1264
1330
|
if (wildcardIdx !== patternParts.length - 1) return null
|
|
1265
1331
|
if (actualParts.length < wildcardIdx + 1) return null
|
|
1266
|
-
const params:
|
|
1332
|
+
const params: MatchedParams = {}
|
|
1267
1333
|
for (let i = 0; i < wildcardIdx; i++) {
|
|
1268
1334
|
const part = patternParts[i]
|
|
1269
1335
|
if (part.startsWith(':')) {
|
|
@@ -1272,12 +1338,11 @@ function matchPath(pattern: string, actual: string): Record<string, string> | nu
|
|
|
1272
1338
|
return null
|
|
1273
1339
|
}
|
|
1274
1340
|
}
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
.join('/')
|
|
1341
|
+
// An ARRAY of segments, as AdonisJS hands it (`@poppinss/matchit` does
|
|
1342
|
+
// `out['*'] = segs.slice(i).map(decode)`, and the generated route types say
|
|
1343
|
+
// `'*': ParamValue[]`). Joining them lost the boundary between a segment
|
|
1344
|
+
// containing an encoded `/` and a real path separator.
|
|
1345
|
+
params['*'] = actualParts.slice(wildcardIdx).map((s) => safeDecodeURIComponent(s))
|
|
1281
1346
|
return params
|
|
1282
1347
|
}
|
|
1283
1348
|
|
|
@@ -1287,7 +1352,7 @@ function matchPath(pattern: string, actual: string): Record<string, string> | nu
|
|
|
1287
1352
|
|
|
1288
1353
|
if (actualParts.length < requiredCount || actualParts.length > maxCount) return null
|
|
1289
1354
|
|
|
1290
|
-
const params:
|
|
1355
|
+
const params: MatchedParams = {}
|
|
1291
1356
|
|
|
1292
1357
|
for (let i = 0; i < patternParts.length; i++) {
|
|
1293
1358
|
const part = patternParts[i]
|