@c9up/ream 0.2.12 → 0.2.14
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 +9 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +11 -0
- package/dist/Application.js.map +1 -1
- package/dist/GracefulShutdown.js +6 -5
- package/dist/GracefulShutdown.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +33 -7
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +54 -9
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/container/Container.d.ts +19 -3
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +83 -30
- package/dist/container/Container.js.map +1 -1
- package/dist/container/ContainerResolver.js.map +1 -1
- package/dist/errors/PipelineStageError.d.ts +10 -4
- package/dist/errors/PipelineStageError.d.ts.map +1 -1
- package/dist/errors/PipelineStageError.js +12 -6
- package/dist/errors/PipelineStageError.js.map +1 -1
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +4 -9
- package/dist/http/Response.js.map +1 -1
- package/dist/http/SseStream.d.ts.map +1 -1
- package/dist/http/SseStream.js +10 -1
- package/dist/http/SseStream.js.map +1 -1
- package/dist/http/etag.d.ts +44 -0
- package/dist/http/etag.d.ts.map +1 -0
- package/dist/http/etag.js +65 -0
- package/dist/http/etag.js.map +1 -0
- package/dist/http/mime.d.ts +33 -0
- package/dist/http/mime.d.ts.map +1 -0
- package/dist/http/mime.js +1346 -0
- package/dist/http/mime.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +78 -59
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +3 -5
- package/scheduler.win32-x64-msvc.node +0 -0
- package/scripts/generate-mime.mjs +119 -0
- package/src/Application.ts +12 -0
- package/src/GracefulShutdown.ts +5 -5
- package/src/bodyparser/MultipartFile.ts +87 -13
- package/src/container/Container.ts +106 -52
- package/src/container/ContainerResolver.ts +1 -1
- package/src/errors/PipelineStageError.ts +13 -7
- package/src/http/Response.ts +4 -8
- package/src/http/SseStream.ts +16 -7
- package/src/http/etag.ts +68 -0
- package/src/http/mime.ts +1346 -0
- package/src/index.ts +3 -3
- package/src/storage/StaticMiddleware.ts +81 -66
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The top-level mime types IANA registers.
|
|
3
|
+
*
|
|
4
|
+
* Closed on purpose. Typing {@link MultipartFile.type} as a bare `string` let
|
|
5
|
+
* `file.type === 'image/png'` compile and quietly never match — a mime filter
|
|
6
|
+
* that passes everything, which is the shape a comment used to warn about and
|
|
7
|
+
* a compiler can simply refuse. Against this union that comparison is an
|
|
8
|
+
* error, and {@link MultipartFile.mime} is what a full-mime check wants.
|
|
9
|
+
*
|
|
10
|
+
* A top-level type outside this list is malformed: an unregistered format
|
|
11
|
+
* belongs under one of these trees, `application/x-…` rather than `x-foo/bar`.
|
|
12
|
+
*
|
|
13
|
+
* Additive, not a replacement: {@link MultipartFile.type} keeps upstream's
|
|
14
|
+
* `string` and its raw segment, and {@link MultipartFile.registeredType}
|
|
15
|
+
* narrows it here for callers who want `=== 'image/png'` refused at compile
|
|
16
|
+
* time. Nothing a valid AdonisJS call site does stops compiling, and no type
|
|
17
|
+
* outside this set is swallowed — closing `type` itself would have done both.
|
|
18
|
+
*/
|
|
19
|
+
export type MimeType =
|
|
20
|
+
| 'application'
|
|
21
|
+
| 'audio'
|
|
22
|
+
| 'example'
|
|
23
|
+
| 'font'
|
|
24
|
+
| 'image'
|
|
25
|
+
| 'message'
|
|
26
|
+
| 'model'
|
|
27
|
+
| 'multipart'
|
|
28
|
+
| 'text'
|
|
29
|
+
| 'video'
|
|
30
|
+
|
|
31
|
+
const MIME_TYPES = new Set<string>([
|
|
32
|
+
'application',
|
|
33
|
+
'audio',
|
|
34
|
+
'example',
|
|
35
|
+
'font',
|
|
36
|
+
'image',
|
|
37
|
+
'message',
|
|
38
|
+
'model',
|
|
39
|
+
'multipart',
|
|
40
|
+
'text',
|
|
41
|
+
'video',
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
/** Whether a parsed top-level type is one IANA registers. */
|
|
45
|
+
function isMimeType(value: string): value is MimeType {
|
|
46
|
+
return MIME_TYPES.has(value)
|
|
47
|
+
}
|
|
48
|
+
|
|
1
49
|
/**
|
|
2
50
|
* MultipartFile — represents an uploaded file.
|
|
3
51
|
*
|
|
@@ -28,14 +76,32 @@ export class MultipartFile {
|
|
|
28
76
|
* `image/png` would otherwise sail through a mime allowlist. A renamed file
|
|
29
77
|
* reports what it actually is.
|
|
30
78
|
*
|
|
31
|
-
* BREAKING as of 0.2.0: it used to hold the whole `image/png`.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
79
|
+
* BREAKING as of 0.2.0: it used to hold the whole `image/png`. A comparison
|
|
80
|
+
* against a full mime — `file.type === 'image/png'` — compiles and never
|
|
81
|
+
* matches, which is what {@link mime} exists for, and what
|
|
82
|
+
* {@link registeredType} refuses at compile time for callers who want that.
|
|
83
|
+
*
|
|
84
|
+
* `string` and the raw segment, as upstream: a type outside the registered
|
|
85
|
+
* set is still reported rather than swallowed.
|
|
34
86
|
*/
|
|
35
87
|
get type(): string | undefined {
|
|
36
88
|
return this.#mimeParts()[0]
|
|
37
89
|
}
|
|
38
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The same primary type, narrowed to what IANA registers.
|
|
93
|
+
*
|
|
94
|
+
* Additive, and the point of it: against a closed union
|
|
95
|
+
* `file.registeredType === 'image/png'` is a compile error instead of a
|
|
96
|
+
* comparison that always fails. `undefined` where {@link type} holds
|
|
97
|
+
* something outside the set — a malformed or unregistered tree — so the two
|
|
98
|
+
* disagree exactly there and nowhere else.
|
|
99
|
+
*/
|
|
100
|
+
get registeredType(): MimeType | undefined {
|
|
101
|
+
const type = this.#mimeParts()[0]
|
|
102
|
+
return type !== undefined && isMimeType(type) ? type : undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
39
105
|
/** The mime SUBTYPE — `png` for `image/png`. Same source as {@link type}. */
|
|
40
106
|
get subtype(): string | undefined {
|
|
41
107
|
return this.#mimeParts()[1]
|
|
@@ -297,7 +363,7 @@ export class MultipartFile {
|
|
|
297
363
|
* passing `file.clientName` straight through.
|
|
298
364
|
*/
|
|
299
365
|
async move(location: string, options?: { name?: string; overwrite?: boolean }): Promise<void> {
|
|
300
|
-
const { mkdir, writeFile
|
|
366
|
+
const { mkdir, writeFile } = await import('node:fs/promises')
|
|
301
367
|
const { join } = await import('node:path')
|
|
302
368
|
|
|
303
369
|
const name = options?.name ?? `${randomBytes(16).toString('hex')}.${this.extname || 'unknown'}`
|
|
@@ -308,20 +374,28 @@ export class MultipartFile {
|
|
|
308
374
|
}
|
|
309
375
|
|
|
310
376
|
const filePath = join(location, name)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
377
|
+
await mkdir(location, { recursive: true })
|
|
378
|
+
|
|
379
|
+
// NAMED DEVIATION — the refusal is ATOMIC where upstream's is not.
|
|
380
|
+
//
|
|
381
|
+
// Upstream checks `pathExists` and then renames, so two uploads landing on
|
|
382
|
+
// the same name both see nothing and both succeed: the second silently
|
|
383
|
+
// replaces the first, which is the one thing `overwrite: false` exists to
|
|
384
|
+
// prevent. `wx` is O_EXCL — the kernel decides, and exactly one of the two
|
|
385
|
+
// writes wins.
|
|
386
|
+
//
|
|
387
|
+
// The message is upstream's, word for word: a caller matching on it keeps
|
|
388
|
+
// working, and only the race is gone.
|
|
389
|
+
try {
|
|
390
|
+
await writeFile(filePath, this.content, options?.overwrite === false ? { flag: 'wx' } : {})
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
317
393
|
throw new Error(
|
|
318
394
|
`"${name}" already exists at "${location}". Set "overwrite = true" to overwrite it`,
|
|
319
395
|
)
|
|
320
396
|
}
|
|
397
|
+
throw error
|
|
321
398
|
}
|
|
322
|
-
|
|
323
|
-
await mkdir(location, { recursive: true })
|
|
324
|
-
await writeFile(filePath, this.content)
|
|
325
399
|
this.markAsMoved(name, filePath)
|
|
326
400
|
}
|
|
327
401
|
|
|
@@ -32,8 +32,8 @@ const RESERVED_TOKEN_NAMES = new Set(['__proto__', 'constructor', 'prototype'])
|
|
|
32
32
|
|
|
33
33
|
/** One resolution chain: the tokens on it, and a set for O(1) membership. */
|
|
34
34
|
interface ResolutionChain {
|
|
35
|
-
stack:
|
|
36
|
-
set: Set<
|
|
35
|
+
stack: ServiceToken[]
|
|
36
|
+
set: Set<ServiceToken>
|
|
37
37
|
/**
|
|
38
38
|
* Values bound on the {@link ContainerResolver} that opened this chain.
|
|
39
39
|
*
|
|
@@ -42,7 +42,7 @@ interface ResolutionChain {
|
|
|
42
42
|
* a controller's constructor dependency sees the same `HttpContext` the
|
|
43
43
|
* middleware bound — and to nothing outside it.
|
|
44
44
|
*/
|
|
45
|
-
values?: ReadonlyMap<
|
|
45
|
+
values?: ReadonlyMap<ServiceToken, unknown>
|
|
46
46
|
/**
|
|
47
47
|
* How many scoped values this chain has handed out so far.
|
|
48
48
|
*
|
|
@@ -69,8 +69,8 @@ interface ResolutionChain {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
export class Container {
|
|
72
|
-
#bindings: Map<
|
|
73
|
-
#singletons: Map<
|
|
72
|
+
#bindings: Map<ServiceToken, Binding> = new Map()
|
|
73
|
+
#singletons: Map<ServiceToken, unknown> = new Map()
|
|
74
74
|
/**
|
|
75
75
|
* Singletons currently being built, by key.
|
|
76
76
|
*
|
|
@@ -79,22 +79,22 @@ export class Container {
|
|
|
79
79
|
* point is one, and silently discarding the first (a second connection
|
|
80
80
|
* pool, a second scheduler). Concurrent callers await the same promise.
|
|
81
81
|
*/
|
|
82
|
-
#pendingSingletons: Map<
|
|
83
|
-
#overrides: Map<
|
|
82
|
+
#pendingSingletons: Map<ServiceToken, Promise<unknown>> = new Map()
|
|
83
|
+
#overrides: Map<ServiceToken, ServiceFactory> = new Map()
|
|
84
84
|
/**
|
|
85
85
|
* Snapshot of singletons taken when `swap`/`override` shadows a previously
|
|
86
86
|
* resolved instance. `restore()` re-seats them without re-invoking the
|
|
87
87
|
* original factory, so callers that captured the live reference keep
|
|
88
88
|
* pointing at the same object after the test ends.
|
|
89
89
|
*/
|
|
90
|
-
#singletonBackup: Map<
|
|
90
|
+
#singletonBackup: Map<ServiceToken, unknown> = new Map()
|
|
91
91
|
/** Alias key → the token it forwards to (AdonisJS `container.alias`). */
|
|
92
|
-
#aliases: Map<
|
|
92
|
+
#aliases: Map<ServiceToken, ServiceToken> = new Map()
|
|
93
93
|
/** parent class → (binding key → the factory that class gets instead). */
|
|
94
|
-
#contextualBindings: Map<new (...args: never[]) => unknown, Map<
|
|
94
|
+
#contextualBindings: Map<new (...args: never[]) => unknown, Map<ServiceToken, ServiceFactory>> =
|
|
95
95
|
new Map()
|
|
96
96
|
/** token key → post-resolution callbacks (AdonisJS `container.resolving`). */
|
|
97
|
-
#resolvingHooks: Map<
|
|
97
|
+
#resolvingHooks: Map<ServiceToken, Array<(value: unknown) => void | Promise<void>>> = new Map()
|
|
98
98
|
/**
|
|
99
99
|
* The resolution chain currently being walked, for cycle detection.
|
|
100
100
|
*
|
|
@@ -176,7 +176,7 @@ export class Container {
|
|
|
176
176
|
else this.#resolvingHooks.set(key, [callback])
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
async #runResolvingHooks(key:
|
|
179
|
+
async #runResolvingHooks(key: ServiceToken, value: unknown): Promise<void> {
|
|
180
180
|
const hooks = this.#resolvingHooks.get(key)
|
|
181
181
|
if (hooks) for (const hook of hooks) await hook(value)
|
|
182
182
|
}
|
|
@@ -235,7 +235,7 @@ export class Container {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
#restoreOne(key:
|
|
238
|
+
#restoreOne(key: ServiceToken): void {
|
|
239
239
|
this.#overrides.delete(key)
|
|
240
240
|
this.#singletons.delete(key)
|
|
241
241
|
if (this.#singletonBackup.has(key)) {
|
|
@@ -272,7 +272,7 @@ export class Container {
|
|
|
272
272
|
// `alias('b','a')` overflowed the stack instead of naming the cycle.
|
|
273
273
|
let resolvedToken = token
|
|
274
274
|
let key = this.#tokenToKey(resolvedToken)
|
|
275
|
-
const seenAliases = new Set<
|
|
275
|
+
const seenAliases = new Set<ServiceToken>([key])
|
|
276
276
|
for (
|
|
277
277
|
let target = this.#aliases.get(key);
|
|
278
278
|
target !== undefined;
|
|
@@ -281,7 +281,7 @@ export class Container {
|
|
|
281
281
|
resolvedToken = target
|
|
282
282
|
key = this.#tokenToKey(target)
|
|
283
283
|
if (seenAliases.has(key)) {
|
|
284
|
-
const cycle = [...seenAliases, key].join(' → ')
|
|
284
|
+
const cycle = [...seenAliases, key].map(describeToken).join(' → ')
|
|
285
285
|
throw new ReamError('E_CIRCULAR_DEPENDENCY', `Circular alias detected: ${cycle}`, {
|
|
286
286
|
hint: 'An alias chain must end at a real binding — remove one of the aliases.',
|
|
287
287
|
context: { chain: cycle },
|
|
@@ -301,7 +301,7 @@ export class Container {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
if (chain.set.has(key)) {
|
|
304
|
-
const cycle = [...chain.stack, key].join(' → ')
|
|
304
|
+
const cycle = [...chain.stack, key].map(describeToken).join(' → ')
|
|
305
305
|
throw new ReamError('E_CIRCULAR_DEPENDENCY', `Circular dependency detected: ${cycle}`, {
|
|
306
306
|
hint: 'Break the cycle by resolving one dependency lazily inside a method (`await container.make(Dep)`) instead of injecting it in the constructor.',
|
|
307
307
|
context: { chain: cycle },
|
|
@@ -401,7 +401,10 @@ export class Container {
|
|
|
401
401
|
* Reuses an open chain rather than starting a second one, so cycle detection
|
|
402
402
|
* still spans the whole resolution when a resolver is used inside a factory.
|
|
403
403
|
*/
|
|
404
|
-
runWithScopedValues<T>(
|
|
404
|
+
runWithScopedValues<T>(
|
|
405
|
+
values: ReadonlyMap<ServiceToken, unknown>,
|
|
406
|
+
fn: () => Promise<T>,
|
|
407
|
+
): Promise<T> {
|
|
405
408
|
const chain = this.#chain.getStore()
|
|
406
409
|
return this.#chain.run(
|
|
407
410
|
chain === undefined ? { stack: [], set: new Set(), values } : { ...chain, values },
|
|
@@ -409,8 +412,8 @@ export class Container {
|
|
|
409
412
|
)
|
|
410
413
|
}
|
|
411
414
|
|
|
412
|
-
/** @internal
|
|
413
|
-
keyFor(token: ServiceToken):
|
|
415
|
+
/** @internal Validate a token and hand back the value the container keys on. */
|
|
416
|
+
keyFor(token: ServiceToken): ServiceToken {
|
|
414
417
|
return this.#tokenToKey(token)
|
|
415
418
|
}
|
|
416
419
|
|
|
@@ -434,6 +437,41 @@ export class Container {
|
|
|
434
437
|
|
|
435
438
|
// ─── Introspection ────────────────────────────────────────
|
|
436
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Every token the container holds, sorted, with the kind beside it.
|
|
442
|
+
*
|
|
443
|
+
* `ream inspect` advertised a section for these and rendered none, because
|
|
444
|
+
* nothing could enumerate them — `has()` answers about one token you already
|
|
445
|
+
* know the name of, which is no help when the question is what is in there.
|
|
446
|
+
*
|
|
447
|
+
* These are bindings, not every `@inject()`-decorated class: a decorated
|
|
448
|
+
* class is discovered when it is resolved, and nothing registers it before
|
|
449
|
+
* that, so no complete list of those exists to return.
|
|
450
|
+
*/
|
|
451
|
+
get bindings(): ReadonlyArray<{ token: string; scope: string; target?: string }> {
|
|
452
|
+
const seen = new Map<ServiceToken, { scope: string; target?: string }>()
|
|
453
|
+
for (const [key, binding] of this.#bindings) seen.set(key, { scope: binding.scope })
|
|
454
|
+
for (const key of this.#singletons.keys()) {
|
|
455
|
+
if (!seen.has(key)) seen.set(key, { scope: 'value' })
|
|
456
|
+
}
|
|
457
|
+
// Aliases LAST and overwriting, because that is the order `resolve()` uses:
|
|
458
|
+
// it follows an alias before it ever looks at a binding. Reporting the
|
|
459
|
+
// binding for a token that also has an alias showed a definition nobody
|
|
460
|
+
// gets — `service` listed as transient while resolving it hands back
|
|
461
|
+
// `target`. Where both exist the alias is what runs, and the binding it
|
|
462
|
+
// shadows is named beside it rather than dropped.
|
|
463
|
+
for (const [key, target] of this.#aliases) {
|
|
464
|
+
const shadowed = seen.get(key)
|
|
465
|
+
seen.set(key, {
|
|
466
|
+
scope: shadowed === undefined ? 'alias' : `alias (shadows ${shadowed.scope})`,
|
|
467
|
+
target: describeToken(target),
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
return [...seen.entries()]
|
|
471
|
+
.map(([token, entry]) => ({ token: describeToken(token), ...entry }))
|
|
472
|
+
.sort((a, b) => a.token.localeCompare(b.token))
|
|
473
|
+
}
|
|
474
|
+
|
|
437
475
|
/** Check if a token is registered or resolvable. */
|
|
438
476
|
has(token: ServiceToken): boolean {
|
|
439
477
|
const key = this.#tokenToKey(token)
|
|
@@ -472,7 +510,7 @@ export class Container {
|
|
|
472
510
|
/** Auto-register all @Service() decorated classes from the global registry. */
|
|
473
511
|
autoRegister(): void {
|
|
474
512
|
for (const [target, metadata] of getServiceRegistry()) {
|
|
475
|
-
const key = metadata.as ?? target
|
|
513
|
+
const key = metadata.as ?? target
|
|
476
514
|
if (!this.#bindings.has(key)) {
|
|
477
515
|
const targetClass = target
|
|
478
516
|
this.#bindings.set(key, {
|
|
@@ -487,7 +525,11 @@ export class Container {
|
|
|
487
525
|
|
|
488
526
|
// ─── Internal resolution ──────────────────────────────────
|
|
489
527
|
|
|
490
|
-
async #resolveInner<T>(
|
|
528
|
+
async #resolveInner<T>(
|
|
529
|
+
key: ServiceToken,
|
|
530
|
+
token: ServiceToken,
|
|
531
|
+
runtimeValues?: unknown[],
|
|
532
|
+
): Promise<T> {
|
|
491
533
|
// 1. Check swaps (test overrides) — a swap factory may be async.
|
|
492
534
|
if (this.#overrides.has(key)) {
|
|
493
535
|
const swapped = await this.#overrides.get(key)?.(this)
|
|
@@ -562,11 +604,12 @@ export class Container {
|
|
|
562
604
|
}
|
|
563
605
|
|
|
564
606
|
// 7. Not found
|
|
565
|
-
const
|
|
566
|
-
const
|
|
607
|
+
const name = describeToken(key)
|
|
608
|
+
const allKeys = [...this.#bindings.keys(), ...this.#overrides.keys()].map(describeToken)
|
|
609
|
+
const suggestion = didYouMean(name, allKeys)
|
|
567
610
|
throw new ReamError(
|
|
568
611
|
'E_CONTAINER_NOT_FOUND',
|
|
569
|
-
`No binding found for '${
|
|
612
|
+
`No binding found for '${name}'.${suggestion ? ` ${suggestion}` : ''}`,
|
|
570
613
|
{
|
|
571
614
|
hint: 'Register it with container.singleton() or decorate with @inject().',
|
|
572
615
|
},
|
|
@@ -604,7 +647,9 @@ export class Container {
|
|
|
604
647
|
): Promise<unknown> {
|
|
605
648
|
const metadata = getServiceMetadata(target)
|
|
606
649
|
const scope = metadata?.scope ?? 'transient'
|
|
607
|
-
|
|
650
|
+
// The class itself, not `target.name`: two classes called `Service` share a
|
|
651
|
+
// name and must not share a singleton cache entry.
|
|
652
|
+
const key = metadata?.as ?? target
|
|
608
653
|
|
|
609
654
|
if (scope === 'singleton' && this.#singletons.has(key)) {
|
|
610
655
|
return this.#singletons.get(key)
|
|
@@ -712,7 +757,7 @@ export class Container {
|
|
|
712
757
|
*/
|
|
713
758
|
#cacheIfAppWide(
|
|
714
759
|
scope: string,
|
|
715
|
-
key:
|
|
760
|
+
key: ServiceToken,
|
|
716
761
|
instance: unknown,
|
|
717
762
|
store: ResolutionChain | undefined,
|
|
718
763
|
readsBefore: number,
|
|
@@ -722,39 +767,36 @@ export class Container {
|
|
|
722
767
|
this.#singletons.set(key, instance)
|
|
723
768
|
}
|
|
724
769
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
`Unique Symbol() tokens are not supported — use Symbol.for("${token.description ?? 'name'}") so different modules see the same symbol.`,
|
|
739
|
-
)
|
|
740
|
-
}
|
|
741
|
-
const desc = token.description
|
|
742
|
-
if (desc === undefined) {
|
|
743
|
-
throw new ReamError('E_CONTAINER_INVALID_TOKEN', 'Symbol tokens must have a description.')
|
|
744
|
-
}
|
|
745
|
-
this.#assertNotReserved(desc, 'symbol description')
|
|
746
|
-
return `Symbol(${desc})`
|
|
747
|
-
}
|
|
748
|
-
return token.name
|
|
770
|
+
/**
|
|
771
|
+
* Validate a token and hand back the value every map keys on: the token.
|
|
772
|
+
*
|
|
773
|
+
* Fold's stores are `Map<string | symbol | AbstractConstructor, …>`, so a
|
|
774
|
+
* token IS its own key and identity is preserved. Deriving a string key
|
|
775
|
+
* instead cost exactly what identity buys: `Symbol.for('cache')` became
|
|
776
|
+
* `"Symbol(cache)"` and collided with the plain string `"Symbol(cache)"`,
|
|
777
|
+
* and a unique `Symbol()` had to be refused because its description is all a
|
|
778
|
+
* string key can carry. Both go away when the token is the key.
|
|
779
|
+
*/
|
|
780
|
+
#tokenToKey(token: ServiceToken): ServiceToken {
|
|
781
|
+
if (typeof token === 'string') this.#assertNotReserved(token)
|
|
782
|
+
return token
|
|
749
783
|
}
|
|
750
784
|
|
|
751
|
-
|
|
785
|
+
/**
|
|
786
|
+
* NAMED DEVIATION from Fold, kept deliberately.
|
|
787
|
+
*
|
|
788
|
+
* These three read as prototype keys, and a token named after one is far
|
|
789
|
+
* likelier to be a mistake than a deliberate binding. Nothing indexes tokens
|
|
790
|
+
* on a plain object any more — every store above is a `Map` — so this is a
|
|
791
|
+
* tripwire for a caller error, not a pollution guard.
|
|
792
|
+
*/
|
|
793
|
+
#assertNotReserved(name: string): void {
|
|
752
794
|
if (RESERVED_TOKEN_NAMES.has(name)) {
|
|
753
795
|
throw new ReamError(
|
|
754
796
|
'E_CONTAINER_RESERVED_TOKEN',
|
|
755
797
|
`Reserved container token name '${name}'.`,
|
|
756
798
|
{
|
|
757
|
-
hint: `'${name}'
|
|
799
|
+
hint: `'${name}' reads as an Object.prototype key. Pick a different token name.`,
|
|
758
800
|
},
|
|
759
801
|
)
|
|
760
802
|
}
|
|
@@ -781,6 +823,18 @@ const NON_INJECTABLE_CONSTRUCTORS = new Set<unknown>([
|
|
|
781
823
|
Error,
|
|
782
824
|
])
|
|
783
825
|
|
|
826
|
+
/**
|
|
827
|
+
* A token as it reads in an error message or in `ream inspect`.
|
|
828
|
+
*
|
|
829
|
+
* Display only — the container keys on the token itself, so two distinct
|
|
830
|
+
* classes that share a name are two bindings that happen to print alike.
|
|
831
|
+
*/
|
|
832
|
+
function describeToken(token: ServiceToken): string {
|
|
833
|
+
if (typeof token === 'string') return token
|
|
834
|
+
if (typeof token === 'symbol') return token.toString()
|
|
835
|
+
return token.name === '' ? 'AnonymousClass' : token.name
|
|
836
|
+
}
|
|
837
|
+
|
|
784
838
|
/** True for a real, injectable class token (excludes native primitive constructors). */
|
|
785
839
|
function isInjectableClass(value: unknown): value is new (...args: never[]) => unknown {
|
|
786
840
|
return typeof value === 'function' && !NON_INJECTABLE_CONSTRUCTORS.has(value)
|
|
@@ -20,7 +20,7 @@ import type { ServiceToken } from './types.js'
|
|
|
20
20
|
export class ContainerResolver {
|
|
21
21
|
readonly #container: Container
|
|
22
22
|
/** Values bound here, keyed the way the container keys its tokens. */
|
|
23
|
-
readonly #values = new Map<
|
|
23
|
+
readonly #values = new Map<ServiceToken, unknown>()
|
|
24
24
|
|
|
25
25
|
constructor(container: Container) {
|
|
26
26
|
this.#container = container
|
|
@@ -6,8 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { ReamError } from './ReamError.js'
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Pipeline stage names in execution order.
|
|
11
|
+
*
|
|
12
|
+
* NOT an error code, despite living beside them — it is the list of stages, and
|
|
13
|
+
* an application imports it by this name. The `E_` prefix belongs to codes
|
|
14
|
+
* alone; a rename here breaks a public import for nothing.
|
|
15
|
+
*/
|
|
16
|
+
export const PIPELINE_STAGES = [
|
|
11
17
|
'Security (Blackhole)',
|
|
12
18
|
'Logging',
|
|
13
19
|
'Global Middleware',
|
|
@@ -20,12 +26,12 @@ export const E_PIPELINE_STAGES = [
|
|
|
20
26
|
'Response Logging',
|
|
21
27
|
] as const
|
|
22
28
|
|
|
23
|
-
export type PipelineStageName = (typeof
|
|
29
|
+
export type PipelineStageName = (typeof PIPELINE_STAGES)[number]
|
|
24
30
|
|
|
25
31
|
/**
|
|
26
32
|
* Create a pipeline stage error with position context.
|
|
27
33
|
*
|
|
28
|
-
* @param stageIndex - Zero-based index in
|
|
34
|
+
* @param stageIndex - Zero-based index in PIPELINE_STAGES (clamped to valid range)
|
|
29
35
|
* @param originalError - The error that occurred
|
|
30
36
|
* @param context - Additional context
|
|
31
37
|
*/
|
|
@@ -34,11 +40,11 @@ export function createPipelineError(
|
|
|
34
40
|
originalError: Error,
|
|
35
41
|
context?: Record<string, string>,
|
|
36
42
|
): ReamError {
|
|
37
|
-
const total =
|
|
43
|
+
const total = PIPELINE_STAGES.length
|
|
38
44
|
const clampedIndex = Math.max(0, Math.min(stageIndex, total - 1))
|
|
39
|
-
const stageName =
|
|
45
|
+
const stageName = PIPELINE_STAGES[clampedIndex]
|
|
40
46
|
const position = `${clampedIndex + 1}/${total}`
|
|
41
|
-
const nextStage = clampedIndex + 1 < total ?
|
|
47
|
+
const nextStage = clampedIndex + 1 < total ? PIPELINE_STAGES[clampedIndex + 1] : '(end)'
|
|
42
48
|
|
|
43
49
|
const errorCode = originalError instanceof ReamError ? originalError.code : 'E_PIPELINE_ERROR'
|
|
44
50
|
|
package/src/http/Response.ts
CHANGED
|
@@ -11,10 +11,10 @@ import { randomUUID } from 'node:crypto'
|
|
|
11
11
|
import { createReadStream } from 'node:fs'
|
|
12
12
|
import { readFile, stat } from 'node:fs/promises'
|
|
13
13
|
import { basename } from 'node:path'
|
|
14
|
-
import etag from 'etag'
|
|
15
|
-
import { contentType } from 'mime-types'
|
|
16
14
|
import { ReamError } from '../errors/ReamError.js'
|
|
17
15
|
import { durationToSeconds } from '../helpers/duration.js'
|
|
16
|
+
import { etag, matchesIfNoneMatch } from './etag.js'
|
|
17
|
+
import { contentType } from './mime.js'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Default ceiling on a buffered response body — the same 100MB the Rust layer
|
|
@@ -738,12 +738,8 @@ export class Response extends Macroable {
|
|
|
738
738
|
if (!((status >= 200 && status < 300) || status === 304)) return false
|
|
739
739
|
const noneMatch = this.#request?.header('if-none-match')
|
|
740
740
|
const currentEtag = this.getHeader('etag')
|
|
741
|
-
if (!
|
|
742
|
-
|
|
743
|
-
return noneMatch.split(',').some((tag) => {
|
|
744
|
-
const t = tag.trim()
|
|
745
|
-
return t === currentEtag || t === `W/${currentEtag}` || `W/${t}` === currentEtag
|
|
746
|
-
})
|
|
741
|
+
if (!currentEtag) return false
|
|
742
|
+
return matchesIfNoneMatch(noneMatch, currentEtag)
|
|
747
743
|
}
|
|
748
744
|
|
|
749
745
|
/**
|
package/src/http/SseStream.ts
CHANGED
|
@@ -106,13 +106,22 @@ export class SseStream {
|
|
|
106
106
|
// stop the timer and run close listeners once, so a dropped-
|
|
107
107
|
// at-open stream can't leave an orphaned keepalive ticking
|
|
108
108
|
// against a dead registry slot.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
//
|
|
110
|
+
// A REJECTION means the same thing as `false` and is treated the same
|
|
111
|
+
// way. It was unhandled before, and an unhandled rejection from a
|
|
112
|
+
// detached timer takes the whole process down on a default Node —
|
|
113
|
+
// one client's socket dying would have ended every other client's.
|
|
114
|
+
void this.#backend
|
|
115
|
+
.writeStream(this.id, ':keepalive\n\n')
|
|
116
|
+
.then((ok) => ok)
|
|
117
|
+
.catch(() => false)
|
|
118
|
+
.then((ok) => {
|
|
119
|
+
if (!ok && this.#open) {
|
|
120
|
+
this.#open = false
|
|
121
|
+
this.#stopPing()
|
|
122
|
+
this.#fireClose()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
116
125
|
}, ping)
|
|
117
126
|
// The keepalive must not pin the Node event loop — letting the
|
|
118
127
|
// process exit on its own when nothing else is keeping it busy
|
package/src/http/etag.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity tags, computed here rather than pulled in.
|
|
3
|
+
*
|
|
4
|
+
* The `etag` package is fifteen lines of hashing behind a dependency, and this
|
|
5
|
+
* is the whole of what a response needs. The format is reproduced byte for
|
|
6
|
+
* byte on purpose: an ETag is a cache key, and changing its shape would
|
|
7
|
+
* invalidate every cached response in flight the day it shipped.
|
|
8
|
+
*
|
|
9
|
+
* `<hex length>-<27 chars of base64 sha1>`, quoted, with `W/` in front when
|
|
10
|
+
* the caller asks for a weak one. The empty body has a fixed answer because
|
|
11
|
+
* hashing nothing is the same work every time.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
/** base64 sha1 of the empty string, truncated as below — precomputed. */
|
|
17
|
+
const EMPTY = '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"'
|
|
18
|
+
|
|
19
|
+
/** The tag for a response body (`etag` package parity, string or Buffer). */
|
|
20
|
+
export function etag(body: string | Buffer, options?: { weak?: boolean }): string {
|
|
21
|
+
const tag = body.length === 0 ? EMPTY : entityTag(body)
|
|
22
|
+
return options?.weak === true ? `W/${tag}` : tag
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function entityTag(body: string | Buffer): string {
|
|
26
|
+
const hash = createHash('sha1')
|
|
27
|
+
.update(typeof body === 'string' ? Buffer.from(body, 'utf8') : body)
|
|
28
|
+
.digest('base64')
|
|
29
|
+
.slice(0, 27)
|
|
30
|
+
// Byte length, not character count: a multi-byte body is longer on the wire
|
|
31
|
+
// than its string length says.
|
|
32
|
+
const length = typeof body === 'string' ? Buffer.byteLength(body, 'utf8') : body.length
|
|
33
|
+
return `"${length.toString(16)}-${hash}"`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The tag for a file, from its metadata rather than its bytes.
|
|
38
|
+
*
|
|
39
|
+
* `W/"<size hex>-<mtime hex>"`, which is what the `etag` package produces for
|
|
40
|
+
* a `Stats` and therefore what Express's `send` — and so every static file
|
|
41
|
+
* server people have cached against — puts on a static asset. Weak because it
|
|
42
|
+
* is derived from metadata: two files with the same size and mtime are
|
|
43
|
+
* equivalent for caching, not provably byte-identical.
|
|
44
|
+
*
|
|
45
|
+
* The quotes are not decoration. RFC 9110 §8.8.3 defines an entity-tag as a
|
|
46
|
+
* quoted string, and a bare hex digest is not one: a strict cache is entitled
|
|
47
|
+
* to ignore it, which turns every conditional request back into a full
|
|
48
|
+
* transfer.
|
|
49
|
+
*/
|
|
50
|
+
export function statTag(stat: { size: number; mtimeMs: number }): string {
|
|
51
|
+
return `W/"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether `If-None-Match` covers `tag` (RFC 9110 §13.1.2).
|
|
56
|
+
*
|
|
57
|
+
* The header is a LIST, and it is compared with the WEAK comparison function —
|
|
58
|
+
* `W/"x"` and `"x"` match. Raw string equality against the whole header value
|
|
59
|
+
* failed a client that sent two tags, and failed every client that sent back
|
|
60
|
+
* the weak form of a strong tag, so both re-downloaded a file they already had.
|
|
61
|
+
*/
|
|
62
|
+
export function matchesIfNoneMatch(header: string | undefined, tag: string): boolean {
|
|
63
|
+
if (!header || tag === '') return false
|
|
64
|
+
if (header.trim() === '*') return true
|
|
65
|
+
const bare = (value: string): string => (value.startsWith('W/') ? value.slice(2) : value)
|
|
66
|
+
const current = bare(tag)
|
|
67
|
+
return header.split(',').some((candidate) => bare(candidate.trim()) === current)
|
|
68
|
+
}
|