@c9up/ream 0.2.17 → 0.2.19

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 (112) hide show
  1. package/README.md +4 -0
  2. package/dist/Application.d.ts +15 -0
  3. package/dist/Application.d.ts.map +1 -1
  4. package/dist/Application.js +15 -0
  5. package/dist/Application.js.map +1 -1
  6. package/dist/HttpKernel.d.ts +10 -0
  7. package/dist/HttpKernel.d.ts.map +1 -1
  8. package/dist/HttpKernel.js +61 -5
  9. package/dist/HttpKernel.js.map +1 -1
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +65 -15
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/Provider.d.ts +15 -0
  14. package/dist/Provider.d.ts.map +1 -1
  15. package/dist/Provider.js.map +1 -1
  16. package/dist/container/Container.d.ts.map +1 -1
  17. package/dist/container/Container.js +174 -45
  18. package/dist/container/Container.js.map +1 -1
  19. package/dist/container/types.d.ts +7 -0
  20. package/dist/container/types.d.ts.map +1 -1
  21. package/dist/dev/hmr.d.ts +37 -0
  22. package/dist/dev/hmr.d.ts.map +1 -0
  23. package/dist/dev/hmr.js +72 -0
  24. package/dist/dev/hmr.js.map +1 -0
  25. package/dist/graphql/GraphQLProvider.d.ts +14 -3
  26. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  27. package/dist/graphql/GraphQLProvider.js +14 -3
  28. package/dist/graphql/GraphQLProvider.js.map +1 -1
  29. package/dist/hot.d.ts +29 -0
  30. package/dist/hot.d.ts.map +1 -0
  31. package/dist/hot.js +135 -0
  32. package/dist/hot.js.map +1 -0
  33. package/dist/http/Exception.d.ts +2 -2
  34. package/dist/http/Exception.d.ts.map +1 -1
  35. package/dist/http/Exception.js +3 -3
  36. package/dist/http/Exception.js.map +1 -1
  37. package/dist/http/Request.d.ts +24 -3
  38. package/dist/http/Request.d.ts.map +1 -1
  39. package/dist/http/Request.js +40 -0
  40. package/dist/http/Request.js.map +1 -1
  41. package/dist/http/RequestValidator.d.ts +80 -0
  42. package/dist/http/RequestValidator.d.ts.map +1 -0
  43. package/dist/http/RequestValidator.js +93 -0
  44. package/dist/http/RequestValidator.js.map +1 -0
  45. package/dist/index.d.ts +5 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +3 -2
  48. package/dist/index.js.map +1 -1
  49. package/dist/middleware/Pipeline.d.ts +2 -2
  50. package/dist/middleware/Pipeline.js +2 -2
  51. package/dist/middleware/Pipeline.js.map +1 -1
  52. package/dist/migrations/MigrationRegistry.d.ts +16 -0
  53. package/dist/migrations/MigrationRegistry.d.ts.map +1 -1
  54. package/dist/migrations/MigrationRegistry.js +24 -0
  55. package/dist/migrations/MigrationRegistry.js.map +1 -1
  56. package/dist/openapi/OpenApiGenerator.d.ts +16 -0
  57. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  58. package/dist/openapi/OpenApiGenerator.js +76 -4
  59. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  60. package/dist/openapi/OpenApiProvider.d.ts +16 -5
  61. package/dist/openapi/OpenApiProvider.d.ts.map +1 -1
  62. package/dist/openapi/OpenApiProvider.js +21 -6
  63. package/dist/openapi/OpenApiProvider.js.map +1 -1
  64. package/dist/router/Router.d.ts +1 -1
  65. package/dist/router/Router.js +1 -1
  66. package/dist/rpc/RpcProvider.d.ts +13 -2
  67. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  68. package/dist/rpc/RpcProvider.js +13 -2
  69. package/dist/rpc/RpcProvider.js.map +1 -1
  70. package/dist/security/CookieSigner.d.ts +3 -2
  71. package/dist/security/CookieSigner.d.ts.map +1 -1
  72. package/dist/security/CookieSigner.js +3 -2
  73. package/dist/security/CookieSigner.js.map +1 -1
  74. package/dist/storage/StaticMiddleware.d.ts +75 -7
  75. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  76. package/dist/storage/StaticMiddleware.js +257 -85
  77. package/dist/storage/StaticMiddleware.js.map +1 -1
  78. package/dist/storage/StaticProvider.d.ts +12 -2
  79. package/dist/storage/StaticProvider.d.ts.map +1 -1
  80. package/dist/storage/StaticProvider.js +24 -5
  81. package/dist/storage/StaticProvider.js.map +1 -1
  82. package/events.win32-x64-msvc.node +0 -0
  83. package/index.darwin-arm64.node +0 -0
  84. package/index.darwin-x64.node +0 -0
  85. package/index.linux-arm64-gnu.node +0 -0
  86. package/index.linux-x64-gnu.node +0 -0
  87. package/index.win32-x64-msvc.node +0 -0
  88. package/package.json +20 -8
  89. package/scheduler.win32-x64-msvc.node +0 -0
  90. package/scripts/copy-napi.mjs +11 -3
  91. package/src/Application.ts +15 -0
  92. package/src/HttpKernel.ts +87 -5
  93. package/src/Ignitor.ts +70 -16
  94. package/src/Provider.ts +15 -0
  95. package/src/container/Container.ts +178 -48
  96. package/src/container/types.ts +11 -0
  97. package/src/dev/hmr.ts +83 -0
  98. package/src/graphql/GraphQLProvider.ts +14 -3
  99. package/src/hot.ts +165 -0
  100. package/src/http/Exception.ts +4 -4
  101. package/src/http/Request.ts +67 -3
  102. package/src/http/RequestValidator.ts +143 -0
  103. package/src/index.ts +10 -3
  104. package/src/middleware/Pipeline.ts +4 -4
  105. package/src/migrations/MigrationRegistry.ts +23 -0
  106. package/src/openapi/OpenApiGenerator.ts +84 -4
  107. package/src/openapi/OpenApiProvider.ts +21 -6
  108. package/src/router/Router.ts +1 -1
  109. package/src/rpc/RpcProvider.ts +13 -2
  110. package/src/security/CookieSigner.ts +3 -2
  111. package/src/storage/StaticMiddleware.ts +338 -97
  112. package/src/storage/StaticProvider.ts +26 -5
@@ -21,7 +21,7 @@
21
21
  // crate listed in `NAPI_CRATES` ships in the tarball.
22
22
 
23
23
  import { copyFileSync, existsSync } from 'node:fs'
24
- import { dirname, join } from 'node:path'
24
+ import { dirname, join, resolve } from 'node:path'
25
25
  import { arch, env, platform } from 'node:process'
26
26
  import { fileURLToPath } from 'node:url'
27
27
 
@@ -48,6 +48,14 @@ const HOST_SUFFIX_MAP = {
48
48
  'win32-x64': 'win32-x64-msvc',
49
49
  }
50
50
 
51
+ // Cargo writes its artifacts under CARGO_TARGET_DIR when that is set — a
52
+ // shared cache, a CI mount — so they are not under this package's `target/` at
53
+ // all. A relative value is resolved against the directory cargo ran in, which
54
+ // is this package root.
55
+ const targetDir = env.CARGO_TARGET_DIR
56
+ ? resolve(root, env.CARGO_TARGET_DIR)
57
+ : join(root, 'target')
58
+
51
59
  const triple = env.CARGO_BUILD_TARGET ?? ''
52
60
  let suffix
53
61
  let os
@@ -59,11 +67,11 @@ if (triple) {
59
67
  }
60
68
  suffix = entry.suffix
61
69
  os = entry.os
62
- releaseDir = join(root, 'target', triple, 'release')
70
+ releaseDir = join(targetDir, triple, 'release')
63
71
  } else {
64
72
  suffix = HOST_SUFFIX_MAP[`${platform}-${arch}`]
65
73
  os = platform
66
- releaseDir = join(root, 'target', 'release')
74
+ releaseDir = join(targetDir, 'release')
67
75
  if (!suffix) {
68
76
  throw new Error(`[ream:napi] unsupported platform/arch: ${platform}-${arch}`)
69
77
  }
@@ -73,6 +73,21 @@ export interface ReamrcContents {
73
73
  export class Application implements AppContext {
74
74
  readonly container: Container
75
75
  readonly config: ConfigStore
76
+ /**
77
+ * Whether the inker template engine is installed (AdonisJS `usingEdgeJS`).
78
+ *
79
+ * Set by `InkerProvider`'s CONSTRUCTOR, so it is already true by the time any
80
+ * other provider boots. A package that wants to contribute to templates —
81
+ * rosetta pushing its `t()` global, say — reads this before importing
82
+ * `@c9up/inker`, and stays installable without it when the answer is no.
83
+ *
84
+ * A flag rather than a container lookup, and that is the whole point: the
85
+ * engine is a module singleton, so asking the container would mean resolving
86
+ * a binding whose lifecycle has to have reached the right phase. This answers
87
+ * the only question the caller actually has — is the package there — without
88
+ * one.
89
+ */
90
+ usingInker = false
76
91
  #appRoot?: URL
77
92
  #directories: DirectoriesNode = { ...defaultDirectories }
78
93
  #rcFile: ReamrcContents = {}
package/src/HttpKernel.ts CHANGED
@@ -32,6 +32,16 @@ export interface HttpKernelConfig {
32
32
  container?: Container
33
33
  exceptionHandler?: ExceptionHandler
34
34
  serverMiddleware?: MiddlewareFunction[]
35
+ /**
36
+ * A `<script>` appended to every HTML response, dev only.
37
+ *
38
+ * Set by the Ignitor when the application runs in development; `undefined`
39
+ * everywhere else, so a production response is byte-for-byte what the
40
+ * handler produced. It lives here rather than in a middleware because this
41
+ * is the one place every response passes through on its way out, whichever
42
+ * route or error handler produced it.
43
+ */
44
+ devReloadScript?: (nonce?: string) => string
35
45
  routerMiddleware?: MiddlewareFunction[]
36
46
  onError?: (error: unknown, ctx: HttpContext) => void
37
47
  debug?: boolean
@@ -345,7 +355,7 @@ export function createHttpKernel(
345
355
  correlationId,
346
356
  )
347
357
  }
348
- const serialized = await serializeResponse(ctx)
358
+ const serialized = await serializeResponse(ctx, config.devReloadScript)
349
359
  // The body is built; anything registered with `response.onFinish()` runs
350
360
  // now — after the answer is ready, before we hand it back.
351
361
  ctx.response.runFinishCallbacks()
@@ -401,7 +411,7 @@ export function createHttpKernel(
401
411
  correlationId,
402
412
  )
403
413
  }
404
- const serialized = await serializeResponse(ctx)
414
+ const serialized = await serializeResponse(ctx, config.devReloadScript)
405
415
  // The body is built; anything registered with `response.onFinish()` runs
406
416
  // now — after the answer is ready, before we hand it back.
407
417
  ctx.response.runFinishCallbacks()
@@ -452,15 +462,87 @@ function readControllerGuardMetadata(
452
462
  }
453
463
  }
454
464
 
455
- async function serializeResponse(ctx: HttpContext): Promise<HttpKernelResponse> {
465
+ async function serializeResponse(
466
+ ctx: HttpContext,
467
+ devReloadScript?: (nonce?: string) => string,
468
+ ): Promise<HttpKernelResponse> {
456
469
  // The terminal step, as AdonisJS' server calls `response.finish()`: it
457
470
  // stamps the request id and waits for a body still being produced.
458
471
  await ctx.response.finish()
459
472
  const streamId = ctx.response.getStreamId()
473
+ const headers = ctx.response.getHeaders()
474
+ const body = withDevReloadScript(
475
+ ctx.response.getBody(),
476
+ headers,
477
+ streamId,
478
+ devReloadScript === undefined ? undefined : devReloadScript(ctx.response.nonce),
479
+ )
460
480
  return {
461
481
  status: ctx.response.getStatus(),
462
- headers: ctx.response.getHeaders(),
463
- body: ctx.response.getBody(),
482
+ headers: reconcileRepresentation(headers, ctx.response.getBody(), body),
483
+ body,
464
484
  ...(streamId !== undefined ? { streamId } : {}),
465
485
  }
466
486
  }
487
+
488
+ /**
489
+ * Keep the representation headers describing the body that is actually sent.
490
+ *
491
+ * `content-length`, `etag` and the digest headers are computed from the body
492
+ * BEFORE the dev reload script is appended. Left alone they describe the
493
+ * previous one: a `content-length` short by the length of the script truncates
494
+ * it, and an `etag` computed before the change caches modified content under an
495
+ * unchanged validator.
496
+ *
497
+ * `content-length` is recomputed because it is cheap and exact; the validators
498
+ * are DROPPED rather than recomputed, because a validator this layer invents is
499
+ * not the one the handler meant.
500
+ */
501
+ function reconcileRepresentation(
502
+ headers: Record<string, string>,
503
+ original: string,
504
+ sent: string,
505
+ ): Record<string, string> {
506
+ if (sent === original) return headers
507
+ const reconciled = { ...headers }
508
+ if (reconciled['content-length'] !== undefined) {
509
+ reconciled['content-length'] = String(Buffer.byteLength(sent, 'utf8'))
510
+ }
511
+ // Every validator, not the two that came to mind. `Content-Digest` and
512
+ // `Repr-Digest` (RFC 9530) are what replaced `Digest`, and leaving them
513
+ // describing the old body is the same defect one header along — a client
514
+ // that checks them rejects a response that is perfectly fine.
515
+ for (const name of [
516
+ 'etag',
517
+ 'content-md5',
518
+ 'digest',
519
+ 'content-digest',
520
+ 'repr-digest',
521
+ 'want-digest',
522
+ 'want-content-digest',
523
+ 'want-repr-digest',
524
+ ]) {
525
+ delete reconciled[name]
526
+ }
527
+ return reconciled
528
+ }
529
+
530
+ /**
531
+ * Append the dev reload script to an HTML body.
532
+ *
533
+ * Only HTML, and never a stream: appending to an SSE or a download would
534
+ * corrupt it, and a page is the only thing that can act on the script anyway.
535
+ * Before `</body>` when there is one, so the script is inside the document
536
+ * rather than trailing after it.
537
+ */
538
+ function withDevReloadScript(
539
+ body: string,
540
+ headers: Record<string, string>,
541
+ streamId: string | undefined,
542
+ script?: string,
543
+ ): string {
544
+ if (script === undefined || streamId !== undefined || body === '') return body
545
+ if (!(headers['content-type'] ?? '').includes('text/html')) return body
546
+ const closing = body.lastIndexOf('</body>')
547
+ return closing === -1 ? body + script : body.slice(0, closing) + script + body.slice(closing)
548
+ }
package/src/Ignitor.ts CHANGED
@@ -17,6 +17,7 @@ import { Application } from './Application.js'
17
17
  import type { Console } from './console/Console.js'
18
18
  import type { CommandLoader, Kernel as ConsoleKernelInstance } from './console/Kernel.js'
19
19
  import { type CommandClass, isCommandClass } from './console/types.js'
20
+ import { HMR_PATH, hmrClientScript, hmrToken } from './dev/hmr.js'
20
21
  import type { DirectoriesNode } from './directories.js'
21
22
  import type { ErrorEvent } from './ErrorBoundary.js'
22
23
  import { ErrorBoundary } from './ErrorBoundary.js'
@@ -31,6 +32,7 @@ import type { HttpKernelRequest, HttpKernelResponse } from './HttpKernel.js'
31
32
  import { createHttpKernel } from './HttpKernel.js'
32
33
  import { ExceptionHandler } from './http/Exception.js'
33
34
  import { type ChildLoggerSource, HttpContext } from './http/HttpContext.js'
35
+ import { RequestValidator } from './http/RequestValidator.js'
34
36
  import type { MiddlewareFunction } from './middleware/Pipeline.js'
35
37
  import { MiddlewareRegistry } from './middleware/Pipeline.js'
36
38
  import { MigrationRegistry } from './migrations/MigrationRegistry.js'
@@ -325,6 +327,11 @@ export class Ignitor {
325
327
  // must stay agnostic reaches it through the container instead of importing
326
328
  // ream at runtime.
327
329
  this.#app.container.singleton('HttpContext', () => HttpContext)
330
+ // The seam a package installs its per-request messages provider or error
331
+ // reporter on. Bound rather than imported so an i18n provider can reach it
332
+ // without depending on ream — which is the only reason it is a container
333
+ // token and not just an import.
334
+ this.#app.container.singleton('requestValidator', () => RequestValidator)
328
335
  // `appRoot` is the URL passed to `new Ignitor(new URL('../', import.meta.url))`.
329
336
  // Providers resolve it through the container so they can interpret
330
337
  // relative paths in config files (e.g. `pages.root: './resources/pages'`)
@@ -659,6 +666,12 @@ export class Ignitor {
659
666
  }
660
667
 
661
668
  async #phaseStart(): Promise<void> {
669
+ // What the injected reload script polls, mounted BEFORE the socket accepts
670
+ // anything. Registered in `ready()` it came up after the server was already
671
+ // serving, so a page loaded in that window polled a route that did not
672
+ // exist yet.
673
+ this.#registerDevReloadRoute()
674
+
662
675
  // Providers start BEFORE the preload files, matching upstream's warm-up
663
676
  // order (`providers.start()` → `starting` hooks → preloads). A preload is
664
677
  // where an app writes its routes and kernel, and those reach for services
@@ -668,18 +681,28 @@ export class Ignitor {
668
681
  await callProviderPhase(provider, 'start')
669
682
  }
670
683
 
671
- // Import preload files (routes.ts, kernel.ts, etc.)
684
+ // Import preload files (routes.ts, kernel.ts, etc.).
685
+ //
686
+ // Filtered on the environment first, then imported TOGETHER — upstream's
687
+ // `PreloadsManager.import()` is a filter followed by one `Promise.all`, and
688
+ // this used to walk them one at a time.
689
+ //
690
+ // The consequence is worth knowing: evaluation order between two preload
691
+ // files is now whichever finishes loading first, so an application cannot
692
+ // rely on the array's order to decide which of two overlapping routes
693
+ // answers. Work that must be ordered belongs inside ONE preload, where the
694
+ // order is the file's own — which is how upstream applications are written.
672
695
  if (this.#reamrc?.preloads) {
673
- for (const preloadEntry of this.#reamrc.preloads) {
674
- const preloadImport = typeof preloadEntry === 'function' ? preloadEntry : preloadEntry.file
675
- const env =
676
- typeof preloadEntry === 'function'
677
- ? undefined
678
- : (preloadEntry as { environment?: string[] }).environment
679
-
680
- if (env && !env.includes(this.#app.getEnvironment())) continue
681
- await preloadImport()
682
- }
696
+ const applicable = this.#reamrc.preloads.filter((preloadEntry) => {
697
+ if (typeof preloadEntry === 'function') return true
698
+ const env = (preloadEntry as { environment?: string[] }).environment
699
+ return !env || env.includes(this.#app.getEnvironment())
700
+ })
701
+ await Promise.all(
702
+ applicable.map((preloadEntry) =>
703
+ typeof preloadEntry === 'function' ? preloadEntry() : preloadEntry.file(),
704
+ ),
705
+ )
683
706
  }
684
707
 
685
708
  // Auto-load module files (routes.ts, etc.) from modules directory
@@ -737,6 +760,7 @@ export class Ignitor {
737
760
  this.#server.getErrorHandler() ?? new ExceptionHandler(!this.#app.inProduction),
738
761
  serverMiddleware: this.#server.getServerMiddleware(),
739
762
  routerMiddleware: this.#router.getRouterMiddleware(),
763
+ devReloadScript: this.#devReloadScript(),
740
764
  onError: (error, ctx) => {
741
765
  this.#errorBoundary.serviceError('HttpKernel', error, ctx.id)
742
766
  },
@@ -859,11 +883,13 @@ export class Ignitor {
859
883
  // already-loaded ESM module returns the CACHED module without re-executing
860
884
  // its body (the thunk's specifier is opaque, so it cannot be cache-busted).
861
885
  // The "reload" therefore destroyed every preload-registered route on the
862
- // first file save and never restored them. Process-level restart is the
863
- // reload mechanism (`ream dev` runs `tsx watch`, which restarts on change);
864
- // this watcher only surfaces an informational event for hosts that embed
865
- // the Ignitor without a supervisor. A future true HMR needs a loader-hook
866
- // (hot-hook style) that can invalidate the ESM cache — plug it in here.
886
+ // first file save and never restored them.
887
+ //
888
+ // Module-level reloading now happens OUTSIDE the Ignitor: `ream dev` loads
889
+ // `@c9up/ream/hot`, which registers hot-hook's loader and can invalidate
890
+ // the ESM cache — the loader hook this comment used to ask for. What is
891
+ // left here only surfaces an informational event for hosts that embed the
892
+ // Ignitor without a supervisor.
867
893
  if (this.isDevMode()) {
868
894
  const watchDirs = this.#config.watchDirs ?? ['app', 'start']
869
895
  this.#hotReloadCleanup = startHotReload({
@@ -1125,6 +1151,7 @@ export class Ignitor {
1125
1151
  this.#server.getErrorHandler() ?? new ExceptionHandler(!this.#app.inProduction),
1126
1152
  serverMiddleware: this.#server.getServerMiddleware(),
1127
1153
  routerMiddleware: this.#router.getRouterMiddleware(),
1154
+ devReloadScript: this.#devReloadScript(),
1128
1155
  })
1129
1156
  return this.#kernel
1130
1157
  }
@@ -1135,6 +1162,33 @@ export class Ignitor {
1135
1162
  return this.#app.inDev
1136
1163
  }
1137
1164
 
1165
+ /**
1166
+ * The reload script, in development only.
1167
+ *
1168
+ * Gated on the environment rather than on `hot-hook` being present: the
1169
+ * script is just as useful without it, since a restart is exactly what it
1170
+ * detects. In production it is `undefined` and the response is untouched.
1171
+ */
1172
+ #devReloadScript(): ((nonce?: string) => string) | undefined {
1173
+ return this.isDevMode() ? hmrClientScript : undefined
1174
+ }
1175
+
1176
+ /**
1177
+ * Serve the token the injected script polls.
1178
+ *
1179
+ * Registered on the router in development, next to the application's own
1180
+ * routes, so it goes through the same server as everything else. Plain text
1181
+ * and uncached: it is read once a second and its whole content is the answer.
1182
+ */
1183
+ #registerDevReloadRoute(): void {
1184
+ if (!this.isDevMode()) return
1185
+ this.#router.get(HMR_PATH, ({ response }) => {
1186
+ response.header('cache-control', 'no-store')
1187
+ response.header('content-type', 'text/plain; charset=utf-8')
1188
+ response.send(hmrToken())
1189
+ })
1190
+ }
1191
+
1138
1192
  getPhase(): string {
1139
1193
  return this.#phase
1140
1194
  }
package/src/Provider.ts CHANGED
@@ -32,6 +32,21 @@ export interface ConfigReader {
32
32
  export interface AppContext {
33
33
  container: Container
34
34
  config: ConfigReader
35
+ /**
36
+ * Whether the inker template engine is installed — see
37
+ * `Application#usingInker`. Optional here because a host that is not ream
38
+ * satisfies this contract structurally and has no such flag; a provider
39
+ * reading it treats `undefined` as "no engine".
40
+ */
41
+ usingInker?: boolean
42
+ /**
43
+ * Absolute path to the application's `public/` directory — see
44
+ * `Application#publicPath`. Optional for the same reason as the flag above:
45
+ * a host that is not ream satisfies this contract structurally and has no
46
+ * directory layout. A provider that needs it falls back to a relative
47
+ * `'public'`, resolved against the working directory.
48
+ */
49
+ publicPath?(...paths: string[]): string
35
50
  }
36
51
 
37
52
  /**