@c9up/ream 0.2.13 → 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.
Files changed (46) hide show
  1. package/dist/GracefulShutdown.js +6 -5
  2. package/dist/GracefulShutdown.js.map +1 -1
  3. package/dist/bodyparser/MultipartFile.d.ts +33 -7
  4. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  5. package/dist/bodyparser/MultipartFile.js +54 -9
  6. package/dist/bodyparser/MultipartFile.js.map +1 -1
  7. package/dist/container/Container.d.ts +19 -3
  8. package/dist/container/Container.d.ts.map +1 -1
  9. package/dist/container/Container.js +83 -30
  10. package/dist/container/Container.js.map +1 -1
  11. package/dist/container/ContainerResolver.js.map +1 -1
  12. package/dist/http/Response.d.ts.map +1 -1
  13. package/dist/http/Response.js +4 -9
  14. package/dist/http/Response.js.map +1 -1
  15. package/dist/http/SseStream.d.ts.map +1 -1
  16. package/dist/http/SseStream.js +10 -1
  17. package/dist/http/SseStream.js.map +1 -1
  18. package/dist/http/etag.d.ts +44 -0
  19. package/dist/http/etag.d.ts.map +1 -0
  20. package/dist/http/etag.js +65 -0
  21. package/dist/http/etag.js.map +1 -0
  22. package/dist/http/mime.d.ts +33 -0
  23. package/dist/http/mime.d.ts.map +1 -0
  24. package/dist/http/mime.js +1346 -0
  25. package/dist/http/mime.js.map +1 -0
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  30. package/dist/storage/StaticMiddleware.js +78 -59
  31. package/dist/storage/StaticMiddleware.js.map +1 -1
  32. package/events.win32-x64-msvc.node +0 -0
  33. package/index.win32-x64-msvc.node +0 -0
  34. package/package.json +3 -5
  35. package/scheduler.win32-x64-msvc.node +0 -0
  36. package/scripts/generate-mime.mjs +119 -0
  37. package/src/GracefulShutdown.ts +5 -5
  38. package/src/bodyparser/MultipartFile.ts +87 -13
  39. package/src/container/Container.ts +106 -52
  40. package/src/container/ContainerResolver.ts +1 -1
  41. package/src/http/Response.ts +4 -8
  42. package/src/http/SseStream.ts +16 -7
  43. package/src/http/etag.ts +68 -0
  44. package/src/http/mime.ts +1346 -0
  45. package/src/index.ts +2 -2
  46. 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`. TypeScript
32
- * cannot catch a comparison against a full mime string — `file.type ===
33
- * 'image/png'` still compiles and simply never matches. Use {@link mime}.
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, access } = await import('node:fs/promises')
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
- if (options?.overwrite === false) {
312
- const exists = await access(filePath).then(
313
- () => true,
314
- () => false,
315
- )
316
- if (exists) {
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: string[]
36
- set: Set<string>
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<string, unknown>
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<string, Binding> = new Map()
73
- #singletons: Map<string, unknown> = new 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<string, Promise<unknown>> = new Map()
83
- #overrides: Map<string, ServiceFactory> = new 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<string, unknown> = new Map()
90
+ #singletonBackup: Map<ServiceToken, unknown> = new Map()
91
91
  /** Alias key → the token it forwards to (AdonisJS `container.alias`). */
92
- #aliases: Map<string, ServiceToken> = new 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<string, ServiceFactory>> =
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<string, Array<(value: unknown) => void | Promise<void>>> = new 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: string, value: unknown): Promise<void> {
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: string): void {
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<string>([key])
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>(values: ReadonlyMap<string, unknown>, fn: () => Promise<T>): Promise<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 Normalise a token the way the container keys it. */
413
- keyFor(token: ServiceToken): string {
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.name
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>(key: string, token: ServiceToken, runtimeValues?: unknown[]): Promise<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 allKeys = [...this.#bindings.keys(), ...this.#overrides.keys()]
566
- const suggestion = didYouMean(key, allKeys)
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 '${key}'.${suggestion ? ` ${suggestion}` : ''}`,
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
- const key = metadata?.as ?? target.name
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: string,
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
- #tokenToKey(token: ServiceToken): string {
726
- if (typeof token === 'string') {
727
- this.#assertNotReserved(token, 'string')
728
- return token
729
- }
730
- if (typeof token === 'symbol') {
731
- // Reject unique `Symbol(x)` — two `Symbol("foo")` calls create distinct
732
- // symbols, so an override registered with one would be invisible to a
733
- // resolver holding the other. Force callers onto `Symbol.for(x)` which
734
- // is the global registry and round-trips across modules.
735
- if (Symbol.keyFor(token) === undefined) {
736
- throw new ReamError(
737
- 'E_CONTAINER_INVALID_TOKEN',
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
- #assertNotReserved(name: string, kind: 'string' | 'symbol description'): void {
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}' would collide with an Object.prototype key. Pick a different ${kind}.`,
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<string, unknown>()
23
+ readonly #values = new Map<ServiceToken, unknown>()
24
24
 
25
25
  constructor(container: Container) {
26
26
  this.#container = container
@@ -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 (!noneMatch || !currentEtag) return false
742
- if (noneMatch.trim() === '*') return true
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
  /**
@@ -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
- void this.#backend.writeStream(this.id, ':keepalive\n\n').then((ok) => {
110
- if (!ok && this.#open) {
111
- this.#open = false
112
- this.#stopPing()
113
- this.#fireClose()
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
@@ -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
+ }