@c9up/ream 0.2.17 → 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 (84) hide show
  1. package/dist/HttpKernel.d.ts +10 -0
  2. package/dist/HttpKernel.d.ts.map +1 -1
  3. package/dist/HttpKernel.js +61 -5
  4. package/dist/HttpKernel.js.map +1 -1
  5. package/dist/Ignitor.d.ts.map +1 -1
  6. package/dist/Ignitor.js +65 -15
  7. package/dist/Ignitor.js.map +1 -1
  8. package/dist/container/Container.d.ts.map +1 -1
  9. package/dist/container/Container.js +174 -45
  10. package/dist/container/Container.js.map +1 -1
  11. package/dist/container/types.d.ts +7 -0
  12. package/dist/container/types.d.ts.map +1 -1
  13. package/dist/dev/hmr.d.ts +37 -0
  14. package/dist/dev/hmr.d.ts.map +1 -0
  15. package/dist/dev/hmr.js +72 -0
  16. package/dist/dev/hmr.js.map +1 -0
  17. package/dist/graphql/GraphQLProvider.d.ts +14 -3
  18. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  19. package/dist/graphql/GraphQLProvider.js +14 -3
  20. package/dist/graphql/GraphQLProvider.js.map +1 -1
  21. package/dist/hot.d.ts +29 -0
  22. package/dist/hot.d.ts.map +1 -0
  23. package/dist/hot.js +135 -0
  24. package/dist/hot.js.map +1 -0
  25. package/dist/http/Exception.d.ts +2 -2
  26. package/dist/http/Exception.d.ts.map +1 -1
  27. package/dist/http/Exception.js +3 -3
  28. package/dist/http/Exception.js.map +1 -1
  29. package/dist/http/Request.d.ts +11 -0
  30. package/dist/http/Request.d.ts.map +1 -1
  31. package/dist/http/Request.js +40 -0
  32. package/dist/http/Request.js.map +1 -1
  33. package/dist/http/RequestValidator.d.ts +80 -0
  34. package/dist/http/RequestValidator.d.ts.map +1 -0
  35. package/dist/http/RequestValidator.js +93 -0
  36. package/dist/http/RequestValidator.js.map +1 -0
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/middleware/Pipeline.d.ts +2 -2
  42. package/dist/middleware/Pipeline.js +2 -2
  43. package/dist/middleware/Pipeline.js.map +1 -1
  44. package/dist/migrations/MigrationRegistry.d.ts +16 -0
  45. package/dist/migrations/MigrationRegistry.d.ts.map +1 -1
  46. package/dist/migrations/MigrationRegistry.js +24 -0
  47. package/dist/migrations/MigrationRegistry.js.map +1 -1
  48. package/dist/openapi/OpenApiGenerator.d.ts +16 -0
  49. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  50. package/dist/openapi/OpenApiGenerator.js +76 -4
  51. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  52. package/dist/openapi/OpenApiProvider.d.ts +16 -5
  53. package/dist/openapi/OpenApiProvider.d.ts.map +1 -1
  54. package/dist/openapi/OpenApiProvider.js +21 -6
  55. package/dist/openapi/OpenApiProvider.js.map +1 -1
  56. package/dist/rpc/RpcProvider.d.ts +13 -2
  57. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  58. package/dist/rpc/RpcProvider.js +13 -2
  59. package/dist/rpc/RpcProvider.js.map +1 -1
  60. package/dist/storage/StaticProvider.d.ts +12 -2
  61. package/dist/storage/StaticProvider.d.ts.map +1 -1
  62. package/dist/storage/StaticProvider.js +12 -2
  63. package/dist/storage/StaticProvider.js.map +1 -1
  64. package/events.win32-x64-msvc.node +0 -0
  65. package/index.win32-x64-msvc.node +0 -0
  66. package/package.json +14 -2
  67. package/scheduler.win32-x64-msvc.node +0 -0
  68. package/src/HttpKernel.ts +87 -5
  69. package/src/Ignitor.ts +70 -16
  70. package/src/container/Container.ts +178 -48
  71. package/src/container/types.ts +11 -0
  72. package/src/dev/hmr.ts +83 -0
  73. package/src/graphql/GraphQLProvider.ts +14 -3
  74. package/src/hot.ts +165 -0
  75. package/src/http/Exception.ts +4 -4
  76. package/src/http/Request.ts +54 -0
  77. package/src/http/RequestValidator.ts +143 -0
  78. package/src/index.ts +8 -1
  79. package/src/middleware/Pipeline.ts +4 -4
  80. package/src/migrations/MigrationRegistry.ts +23 -0
  81. package/src/openapi/OpenApiGenerator.ts +84 -4
  82. package/src/openapi/OpenApiProvider.ts +21 -6
  83. package/src/rpc/RpcProvider.ts +13 -2
  84. package/src/storage/StaticProvider.ts +12 -2
@@ -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
+ }
@@ -9,8 +9,9 @@
9
9
  * const gql = app.container.make<GraphQLEngine>('graphql')
10
10
  * gql.resolver('Query', 'tasks', TaskResolver, 'tasks')
11
11
  *
12
- * - `boot()` — mount the engine at `engine.path` (default `/graphql`) for both
13
- * GET (playground/introspection) and POST (queries).
12
+ * - `start()` — mount the engine at `engine.path` (default `/graphql`) for
13
+ * both GET (playground/introspection) and POST (queries). In `start`, which
14
+ * is the phase upstream documents for routes.
14
15
  *
15
16
  * @implements Story 24.5
16
17
  */
@@ -66,7 +67,17 @@ export class GraphQLProvider extends Provider {
66
67
  this.#registered = true
67
68
  }
68
69
 
69
- override async boot(): Promise<void> {
70
+ /**
71
+ * Routes and server middleware go in `start`, not `boot`.
72
+ *
73
+ * Upstream documents that phase for exactly this, and the order is what makes
74
+ * it matter: providers boot, then providers START, then the preloads run —
75
+ * and the preloads are where an application writes its own routes and kernel.
76
+ * Mounted in `boot`, a framework route landed ahead of every application
77
+ * route, so an overlapping path was answered by the framework rather than by
78
+ * the app that meant to override it.
79
+ */
80
+ override async start(): Promise<void> {
70
81
  const engine = this.#engine
71
82
  if (!engine) return
72
83
  const router = await this.app.container.make<Router>('router')
package/src/hot.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Dev-only entry hook: hot module replacement for the running server.
3
+ *
4
+ * `ream dev` loads this before the application entry:
5
+ *
6
+ * node --import @swc-node/register/esm-register --import @c9up/ream/hot bin/server.ts
7
+ *
8
+ * It is a thin shim over `hot-hook`, which does the actual work: it registers
9
+ * an ESM loader that tracks the import graph, and when a file inside a declared
10
+ * boundary changes it invalidates just that module instead of the process. The
11
+ * `hotHook` key in the application's `package.json` configures it, exactly as
12
+ * upstream reads it.
13
+ *
14
+ * NAMED DEVIATION — why this file exists at all.
15
+ *
16
+ * Upstream's dev server is itself a Node process: it forks the app, so the two
17
+ * talk over Node's IPC channel, and `hot-hook` reports a change it cannot swap
18
+ * by calling `process.send({ type: 'hot-hook:full-reload' })`. Our dev server is
19
+ * a Rust binary. A process it spawns has NO IPC channel, so `process.send` is
20
+ * `undefined` and that message goes nowhere: the server would keep serving the
21
+ * old module and say nothing.
22
+ *
23
+ * `hot.init()` takes an `onFullReloadAsked` callback for exactly this case, and
24
+ * that is the whole of the shim: turn "I cannot hot-swap this" into an exit
25
+ * code the Rust parent understands and restarts on. Everything else is
26
+ * upstream's behaviour, unmodified.
27
+ */
28
+
29
+ import { hotReloadHappened } from './dev/hmr.js'
30
+
31
+ /** Exit code meaning "restart me" — see `EXIT_RESTART` in the CLI's dev module. */
32
+ const FULL_RELOAD_EXIT_CODE = 75
33
+
34
+ interface HotHook {
35
+ init(options: {
36
+ rootDirectory: string
37
+ root?: string
38
+ boundaries?: string[]
39
+ restart?: string[]
40
+ ignore?: string[]
41
+ throwWhenBoundariesAreNotDynamicallyImported?: boolean
42
+ onFullReloadAsked?: () => void
43
+ }): Promise<void>
44
+ }
45
+
46
+ interface HotHookConfig {
47
+ root?: string
48
+ boundaries?: string[]
49
+ restart?: string[]
50
+ ignore?: string[]
51
+ throwWhenBoundariesAreNotDynamicallyImported?: boolean
52
+ }
53
+
54
+ /** Read `hotHook` out of the application's package.json, with no assumptions. */
55
+ function readConfig(pkg: unknown): HotHookConfig {
56
+ if (typeof pkg !== 'object' || pkg === null) return {}
57
+ const raw = Reflect.get(pkg, 'hotHook')
58
+ if (typeof raw !== 'object' || raw === null) return {}
59
+ const stringArray = (key: string): string[] | undefined => {
60
+ const value = Reflect.get(raw, key)
61
+ if (!Array.isArray(value)) return undefined
62
+ return value.filter((entry): entry is string => typeof entry === 'string')
63
+ }
64
+ const root = Reflect.get(raw, 'root')
65
+ const strict = Reflect.get(raw, 'throwWhenBoundariesAreNotDynamicallyImported')
66
+ return {
67
+ root: typeof root === 'string' ? root : undefined,
68
+ boundaries: stringArray('boundaries'),
69
+ restart: stringArray('restart'),
70
+ ignore: stringArray('ignore'),
71
+ throwWhenBoundariesAreNotDynamicallyImported: typeof strict === 'boolean' ? strict : undefined,
72
+ }
73
+ }
74
+
75
+ const { readFile } = await import('node:fs/promises')
76
+ const { dirname, resolve } = await import('node:path')
77
+
78
+ const packageJsonPath = resolve(process.cwd(), 'package.json')
79
+ let config: HotHookConfig = {}
80
+ try {
81
+ config = readConfig(JSON.parse(await readFile(packageJsonPath, 'utf8')))
82
+ } catch {
83
+ // No package.json, or unreadable: hot-hook still works, it just has no
84
+ // boundaries, so every change asks for a restart. That is the old
85
+ // behaviour, not a failure worth stopping dev over.
86
+ }
87
+
88
+ // `hot-hook` is an optional peer, declared by the application because it is a
89
+ // dev dependency there and has no business in a production install. Imported by
90
+ // specifier built at runtime so a production bundle never resolves it.
91
+ function isHotHook(value: unknown): value is HotHook {
92
+ return (
93
+ typeof value === 'object' && value !== null && typeof Reflect.get(value, 'init') === 'function'
94
+ )
95
+ }
96
+
97
+ // `hot-hook` belongs to the APPLICATION: it is a dev dependency there and has
98
+ // no business in a production install of the framework. So it is resolved from
99
+ // the project directory rather than from this file — under pnpm's strict layout
100
+ // a bare `import("hot-hook")` here looks inside @c9up/ream and finds nothing.
101
+ // It is the same reason upstream passes `hot-hook/register` from the app's own
102
+ // cwd instead of re-exporting it from the framework.
103
+ const { createRequire } = await import('node:module')
104
+ const { pathToFileURL } = await import('node:url')
105
+
106
+ let hot: HotHook | undefined
107
+ try {
108
+ const resolved = createRequire(packageJsonPath).resolve('hot-hook')
109
+ const module: unknown = await import(pathToFileURL(resolved).href)
110
+ const candidate =
111
+ typeof module === 'object' && module !== null ? Reflect.get(module, 'hot') : undefined
112
+ if (!isHotHook(candidate)) {
113
+ throw new Error('hot-hook did not expose a `hot` object with init()')
114
+ }
115
+ hot = candidate
116
+ } catch (error) {
117
+ // Loud, and it names the fix. Silence here would start a perfectly healthy
118
+ // server that never picks up an edit, which reads as "HMR is broken" rather
119
+ // than "HMR is not installed".
120
+ //
121
+ // It does NOT throw: a dev server that refuses to start is worse than one
122
+ // without hot reloading, and the CLI only loads this file when it has
123
+ // already seen hot-hook in the project — so reaching here means it went
124
+ // missing under a running session.
125
+ console.error(
126
+ `[ream] hot module replacement is unavailable: ${error instanceof Error ? error.message : String(error)}\n` +
127
+ '[ream] Install it with: pnpm add -D hot-hook, then restart `ream dev`.\n' +
128
+ '[ream] Until then the server runs, but an edit will not be picked up.',
129
+ )
130
+ }
131
+
132
+ // Impersonate the IPC channel upstream's dev server provides.
133
+ //
134
+ // hot-hook reports BOTH of its outcomes through `process.send`: a change it
135
+ // swapped in place (`hot-hook:invalidated`) and one it could not
136
+ // (`hot-hook:full-reload`). Under a Node parent that function exists and both
137
+ // arrive; under our Rust parent it is `undefined` and both are dropped —
138
+ // `onFullReloadAsked` alone recovers only half of that, leaving a hot swap
139
+ // invisible to the very process it happened in, and so to the browser.
140
+ //
141
+ // This is not a trick played on hot-hook: it is exactly the contract it
142
+ // expects, provided by us instead of by Node.
143
+ const previousSend = process.send
144
+ process.send = (message: unknown, ...rest: unknown[]): boolean => {
145
+ const type =
146
+ typeof message === 'object' && message !== null ? Reflect.get(message, 'type') : undefined
147
+ if (type === 'hot-hook:invalidated') {
148
+ hotReloadHappened()
149
+ }
150
+ // If a real channel ever exists (embedded under a Node supervisor) it still
151
+ // gets its message: this observes, it does not intercept.
152
+ if (typeof previousSend === 'function') {
153
+ return Reflect.apply(previousSend, process, [message, ...rest]) === true
154
+ }
155
+ return true
156
+ }
157
+
158
+ await hot?.init({
159
+ ...config,
160
+ rootDirectory: dirname(packageJsonPath),
161
+ root: config.root ? resolve(dirname(packageJsonPath), config.root) : undefined,
162
+ // Exiting on a known code is how the Rust parent learns it must restart —
163
+ // see `EXIT_RESTART` in the CLI's dev module.
164
+ onFullReloadAsked: () => process.exit(FULL_RELOAD_EXIT_CODE),
165
+ })
@@ -156,7 +156,7 @@ export class E_VALIDATION_ERROR extends Exception {
156
156
  static override code = 'E_VALIDATION_ERROR'
157
157
 
158
158
  /**
159
- * Per-field failures. Named `messages` because that is what VineJS, rune and
159
+ * Per-field failures. Named `messages` because that is what rune and
160
160
  * AdonisJS's own validation renderers read — a handler copied from an Adonis
161
161
  * app reaches for `error.messages`.
162
162
  */
@@ -249,7 +249,7 @@ export type HttpError = {
249
249
  code: string
250
250
  stack?: string
251
251
  cause?: unknown
252
- /** Per-field failures, on a validation error (VineJS / rune shape). */
252
+ /** Per-field failures, on a validation error (rune's shape). */
253
253
  messages?: unknown
254
254
  /** Extra detail an exception chose to carry. */
255
255
  errors?: unknown
@@ -487,7 +487,7 @@ export class ExceptionHandler extends Macroable {
487
487
 
488
488
  // Validation failures have per-field detail and their own renderers. Keyed
489
489
  // on the code plus the presence of `messages`, exactly as AdonisJS does, so
490
- // rune's and VineJS's errors both land here.
490
+ // rune's errors land here.
491
491
  if (httpError.code === 'E_VALIDATION_ERROR' && httpError.messages !== undefined) {
492
492
  await this.renderValidationError(httpError, ctx)
493
493
  return
@@ -523,7 +523,7 @@ export class ExceptionHandler extends Macroable {
523
523
  }
524
524
 
525
525
  /**
526
- * The fields a validation message carries (VineJS / rune `RuneErrorNode`).
526
+ * The fields a validation message carries (rune's `RuneErrorNode`).
527
527
  *
528
528
  * Read defensively: the messages come from whichever validator the app wired,
529
529
  * and a renderer must not throw while rendering someone else's error.