@c9up/ream 0.2.16 → 0.2.18

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 (93) hide show
  1. package/dist/Application.d.ts +54 -8
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +77 -7
  4. package/dist/Application.js.map +1 -1
  5. package/dist/HttpKernel.d.ts +10 -0
  6. package/dist/HttpKernel.d.ts.map +1 -1
  7. package/dist/HttpKernel.js +61 -5
  8. package/dist/HttpKernel.js.map +1 -1
  9. package/dist/Ignitor.d.ts.map +1 -1
  10. package/dist/Ignitor.js +76 -18
  11. package/dist/Ignitor.js.map +1 -1
  12. package/dist/commands/ScheduleList.d.ts.map +1 -1
  13. package/dist/commands/ScheduleList.js +29 -0
  14. package/dist/commands/ScheduleList.js.map +1 -1
  15. package/dist/container/Container.d.ts.map +1 -1
  16. package/dist/container/Container.js +174 -45
  17. package/dist/container/Container.js.map +1 -1
  18. package/dist/container/types.d.ts +7 -0
  19. package/dist/container/types.d.ts.map +1 -1
  20. package/dist/dev/hmr.d.ts +37 -0
  21. package/dist/dev/hmr.d.ts.map +1 -0
  22. package/dist/dev/hmr.js +72 -0
  23. package/dist/dev/hmr.js.map +1 -0
  24. package/dist/graphql/GraphQLProvider.d.ts +14 -3
  25. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  26. package/dist/graphql/GraphQLProvider.js +14 -3
  27. package/dist/graphql/GraphQLProvider.js.map +1 -1
  28. package/dist/hot.d.ts +29 -0
  29. package/dist/hot.d.ts.map +1 -0
  30. package/dist/hot.js +135 -0
  31. package/dist/hot.js.map +1 -0
  32. package/dist/http/Exception.d.ts +2 -2
  33. package/dist/http/Exception.d.ts.map +1 -1
  34. package/dist/http/Exception.js +3 -3
  35. package/dist/http/Exception.js.map +1 -1
  36. package/dist/http/Request.d.ts +11 -0
  37. package/dist/http/Request.d.ts.map +1 -1
  38. package/dist/http/Request.js +40 -0
  39. package/dist/http/Request.js.map +1 -1
  40. package/dist/http/RequestValidator.d.ts +80 -0
  41. package/dist/http/RequestValidator.d.ts.map +1 -0
  42. package/dist/http/RequestValidator.js +93 -0
  43. package/dist/http/RequestValidator.js.map +1 -0
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/middleware/Pipeline.d.ts +2 -2
  49. package/dist/middleware/Pipeline.js +2 -2
  50. package/dist/middleware/Pipeline.js.map +1 -1
  51. package/dist/migrations/MigrationRegistry.d.ts +16 -0
  52. package/dist/migrations/MigrationRegistry.d.ts.map +1 -1
  53. package/dist/migrations/MigrationRegistry.js +24 -0
  54. package/dist/migrations/MigrationRegistry.js.map +1 -1
  55. package/dist/openapi/OpenApiGenerator.d.ts +16 -0
  56. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  57. package/dist/openapi/OpenApiGenerator.js +76 -4
  58. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  59. package/dist/openapi/OpenApiProvider.d.ts +16 -5
  60. package/dist/openapi/OpenApiProvider.d.ts.map +1 -1
  61. package/dist/openapi/OpenApiProvider.js +21 -6
  62. package/dist/openapi/OpenApiProvider.js.map +1 -1
  63. package/dist/rpc/RpcProvider.d.ts +13 -2
  64. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  65. package/dist/rpc/RpcProvider.js +13 -2
  66. package/dist/rpc/RpcProvider.js.map +1 -1
  67. package/dist/storage/StaticProvider.d.ts +12 -2
  68. package/dist/storage/StaticProvider.d.ts.map +1 -1
  69. package/dist/storage/StaticProvider.js +12 -2
  70. package/dist/storage/StaticProvider.js.map +1 -1
  71. package/events.win32-x64-msvc.node +0 -0
  72. package/index.win32-x64-msvc.node +0 -0
  73. package/package.json +14 -2
  74. package/scheduler.win32-x64-msvc.node +0 -0
  75. package/src/Application.ts +89 -8
  76. package/src/HttpKernel.ts +87 -5
  77. package/src/Ignitor.ts +82 -19
  78. package/src/commands/ScheduleList.ts +30 -0
  79. package/src/container/Container.ts +178 -48
  80. package/src/container/types.ts +11 -0
  81. package/src/dev/hmr.ts +83 -0
  82. package/src/graphql/GraphQLProvider.ts +14 -3
  83. package/src/hot.ts +165 -0
  84. package/src/http/Exception.ts +4 -4
  85. package/src/http/Request.ts +54 -0
  86. package/src/http/RequestValidator.ts +143 -0
  87. package/src/index.ts +8 -1
  88. package/src/middleware/Pipeline.ts +4 -4
  89. package/src/migrations/MigrationRegistry.ts +23 -0
  90. package/src/openapi/OpenApiGenerator.ts +84 -4
  91. package/src/openapi/OpenApiProvider.ts +21 -6
  92. package/src/rpc/RpcProvider.ts +13 -2
  93. package/src/storage/StaticProvider.ts +12 -2
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
  },
@@ -799,15 +823,24 @@ export class Ignitor {
799
823
  }
800
824
  await this.#_httpServer.listen()
801
825
 
826
+ // Whoever opened the socket closes it. `app.terminate()` runs the
827
+ // `terminating` hooks in reverse before shutting the providers down, so
828
+ // an application terminated by ANY path — this Ignitor's signal handler,
829
+ // an `app.listen('SIGTERM', () => app.terminate())` the entry point wired
830
+ // itself, a crash handler — drains the HTTP server. It used to skip it:
831
+ // `terminate()` shut the providers down and called `process.exit(0)`,
832
+ // which could win the race against a drain already in progress.
833
+ this.#app.terminating(() => this.stop())
834
+
802
835
  // Wire OS-signal graceful shutdown. Without it the process never closes
803
836
  // the HTTP server on SIGTERM/SIGINT, so live keep-alive / SSE sockets keep
804
837
  // the event loop alive — a `ream dev` restart or Ctrl+C ends in a watcher
805
838
  // force-kill, and an orchestrator's rolling deploy drops in-flight work.
806
- // onShutdown = this.stop(), which closes the port (aborting connections),
807
- // shuts providers down (DB pools), and releases the locators.
839
+ // It terminates the APPLICATION rather than stopping this Ignitor, so
840
+ // there is one authority however the shutdown was triggered.
808
841
  if (this.#config.gracefulShutdown !== false) {
809
842
  this.#shutdownHandle = installGracefulShutdown({
810
- onShutdown: () => this.stop(),
843
+ onShutdown: () => this.#app.terminate(),
811
844
  logger: {
812
845
  info: (message) => this.#emitSystemInfo('GracefulShutdown', message, 'info'),
813
846
  error: (message) => this.#emitSystemInfo('GracefulShutdown', message, 'error'),
@@ -850,11 +883,13 @@ export class Ignitor {
850
883
  // already-loaded ESM module returns the CACHED module without re-executing
851
884
  // its body (the thunk's specifier is opaque, so it cannot be cache-busted).
852
885
  // The "reload" therefore destroyed every preload-registered route on the
853
- // first file save and never restored them. Process-level restart is the
854
- // reload mechanism (`ream dev` runs `tsx watch`, which restarts on change);
855
- // this watcher only surfaces an informational event for hosts that embed
856
- // the Ignitor without a supervisor. A future true HMR needs a loader-hook
857
- // (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.
858
893
  if (this.isDevMode()) {
859
894
  const watchDirs = this.#config.watchDirs ?? ['app', 'start']
860
895
  this.#hotReloadCleanup = startHotReload({
@@ -1116,6 +1151,7 @@ export class Ignitor {
1116
1151
  this.#server.getErrorHandler() ?? new ExceptionHandler(!this.#app.inProduction),
1117
1152
  serverMiddleware: this.#server.getServerMiddleware(),
1118
1153
  routerMiddleware: this.#router.getRouterMiddleware(),
1154
+ devReloadScript: this.#devReloadScript(),
1119
1155
  })
1120
1156
  return this.#kernel
1121
1157
  }
@@ -1126,6 +1162,33 @@ export class Ignitor {
1126
1162
  return this.#app.inDev
1127
1163
  }
1128
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
+
1129
1192
  getPhase(): string {
1130
1193
  return this.#phase
1131
1194
  }
@@ -33,6 +33,9 @@ export default class ScheduleList extends BaseCommand {
33
33
  const tasks = scheduler.listTasks()
34
34
  if (tasks.length === 0) {
35
35
  this.logger.info('No scheduled tasks registered.')
36
+ for (const line of whyNothingWasFound(this.app.rcFile.modules)) {
37
+ this.logger.info(` ${line}`)
38
+ }
36
39
  return
37
40
  }
38
41
 
@@ -64,3 +67,30 @@ export default class ScheduleList extends BaseCommand {
64
67
  }
65
68
  }
66
69
  }
70
+
71
+ /**
72
+ * Why an empty list is empty.
73
+ *
74
+ * Discovery walks the IoC service registry, and a class the process never
75
+ * imported is not in it. `@Schedule` in `app/modules/**` therefore depends on
76
+ * the module auto-loader having imported the file, and that has two conditions
77
+ * a reader of the folder-structure guide would not expect. Saying "no tasks"
78
+ * and stopping leaves them to find both by reading the Ignitor.
79
+ */
80
+ function whyNothingWasFound(modules: { path?: string; autoload?: string[] } | undefined): string[] {
81
+ if (modules?.path === undefined) {
82
+ return [
83
+ 'A @Schedule is only found once something has imported the class that declares it.',
84
+ 'reamrc.ts has no `modules.path`, so nothing under app/modules/ is auto-loaded at all.',
85
+ "Either add `modules: { path: './app/modules', autoload: ['routes', 'events'] }`,",
86
+ 'or import the file from a preload — which is explicit, and works anywhere.',
87
+ ]
88
+ }
89
+ const autoload = modules.autoload ?? ['routes', 'events']
90
+ return [
91
+ 'A @Schedule is only found once something has imported the class that declares it.',
92
+ `modules.autoload is [${autoload.map((f) => `'${f}'`).join(', ')}], so app/modules/<name>/<file>.ts`,
93
+ 'is imported only under one of those names. Add the file name to that list,',
94
+ 'or import it from a preload — which is explicit, and works anywhere.',
95
+ ]
96
+ }
@@ -115,15 +115,36 @@ export class Container {
115
115
  /** Register a singleton binding (factory called once, cached). */
116
116
  singleton<_T>(token: ServiceToken, factory: ServiceFactory): void {
117
117
  const key = this.#tokenToKey(token)
118
+ this.#forgetCached(key)
118
119
  this.#bindings.set(key, { token, factory, scope: 'singleton', dependencies: [] })
119
120
  }
120
121
 
121
122
  /** Register a transient binding (new instance per resolve). */
122
123
  bind<_T>(token: ServiceToken, factory: ServiceFactory): void {
123
124
  const key = this.#tokenToKey(token)
125
+ this.#forgetCached(key)
124
126
  this.#bindings.set(key, { token, factory, scope: 'transient', dependencies: [] })
125
127
  }
126
128
 
129
+ /**
130
+ * Drop what a previous binding produced, because the binding is being
131
+ * replaced.
132
+ *
133
+ * Resolution reads the cache BEFORE the binding, so a token re-registered
134
+ * with a new factory kept answering with the old instance — a rebind that
135
+ * changed nothing, silently. That is what a provider does when it boots a
136
+ * second time in one process (a hot reload, a test that restarts the app):
137
+ * it rebinds, and the container went on handing out the connection the
138
+ * previous shutdown had closed.
139
+ *
140
+ * The in-flight promise goes too: a resolution already awaiting the OLD
141
+ * factory must not become the value of the new binding.
142
+ */
143
+ #forgetCached(key: ServiceToken): void {
144
+ this.#singletons.delete(key)
145
+ this.#pendingSingletons.delete(key)
146
+ }
147
+
127
148
  /** Bind an existing value directly. */
128
149
  bindValue<T>(token: ServiceToken, value: T): void {
129
150
  const key = this.#tokenToKey(token)
@@ -348,38 +369,48 @@ export class Container {
348
369
  )
349
370
  }
350
371
  const outerParent = chain.parent
351
- chain.parent = target
352
- const paramTypes: unknown[] =
353
- Reflect.getMetadata('design:paramtypes', target.prototype, method) ?? []
354
- // `@Inject('token')` on a method parameter — a named binding the reflected
355
- // type cannot express (an interface, or a class registered under a name).
356
- const injectTokens = getMethodInjectTokens(target.prototype, method)
357
-
358
- // `@Inject` counts towards the parameter span too: a method may carry named
359
- // tokens without `design:paramtypes` being emitted for it, and computing the
360
- // span from the reflected types alone left the loop at zero iterations —
361
- // the tokens were read and then never used.
362
- const injectSpan = injectTokens.size === 0 ? 0 : Math.max(...injectTokens.keys()) + 1
363
- const len = Math.max(paramTypes.length, runtimeValues?.length ?? 0, injectSpan)
364
- // Sequential resolution keeps the shared cycle-detection stack consistent.
372
+ // Declared outside the try so the call below can still read it: only the
373
+ // RESOLUTION is guarded, because that is what can throw with the parent
374
+ // installed.
365
375
  const args: unknown[] = []
366
- for (let index = 0; index < len; index += 1) {
367
- // Runtime values take precedence (and fill slots beyond paramTypes).
368
- if (runtimeValues && index < runtimeValues.length) {
369
- args.push(runtimeValues[index])
370
- continue
371
- }
372
- const named = injectTokens.get(index)
373
- if (named !== undefined) {
374
- args.push(await this.resolve(named))
375
- continue
376
+ chain.parent = target
377
+ try {
378
+ const paramTypes: unknown[] =
379
+ Reflect.getMetadata('design:paramtypes', target.prototype, method) ?? []
380
+ // `@Inject('token')` on a method parameter — a named binding the reflected
381
+ // type cannot express (an interface, or a class registered under a name).
382
+ const injectTokens = getMethodInjectTokens(target.prototype, method)
383
+
384
+ // `@Inject` counts towards the parameter span too: a method may carry named
385
+ // tokens without `design:paramtypes` being emitted for it, and computing the
386
+ // span from the reflected types alone left the loop at zero iterations —
387
+ // the tokens were read and then never used.
388
+ const injectSpan = injectTokens.size === 0 ? 0 : Math.max(...injectTokens.keys()) + 1
389
+ const len = Math.max(paramTypes.length, runtimeValues?.length ?? 0, injectSpan)
390
+ // Sequential resolution keeps the shared cycle-detection stack consistent.
391
+ for (let index = 0; index < len; index += 1) {
392
+ // Runtime values take precedence (and fill slots beyond paramTypes).
393
+ if (runtimeValues && index < runtimeValues.length) {
394
+ args.push(runtimeValues[index])
395
+ continue
396
+ }
397
+ const named = injectTokens.get(index)
398
+ if (named !== undefined) {
399
+ args.push(await this.resolve(named))
400
+ continue
401
+ }
402
+ const type = paramTypes[index]
403
+ args.push(isInjectableClass(type) ? await this.resolve(type) : undefined)
376
404
  }
377
- const type = paramTypes[index]
378
- args.push(isInjectableClass(type) ? await this.resolve(type) : undefined)
405
+ } finally {
406
+ // RESTORED whatever happened. Without the `finally`, a parameter that
407
+ // failed to resolve left the called class installed as the parent, so a
408
+ // caller that caught the error saw its contextual bindings applied to
409
+ // every later resolution — a binding meant for one handler leaking into
410
+ // the whole application.
411
+ chain.parent = outerParent
379
412
  }
380
413
 
381
- chain.parent = outerParent
382
-
383
414
  const member: unknown = instance[method]
384
415
  if (!isCallable(member)) {
385
416
  throw new Error(`Container.call: '${method}' is not a callable method`)
@@ -576,19 +607,47 @@ export class Container {
576
607
  return instance as T
577
608
  }
578
609
 
579
- // A build already under way: join it rather than start a second one.
610
+ // A build already under way: join it rather than start a second one —
611
+ // but only take its result if it was actually PUBLISHED. A factory that
612
+ // reads a request-scoped value builds that request's instance, and
613
+ // `#cacheIfAppWide` deliberately refuses to cache it; handing it to the
614
+ // resolver waiting behind was one request's HttpContext, identity or
615
+ // tenant answering for another. That resolution builds its own instead.
580
616
  const inFlight = this.#pendingSingletons.get(key)
581
- if (inFlight) return (await inFlight) as T
617
+ if (inFlight) {
618
+ const shared = await inFlight
619
+ if (this.#wasPublished(key, shared)) return shared as T
620
+ }
582
621
 
583
622
  const readsBefore = store?.scopedReads ?? 0
584
623
  const building = (async (): Promise<unknown> => {
585
624
  const instance = binding.factory ? await binding.factory(this) : undefined
625
+ // Only cache while this is still the binding that started the build.
626
+ // A rebind that landed mid-flight already dropped the cache; letting
627
+ // the old factory write its result afterwards restored exactly what the
628
+ // rebind existed to replace.
629
+ if (this.#bindings.get(key) !== binding) return instance
630
+ // Hooks FIRST, cache after. Published before they ran, a singleton
631
+ // whose `resolving` hook threw was already in the cache: the caller saw
632
+ // the error, and the next resolution was handed the same half-built
633
+ // instance with no hook, no error and nothing to say a step had been
634
+ // skipped. A hook that opens a connection, validates a config or wraps
635
+ // a security decorator failing is precisely when the instance must not
636
+ // be reachable.
637
+ //
638
+ // Upstream's hooks are documented as modifying the value BEFORE it is
639
+ // returned, which only holds if the value is not already shared.
640
+ //
641
+ // Inside the promise, so the hooks run exactly once per instance no
642
+ // matter how many callers were waiting; the pending promise is what
643
+ // keeps concurrent callers from building a second one.
644
+ await this.#runResolvingHooks(key, instance)
645
+ // Re-checked: a rebind can land while the hooks run, and the cache it
646
+ // dropped must not be refilled by the build it replaced.
647
+ if (this.#bindings.get(key) !== binding) return instance
586
648
  // See `ResolutionChain.scopedReads`: a build that consumed a
587
649
  // request-scoped value is that request's, not the application's.
588
650
  this.#cacheIfAppWide('singleton', key, instance, store, readsBefore)
589
- // Inside the promise, so the hooks run exactly once per instance no
590
- // matter how many callers were waiting.
591
- await this.#runResolvingHooks(key, instance)
592
651
  return instance
593
652
  })()
594
653
  this.#pendingSingletons.set(key, building)
@@ -596,15 +655,17 @@ export class Container {
596
655
  return (await building) as T
597
656
  } finally {
598
657
  // Cleared either way: a failed build must not poison later attempts.
599
- this.#pendingSingletons.delete(key)
658
+ // Guarded, so a rebind that installed its own pending build is not
659
+ // undone by the old one finishing afterwards.
660
+ if (this.#pendingSingletons.get(key) === building) {
661
+ this.#pendingSingletons.delete(key)
662
+ }
600
663
  }
601
664
  }
602
665
 
603
666
  // 6. Auto-construct if it's a class
604
667
  if (typeof token === 'function') {
605
- const instance = (await this.#autoConstruct(token, runtimeValues)) as T
606
- await this.#runResolvingHooks(key, instance)
607
- return instance
668
+ return (await this.#resolveAutoConstructed(token, runtimeValues)) as T
608
669
  }
609
670
 
610
671
  // 7. Not found
@@ -628,6 +689,66 @@ export class Container {
628
689
  * 2. Reflect.getMetadata('design:paramtypes') — decorator metadata (requires SWC/tsc)
629
690
  * 3. No params → plain `new Class()`
630
691
  */
692
+ /**
693
+ * Resolve a class the container builds itself — through the SAME pipeline as
694
+ * an explicit singleton binding.
695
+ *
696
+ * `@Service({ scope: 'singleton' })` never goes through a binding, so this
697
+ * path had its own, weaker rules: the instance was cached on the way out of
698
+ * construction and the `resolving` hooks ran afterwards, so a hook that threw
699
+ * left a half-built object in the cache for the next caller — the failure the
700
+ * explicit path was fixed for, on the path most applications actually use.
701
+ * It also never joined `#pendingSingletons`, so two resolutions in flight at
702
+ * once each built their own and whichever finished last was the one everybody
703
+ * got.
704
+ *
705
+ * Pending promise, then construction, then hooks, then publication. In that
706
+ * order, on both paths.
707
+ */
708
+ async #resolveAutoConstructed(
709
+ target: abstract new (...args: never[]) => unknown,
710
+ runtimeValues?: unknown[],
711
+ ): Promise<unknown> {
712
+ const metadata = getServiceMetadata(target)
713
+ const key = metadata?.as ?? target
714
+
715
+ if (metadata?.scope !== 'singleton') {
716
+ const instance = await this.#autoConstruct(target, runtimeValues)
717
+ await this.#runResolvingHooks(key, instance)
718
+ return instance
719
+ }
720
+
721
+ if (this.#singletons.has(key)) return this.#singletons.get(key)
722
+ const inFlight = this.#pendingSingletons.get(key)
723
+ if (inFlight) {
724
+ const shared = await inFlight
725
+ // Joined ONLY if that build was published application-wide. A build that
726
+ // consumed a request-scoped value belongs to its request — handing it to
727
+ // another one is exactly the leak `createResolver()` exists to prevent —
728
+ // so this resolution falls through and builds its own instead.
729
+ if (this.#wasPublished(key, shared)) return shared
730
+ }
731
+
732
+ // Same rule as the explicit bindings: see `ResolutionChain.scopedReads`.
733
+ const store = this.#chain.getStore()
734
+ const readsBefore = store?.scopedReads ?? 0
735
+ const building = (async (): Promise<unknown> => {
736
+ const instance = await this.#autoConstruct(target, runtimeValues)
737
+ await this.#runResolvingHooks(key, instance)
738
+ this.#cacheIfAppWide('singleton', key, instance, store, readsBefore)
739
+ return instance
740
+ })()
741
+ this.#pendingSingletons.set(key, building)
742
+ try {
743
+ return await building
744
+ } finally {
745
+ // Cleared either way: a failed build must not poison later attempts.
746
+ if (this.#pendingSingletons.get(key) === building) {
747
+ this.#pendingSingletons.delete(key)
748
+ }
749
+ }
750
+ }
751
+
631
752
  async #autoConstruct(
632
753
  target: abstract new (...args: never[]) => unknown,
633
754
  runtimeValues?: unknown[],
@@ -649,20 +770,19 @@ export class Container {
649
770
  target: abstract new (...args: never[]) => unknown,
650
771
  runtimeValues?: unknown[],
651
772
  ): Promise<unknown> {
652
- const metadata = getServiceMetadata(target)
653
- const scope = metadata?.scope ?? 'transient'
654
773
  // The class itself, not `target.name`: two classes called `Service` share a
655
774
  // name and must not share a singleton cache entry.
775
+ const metadata = getServiceMetadata(target)
656
776
  const key = metadata?.as ?? target
657
777
 
658
- if (scope === 'singleton' && this.#singletons.has(key)) {
778
+ // A cached singleton short-circuits construction — for the callers that
779
+ // reach this directly (`autoRegister`'s factory). PUBLISHING it is not this
780
+ // method's business: it builds, and `#resolveAutoConstructed` decides when
781
+ // the result may be shared, after the hooks have had their say.
782
+ if (metadata?.scope === 'singleton' && this.#singletons.has(key)) {
659
783
  return this.#singletons.get(key)
660
784
  }
661
785
 
662
- // Same rule as the explicit singleton bindings: see `scopedReads`.
663
- const store = this.#chain.getStore()
664
- const readsBefore = store?.scopedReads ?? 0
665
-
666
786
  // A runtime value at this index (from `make(Class, [req, res])`) wins over
667
787
  // container resolution for that constructor slot.
668
788
  const runtimeAt = (index: number): unknown => runtimeValues?.[index]
@@ -698,7 +818,6 @@ export class Container {
698
818
  deps.push(depToken ? await this.resolve(depToken) : undefined)
699
819
  }
700
820
  const instance = Reflect.construct(target, deps)
701
- this.#cacheIfAppWide(scope, key, instance, store, readsBefore)
702
821
  return instance
703
822
  }
704
823
  // (b) No @Inject/metadata, but the caller supplied runtime values — build
@@ -707,7 +826,6 @@ export class Container {
707
826
  if (target.length > 0 && (runtimeValues?.length ?? 0) > 0) {
708
827
  const deps = Array.from({ length: target.length }, (_value, index) => runtimeAt(index))
709
828
  const instance = Reflect.construct(target, deps)
710
- this.#cacheIfAppWide(scope, key, instance, store, readsBefore)
711
829
  return instance
712
830
  }
713
831
  // (c) The constructor declares parameters but we have no way to resolve
@@ -729,7 +847,6 @@ export class Container {
729
847
  // one is a compile error even though the runtime constructs it happily —
730
848
  // `abstract` exists only at compile time.
731
849
  const instance = Reflect.construct(target, [])
732
- this.#cacheIfAppWide(scope, key, instance, store, readsBefore)
733
850
  return instance
734
851
  }
735
852
 
@@ -752,7 +869,6 @@ export class Container {
752
869
  }
753
870
 
754
871
  const instance = Reflect.construct(target, deps)
755
- this.#cacheIfAppWide(scope, key, instance, store, readsBefore)
756
872
  return instance
757
873
  }
758
874
 
@@ -764,6 +880,20 @@ export class Container {
764
880
  * later one. AdonisJS does not guard this; it is the kind of silent
765
881
  * cross-request leak worth refusing.
766
882
  */
883
+ /**
884
+ * Is `instance` the value that was published under `key`?
885
+ *
886
+ * `Object.is`, not `===`: a factory answering `NaN` compares unequal to
887
+ * itself, so a waiter concluded the build had not been published and built a
888
+ * second singleton. And `has()` before the comparison, because an absent
889
+ * entry and one holding `undefined` both read back as `undefined` — a
890
+ * request-scoped build that produced nothing was then shared with the next
891
+ * request, which is the whole thing this check exists to prevent.
892
+ */
893
+ #wasPublished(key: ServiceToken, instance: unknown): boolean {
894
+ return this.#singletons.has(key) && Object.is(this.#singletons.get(key), instance)
895
+ }
896
+
767
897
  #cacheIfAppWide(
768
898
  scope: string,
769
899
  key: ServiceToken,
@@ -38,6 +38,17 @@ export type ServiceToken = (abstract new (...args: never[]) => unknown) | string
38
38
  */
39
39
  export interface FactoryResolver {
40
40
  make<T>(token: ServiceToken, runtimeValues?: unknown[]): Promise<T>
41
+ /**
42
+ * Call a method with its dependencies injected. Declared here because a
43
+ * factory that dispatches to a handler needs it and both implementations
44
+ * have it — leaving it off did not make the capability go away, it only
45
+ * hid it from the callers allowed to use it.
46
+ */
47
+ call<T, K extends string & keyof T>(
48
+ instance: T,
49
+ method: K,
50
+ runtimeValues?: unknown[],
51
+ ): Promise<unknown>
41
52
  }
42
53
 
43
54
  /**
package/src/dev/hmr.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What the browser watches to know the server changed under it.
3
+ *
4
+ * Two things can happen while `ream dev` runs, and the page has no way to see
5
+ * either: a module is swapped in the running process (hot-hook invalidated it),
6
+ * or the process restarts. Both change what the server would render, and both
7
+ * used to leave a stale page on screen until someone pressed reload.
8
+ *
9
+ * NAMED DEVIATION — upstream has no equivalent. AdonisJS' assembler does not
10
+ * refresh the browser; that comes from its Vite integration, i.e. from the
11
+ * asset pipeline. Ream has no such pipeline in the general case, so this lives
12
+ * in the framework, and it is deliberately the smallest thing that works: a
13
+ * token the page polls, not a socket. A socket would still need a reconnect
14
+ * loop and a boot identity to survive the restart half, which is most of a
15
+ * poller with more moving parts.
16
+ */
17
+
18
+ /** Where the token is served, and what the injected script polls. */
19
+ export const HMR_PATH = '/__ream/hmr'
20
+
21
+ /**
22
+ * Identifies THIS process. A restart produces a new one, which is how the page
23
+ * detects a reboot without the server having to survive it to say so.
24
+ */
25
+ const BOOT_ID = `${process.pid}-${Date.now().toString(36)}`
26
+
27
+ /** Bumped by `@c9up/ream/hot` every time hot-hook swaps a module. */
28
+ const COUNTER = Symbol.for('ream.dev.hotReloadCount')
29
+
30
+ interface CounterHolder {
31
+ [COUNTER]?: number
32
+ }
33
+
34
+ /**
35
+ * A module-level variable would not do: `@c9up/ream/hot` is loaded through a
36
+ * different module graph than the running application (it is an `--import`
37
+ * entry), so the two would each hold their own copy. A well-known symbol on
38
+ * `globalThis` is the one place both can see.
39
+ */
40
+ function holder(): CounterHolder {
41
+ return globalThis as unknown as CounterHolder
42
+ }
43
+
44
+ /** Called by the hot entry when hot-hook reports a module was swapped. */
45
+ export function hotReloadHappened(): void {
46
+ const store = holder()
47
+ store[COUNTER] = (store[COUNTER] ?? 0) + 1
48
+ }
49
+
50
+ /** How many hot swaps this process has seen. */
51
+ export function hotReloadCount(): number {
52
+ return holder()[COUNTER] ?? 0
53
+ }
54
+
55
+ /**
56
+ * The token. Changes on a restart (new boot id) and on a hot swap (new count),
57
+ * which are exactly the two cases the page must react to.
58
+ */
59
+ export function hmrToken(): string {
60
+ return `${BOOT_ID}.${hotReloadCount()}`
61
+ }
62
+
63
+ /**
64
+ * The script injected into HTML pages in dev.
65
+ *
66
+ * It reloads when the token changes, NOT when the request fails: a server that
67
+ * is restarting is unreachable for a moment, and reloading then would only show
68
+ * the browser's error page. It waits for an answer, and the answer carries a
69
+ * new boot id.
70
+ */
71
+ export function hmrClientScript(nonce?: string): string {
72
+ // A nonce-based CSP — which the scaffold ships, via `@c9up/blackhole` —
73
+ // rejects an inline script that does not carry the request's nonce, and the
74
+ // page simply never reloads. `response.nonce` is where it lives; the quotes
75
+ // are escaped because a nonce is generated, not user input, but a broken
76
+ // attribute would silently disable the tag rather than fail loudly.
77
+ const attribute = nonce === undefined ? '' : ` nonce="${nonce.replace(/"/g, '&quot;')}"`
78
+ // A failed poll RETRIES. Returning on `!r.ok` stopped the loop for good, so a
79
+ // page opened in the window before the endpoint is mounted — or during any
80
+ // blip — never reloaded again for the life of that tab, which looks exactly
81
+ // like the feature not working.
82
+ return `<script${attribute}>(()=>{let t=null;const p=${JSON.stringify(HMR_PATH)};const tick=async()=>{try{const r=await fetch(p,{cache:'no-store'});if(r.ok){const n=(await r.text()).trim();if(t===null){t=n}else if(n!==t){location.reload();return}}}catch(e){}setTimeout(tick,1000)};tick()})()</script>`
83
+ }