@better-forge/mirror 0.1.0 → 0.1.1

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 BetterStatusPage contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BetterStatusPage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # @better-forge/mirror
2
-
3
- Publishes a set of files to static hosting.
4
-
5
- - `createS3Transport`: uploads to S3-compatible storage (AWS S3, Cloudflare R2, Backblaze B2, MinIO) with a SigV4
6
- signature over `fetch`; no SDK.
7
- - `createMirrorPublisher`: uploads files in the given order, skipping those whose content did not change since the
8
- last successful upload, with a timeout per request.
9
- - `createIntervalRunner`: runs a job every interval, never two at once.
10
-
11
- Depends on: nothing.
1
+ # @better-forge/mirror
2
+
3
+ Publishes a set of files to static hosting.
4
+
5
+ - `createS3Transport`: uploads to S3-compatible storage (AWS S3, Cloudflare R2, Backblaze B2, MinIO) with a SigV4
6
+ signature over `fetch`; no SDK.
7
+ - `createMirrorPublisher`: uploads files in the given order, skipping those whose content did not change since the
8
+ last successful upload, with a timeout per request.
9
+ - `createIntervalRunner`: runs a job every interval, never two at once.
10
+
11
+ Depends on: nothing.
package/package.json CHANGED
@@ -1,44 +1,44 @@
1
- {
2
- "name": "@better-forge/mirror",
3
- "version": "0.1.0",
4
- "description": "Publishes a set of files to static hosting: S3-compatible storage transport, change detection and a single-flight interval runner.",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/BElluu/Better-Forge.git",
9
- "directory": "packages/mirror"
10
- },
11
- "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/mirror#readme",
12
- "bugs": {
13
- "url": "https://github.com/BElluu/Better-Forge/issues"
14
- },
15
- "publishConfig": {
16
- "access": "public"
17
- },
18
- "type": "module",
19
- "main": "./dist/index.js",
20
- "types": "./dist/index.d.ts",
21
- "exports": {
22
- ".": {
23
- "types": "./dist/index.d.ts",
24
- "import": "./dist/index.js"
25
- },
26
- "./package.json": "./package.json"
27
- },
28
- "files": [
29
- "dist",
30
- "src",
31
- "!src/**/*.test.*"
32
- ],
33
- "scripts": {
34
- "build": "tsc -p tsconfig.json",
35
- "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
- },
37
- "devDependencies": {
38
- "@types/node": "^26.6.2",
39
- "typescript": "^5.7.3"
40
- },
41
- "engines": {
42
- "node": ">=26.10.0"
43
- }
44
- }
1
+ {
2
+ "name": "@better-forge/mirror",
3
+ "version": "0.1.1",
4
+ "description": "Publishes a set of files to static hosting: S3-compatible storage transport, change detection and a single-flight interval runner.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/BElluu/Better-Forge.git",
9
+ "directory": "packages/mirror"
10
+ },
11
+ "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/mirror#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/BElluu/Better-Forge/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "!src/**/*.test.*"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^26.6.2",
39
+ "typescript": "^5.7.3"
40
+ },
41
+ "engines": {
42
+ "node": ">=26.10.0"
43
+ }
44
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createS3Transport, type S3Options } from './s3.js'
2
- export { createMirrorPublisher, type MirrorObject, type MirrorTransport, type MirrorPublisherOptions, type PublishResult } from './publisher.js'
3
- export { createIntervalRunner, type IntervalRunnerOptions } from './runner.js'
4
- export { signRequest, sha256Hex, type SignInput } from './sigv4.js'
1
+ export { createS3Transport, type S3Options } from './s3.js'
2
+ export { createMirrorPublisher, type MirrorObject, type MirrorTransport, type MirrorPublisherOptions, type PublishResult } from './publisher.js'
3
+ export { createIntervalRunner, type IntervalRunnerOptions } from './runner.js'
4
+ export { signRequest, sha256Hex, type SignInput } from './sigv4.js'
package/src/publisher.ts CHANGED
@@ -1,56 +1,56 @@
1
- import { sha256Hex } from './sigv4.js'
2
-
3
- export interface MirrorObject {
4
- /** Path below the target's root, without a leading slash. */
5
- key: string
6
- body: string | Uint8Array
7
- contentType: string
8
- cacheControl?: string
9
- }
10
-
11
- export interface MirrorTransport {
12
- put(object: MirrorObject, signal?: AbortSignal): Promise<void>
13
- delete(key: string, signal?: AbortSignal): Promise<void>
14
- }
15
-
16
- export interface PublishResult {
17
- uploaded: string[]
18
- unchanged: number
19
- }
20
-
21
- export interface MirrorPublisherOptions {
22
- transport: MirrorTransport
23
- /** Per request, in milliseconds. */
24
- timeoutMs?: number
25
- }
26
-
27
- /**
28
- * Uploads objects in the order given, so a page that refers to other files goes after them. An object whose content
29
- * is the same as at its last successful upload is skipped. The first failure stops the run: whatever was not
30
- * uploaded is retried by the next one.
31
- */
32
- export function createMirrorPublisher({ transport, timeoutMs = 30_000 }: MirrorPublisherOptions) {
33
- const uploadedHashes = new Map<string, string>()
34
-
35
- return {
36
- async publish(objects: MirrorObject[]): Promise<PublishResult> {
37
- const uploaded: string[] = []
38
- let unchanged = 0
39
- for (const object of objects) {
40
- const hash = sha256Hex(object.body)
41
- if (uploadedHashes.get(object.key) === hash) {
42
- unchanged++
43
- continue
44
- }
45
- await transport.put(object, AbortSignal.timeout(timeoutMs))
46
- uploadedHashes.set(object.key, hash)
47
- uploaded.push(object.key)
48
- }
49
- return { uploaded, unchanged }
50
- },
51
- /** Forget what was uploaded, so the next run uploads everything. */
52
- reset() {
53
- uploadedHashes.clear()
54
- },
55
- }
56
- }
1
+ import { sha256Hex } from './sigv4.js'
2
+
3
+ export interface MirrorObject {
4
+ /** Path below the target's root, without a leading slash. */
5
+ key: string
6
+ body: string | Uint8Array
7
+ contentType: string
8
+ cacheControl?: string
9
+ }
10
+
11
+ export interface MirrorTransport {
12
+ put(object: MirrorObject, signal?: AbortSignal): Promise<void>
13
+ delete(key: string, signal?: AbortSignal): Promise<void>
14
+ }
15
+
16
+ export interface PublishResult {
17
+ uploaded: string[]
18
+ unchanged: number
19
+ }
20
+
21
+ export interface MirrorPublisherOptions {
22
+ transport: MirrorTransport
23
+ /** Per request, in milliseconds. */
24
+ timeoutMs?: number
25
+ }
26
+
27
+ /**
28
+ * Uploads objects in the order given, so a page that refers to other files goes after them. An object whose content
29
+ * is the same as at its last successful upload is skipped. The first failure stops the run: whatever was not
30
+ * uploaded is retried by the next one.
31
+ */
32
+ export function createMirrorPublisher({ transport, timeoutMs = 30_000 }: MirrorPublisherOptions) {
33
+ const uploadedHashes = new Map<string, string>()
34
+
35
+ return {
36
+ async publish(objects: MirrorObject[]): Promise<PublishResult> {
37
+ const uploaded: string[] = []
38
+ let unchanged = 0
39
+ for (const object of objects) {
40
+ const hash = sha256Hex(object.body)
41
+ if (uploadedHashes.get(object.key) === hash) {
42
+ unchanged++
43
+ continue
44
+ }
45
+ await transport.put(object, AbortSignal.timeout(timeoutMs))
46
+ uploadedHashes.set(object.key, hash)
47
+ uploaded.push(object.key)
48
+ }
49
+ return { uploaded, unchanged }
50
+ },
51
+ /** Forget what was uploaded, so the next run uploads everything. */
52
+ reset() {
53
+ uploadedHashes.clear()
54
+ },
55
+ }
56
+ }
package/src/runner.ts CHANGED
@@ -1,48 +1,48 @@
1
- export interface IntervalRunnerOptions {
2
- /** Read before every wait, so a changed setting applies without a restart. */
3
- intervalMs: () => number
4
- job: () => Promise<void>
5
- }
6
-
7
- /** Runs a job again `intervalMs` after the previous run ended, and never two at once. */
8
- export function createIntervalRunner({ intervalMs, job }: IntervalRunnerOptions) {
9
- let timer: ReturnType<typeof setTimeout> | null = null
10
- let running: Promise<void> | null = null
11
- let stopped = true
12
-
13
- const schedule = () => {
14
- if (stopped) return
15
- timer = setTimeout(() => { void runNow() }, intervalMs())
16
- timer.unref()
17
- }
18
-
19
- /** Starts a run now unless one is going; resolves when the run in progress (new or not) ends. */
20
- function runNow(): Promise<void> {
21
- if (running) return running
22
- if (timer) clearTimeout(timer)
23
- running = job().catch(() => { /* the job reports its own failures */ }).finally(() => {
24
- running = null
25
- schedule()
26
- })
27
- return running
28
- }
29
-
30
- return {
31
- start() {
32
- if (!stopped) return
33
- stopped = false
34
- void runNow()
35
- },
36
- /**
37
- * Stops scheduling. A run in progress is waited for unless `wait` is false; it then ends on its own and schedules
38
- * nothing, unless the runner was started again meanwhile.
39
- */
40
- async stop({ wait = true }: { wait?: boolean } = {}) {
41
- stopped = true
42
- if (timer) clearTimeout(timer)
43
- timer = null
44
- if (wait) await running
45
- },
46
- runNow,
47
- }
48
- }
1
+ export interface IntervalRunnerOptions {
2
+ /** Read before every wait, so a changed setting applies without a restart. */
3
+ intervalMs: () => number
4
+ job: () => Promise<void>
5
+ }
6
+
7
+ /** Runs a job again `intervalMs` after the previous run ended, and never two at once. */
8
+ export function createIntervalRunner({ intervalMs, job }: IntervalRunnerOptions) {
9
+ let timer: ReturnType<typeof setTimeout> | null = null
10
+ let running: Promise<void> | null = null
11
+ let stopped = true
12
+
13
+ const schedule = () => {
14
+ if (stopped) return
15
+ timer = setTimeout(() => { void runNow() }, intervalMs())
16
+ timer.unref()
17
+ }
18
+
19
+ /** Starts a run now unless one is going; resolves when the run in progress (new or not) ends. */
20
+ function runNow(): Promise<void> {
21
+ if (running) return running
22
+ if (timer) clearTimeout(timer)
23
+ running = job().catch(() => { /* the job reports its own failures */ }).finally(() => {
24
+ running = null
25
+ schedule()
26
+ })
27
+ return running
28
+ }
29
+
30
+ return {
31
+ start() {
32
+ if (!stopped) return
33
+ stopped = false
34
+ void runNow()
35
+ },
36
+ /**
37
+ * Stops scheduling. A run in progress is waited for unless `wait` is false; it then ends on its own and schedules
38
+ * nothing, unless the runner was started again meanwhile.
39
+ */
40
+ async stop({ wait = true }: { wait?: boolean } = {}) {
41
+ stopped = true
42
+ if (timer) clearTimeout(timer)
43
+ timer = null
44
+ if (wait) await running
45
+ },
46
+ runNow,
47
+ }
48
+ }
package/src/s3.ts CHANGED
@@ -1,64 +1,64 @@
1
- import { sha256Hex, signRequest } from './sigv4.js'
2
- import type { MirrorObject, MirrorTransport } from './publisher.js'
3
-
4
- export interface S3Options {
5
- bucket: string
6
- region: string
7
- accessKeyId: string
8
- secretAccessKey: string
9
- /** Base URL of an S3-compatible service (R2, B2, MinIO); leave out for AWS S3. */
10
- endpoint?: string
11
- /** Prepended to every key, e.g. `status/`. */
12
- prefix?: string
13
- /** `https://host/bucket/key` instead of `https://bucket.host/key`. Always on when an endpoint is given. */
14
- forcePathStyle?: boolean
15
- }
16
-
17
- /** Only the service's error code reaches a message, never its body, which may echo request details. */
18
- const errorCode = (body: string): string => /<Code>([A-Za-z0-9]+)<\/Code>/.exec(body)?.[1] ?? ''
19
-
20
- export function createS3Transport(options: S3Options): MirrorTransport {
21
- const prefix = (options.prefix ?? '').replace(/^\/+/, '')
22
-
23
- function urlFor(key: string): URL {
24
- const objectKey = prefix + key
25
- if (options.endpoint) return new URL(`${options.endpoint.replace(/\/+$/, '')}/${options.bucket}/${objectKey}`)
26
- // A dot in the bucket name breaks the TLS name of the virtual-host form.
27
- return options.forcePathStyle || options.bucket.includes('.')
28
- ? new URL(`https://s3.${options.region}.amazonaws.com/${options.bucket}/${objectKey}`)
29
- : new URL(`https://${options.bucket}.s3.${options.region}.amazonaws.com/${objectKey}`)
30
- }
31
-
32
- async function send(
33
- method: 'PUT' | 'DELETE', key: string, body: Buffer<ArrayBuffer>, contentType: string | null, cacheControl: string | undefined, signal?: AbortSignal,
34
- ) {
35
- const url = urlFor(key)
36
- const headers = signRequest({
37
- method,
38
- url,
39
- headers: contentType ? { 'content-type': contentType } : {},
40
- payloadHash: sha256Hex(body),
41
- region: options.region,
42
- service: 's3',
43
- accessKeyId: options.accessKeyId,
44
- secretAccessKey: options.secretAccessKey,
45
- now: new Date(),
46
- })
47
- // Sent unsigned, as the service allows.
48
- if (cacheControl) headers['cache-control'] = cacheControl
49
- // fetch sets `host` from the URL.
50
- delete headers['host']
51
-
52
- // A redirect is never followed: it would send the body to a host nobody configured.
53
- const res = await fetch(url, { method, headers, ...(method === 'PUT' ? { body } : {}), redirect: 'manual', ...(signal ? { signal } : {}) })
54
- if (!res.ok) {
55
- const code = errorCode(await res.text().catch(() => ''))
56
- throw new Error(`${method === 'PUT' ? 'Upload of' : 'Removal of'} ${key} failed: HTTP ${res.status}${code ? ` ${code}` : ''}`)
57
- }
58
- }
59
-
60
- return {
61
- put: (object: MirrorObject, signal?: AbortSignal) => send('PUT', object.key, Buffer.from(object.body), object.contentType, object.cacheControl, signal),
62
- delete: (key: string, signal?: AbortSignal) => send('DELETE', key, Buffer.alloc(0), null, undefined, signal),
63
- }
64
- }
1
+ import { sha256Hex, signRequest } from './sigv4.js'
2
+ import type { MirrorObject, MirrorTransport } from './publisher.js'
3
+
4
+ export interface S3Options {
5
+ bucket: string
6
+ region: string
7
+ accessKeyId: string
8
+ secretAccessKey: string
9
+ /** Base URL of an S3-compatible service (R2, B2, MinIO); leave out for AWS S3. */
10
+ endpoint?: string
11
+ /** Prepended to every key, e.g. `status/`. */
12
+ prefix?: string
13
+ /** `https://host/bucket/key` instead of `https://bucket.host/key`. Always on when an endpoint is given. */
14
+ forcePathStyle?: boolean
15
+ }
16
+
17
+ /** Only the service's error code reaches a message, never its body, which may echo request details. */
18
+ const errorCode = (body: string): string => /<Code>([A-Za-z0-9]+)<\/Code>/.exec(body)?.[1] ?? ''
19
+
20
+ export function createS3Transport(options: S3Options): MirrorTransport {
21
+ const prefix = (options.prefix ?? '').replace(/^\/+/, '')
22
+
23
+ function urlFor(key: string): URL {
24
+ const objectKey = prefix + key
25
+ if (options.endpoint) return new URL(`${options.endpoint.replace(/\/+$/, '')}/${options.bucket}/${objectKey}`)
26
+ // A dot in the bucket name breaks the TLS name of the virtual-host form.
27
+ return options.forcePathStyle || options.bucket.includes('.')
28
+ ? new URL(`https://s3.${options.region}.amazonaws.com/${options.bucket}/${objectKey}`)
29
+ : new URL(`https://${options.bucket}.s3.${options.region}.amazonaws.com/${objectKey}`)
30
+ }
31
+
32
+ async function send(
33
+ method: 'PUT' | 'DELETE', key: string, body: Buffer<ArrayBuffer>, contentType: string | null, cacheControl: string | undefined, signal?: AbortSignal,
34
+ ) {
35
+ const url = urlFor(key)
36
+ const headers = signRequest({
37
+ method,
38
+ url,
39
+ headers: contentType ? { 'content-type': contentType } : {},
40
+ payloadHash: sha256Hex(body),
41
+ region: options.region,
42
+ service: 's3',
43
+ accessKeyId: options.accessKeyId,
44
+ secretAccessKey: options.secretAccessKey,
45
+ now: new Date(),
46
+ })
47
+ // Sent unsigned, as the service allows.
48
+ if (cacheControl) headers['cache-control'] = cacheControl
49
+ // fetch sets `host` from the URL.
50
+ delete headers['host']
51
+
52
+ // A redirect is never followed: it would send the body to a host nobody configured.
53
+ const res = await fetch(url, { method, headers, ...(method === 'PUT' ? { body } : {}), redirect: 'manual', ...(signal ? { signal } : {}) })
54
+ if (!res.ok) {
55
+ const code = errorCode(await res.text().catch(() => ''))
56
+ throw new Error(`${method === 'PUT' ? 'Upload of' : 'Removal of'} ${key} failed: HTTP ${res.status}${code ? ` ${code}` : ''}`)
57
+ }
58
+ }
59
+
60
+ return {
61
+ put: (object: MirrorObject, signal?: AbortSignal) => send('PUT', object.key, Buffer.from(object.body), object.contentType, object.cacheControl, signal),
62
+ delete: (key: string, signal?: AbortSignal) => send('DELETE', key, Buffer.alloc(0), null, undefined, signal),
63
+ }
64
+ }
package/src/sigv4.ts CHANGED
@@ -1,51 +1,51 @@
1
- import { createHash, createHmac } from 'node:crypto'
2
-
3
- export interface SignInput {
4
- method: string
5
- url: URL
6
- /** Headers to sign besides `host`, `x-amz-content-sha256` and `x-amz-date`, which are added. */
7
- headers: Record<string, string>
8
- payloadHash: string
9
- region: string
10
- service: string
11
- accessKeyId: string
12
- secretAccessKey: string
13
- now: Date
14
- }
15
-
16
- export const sha256Hex = (data: string | Uint8Array): string => createHash('sha256').update(data).digest('hex')
17
- const hmac = (key: string | Buffer, data: string): Buffer => createHmac('sha256', key).update(data).digest()
18
-
19
- /** RFC 3986 encoding of one path segment, which is how S3 canonicalises object keys. */
20
- const encodeSegment = (segment: string): string =>
21
- encodeURIComponent(segment).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
22
-
23
- /** The headers of a SigV4-signed request: everything the caller passes, plus the signature headers. */
24
- export function signRequest(input: SignInput): Record<string, string> {
25
- const amzDate = input.now.toISOString().replace(/[-:]|\.\d{3}/g, '')
26
- const day = amzDate.slice(0, 8)
27
- const headers: Record<string, string> = {
28
- ...input.headers,
29
- host: input.url.host,
30
- 'x-amz-content-sha256': input.payloadHash,
31
- 'x-amz-date': amzDate,
32
- }
33
- const byName = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value.trim()]))
34
- const names = [...byName.keys()].sort()
35
- const canonicalHeaders = names.map((name) => `${name}:${byName.get(name)}\n`).join('')
36
- const signedHeaders = names.join(';')
37
- const canonicalUri = input.url.pathname.split('/').map((s) => encodeSegment(decodeURIComponent(s))).join('/')
38
- const canonicalQuery = [...input.url.searchParams].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
39
- .map(([k, v]) => `${encodeSegment(k)}=${encodeSegment(v)}`).join('&')
40
- const canonicalRequest = [input.method, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, input.payloadHash].join('\n')
41
-
42
- const scope = `${day}/${input.region}/${input.service}/aws4_request`
43
- const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n')
44
- const signingKey = hmac(hmac(hmac(hmac(`AWS4${input.secretAccessKey}`, day), input.region), input.service), 'aws4_request')
45
- const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex')
46
-
47
- return {
48
- ...headers,
49
- authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
50
- }
51
- }
1
+ import { createHash, createHmac } from 'node:crypto'
2
+
3
+ export interface SignInput {
4
+ method: string
5
+ url: URL
6
+ /** Headers to sign besides `host`, `x-amz-content-sha256` and `x-amz-date`, which are added. */
7
+ headers: Record<string, string>
8
+ payloadHash: string
9
+ region: string
10
+ service: string
11
+ accessKeyId: string
12
+ secretAccessKey: string
13
+ now: Date
14
+ }
15
+
16
+ export const sha256Hex = (data: string | Uint8Array): string => createHash('sha256').update(data).digest('hex')
17
+ const hmac = (key: string | Buffer, data: string): Buffer => createHmac('sha256', key).update(data).digest()
18
+
19
+ /** RFC 3986 encoding of one path segment, which is how S3 canonicalises object keys. */
20
+ const encodeSegment = (segment: string): string =>
21
+ encodeURIComponent(segment).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
22
+
23
+ /** The headers of a SigV4-signed request: everything the caller passes, plus the signature headers. */
24
+ export function signRequest(input: SignInput): Record<string, string> {
25
+ const amzDate = input.now.toISOString().replace(/[-:]|\.\d{3}/g, '')
26
+ const day = amzDate.slice(0, 8)
27
+ const headers: Record<string, string> = {
28
+ ...input.headers,
29
+ host: input.url.host,
30
+ 'x-amz-content-sha256': input.payloadHash,
31
+ 'x-amz-date': amzDate,
32
+ }
33
+ const byName = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value.trim()]))
34
+ const names = [...byName.keys()].sort()
35
+ const canonicalHeaders = names.map((name) => `${name}:${byName.get(name)}\n`).join('')
36
+ const signedHeaders = names.join(';')
37
+ const canonicalUri = input.url.pathname.split('/').map((s) => encodeSegment(decodeURIComponent(s))).join('/')
38
+ const canonicalQuery = [...input.url.searchParams].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
39
+ .map(([k, v]) => `${encodeSegment(k)}=${encodeSegment(v)}`).join('&')
40
+ const canonicalRequest = [input.method, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, input.payloadHash].join('\n')
41
+
42
+ const scope = `${day}/${input.region}/${input.service}/aws4_request`
43
+ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n')
44
+ const signingKey = hmac(hmac(hmac(hmac(`AWS4${input.secretAccessKey}`, day), input.region), input.service), 'aws4_request')
45
+ const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex')
46
+
47
+ return {
48
+ ...headers,
49
+ authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
50
+ }
51
+ }