@c9up/ream 0.1.31 → 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.
Files changed (189) hide show
  1. package/README.md +4 -1
  2. package/dist/Application.d.ts +27 -0
  3. package/dist/Application.d.ts.map +1 -1
  4. package/dist/Application.js +29 -0
  5. package/dist/Application.js.map +1 -1
  6. package/dist/ErrorBoundary.d.ts.map +1 -1
  7. package/dist/ErrorBoundary.js +13 -1
  8. package/dist/ErrorBoundary.js.map +1 -1
  9. package/dist/HttpKernel.d.ts +8 -0
  10. package/dist/HttpKernel.d.ts.map +1 -1
  11. package/dist/HttpKernel.js +35 -12
  12. package/dist/HttpKernel.js.map +1 -1
  13. package/dist/Ignitor.d.ts +8 -0
  14. package/dist/Ignitor.d.ts.map +1 -1
  15. package/dist/Ignitor.js +39 -0
  16. package/dist/Ignitor.js.map +1 -1
  17. package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
  18. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  19. package/dist/bodyparser/BodyParserMiddleware.js +55 -43
  20. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  21. package/dist/bodyparser/qsParse.d.ts +52 -0
  22. package/dist/bodyparser/qsParse.d.ts.map +1 -0
  23. package/dist/bodyparser/qsParse.js +161 -0
  24. package/dist/bodyparser/qsParse.js.map +1 -0
  25. package/dist/bootstrap/hyper-server.d.ts.map +1 -1
  26. package/dist/bootstrap/hyper-server.js +2 -8
  27. package/dist/bootstrap/hyper-server.js.map +1 -1
  28. package/dist/container/Container.d.ts +41 -0
  29. package/dist/container/Container.d.ts.map +1 -1
  30. package/dist/container/Container.js +193 -26
  31. package/dist/container/Container.js.map +1 -1
  32. package/dist/container/ContainerResolver.d.ts +46 -0
  33. package/dist/container/ContainerResolver.d.ts.map +1 -0
  34. package/dist/container/ContainerResolver.js +60 -0
  35. package/dist/container/ContainerResolver.js.map +1 -0
  36. package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
  37. package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
  38. package/dist/container/ContextualBindingsBuilder.js +31 -0
  39. package/dist/container/ContextualBindingsBuilder.js.map +1 -0
  40. package/dist/container/types.d.ts +9 -6
  41. package/dist/container/types.d.ts.map +1 -1
  42. package/dist/decorators/Service.d.ts +1 -5
  43. package/dist/decorators/Service.d.ts.map +1 -1
  44. package/dist/decorators/Service.js +10 -1
  45. package/dist/decorators/Service.js.map +1 -1
  46. package/dist/events/Emitter.d.ts +66 -6
  47. package/dist/events/Emitter.d.ts.map +1 -1
  48. package/dist/events/Emitter.js +165 -28
  49. package/dist/events/Emitter.js.map +1 -1
  50. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  51. package/dist/graphql/GraphQLEngine.js +5 -5
  52. package/dist/graphql/GraphQLEngine.js.map +1 -1
  53. package/dist/helpers/duration.d.ts +32 -0
  54. package/dist/helpers/duration.d.ts.map +1 -0
  55. package/dist/helpers/duration.js +74 -0
  56. package/dist/helpers/duration.js.map +1 -0
  57. package/dist/helpers/napi-loader.d.ts +10 -1
  58. package/dist/helpers/napi-loader.d.ts.map +1 -1
  59. package/dist/helpers/napi-loader.js +18 -3
  60. package/dist/helpers/napi-loader.js.map +1 -1
  61. package/dist/helpers/types.d.ts +1 -1
  62. package/dist/helpers/types.d.ts.map +1 -1
  63. package/dist/http/Exception.d.ts.map +1 -1
  64. package/dist/http/Exception.js +17 -5
  65. package/dist/http/Exception.js.map +1 -1
  66. package/dist/http/HttpContext.d.ts +16 -5
  67. package/dist/http/HttpContext.d.ts.map +1 -1
  68. package/dist/http/HttpContext.js +4 -2
  69. package/dist/http/HttpContext.js.map +1 -1
  70. package/dist/http/RedirectBuilder.d.ts +48 -2
  71. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  72. package/dist/http/RedirectBuilder.js +95 -14
  73. package/dist/http/RedirectBuilder.js.map +1 -1
  74. package/dist/http/Request.d.ts +128 -8
  75. package/dist/http/Request.d.ts.map +1 -1
  76. package/dist/http/Request.js +259 -8
  77. package/dist/http/Request.js.map +1 -1
  78. package/dist/http/Response.d.ts +74 -3
  79. package/dist/http/Response.d.ts.map +1 -1
  80. package/dist/http/Response.js +177 -4
  81. package/dist/http/Response.js.map +1 -1
  82. package/dist/index.d.ts +9 -3
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +5 -1
  85. package/dist/index.js.map +1 -1
  86. package/dist/router/Router.d.ts +36 -7
  87. package/dist/router/Router.d.ts.map +1 -1
  88. package/dist/router/Router.js +66 -22
  89. package/dist/router/Router.js.map +1 -1
  90. package/dist/security/CookieSigner.d.ts +35 -4
  91. package/dist/security/CookieSigner.d.ts.map +1 -1
  92. package/dist/security/CookieSigner.js +99 -10
  93. package/dist/security/CookieSigner.js.map +1 -1
  94. package/dist/server/Server.d.ts +2 -3
  95. package/dist/server/Server.d.ts.map +1 -1
  96. package/dist/server/Server.js +30 -13
  97. package/dist/server/Server.js.map +1 -1
  98. package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
  99. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
  100. package/dist/session/ReadOnlyValuesStore.js +45 -0
  101. package/dist/session/ReadOnlyValuesStore.js.map +1 -0
  102. package/dist/session/Session.d.ts +181 -9
  103. package/dist/session/Session.d.ts.map +1 -1
  104. package/dist/session/Session.js +284 -33
  105. package/dist/session/Session.js.map +1 -1
  106. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  107. package/dist/session/SessionMiddleware.js +87 -33
  108. package/dist/session/SessionMiddleware.js.map +1 -1
  109. package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
  110. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
  111. package/dist/session/drivers/DatabaseDriver.js +132 -0
  112. package/dist/session/drivers/DatabaseDriver.js.map +1 -0
  113. package/dist/session/drivers/FileDriver.d.ts +22 -0
  114. package/dist/session/drivers/FileDriver.d.ts.map +1 -0
  115. package/dist/session/drivers/FileDriver.js +66 -0
  116. package/dist/session/drivers/FileDriver.js.map +1 -0
  117. package/dist/session/drivers/MemoryDriver.d.ts +10 -2
  118. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  119. package/dist/session/drivers/MemoryDriver.js +70 -0
  120. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  121. package/dist/session/drivers/RedisDriver.d.ts +10 -2
  122. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  123. package/dist/session/drivers/RedisDriver.js +58 -0
  124. package/dist/session/drivers/RedisDriver.js.map +1 -1
  125. package/dist/session/templateTags.d.ts +42 -0
  126. package/dist/session/templateTags.d.ts.map +1 -0
  127. package/dist/session/templateTags.js +101 -0
  128. package/dist/session/templateTags.js.map +1 -0
  129. package/dist/testing/RequestBuilder.d.ts +38 -1
  130. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  131. package/dist/testing/RequestBuilder.js +40 -1
  132. package/dist/testing/RequestBuilder.js.map +1 -1
  133. package/dist/testing/TestClient.d.ts +3 -1
  134. package/dist/testing/TestClient.d.ts.map +1 -1
  135. package/dist/testing/TestClient.js +3 -1
  136. package/dist/testing/TestClient.js.map +1 -1
  137. package/dist/types/helpers.d.ts +1 -1
  138. package/dist/types/helpers.d.ts.map +1 -1
  139. package/dist/utils/Macroable.d.ts +14 -5
  140. package/dist/utils/Macroable.d.ts.map +1 -1
  141. package/dist/utils/Macroable.js +20 -18
  142. package/dist/utils/Macroable.js.map +1 -1
  143. package/events.win32-x64-msvc.node +0 -0
  144. package/index.win32-x64-msvc.node +0 -0
  145. package/package.json +1 -1
  146. package/scheduler.win32-x64-msvc.node +0 -0
  147. package/src/Application.ts +39 -0
  148. package/src/ErrorBoundary.ts +13 -1
  149. package/src/HttpKernel.ts +45 -19
  150. package/src/Ignitor.ts +39 -0
  151. package/src/bodyparser/BodyParserMiddleware.ts +66 -44
  152. package/src/bodyparser/qsParse.ts +179 -0
  153. package/src/bootstrap/hyper-server.ts +2 -9
  154. package/src/container/Container.ts +265 -25
  155. package/src/container/ContainerResolver.ts +84 -0
  156. package/src/container/ContextualBindingsBuilder.ts +41 -0
  157. package/src/container/types.ts +11 -7
  158. package/src/decorators/Service.ts +16 -3
  159. package/src/events/Emitter.ts +200 -25
  160. package/src/graphql/GraphQLEngine.ts +7 -8
  161. package/src/helpers/duration.ts +71 -0
  162. package/src/helpers/napi-loader.ts +19 -3
  163. package/src/helpers/types.ts +4 -1
  164. package/src/http/Exception.ts +17 -5
  165. package/src/http/HttpContext.ts +20 -5
  166. package/src/http/RedirectBuilder.ts +126 -11
  167. package/src/http/Request.ts +283 -13
  168. package/src/http/Response.ts +213 -6
  169. package/src/index.ts +17 -3
  170. package/src/router/Router.ts +98 -33
  171. package/src/security/CookieSigner.ts +102 -10
  172. package/src/server/Server.ts +36 -16
  173. package/src/session/ReadOnlyValuesStore.ts +52 -0
  174. package/src/session/Session.ts +364 -37
  175. package/src/session/SessionMiddleware.ts +93 -30
  176. package/src/session/drivers/DatabaseDriver.ts +184 -0
  177. package/src/session/drivers/FileDriver.ts +82 -0
  178. package/src/session/drivers/MemoryDriver.ts +75 -2
  179. package/src/session/drivers/RedisDriver.ts +77 -2
  180. package/src/session/templateTags.ts +124 -0
  181. package/src/testing/RequestBuilder.ts +76 -0
  182. package/src/testing/TestClient.ts +10 -2
  183. package/src/types/helpers.ts +4 -1
  184. package/src/utils/Macroable.ts +23 -8
  185. package/dist/utils/inflect.d.ts +0 -17
  186. package/dist/utils/inflect.d.ts.map +0 -1
  187. package/dist/utils/inflect.js +0 -32
  188. package/dist/utils/inflect.js.map +0 -1
  189. package/src/utils/inflect.ts +0 -29
@@ -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
- maxAge?: number
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
- sameSite?: 'lax' | 'strict' | 'none'
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', `${disposition}; filename="${filename}"`)
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: string, options?: CookieOptions): this {
699
- return this.#writeCookie(name, value, options)
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) parts.push(`Max-Age=${options.maxAge}`)
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 { SessionConfig, SessionDriver } from './session/Session.js'
64
- export { Session } from './session/Session.js'
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 {
@@ -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
- // biome-ignore lint/suspicious/noExplicitAny: required — TypeScript contravariance makes it impossible to type "any constructor" without `any`
36
- export type ControllerAction = [target: new (...args: any[]) => any, method: string]
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
- // biome-ignore lint/suspicious/noExplicitAny: see ControllerAction
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: Record<string, string>
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
- /** Render a view (requires Photon/view provider). */
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 raw = ctx.store.get('view')
655
- if (!isViewEngine(raw)) {
656
- throw new Error('View engine not configured. Register a view provider (Photon) first.')
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 viewEngine = raw
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
- * router.use([() => import('#middleware/auth_middleware')])
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: MiddlewareEntry[]): this {
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
- if (!this.#validateMatchers(params, route.matchers)) continue
1042
- if (!this.#validateMatchers(params, this.#globalMatchers)) continue
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
- if (regex.global || regex.sticky) regex.lastIndex = 0
1246
- if (!regex.test(value)) return false
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): Record<string, string> | null {
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: Record<string, string> = {}
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
- // Decode per segment, then rejoin — an encoded `/` inside a segment stays
1276
- // a value character, the path structure was already split on raw `/`.
1277
- params['*'] = actualParts
1278
- .slice(wildcardIdx)
1279
- .map((s) => safeDecodeURIComponent(s))
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: Record<string, string> = {}
1355
+ const params: MatchedParams = {}
1291
1356
 
1292
1357
  for (let i = 0; i < patternParts.length; i++) {
1293
1358
  const part = patternParts[i]