@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.
- package/dist/HttpKernel.d.ts +10 -0
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +61 -5
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +65 -15
- package/dist/Ignitor.js.map +1 -1
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +174 -45
- package/dist/container/Container.js.map +1 -1
- package/dist/container/types.d.ts +7 -0
- package/dist/container/types.d.ts.map +1 -1
- package/dist/dev/hmr.d.ts +37 -0
- package/dist/dev/hmr.d.ts.map +1 -0
- package/dist/dev/hmr.js +72 -0
- package/dist/dev/hmr.js.map +1 -0
- package/dist/graphql/GraphQLProvider.d.ts +14 -3
- package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
- package/dist/graphql/GraphQLProvider.js +14 -3
- package/dist/graphql/GraphQLProvider.js.map +1 -1
- package/dist/hot.d.ts +29 -0
- package/dist/hot.d.ts.map +1 -0
- package/dist/hot.js +135 -0
- package/dist/hot.js.map +1 -0
- package/dist/http/Exception.d.ts +2 -2
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +3 -3
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts +11 -0
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +40 -0
- package/dist/http/Request.js.map +1 -1
- package/dist/http/RequestValidator.d.ts +80 -0
- package/dist/http/RequestValidator.d.ts.map +1 -0
- package/dist/http/RequestValidator.js +93 -0
- package/dist/http/RequestValidator.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts +2 -2
- package/dist/middleware/Pipeline.js +2 -2
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/migrations/MigrationRegistry.d.ts +16 -0
- package/dist/migrations/MigrationRegistry.d.ts.map +1 -1
- package/dist/migrations/MigrationRegistry.js +24 -0
- package/dist/migrations/MigrationRegistry.js.map +1 -1
- package/dist/openapi/OpenApiGenerator.d.ts +16 -0
- package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
- package/dist/openapi/OpenApiGenerator.js +76 -4
- package/dist/openapi/OpenApiGenerator.js.map +1 -1
- package/dist/openapi/OpenApiProvider.d.ts +16 -5
- package/dist/openapi/OpenApiProvider.d.ts.map +1 -1
- package/dist/openapi/OpenApiProvider.js +21 -6
- package/dist/openapi/OpenApiProvider.js.map +1 -1
- package/dist/rpc/RpcProvider.d.ts +13 -2
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +13 -2
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/storage/StaticProvider.d.ts +12 -2
- package/dist/storage/StaticProvider.d.ts.map +1 -1
- package/dist/storage/StaticProvider.js +12 -2
- package/dist/storage/StaticProvider.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +14 -2
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/HttpKernel.ts +87 -5
- package/src/Ignitor.ts +70 -16
- package/src/container/Container.ts +178 -48
- package/src/container/types.ts +11 -0
- package/src/dev/hmr.ts +83 -0
- package/src/graphql/GraphQLProvider.ts +14 -3
- package/src/hot.ts +165 -0
- package/src/http/Exception.ts +4 -4
- package/src/http/Request.ts +54 -0
- package/src/http/RequestValidator.ts +143 -0
- package/src/index.ts +8 -1
- package/src/middleware/Pipeline.ts +4 -4
- package/src/migrations/MigrationRegistry.ts +23 -0
- package/src/openapi/OpenApiGenerator.ts +84 -4
- package/src/openapi/OpenApiProvider.ts +21 -6
- package/src/rpc/RpcProvider.ts +13 -2
- 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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/container/types.ts
CHANGED
|
@@ -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, '"')}"`
|
|
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
|
-
* - `
|
|
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
|
-
|
|
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
|
+
})
|
package/src/http/Exception.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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 (
|
|
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.
|