@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.
- package/dist/Application.d.ts +54 -8
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +77 -7
- package/dist/Application.js.map +1 -1
- 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 +76 -18
- package/dist/Ignitor.js.map +1 -1
- package/dist/commands/ScheduleList.d.ts.map +1 -1
- package/dist/commands/ScheduleList.js +29 -0
- package/dist/commands/ScheduleList.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/Application.ts +89 -8
- package/src/HttpKernel.ts +87 -5
- package/src/Ignitor.ts +82 -19
- package/src/commands/ScheduleList.ts +30 -0
- 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
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
|
-
|
|
674
|
-
|
|
675
|
-
const env =
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
//
|
|
807
|
-
//
|
|
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.
|
|
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.
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|