@c9up/ream 0.2.13 → 0.2.15

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 (192) hide show
  1. package/README.md +1 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +6 -1
  4. package/dist/Application.js.map +1 -1
  5. package/dist/GracefulShutdown.js +6 -5
  6. package/dist/GracefulShutdown.js.map +1 -1
  7. package/dist/Provider.d.ts +20 -2
  8. package/dist/Provider.d.ts.map +1 -1
  9. package/dist/Provider.js.map +1 -1
  10. package/dist/bodyparser/MultipartFile.d.ts +33 -7
  11. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  12. package/dist/bodyparser/MultipartFile.js +61 -12
  13. package/dist/bodyparser/MultipartFile.js.map +1 -1
  14. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  15. package/dist/bodyparser/parseSize.js +3 -2
  16. package/dist/bodyparser/parseSize.js.map +1 -1
  17. package/dist/commands/MigrateBase.d.ts.map +1 -1
  18. package/dist/commands/MigrateBase.js +4 -1
  19. package/dist/commands/MigrateBase.js.map +1 -1
  20. package/dist/console/Kernel.d.ts.map +1 -1
  21. package/dist/console/Kernel.js +7 -0
  22. package/dist/console/Kernel.js.map +1 -1
  23. package/dist/console/types.d.ts +14 -0
  24. package/dist/console/types.d.ts.map +1 -1
  25. package/dist/console/types.js.map +1 -1
  26. package/dist/container/Container.d.ts +19 -3
  27. package/dist/container/Container.d.ts.map +1 -1
  28. package/dist/container/Container.js +89 -31
  29. package/dist/container/Container.js.map +1 -1
  30. package/dist/container/ContainerResolver.js.map +1 -1
  31. package/dist/container/types.d.ts +11 -1
  32. package/dist/container/types.d.ts.map +1 -1
  33. package/dist/decorators/Service.d.ts +1 -1
  34. package/dist/decorators/Service.d.ts.map +1 -1
  35. package/dist/env/Env.d.ts.map +1 -1
  36. package/dist/env/Env.js +4 -2
  37. package/dist/env/Env.js.map +1 -1
  38. package/dist/env/interpolate.d.ts.map +1 -1
  39. package/dist/env/interpolate.js +5 -3
  40. package/dist/env/interpolate.js.map +1 -1
  41. package/dist/errors/FuzzyMatcher.d.ts.map +1 -1
  42. package/dist/errors/FuzzyMatcher.js +30 -13
  43. package/dist/errors/FuzzyMatcher.js.map +1 -1
  44. package/dist/errors/PipelineStageError.d.ts.map +1 -1
  45. package/dist/errors/PipelineStageError.js +4 -2
  46. package/dist/errors/PipelineStageError.js.map +1 -1
  47. package/dist/events/Emitter.d.ts +21 -4
  48. package/dist/events/Emitter.d.ts.map +1 -1
  49. package/dist/events/Emitter.js +6 -2
  50. package/dist/events/Emitter.js.map +1 -1
  51. package/dist/events/testing/FakeBus.d.ts +1 -1
  52. package/dist/events/testing/FakeBus.d.ts.map +1 -1
  53. package/dist/events/testing/FakeBus.js.map +1 -1
  54. package/dist/graphql/GraphQLEngine.d.ts +2 -2
  55. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  56. package/dist/graphql/GraphQLEngine.js +4 -2
  57. package/dist/graphql/GraphQLEngine.js.map +1 -1
  58. package/dist/helpers/bytes.d.ts.map +1 -1
  59. package/dist/helpers/bytes.js +13 -2
  60. package/dist/helpers/bytes.js.map +1 -1
  61. package/dist/helpers/string.js +3 -3
  62. package/dist/helpers/string.js.map +1 -1
  63. package/dist/http/Exception.d.ts +8 -4
  64. package/dist/http/Exception.d.ts.map +1 -1
  65. package/dist/http/Exception.js +14 -8
  66. package/dist/http/Exception.js.map +1 -1
  67. package/dist/http/Request.d.ts.map +1 -1
  68. package/dist/http/Request.js +10 -7
  69. package/dist/http/Request.js.map +1 -1
  70. package/dist/http/Response.d.ts.map +1 -1
  71. package/dist/http/Response.js +4 -9
  72. package/dist/http/Response.js.map +1 -1
  73. package/dist/http/SseStream.d.ts.map +1 -1
  74. package/dist/http/SseStream.js +10 -1
  75. package/dist/http/SseStream.js.map +1 -1
  76. package/dist/http/etag.d.ts +44 -0
  77. package/dist/http/etag.d.ts.map +1 -0
  78. package/dist/http/etag.js +65 -0
  79. package/dist/http/etag.js.map +1 -0
  80. package/dist/http/mime.d.ts +33 -0
  81. package/dist/http/mime.d.ts.map +1 -0
  82. package/dist/http/mime.js +1347 -0
  83. package/dist/http/mime.js.map +1 -0
  84. package/dist/index.d.ts +3 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/native/generated.d.ts +12 -8
  88. package/dist/native/generated.d.ts.map +1 -1
  89. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  90. package/dist/openapi/OpenApiGenerator.js +6 -4
  91. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  92. package/dist/router/Router.d.ts +10 -1
  93. package/dist/router/Router.d.ts.map +1 -1
  94. package/dist/router/Router.js +22 -10
  95. package/dist/router/Router.js.map +1 -1
  96. package/dist/rpc/RpcRouter.d.ts +16 -3
  97. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  98. package/dist/rpc/RpcRouter.js +34 -3
  99. package/dist/rpc/RpcRouter.js.map +1 -1
  100. package/dist/scheduler/Schedule.d.ts +6 -1
  101. package/dist/scheduler/Schedule.d.ts.map +1 -1
  102. package/dist/scheduler/Schedule.js +5 -0
  103. package/dist/scheduler/Schedule.js.map +1 -1
  104. package/dist/security/CookieSigner.d.ts.map +1 -1
  105. package/dist/security/CookieSigner.js +12 -5
  106. package/dist/security/CookieSigner.js.map +1 -1
  107. package/dist/security/SignedUrl.js +3 -2
  108. package/dist/security/SignedUrl.js.map +1 -1
  109. package/dist/session/drivers/DatabaseDriver.d.ts +13 -1
  110. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  111. package/dist/session/drivers/DatabaseDriver.js +23 -3
  112. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  113. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  114. package/dist/session/drivers/RedisDriver.js +10 -2
  115. package/dist/session/drivers/RedisDriver.js.map +1 -1
  116. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  117. package/dist/storage/StaticMiddleware.js +78 -59
  118. package/dist/storage/StaticMiddleware.js.map +1 -1
  119. package/dist/testing/ApiResponse.d.ts.map +1 -1
  120. package/dist/testing/ApiResponse.js +10 -11
  121. package/dist/testing/ApiResponse.js.map +1 -1
  122. package/dist/testing/TestClient.js +6 -6
  123. package/dist/testing/TestClient.js.map +1 -1
  124. package/dist/utils/Macroable.d.ts +6 -2
  125. package/dist/utils/Macroable.d.ts.map +1 -1
  126. package/dist/utils/Macroable.js +4 -0
  127. package/dist/utils/Macroable.js.map +1 -1
  128. package/dist/utils/objectPath.d.ts.map +1 -1
  129. package/dist/utils/objectPath.js +14 -6
  130. package/dist/utils/objectPath.js.map +1 -1
  131. package/events.darwin-arm64.node +0 -0
  132. package/events.darwin-x64.node +0 -0
  133. package/events.linux-arm64-gnu.node +0 -0
  134. package/events.linux-x64-gnu.node +0 -0
  135. package/events.win32-x64-msvc.node +0 -0
  136. package/index.darwin-arm64.node +0 -0
  137. package/index.darwin-x64.node +0 -0
  138. package/index.linux-arm64-gnu.node +0 -0
  139. package/index.linux-x64-gnu.node +0 -0
  140. package/index.win32-x64-msvc.node +0 -0
  141. package/package.json +5 -7
  142. package/scheduler.darwin-arm64.node +0 -0
  143. package/scheduler.darwin-x64.node +0 -0
  144. package/scheduler.linux-arm64-gnu.node +0 -0
  145. package/scheduler.linux-x64-gnu.node +0 -0
  146. package/scheduler.win32-x64-msvc.node +0 -0
  147. package/scripts/build-napi-types.mjs +4 -3
  148. package/scripts/generate-mime.mjs +119 -0
  149. package/scripts/generate-napi-types.mjs +28 -12
  150. package/src/Application.ts +6 -1
  151. package/src/GracefulShutdown.ts +5 -5
  152. package/src/Provider.ts +21 -2
  153. package/src/bodyparser/MultipartFile.ts +94 -16
  154. package/src/bodyparser/parseSize.ts +3 -2
  155. package/src/commands/MigrateBase.ts +4 -1
  156. package/src/console/BaseCommand.ts +1 -1
  157. package/src/console/Kernel.ts +8 -0
  158. package/src/console/types.ts +15 -0
  159. package/src/container/Container.ts +120 -57
  160. package/src/container/ContainerResolver.ts +1 -1
  161. package/src/container/types.ts +11 -1
  162. package/src/decorators/Service.ts +1 -1
  163. package/src/env/Env.ts +4 -2
  164. package/src/env/interpolate.ts +5 -3
  165. package/src/errors/FuzzyMatcher.ts +34 -13
  166. package/src/errors/PipelineStageError.ts +4 -2
  167. package/src/events/Emitter.ts +34 -8
  168. package/src/events/testing/FakeBus.ts +12 -5
  169. package/src/graphql/GraphQLEngine.ts +11 -7
  170. package/src/helpers/bytes.ts +13 -2
  171. package/src/helpers/string.ts +3 -3
  172. package/src/http/Exception.ts +14 -8
  173. package/src/http/Request.ts +8 -5
  174. package/src/http/Response.ts +4 -8
  175. package/src/http/SseStream.ts +16 -7
  176. package/src/http/etag.ts +68 -0
  177. package/src/http/mime.ts +1347 -0
  178. package/src/index.ts +3 -3
  179. package/src/native/generated.ts +12 -8
  180. package/src/openapi/OpenApiGenerator.ts +6 -4
  181. package/src/router/Router.ts +31 -11
  182. package/src/rpc/RpcRouter.ts +38 -8
  183. package/src/scheduler/Schedule.ts +8 -1
  184. package/src/security/CookieSigner.ts +15 -5
  185. package/src/security/SignedUrl.ts +3 -2
  186. package/src/session/drivers/DatabaseDriver.ts +36 -8
  187. package/src/session/drivers/RedisDriver.ts +13 -5
  188. package/src/storage/StaticMiddleware.ts +81 -66
  189. package/src/testing/ApiResponse.ts +9 -10
  190. package/src/testing/TestClient.ts +6 -6
  191. package/src/utils/Macroable.ts +6 -2
  192. package/src/utils/objectPath.ts +12 -6
@@ -182,7 +182,7 @@ export interface ResolverOptions {
182
182
  interface ResolverEntry {
183
183
  typeName: string
184
184
  fieldName: string
185
- handlerClass: new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>
185
+ handlerClass: abstract new (...args: never[]) => object
186
186
  methodName: string
187
187
  options: ResolverOptions
188
188
  }
@@ -204,7 +204,7 @@ export interface SelectionField {
204
204
  export class GraphQLEngine {
205
205
  #schemaSource: string
206
206
  #resolvers: Map<string, ResolverEntry> = new Map()
207
- #container?: { make<T>(target: new (...args: unknown[]) => T): Promise<T> }
207
+ #container?: { make<T>(target: abstract new (...args: never[]) => T): Promise<T> }
208
208
  readonly path: string
209
209
  #playground: boolean
210
210
  #maxQueryBytes: number
@@ -239,7 +239,9 @@ export class GraphQLEngine {
239
239
  }
240
240
 
241
241
  /** Set IoC container for resolver instantiation. */
242
- useContainer(container: { make<T>(target: new (...args: unknown[]) => T): Promise<T> }): void {
242
+ useContainer(container: {
243
+ make<T>(target: abstract new (...args: never[]) => T): Promise<T>
244
+ }): void {
243
245
  this.#container = container
244
246
  }
245
247
 
@@ -247,7 +249,7 @@ export class GraphQLEngine {
247
249
  resolver(
248
250
  typeName: string,
249
251
  fieldName: string,
250
- handlerClass: new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>,
252
+ handlerClass: abstract new (...args: never[]) => object,
251
253
  methodName: string,
252
254
  options?: ResolverOptions,
253
255
  ): void {
@@ -369,11 +371,13 @@ export class GraphQLEngine {
369
371
 
370
372
  try {
371
373
  // Resolve handler via IoC or direct instantiation.
372
- const instance = this.#container
374
+ // `Reflect.construct`, not `new`: the resolver may be an abstract class,
375
+ // which the runtime builds happily and `new` refuses at compile time.
376
+ const instance: object = this.#container
373
377
  ? await this.#container.make(entry.handlerClass)
374
- : new entry.handlerClass()
378
+ : Reflect.construct(entry.handlerClass, [])
375
379
 
376
- const handler = instance[entry.methodName]
380
+ const handler = Reflect.get(instance, entry.methodName)
377
381
  if (typeof handler !== 'function') {
378
382
  return {
379
383
  ok: false,
@@ -18,6 +18,11 @@ const UNITS = {
18
18
 
19
19
  type Unit = keyof typeof UNITS
20
20
 
21
+ /** A suffix the table knows. Checked, so nothing has to be asserted. */
22
+ function isUnit(value: string): value is Unit {
23
+ return value in UNITS
24
+ }
25
+
21
26
  const PARSE = /^((?:-|\+)?(?:\d+(?:\.\d+)?)) *(kb|mb|gb|tb|pb)$/i
22
27
  /** Trailing `.00`, or the zeros trailing a real decimal. */
23
28
  const TRAILING_ZEROS = /(?:\.0*|(\.[^0]+)0+)$/
@@ -33,10 +38,16 @@ export function parse(value: string | number): number | null {
33
38
  if (typeof value !== 'string') return null
34
39
 
35
40
  const matched = PARSE.exec(value)
36
- const amount = matched ? Number.parseFloat(matched[1]) : Number.parseInt(value, 10)
41
+ // Both groups are required by the pattern, so a match carries both — but
42
+ // read as `matched[2]` the second was typed "a string, or nothing", and the
43
+ // `as Unit` that followed asserted past exactly that. Named, with the
44
+ // suffix checked against the table it indexes, nothing is asserted.
45
+ const [, digits, suffix] = matched ?? []
46
+ const amount = digits !== undefined ? Number.parseFloat(digits) : Number.parseInt(value, 10)
37
47
  if (Number.isNaN(amount)) return null
38
48
 
39
- const unit = (matched ? matched[2].toLowerCase() : 'b') as Unit
49
+ const unit = suffix === undefined ? 'b' : suffix.toLowerCase()
50
+ if (!isUnit(unit)) return null
40
51
  return Math.floor(UNITS[unit] * amount)
41
52
  }
42
53
 
@@ -214,9 +214,9 @@ export function pluralize(word: string, count?: number): string {
214
214
  * @example humanize('user_name') // 'User name'
215
215
  */
216
216
  export function humanize(s: string): string {
217
- const words = splitWords(s)
218
- if (words.length === 0) return ''
219
- return [words[0]?.charAt(0).toUpperCase() + words[0]?.slice(1), ...words.slice(1)].join(' ')
217
+ const [first, ...rest] = splitWords(s)
218
+ if (first === undefined) return ''
219
+ return [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(' ')
220
220
  }
221
221
 
222
222
  // ---------------------------------------------------------------------------
@@ -315,8 +315,10 @@ export class ExceptionHandler extends Macroable {
315
315
  #expandStatusPages(): Record<number, StatusPageRenderer> {
316
316
  if (!this.#expandedStatusPages) {
317
317
  const expanded: Record<number, StatusPageRenderer> = {}
318
- for (const range of Object.keys(this.statusPages)) {
319
- Object.assign(expanded, parseStatusRange(range, this.statusPages[range]))
318
+ // `entries` rather than `keys` plus a lookup: the value comes back
319
+ // already known to be there, instead of read again as maybe-missing.
320
+ for (const [range, renderer] of Object.entries(this.statusPages)) {
321
+ Object.assign(expanded, parseStatusRange(range, renderer))
320
322
  }
321
323
  this.#expandedStatusPages = expanded
322
324
  }
@@ -440,13 +442,17 @@ export class ExceptionHandler extends Macroable {
440
442
  /**
441
443
  * Pick a renderer by content negotiation.
442
444
  *
443
- * DEVIATION (named): ream lists `json` FIRST, so a request with no or `*` /`*`
444
- * Accept defaults to JSON (API-first). AdonisJS lists `html` first
445
- * (full-stack default). Explicit `text/html` / `vnd.api+json` are honoured
446
- * identically.
445
+ * `html` is listed first, as AdonisJS does, so a request that states no
446
+ * preference — no `Accept` at all, or `*` /`*` — gets the error page rather
447
+ * than a JSON body. A browser always sends `text/html` and an API client
448
+ * always asks for JSON, so the order only decides for the clients in
449
+ * between: curl, a health probe, a proxy that stripped the header. For a
450
+ * framework that ships a view layer, a page is the better answer there.
451
+ *
452
+ * The fallback stays JSON, for a client that accepts none of the three.
447
453
  */
448
454
  async renderError(error: HttpError, ctx: HttpContext): Promise<void> {
449
- switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
455
+ switch (ctx.request.accepts(['html', 'json', 'application/vnd.api+json'])) {
450
456
  case 'application/vnd.api+json':
451
457
  return this.renderErrorAsJSONAPI(error, ctx)
452
458
  case 'html':
@@ -458,7 +464,7 @@ export class ExceptionHandler extends Macroable {
458
464
 
459
465
  /** Pick a validation renderer by content negotiation. */
460
466
  async renderValidationError(error: HttpError, ctx: HttpContext): Promise<void> {
461
- switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
467
+ switch (ctx.request.accepts(['html', 'json', 'application/vnd.api+json'])) {
462
468
  case 'application/vnd.api+json':
463
469
  return this.renderValidationErrorAsJSONAPI(error, ctx)
464
470
  case 'html':
@@ -272,7 +272,10 @@ export class Request extends Macroable {
272
272
  host(): string | null {
273
273
  if (this.#trustProxy) {
274
274
  const forwarded = this.#raw.headers['x-forwarded-host']
275
- if (forwarded) return forwarded.split(',')[0].trim()
275
+ // The first hop in the list, named rather than indexed: `split` always
276
+ // yields one element, and saying so beats being told it might not.
277
+ const [first] = forwarded?.split(',') ?? []
278
+ if (first !== undefined) return first.trim()
276
279
  }
277
280
  return this.#raw.headers.host ?? null
278
281
  }
@@ -285,7 +288,8 @@ export class Request extends Macroable {
285
288
  protocol(): string {
286
289
  if (this.#trustProxy) {
287
290
  const forwarded = this.#raw.headers['x-forwarded-proto']
288
- if (forwarded) return forwarded.split(',')[0].trim().toLowerCase()
291
+ const [first] = forwarded?.split(',') ?? []
292
+ if (first !== undefined) return first.trim().toLowerCase()
289
293
  }
290
294
  return this.#raw.scheme ?? 'http'
291
295
  }
@@ -900,9 +904,8 @@ export class Request extends Macroable {
900
904
  fieldName: string,
901
905
  options?: import('../bodyparser/MultipartFile.js').FileValidationOptions,
902
906
  ): import('../bodyparser/MultipartFile.js').MultipartFile | null {
903
- const files = this.#files.get(fieldName)
904
- if (!files || files.length === 0) return null
905
- const file = files[0]
907
+ const [file] = this.#files.get(fieldName) ?? []
908
+ if (file === undefined) return null
906
909
  if (options) file.validate(options)
907
910
  return file
908
911
  }
@@ -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
+ }