@c9up/ream 0.2.27 → 0.2.29

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 (195) hide show
  1. package/README.md +1 -0
  2. package/dist/Codemods.d.ts +16 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +182 -0
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/Ignitor.d.ts +11 -1
  7. package/dist/Ignitor.d.ts.map +1 -1
  8. package/dist/Ignitor.js +27 -21
  9. package/dist/Ignitor.js.map +1 -1
  10. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  11. package/dist/bodyparser/BodyParserMiddleware.js +3 -0
  12. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  13. package/dist/bodyparser/qsParse.d.ts +10 -1
  14. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  15. package/dist/bodyparser/qsParse.js +53 -15
  16. package/dist/bodyparser/qsParse.js.map +1 -1
  17. package/dist/codemods/source.d.ts +57 -0
  18. package/dist/codemods/source.d.ts.map +1 -0
  19. package/dist/codemods/source.js +282 -0
  20. package/dist/codemods/source.js.map +1 -0
  21. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  22. package/dist/graphql/GraphQLEngine.js +3 -2
  23. package/dist/graphql/GraphQLEngine.js.map +1 -1
  24. package/dist/http/Exception.d.ts +14 -1
  25. package/dist/http/Exception.d.ts.map +1 -1
  26. package/dist/http/Exception.js +10 -2
  27. package/dist/http/Exception.js.map +1 -1
  28. package/dist/http/HttpContext.d.ts +11 -0
  29. package/dist/http/HttpContext.d.ts.map +1 -1
  30. package/dist/http/HttpContext.js +2 -1
  31. package/dist/http/HttpContext.js.map +1 -1
  32. package/dist/http/RedirectBuilder.d.ts +9 -3
  33. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  34. package/dist/http/RedirectBuilder.js +39 -37
  35. package/dist/http/RedirectBuilder.js.map +1 -1
  36. package/dist/http/Request.d.ts +42 -17
  37. package/dist/http/Request.d.ts.map +1 -1
  38. package/dist/http/Request.js +65 -29
  39. package/dist/http/Request.js.map +1 -1
  40. package/dist/http/SseStream.js +6 -7
  41. package/dist/http/SseStream.js.map +1 -1
  42. package/dist/http/permissions.d.ts +19 -0
  43. package/dist/http/permissions.d.ts.map +1 -0
  44. package/dist/http/permissions.js +28 -0
  45. package/dist/http/permissions.js.map +1 -0
  46. package/dist/http-client/HttpClient.d.ts +111 -0
  47. package/dist/http-client/HttpClient.d.ts.map +1 -0
  48. package/dist/http-client/HttpClient.js +386 -0
  49. package/dist/http-client/HttpClient.js.map +1 -0
  50. package/dist/http-client/addresses.d.ts +14 -0
  51. package/dist/http-client/addresses.d.ts.map +1 -0
  52. package/dist/http-client/addresses.js +57 -0
  53. package/dist/http-client/addresses.js.map +1 -0
  54. package/dist/http-client/fake.d.ts +50 -0
  55. package/dist/http-client/fake.d.ts.map +1 -0
  56. package/dist/http-client/fake.js +95 -0
  57. package/dist/http-client/fake.js.map +1 -0
  58. package/dist/http-client/index.d.ts +7 -0
  59. package/dist/http-client/index.d.ts.map +1 -0
  60. package/dist/http-client/index.js +7 -0
  61. package/dist/http-client/index.js.map +1 -0
  62. package/dist/index.d.ts +5 -4
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +2 -2
  65. package/dist/index.js.map +1 -1
  66. package/dist/middleware/Pipeline.d.ts.map +1 -1
  67. package/dist/middleware/Pipeline.js +6 -3
  68. package/dist/middleware/Pipeline.js.map +1 -1
  69. package/dist/native/generated.d.ts +108 -3
  70. package/dist/native/generated.d.ts.map +1 -1
  71. package/dist/rpc/RpcProvider.d.ts +6 -0
  72. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  73. package/dist/rpc/RpcProvider.js +4 -1
  74. package/dist/rpc/RpcProvider.js.map +1 -1
  75. package/dist/rpc/RpcRouter.d.ts +27 -14
  76. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  77. package/dist/rpc/RpcRouter.js +95 -38
  78. package/dist/rpc/RpcRouter.js.map +1 -1
  79. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  80. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  81. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  82. package/dist/security/CookieSigner.d.ts.map +1 -1
  83. package/dist/security/CookieSigner.js +17 -3
  84. package/dist/security/CookieSigner.js.map +1 -1
  85. package/dist/security/SignedUrl.d.ts.map +1 -1
  86. package/dist/security/SignedUrl.js +6 -1
  87. package/dist/security/SignedUrl.js.map +1 -1
  88. package/dist/server/Server.d.ts +75 -9
  89. package/dist/server/Server.d.ts.map +1 -1
  90. package/dist/server/Server.js +60 -9
  91. package/dist/server/Server.js.map +1 -1
  92. package/dist/server/nativeGrpc.d.ts +27 -0
  93. package/dist/server/nativeGrpc.d.ts.map +1 -0
  94. package/dist/server/nativeGrpc.js +47 -0
  95. package/dist/server/nativeGrpc.js.map +1 -0
  96. package/dist/server/nativeLimits.d.ts +49 -0
  97. package/dist/server/nativeLimits.d.ts.map +1 -0
  98. package/dist/server/nativeLimits.js +67 -0
  99. package/dist/server/nativeLimits.js.map +1 -0
  100. package/dist/session/Session.d.ts +45 -0
  101. package/dist/session/Session.d.ts.map +1 -1
  102. package/dist/session/Session.js +31 -2
  103. package/dist/session/Session.js.map +1 -1
  104. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  105. package/dist/session/SessionMiddleware.js +52 -13
  106. package/dist/session/SessionMiddleware.js.map +1 -1
  107. package/dist/session/config.d.ts +5 -0
  108. package/dist/session/config.d.ts.map +1 -1
  109. package/dist/session/config.js +8 -0
  110. package/dist/session/config.js.map +1 -1
  111. package/dist/session/drivers/CookieDriver.d.ts +12 -3
  112. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  113. package/dist/session/drivers/CookieDriver.js +20 -6
  114. package/dist/session/drivers/CookieDriver.js.map +1 -1
  115. package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
  116. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  117. package/dist/session/drivers/DatabaseDriver.js +6 -0
  118. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  119. package/dist/session/drivers/FileDriver.d.ts +8 -0
  120. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  121. package/dist/session/drivers/FileDriver.js +85 -11
  122. package/dist/session/drivers/FileDriver.js.map +1 -1
  123. package/dist/session/drivers/MemoryDriver.d.ts +2 -0
  124. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  125. package/dist/session/drivers/MemoryDriver.js +21 -5
  126. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  127. package/dist/session/drivers/RedisDriver.d.ts +2 -0
  128. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  129. package/dist/session/drivers/RedisDriver.js +6 -0
  130. package/dist/session/drivers/RedisDriver.js.map +1 -1
  131. package/dist/session/errors.d.ts +27 -0
  132. package/dist/session/errors.d.ts.map +1 -1
  133. package/dist/session/errors.js +33 -0
  134. package/dist/session/errors.js.map +1 -1
  135. package/dist/session/templateTags.d.ts +14 -0
  136. package/dist/session/templateTags.d.ts.map +1 -1
  137. package/dist/session/templateTags.js +45 -17
  138. package/dist/session/templateTags.js.map +1 -1
  139. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  140. package/dist/storage/StaticMiddleware.js +29 -18
  141. package/dist/storage/StaticMiddleware.js.map +1 -1
  142. package/events.darwin-arm64.node +0 -0
  143. package/events.darwin-x64.node +0 -0
  144. package/events.linux-arm64-gnu.node +0 -0
  145. package/events.linux-x64-gnu.node +0 -0
  146. package/events.win32-x64-msvc.node +0 -0
  147. package/index.darwin-arm64.node +0 -0
  148. package/index.darwin-x64.node +0 -0
  149. package/index.linux-arm64-gnu.node +0 -0
  150. package/index.linux-x64-gnu.node +0 -0
  151. package/index.win32-x64-msvc.node +0 -0
  152. package/package.json +11 -2
  153. package/scheduler.darwin-arm64.node +0 -0
  154. package/scheduler.darwin-x64.node +0 -0
  155. package/scheduler.linux-arm64-gnu.node +0 -0
  156. package/scheduler.linux-x64-gnu.node +0 -0
  157. package/scheduler.win32-x64-msvc.node +0 -0
  158. package/src/Codemods.ts +265 -0
  159. package/src/Ignitor.ts +48 -20
  160. package/src/bodyparser/BodyParserMiddleware.ts +3 -0
  161. package/src/bodyparser/qsParse.ts +69 -14
  162. package/src/codemods/source.ts +303 -0
  163. package/src/graphql/GraphQLEngine.ts +3 -2
  164. package/src/http/Exception.ts +17 -2
  165. package/src/http/HttpContext.ts +14 -1
  166. package/src/http/RedirectBuilder.ts +48 -37
  167. package/src/http/Request.ts +87 -29
  168. package/src/http/SseStream.ts +6 -7
  169. package/src/http/permissions.ts +31 -0
  170. package/src/http-client/HttpClient.ts +505 -0
  171. package/src/http-client/addresses.ts +58 -0
  172. package/src/http-client/fake.ts +141 -0
  173. package/src/http-client/index.ts +21 -0
  174. package/src/index.ts +9 -1
  175. package/src/middleware/Pipeline.ts +7 -3
  176. package/src/native/generated.ts +123 -3
  177. package/src/rpc/RpcProvider.ts +10 -1
  178. package/src/rpc/RpcRouter.ts +126 -42
  179. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  180. package/src/security/CookieSigner.ts +17 -3
  181. package/src/security/SignedUrl.ts +6 -1
  182. package/src/server/Server.ts +103 -9
  183. package/src/server/nativeGrpc.ts +59 -0
  184. package/src/server/nativeLimits.ts +94 -0
  185. package/src/session/Session.ts +72 -2
  186. package/src/session/SessionMiddleware.ts +53 -14
  187. package/src/session/config.ts +7 -0
  188. package/src/session/drivers/CookieDriver.ts +21 -6
  189. package/src/session/drivers/DatabaseDriver.ts +10 -0
  190. package/src/session/drivers/FileDriver.ts +91 -11
  191. package/src/session/drivers/MemoryDriver.ts +20 -5
  192. package/src/session/drivers/RedisDriver.ts +7 -0
  193. package/src/session/errors.ts +48 -0
  194. package/src/session/templateTags.ts +57 -0
  195. package/src/storage/StaticMiddleware.ts +28 -18
package/src/Codemods.ts CHANGED
@@ -1,9 +1,36 @@
1
1
  import * as fs from 'node:fs'
2
2
  import * as path from 'node:path'
3
+ import {
4
+ appendToLiteral,
5
+ findCall,
6
+ findExportDefaultObject,
7
+ isSingleExpression,
8
+ matchClose,
9
+ properties,
10
+ segments,
11
+ } from './codemods/source.js'
3
12
  import { render as renderTemplate, type StubState } from './stubs/template.js'
4
13
 
5
14
  export type { StubState }
6
15
 
16
+ /** The environments a preload (or provider) entry can be scoped to. */
17
+ export type PreloadEnvironment = 'web' | 'console' | 'test' | 'repl'
18
+
19
+ const PRELOAD_ENVIRONMENTS: readonly PreloadEnvironment[] = ['web', 'console', 'test', 'repl']
20
+
21
+ /** Validation rules a configure hook adds to `start/env.ts`. */
22
+ export interface EnvValidationNode {
23
+ /** Written once, as a banner comment above the rules this call adds. */
24
+ leadingComment?: string
25
+ /** Variable name → rule source, e.g. `{ REDIS_PORT: 'Env.schema.number()' }`. */
26
+ variables: Record<string, string>
27
+ /**
28
+ * Replace a rule the file already has. Off by default, so a rule the user
29
+ * edited survives a re-run; configure hooks pass their `--force` flag.
30
+ */
31
+ overwrite?: boolean
32
+ }
33
+
7
34
  export interface Codemods {
8
35
  addProvider(importPath: string): Promise<void>
9
36
  addMetaFile(pattern: string, reloadServer?: boolean): Promise<void>
@@ -17,6 +44,8 @@ export interface Codemods {
17
44
  ): Promise<GeneratedStub>
18
45
  registerCommand(importPath: string): Promise<void>
19
46
  registerMiddleware(importPath: string, options?: { tier?: 'server' | 'router' }): Promise<void>
47
+ defineEnvValidations(definition: EnvValidationNode): Promise<string[]>
48
+ addPreloadFile(modulePath: string, environments?: PreloadEnvironment[]): Promise<void>
20
49
  }
21
50
 
22
51
  /** What a stub may interpolate. Scalars only — a stub is text, not a program. */
@@ -128,6 +157,76 @@ function assertCanonicallyInside(root: string, filePath: string, displayName: st
128
157
  }
129
158
  }
130
159
 
160
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
161
+
162
+ /**
163
+ * A module path goes between the quotes of an `import('...')`; anything that
164
+ * could close them, escape or end the line is refused rather than escaped.
165
+ */
166
+ const SAFE_MODULE_PATH = /^[^'"`\\\r\n\u2028\u2029]+$/
167
+
168
+ const BANNER_RULE = '|----------------------------------------------------------'
169
+
170
+ /** The rules of `definition`, refused before anything is written when unsafe. */
171
+ function assertEnvValidations(definition: EnvValidationNode): void {
172
+ const comment = definition.leadingComment
173
+ if (comment !== undefined && (/[\r\n\u2028\u2029]/.test(comment) || comment.includes('*/'))) {
174
+ throw new Error(
175
+ '[configure] defineEnvValidations: the leading comment must be one line without `*/`',
176
+ )
177
+ }
178
+ for (const [name, rule] of Object.entries(definition.variables)) {
179
+ if (!ENV_NAME.test(name)) {
180
+ throw new Error(
181
+ `[configure] defineEnvValidations: '${name}' is not an environment variable name`,
182
+ )
183
+ }
184
+ if (typeof rule !== 'string' || !isSingleExpression(rule)) {
185
+ throw new Error(
186
+ `[configure] defineEnvValidations: the rule for ${name} is not one complete expression: ${String(rule)}`,
187
+ )
188
+ }
189
+ }
190
+ }
191
+
192
+ /** The `{` and `}` of the schema object passed to `Env.create`. */
193
+ function locateEnvSchema(content: string): { open: number; close: number } {
194
+ const paren = findCall(content, 'Env.create')
195
+ const parenClose = paren === -1 ? -1 : matchClose(content, paren)
196
+ if (paren === -1 || parenClose === -1) {
197
+ throw new Error(
198
+ '[configure] Could not find an Env.create(...) call in start/env.ts — add the validations by hand.',
199
+ )
200
+ }
201
+ const open = segments(content, paren + 1, parenClose)[1]?.codeStart ?? -1
202
+ const close = content[open] === '{' ? matchClose(content, open) : -1
203
+ if (close === -1) {
204
+ throw new Error(
205
+ '[configure] The second argument of Env.create in start/env.ts is not an object literal — add the validations by hand.',
206
+ )
207
+ }
208
+ return { open, close }
209
+ }
210
+
211
+ /** The `{` and `}` of the object `reamrc.ts` exports (through `defineConfig` or bare). */
212
+ function locateRcObject(content: string): { open: number; close: number } | null {
213
+ const paren = findCall(content, 'defineConfig')
214
+ let open = -1
215
+ if (paren !== -1) {
216
+ const parenClose = matchClose(content, paren)
217
+ if (parenClose !== -1) open = segments(content, paren + 1, parenClose)[0]?.codeStart ?? -1
218
+ } else {
219
+ open = findExportDefaultObject(content)
220
+ }
221
+ const close = open !== -1 && content[open] === '{' ? matchClose(content, open) : -1
222
+ return close === -1 ? null : { open, close }
223
+ }
224
+
225
+ /** The module an rc array element lazily imports. */
226
+ function importedModule(element: string): string | undefined {
227
+ return /import\s*\(\s*(['"])([^'"]*)\1\s*\)/.exec(element)?.[2]
228
+ }
229
+
131
230
  export function createCodemods(options?: { force?: boolean; cwd?: string }): Codemods {
132
231
  const force = options?.force ?? false
133
232
  const root = path.resolve(options?.cwd ?? process.cwd())
@@ -516,5 +615,171 @@ export function createCodemods(options?: { force?: boolean; cwd?: string }): Cod
516
615
  content = `${content.slice(0, targetBlock.start)}${eol}${entry}${content.slice(targetBlock.start)}`
517
616
  fs.writeFileSync(kernelPath, content)
518
617
  },
618
+
619
+ /**
620
+ * Add validation rules to the schema `start/env.ts` passes to
621
+ * `Env.create`, so the variables a package writes to `.env` are checked
622
+ * at boot like the app's own.
623
+ *
624
+ * A rule already in the file is left alone, unless `overwrite` is set:
625
+ * the user may have tightened it. Returns the variables kept that way
626
+ * with a rule different from the one asked for, so the caller can say
627
+ * so. The banner comment is written only when the first new rule comes
628
+ * before any variable the file already had.
629
+ */
630
+ async defineEnvValidations(definition: EnvValidationNode): Promise<string[]> {
631
+ const envPath = path.join(root, 'start', 'env.ts')
632
+ if (!fs.existsSync(envPath)) {
633
+ throw new Error(
634
+ `[configure] start/env.ts not found — cannot add the validations for ${Object.keys(definition.variables).join(', ')}.`,
635
+ )
636
+ }
637
+ assertCanonicallyInside(root, envPath, 'start/env.ts')
638
+ assertEnvValidations(definition)
639
+
640
+ const original = fs.readFileSync(envPath, 'utf8')
641
+ const eol = detectLineEnding(original)
642
+ let content = original
643
+ const schema = locateEnvSchema(content)
644
+ const existing = new Map(
645
+ properties(content, schema.open, schema.close).map((property) => [property.key, property]),
646
+ )
647
+
648
+ const kept: string[] = []
649
+ const replacements: Array<{ start: number; end: number; text: string }> = []
650
+ const additions: Array<[string, string]> = []
651
+ let shouldAddComment = true
652
+ let decorate = false
653
+ for (const [name, rule] of Object.entries(definition.variables)) {
654
+ const property = existing.get(name)
655
+ if (property === undefined) {
656
+ if (additions.length === 0) decorate = shouldAddComment
657
+ shouldAddComment = false
658
+ additions.push([name, rule])
659
+ continue
660
+ }
661
+ shouldAddComment = false
662
+ const current = content.slice(property.valueStart, property.valueEnd)
663
+ if (definition.overwrite) {
664
+ replacements.push({ start: property.valueStart, end: property.valueEnd, text: rule })
665
+ } else if (current.replace(/\s+/g, '') !== rule.replace(/\s+/g, '')) {
666
+ kept.push(name)
667
+ }
668
+ }
669
+
670
+ for (const { start, end, text } of replacements.sort((a, b) => b.start - a.start)) {
671
+ content = `${content.slice(0, start)}${text}${content.slice(end)}`
672
+ }
673
+
674
+ if (additions.length > 0) {
675
+ const { open, close } = locateEnvSchema(content)
676
+ const comment = definition.leadingComment
677
+ content = appendToLiteral(
678
+ content,
679
+ open,
680
+ close,
681
+ eol,
682
+ (indent) => [
683
+ ...(decorate && comment !== undefined
684
+ ? [
685
+ `${indent}/*`,
686
+ `${indent}${BANNER_RULE}`,
687
+ `${indent}| ${comment}`,
688
+ `${indent}${BANNER_RULE}`,
689
+ `${indent}*/`,
690
+ ]
691
+ : []),
692
+ ...additions.map(([name, rule]) => `${indent}${name}: ${rule},`),
693
+ ],
694
+ decorate,
695
+ )
696
+ }
697
+
698
+ if (content !== original) fs.writeFileSync(envPath, content)
699
+ return kept
700
+ },
701
+
702
+ /**
703
+ * Register a preload file in `reamrc.ts`, appended to `preloads` (the
704
+ * array is created when absent). Listing only some environments writes
705
+ * a `{ file, environment }` entry; none, or all of them, a plain lazy
706
+ * import. A module already in `preloads` is left as it is.
707
+ */
708
+ async addPreloadFile(modulePath: string, environments?: PreloadEnvironment[]): Promise<void> {
709
+ if (!SAFE_MODULE_PATH.test(modulePath)) {
710
+ throw new Error(`[configure] Unsafe preload module path: ${JSON.stringify(modulePath)}`)
711
+ }
712
+ for (const environment of environments ?? []) {
713
+ if (!PRELOAD_ENVIRONMENTS.includes(environment)) {
714
+ throw new Error(
715
+ `[configure] Unknown environment '${String(environment)}' for preload ${modulePath}. Expected one of ${PRELOAD_ENVIRONMENTS.join(', ')}.`,
716
+ )
717
+ }
718
+ }
719
+
720
+ const rcPath = path.join(root, 'reamrc.ts')
721
+ if (!fs.existsSync(rcPath)) {
722
+ throw new Error(
723
+ `[configure] reamrc.ts not found — cannot register preload ${modulePath}. Run 'ream new' first.`,
724
+ )
725
+ }
726
+ assertCanonicallyInside(root, rcPath, 'reamrc.ts')
727
+
728
+ const content = fs.readFileSync(rcPath, 'utf8')
729
+ const eol = detectLineEnding(content)
730
+ const config = locateRcObject(content)
731
+ if (config === null) {
732
+ throw new Error(
733
+ `[configure] Could not find 'defineConfig({ ... })' in reamrc.ts — preload ${modulePath} not added.`,
734
+ )
735
+ }
736
+
737
+ // Scoped only when it actually leaves an environment out.
738
+ const scope = PRELOAD_ENVIRONMENTS.some(
739
+ (environment) => environments !== undefined && !environments.includes(environment),
740
+ )
741
+ ? environments
742
+ : undefined
743
+ const entry = (indent: string): string[] =>
744
+ scope
745
+ ? [
746
+ `${indent}{`,
747
+ `${indent} file: () => import('${modulePath}'),`,
748
+ `${indent} environment: [${scope.map((e) => `'${e}'`).join(', ')}],`,
749
+ `${indent}},`,
750
+ ]
751
+ : [`${indent}() => import('${modulePath}'),`]
752
+
753
+ const preloads = properties(content, config.open, config.close).find(
754
+ (property) => property.key === 'preloads',
755
+ )
756
+ if (preloads === undefined) {
757
+ fs.writeFileSync(
758
+ rcPath,
759
+ appendToLiteral(content, config.open, config.close, eol, (indent) => [
760
+ `${indent}preloads: [`,
761
+ ...entry(`${indent} `),
762
+ `${indent}],`,
763
+ ]),
764
+ )
765
+ return
766
+ }
767
+
768
+ const arrayClose =
769
+ content[preloads.valueStart] === '[' ? matchClose(content, preloads.valueStart) : -1
770
+ if (arrayClose === -1) {
771
+ throw new Error(
772
+ `[configure] reamrc.ts declares preloads as something other than an array literal — add ${modulePath} by hand.`,
773
+ )
774
+ }
775
+ const registered = segments(content, preloads.valueStart + 1, arrayClose).some(
776
+ (element) => importedModule(content.slice(element.codeStart, element.end)) === modulePath,
777
+ )
778
+ if (registered) return
779
+ fs.writeFileSync(
780
+ rcPath,
781
+ appendToLiteral(content, preloads.valueStart, arrayClose, eol, entry),
782
+ )
783
+ },
519
784
  }
520
785
  }
package/src/Ignitor.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { Application } from './Application.js'
17
+ import type { BodyParserConfig } from './bodyparser/BodyParserMiddleware.js'
17
18
  import type { Console } from './console/Console.js'
18
19
  import type { CommandLoader, Kernel as ConsoleKernelInstance } from './console/Kernel.js'
19
20
  import { type CommandClass, isCommandClass } from './console/types.js'
@@ -51,13 +52,20 @@ import {
51
52
  import { Router } from './router/Router.js'
52
53
  import { CookieSigner } from './security/CookieSigner.js'
53
54
  import { SignedUrl } from './security/SignedUrl.js'
54
- import { Server } from './server/Server.js'
55
+ import { cancellableGrpc, type NativeGrpcHandler } from './server/nativeGrpc.js'
56
+ import {
57
+ type NativeBodyLimits,
58
+ type NativeConnectionConfig,
59
+ nativeBodyLimits,
60
+ nativeConnectionConfig,
61
+ } from './server/nativeLimits.js'
62
+ import { type GrpcLimits, Server } from './server/Server.js'
55
63
  import { clearApp, setApp } from './services/app.js'
56
64
  import { clearEncryption, setEncryption } from './services/encryption.js'
57
65
  import { clearLogger, setLogger } from './services/logger.js'
58
66
  import { clearRouter, setRouter } from './services/router.js'
59
67
  import { clearServer, setServer } from './services/server.js'
60
- import { registerSessionTemplateTags } from './session/templateTags.js'
68
+ import { installSessionTemplateTags } from './session/templateTags.js'
61
69
 
62
70
  /** Application environment. */
63
71
  /**
@@ -268,6 +276,10 @@ export function defineConfig(config: ReamrcConfig): ReamrcConfig {
268
276
  /** Minimal interface for the HTTP server (NAPI or mock). */
269
277
  export interface HyperServerLike {
270
278
  onRequest(callback: (request: HttpKernelRequest) => Promise<HttpKernelResponse>): void
279
+ /** Optional — present on the real `HyperServer`; required once `server.grpc()` is used. */
280
+ onGrpc?(callback: NativeGrpcHandler, limits?: GrpcLimits): void
281
+ /** Optional — reports the calls a client gave up on; without it no call's signal aborts. */
282
+ onGrpcCancel?(callback: (id: number) => void): void
271
283
  /**
272
284
  * Optional — present on the real `HyperServer` (Rust-backed), absent on
273
285
  * trivial test mocks. Ignitor only calls it when a shield config is set.
@@ -277,6 +289,10 @@ export interface HyperServerLike {
277
289
  configureTrustedProxies?(cidrs: string[]): void
278
290
  /** Optional — same lifecycle as `configureShield`. */
279
291
  configureRateLimit?(config: { max: number; windowSecs: number } | null): void
292
+ /** Optional — same lifecycle as `configureShield`. */
293
+ configureBodyLimits?(config: NativeBodyLimits): void
294
+ /** Optional — same lifecycle as `configureShield`. */
295
+ configureConnections?(config: NativeConnectionConfig): void
280
296
  listen(): Promise<void>
281
297
  port(): Promise<number>
282
298
  close(): Promise<void>
@@ -682,26 +698,12 @@ export class Ignitor {
682
698
 
683
699
  /**
684
700
  * Give the template engine `@error` / `@errors` / `@inputError` /
685
- * `@flashMessage`, as AdonisJS's session plugin does.
686
- *
687
- * The engine is duck-typed out of the container so ream carries no dependency
688
- * on it, and an app with no template layer simply skips this.
701
+ * `@flashMessage`, as AdonisJS's session plugin does — through the engine
702
+ * package's module singleton, guarded by `usingInker` (see
703
+ * `installSessionTemplateTags`). An app with no template layer skips this.
689
704
  */
690
705
  async #registerSessionTemplateTags(): Promise<void> {
691
- for (const token of ['inker', 'view']) {
692
- if (!this.#app.container.has(token)) continue
693
- const binding: unknown = await this.#app.container.resolve(token)
694
- for (const candidate of [binding, Reflect.get(Object(binding), '_templates')]) {
695
- const register = Reflect.get(Object(candidate), 'registerTag')
696
- if (typeof register !== 'function') continue
697
- registerSessionTemplateTags({
698
- registerTag: (tag) => {
699
- register.call(candidate, tag)
700
- },
701
- })
702
- return
703
- }
704
- }
706
+ await installSessionTemplateTags(this.#app.usingInker === true)
705
707
  }
706
708
 
707
709
  async #phaseRegister(): Promise<void> {
@@ -903,6 +905,19 @@ export class Ignitor {
903
905
  this.#config.host ?? process.env.HOST ?? (this.#app.inProduction ? '0.0.0.0' : 'localhost')
904
906
  this.#_httpServer = this.#config.serverFactory(availablePort, this.#host)
905
907
  this.#_httpServer.onRequest(kernel)
908
+ const grpc = this.#server.getGrpcHandler()
909
+ if (grpc) {
910
+ if (typeof this.#_httpServer.onGrpc !== 'function') {
911
+ throw new ReamError(
912
+ 'E_IGNITOR_GRPC_UNSUPPORTED',
913
+ 'A gRPC handler is registered, but this server cannot serve gRPC.',
914
+ { hint: 'Use the native HyperServer, or a test server that implements onGrpc().' },
915
+ )
916
+ }
917
+ const calls = cancellableGrpc(grpc)
918
+ this.#_httpServer.onGrpcCancel?.(calls.onCancel)
919
+ this.#_httpServer.onGrpc(calls.onCall, this.#server.getGrpcLimits())
920
+ }
906
921
  // Pre-resolve client IPs in Rust from the trusted-proxy CIDRs before
907
922
  // listen. Security filtering itself lives in @c9up/blackhole.
908
923
  const trustedProxies = this.#server.getTrustedProxies()
@@ -912,6 +927,19 @@ export class Ignitor {
912
927
  ) {
913
928
  this.#_httpServer.configureTrustedProxies([...trustedProxies])
914
929
  }
930
+ // Body limits from `config/bodyparser.ts`, enforced while the native
931
+ // server reads a body: nothing past them is ever buffered.
932
+ const bodyparser = this.#app.config.get<BodyParserConfig>('bodyparser')
933
+ if (bodyparser && typeof this.#_httpServer.configureBodyLimits === 'function') {
934
+ this.#_httpServer.configureBodyLimits(nativeBodyLimits(bodyparser))
935
+ }
936
+ // AdonisJS `config/app.ts` → `http: { headersTimeout, requestTimeout,
937
+ // keepAliveTimeout }`, plus `maxConnections`.
938
+ if (typeof this.#_httpServer.configureConnections === 'function') {
939
+ this.#_httpServer.configureConnections(
940
+ nativeConnectionConfig(this.#app.config.get<unknown>('app.http')),
941
+ )
942
+ }
915
943
  await this.#_httpServer.listen()
916
944
 
917
945
  // Whoever opened the socket closes it. `app.terminate()` runs the
@@ -136,6 +136,9 @@ export default class BodyParserMiddleware {
136
136
  }
137
137
 
138
138
  const contentType = ctx.request.header('content-type') ?? ''
139
+ // A body over its native limit (`bodyTooLarge`) was never kept: `raw()`
140
+ // throws the same 413 below instead of handing back an empty string. The
141
+ // route is already matched here, so an unknown URL answered 404 first.
139
142
  const rawBody = ctx.request.raw()
140
143
 
141
144
  // Size check. Thrown rather than written: the app's exception handler
@@ -11,7 +11,7 @@
11
11
  * dependency it can write in a screenful, and the limits below are easier to
12
12
  * defend when they are visible.
13
13
  *
14
- * Three guards, all of which `qs` also applies:
14
+ * Four guards, all of which `qs` also applies:
15
15
  *
16
16
  * - **Prototype keys are dropped.** `__proto__[x]=1` in a form body is the
17
17
  * classic prototype-pollution vector; the key never reaches an object.
@@ -19,11 +19,14 @@
19
19
  * as a literal key rather than allocating unbounded nesting.
20
20
  * - **Parameter count is capped** (`qs` defaults to 1000), so a body of a
21
21
  * million `a[0]`, `a[1]`… cannot be turned into a million-entry object.
22
+ * - **Explicit array indices are capped** (`qs` `arrayLimit`, default 20).
23
+ * A larger index is an object key, so `ids[4294967294]=1` stays one entry.
22
24
  */
23
25
 
24
26
  /** `qs` defaults, reproduced so the behaviour is the same out of the box. */
25
27
  export const DEFAULT_DEPTH = 5
26
28
  export const DEFAULT_PARAMETER_LIMIT = 1000
29
+ export const DEFAULT_ARRAY_LIMIT = 20
27
30
 
28
31
  export interface QsParseOptions {
29
32
  /**
@@ -34,6 +37,12 @@ export interface QsParseOptions {
34
37
  allowDots?: boolean
35
38
  depth?: number
36
39
  parameterLimit?: number
40
+ /**
41
+ * Highest explicit index + 1 that still builds an array (`qs` defaults to
42
+ * 20). `ids[25]=x` becomes `{ ids: { '25': 'x' } }`, so a 16-byte request
43
+ * cannot allocate a 100-million-slot array.
44
+ */
45
+ arrayLimit?: number
37
46
  /** Turn `''` into `null`, as AdonisJS' `convertEmptyStringsToNull` does. */
38
47
  convertEmptyStringsToNull?: boolean
39
48
  /** Trim surrounding whitespace, as AdonisJS' `trimWhitespaces` does. */
@@ -77,15 +86,53 @@ function isIndex(segment: string): boolean {
77
86
  return /^\d+$/.test(segment)
78
87
  }
79
88
 
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
89
+ type Container = Record<string, unknown> | unknown[]
90
+
91
+ function isContainer(value: unknown): value is Container {
92
+ return typeof value === 'object' && value !== null
93
+ }
94
+
95
+ /** `{ '0': a, '1': b }` from `[a, b]`, holes left out — what `qs` produces on overflow. */
96
+ function arrayToObject(array: unknown[]): Record<string, unknown> {
97
+ const object: Record<string, unknown> = {}
98
+ array.forEach((item, index) => {
99
+ object[String(index)] = item
100
+ })
101
+ return object
102
+ }
103
+
104
+ /**
105
+ * Assign `value` at `segments` inside `target`, creating containers as needed.
106
+ *
107
+ * An explicit index at or above `arrayLimit` never addresses an array: the
108
+ * container becomes (or is converted into) an object keyed by that index,
109
+ * as `qs` does. Without it, `ids[100000000]=1` allocates a sparse array whose
110
+ * iteration or serialisation blocks the event loop for seconds. Appends
111
+ * (`tags[]`, a repeated key) stay arrays — each one costs a parameter, so
112
+ * `parameterLimit` already bounds them, and a long checkbox group keeps the
113
+ * shape its handler expects (`qs` turns those into objects past the limit).
114
+ */
115
+ function assign(
116
+ target: Record<string, unknown>,
117
+ segments: string[],
118
+ value: unknown,
119
+ arrayLimit: number,
120
+ ): void {
121
+ let cursor: Container = target
122
+ // Swaps `cursor` inside its parent when an array has to become an object.
123
+ let replace: (next: Container) => void = () => {}
83
124
  for (let i = 0; i < segments.length; i++) {
84
125
  const segment = segments[i] ?? ''
85
126
  const last = i === segments.length - 1
86
127
 
87
128
  if (FORBIDDEN_KEYS.has(segment)) return
88
129
 
130
+ if (Array.isArray(cursor) && isIndex(segment) && Number(segment) >= arrayLimit) {
131
+ const converted = arrayToObject(cursor)
132
+ replace(converted)
133
+ cursor = converted
134
+ }
135
+
89
136
  if (last) {
90
137
  if (segment === '') {
91
138
  // `tags[]` — append.
@@ -109,24 +156,31 @@ function assign(target: Record<string, unknown>, segments: string[], value: unkn
109
156
  }
110
157
 
111
158
  const nextSegment = segments[i + 1] ?? ''
112
- const wantsArray = nextSegment === '' || isIndex(nextSegment)
159
+ const wantsArray =
160
+ nextSegment === '' || (isIndex(nextSegment) && Number(nextSegment) < arrayLimit)
113
161
 
114
162
  if (Array.isArray(cursor)) {
115
163
  if (!isIndex(segment)) return
164
+ const array: unknown[] = cursor
116
165
  const index = Number(segment)
117
- const existing = cursor[index]
118
- if (existing === undefined || typeof existing !== 'object' || existing === null) {
119
- cursor[index] = wantsArray ? [] : {}
166
+ const existing: unknown = array[index]
167
+ const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
168
+ array[index] = child
169
+ replace = (next) => {
170
+ array[index] = next
120
171
  }
121
- cursor = cursor[index] as Record<string, unknown> | unknown[]
172
+ cursor = child
122
173
  continue
123
174
  }
124
175
 
125
- const existing = cursor[segment]
126
- if (existing === undefined || typeof existing !== 'object' || existing === null) {
127
- cursor[segment] = wantsArray ? [] : {}
176
+ const object: Record<string, unknown> = cursor
177
+ const existing: unknown = object[segment]
178
+ const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
179
+ object[segment] = child
180
+ replace = (next) => {
181
+ object[segment] = next
128
182
  }
129
- cursor = cursor[segment] as Record<string, unknown> | unknown[]
183
+ cursor = child
130
184
  }
131
185
  }
132
186
 
@@ -155,6 +209,7 @@ export function parseQueryString(
155
209
  ): Record<string, unknown> {
156
210
  const depth = options.depth ?? DEFAULT_DEPTH
157
211
  const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
212
+ const arrayLimit = options.arrayLimit ?? DEFAULT_ARRAY_LIMIT
158
213
  const result: Record<string, unknown> = {}
159
214
  if (!body) return result
160
215
 
@@ -173,7 +228,7 @@ export function parseQueryString(
173
228
  if (options.trimWhitespaces && typeof value === 'string') value = value.trim()
174
229
  if (options.convertEmptyStringsToNull && value === '') value = null
175
230
 
176
- assign(result, splitKey(key, depth, options.allowDots ?? true), value)
231
+ assign(result, splitKey(key, depth, options.allowDots ?? true), value, arrayLimit)
177
232
  }
178
233
  return result
179
234
  }