@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.
- package/README.md +1 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +6 -1
- package/dist/Application.js.map +1 -1
- package/dist/GracefulShutdown.js +6 -5
- package/dist/GracefulShutdown.js.map +1 -1
- package/dist/Provider.d.ts +20 -2
- package/dist/Provider.d.ts.map +1 -1
- package/dist/Provider.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 +61 -12
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/bodyparser/parseSize.d.ts.map +1 -1
- package/dist/bodyparser/parseSize.js +3 -2
- package/dist/bodyparser/parseSize.js.map +1 -1
- package/dist/commands/MigrateBase.d.ts.map +1 -1
- package/dist/commands/MigrateBase.js +4 -1
- package/dist/commands/MigrateBase.js.map +1 -1
- package/dist/console/Kernel.d.ts.map +1 -1
- package/dist/console/Kernel.js +7 -0
- package/dist/console/Kernel.js.map +1 -1
- package/dist/console/types.d.ts +14 -0
- package/dist/console/types.d.ts.map +1 -1
- package/dist/console/types.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 +89 -31
- package/dist/container/Container.js.map +1 -1
- package/dist/container/ContainerResolver.js.map +1 -1
- package/dist/container/types.d.ts +11 -1
- package/dist/container/types.d.ts.map +1 -1
- package/dist/decorators/Service.d.ts +1 -1
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +4 -2
- package/dist/env/Env.js.map +1 -1
- package/dist/env/interpolate.d.ts.map +1 -1
- package/dist/env/interpolate.js +5 -3
- package/dist/env/interpolate.js.map +1 -1
- package/dist/errors/FuzzyMatcher.d.ts.map +1 -1
- package/dist/errors/FuzzyMatcher.js +30 -13
- package/dist/errors/FuzzyMatcher.js.map +1 -1
- package/dist/errors/PipelineStageError.d.ts.map +1 -1
- package/dist/errors/PipelineStageError.js +4 -2
- package/dist/errors/PipelineStageError.js.map +1 -1
- package/dist/events/Emitter.d.ts +21 -4
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +6 -2
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/testing/FakeBus.d.ts +1 -1
- package/dist/events/testing/FakeBus.d.ts.map +1 -1
- package/dist/events/testing/FakeBus.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts +2 -2
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +4 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/helpers/bytes.d.ts.map +1 -1
- package/dist/helpers/bytes.js +13 -2
- package/dist/helpers/bytes.js.map +1 -1
- package/dist/helpers/string.js +3 -3
- package/dist/helpers/string.js.map +1 -1
- package/dist/http/Exception.d.ts +8 -4
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +14 -8
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +10 -7
- package/dist/http/Request.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 +1347 -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 +1 -1
- package/dist/native/generated.d.ts +12 -8
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
- package/dist/openapi/OpenApiGenerator.js +6 -4
- package/dist/openapi/OpenApiGenerator.js.map +1 -1
- package/dist/router/Router.d.ts +10 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +22 -10
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +16 -3
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +34 -3
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/Schedule.d.ts +6 -1
- package/dist/scheduler/Schedule.d.ts.map +1 -1
- package/dist/scheduler/Schedule.js +5 -0
- package/dist/scheduler/Schedule.js.map +1 -1
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +12 -5
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/security/SignedUrl.js +3 -2
- package/dist/security/SignedUrl.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +13 -1
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
- package/dist/session/drivers/DatabaseDriver.js +23 -3
- package/dist/session/drivers/DatabaseDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +10 -2
- package/dist/session/drivers/RedisDriver.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/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +10 -11
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/TestClient.js +6 -6
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.d.ts +6 -2
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +4 -0
- package/dist/utils/Macroable.js.map +1 -1
- package/dist/utils/objectPath.d.ts.map +1 -1
- package/dist/utils/objectPath.js +14 -6
- package/dist/utils/objectPath.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +5 -7
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/scripts/build-napi-types.mjs +4 -3
- package/scripts/generate-mime.mjs +119 -0
- package/scripts/generate-napi-types.mjs +28 -12
- package/src/Application.ts +6 -1
- package/src/GracefulShutdown.ts +5 -5
- package/src/Provider.ts +21 -2
- package/src/bodyparser/MultipartFile.ts +94 -16
- package/src/bodyparser/parseSize.ts +3 -2
- package/src/commands/MigrateBase.ts +4 -1
- package/src/console/BaseCommand.ts +1 -1
- package/src/console/Kernel.ts +8 -0
- package/src/console/types.ts +15 -0
- package/src/container/Container.ts +120 -57
- package/src/container/ContainerResolver.ts +1 -1
- package/src/container/types.ts +11 -1
- package/src/decorators/Service.ts +1 -1
- package/src/env/Env.ts +4 -2
- package/src/env/interpolate.ts +5 -3
- package/src/errors/FuzzyMatcher.ts +34 -13
- package/src/errors/PipelineStageError.ts +4 -2
- package/src/events/Emitter.ts +34 -8
- package/src/events/testing/FakeBus.ts +12 -5
- package/src/graphql/GraphQLEngine.ts +11 -7
- package/src/helpers/bytes.ts +13 -2
- package/src/helpers/string.ts +3 -3
- package/src/http/Exception.ts +14 -8
- package/src/http/Request.ts +8 -5
- 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 +1347 -0
- package/src/index.ts +3 -3
- package/src/native/generated.ts +12 -8
- package/src/openapi/OpenApiGenerator.ts +6 -4
- package/src/router/Router.ts +31 -11
- package/src/rpc/RpcRouter.ts +38 -8
- package/src/scheduler/Schedule.ts +8 -1
- package/src/security/CookieSigner.ts +15 -5
- package/src/security/SignedUrl.ts +3 -2
- package/src/session/drivers/DatabaseDriver.ts +36 -8
- package/src/session/drivers/RedisDriver.ts +13 -5
- package/src/storage/StaticMiddleware.ts +81 -66
- package/src/testing/ApiResponse.ts +9 -10
- package/src/testing/TestClient.ts +6 -6
- package/src/utils/Macroable.ts +6 -2
- 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:
|
|
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:
|
|
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: {
|
|
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:
|
|
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
|
-
|
|
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
|
-
:
|
|
378
|
+
: Reflect.construct(entry.handlerClass, [])
|
|
375
379
|
|
|
376
|
-
const handler = instance
|
|
380
|
+
const handler = Reflect.get(instance, entry.methodName)
|
|
377
381
|
if (typeof handler !== 'function') {
|
|
378
382
|
return {
|
|
379
383
|
ok: false,
|
package/src/helpers/bytes.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
package/src/helpers/string.ts
CHANGED
|
@@ -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
|
|
218
|
-
if (
|
|
219
|
-
return [
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/http/Exception.ts
CHANGED
|
@@ -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
|
-
|
|
319
|
-
|
|
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
|
-
*
|
|
444
|
-
* Accept
|
|
445
|
-
*
|
|
446
|
-
*
|
|
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(['
|
|
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(['
|
|
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':
|
package/src/http/Request.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
904
|
-
if (
|
|
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
|
}
|
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
|
+
}
|