@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 +21 -21
- package/README.md +11 -11
- package/package.json +44 -44
- package/src/index.ts +4 -4
- package/src/publisher.ts +56 -56
- package/src/runner.ts +48 -48
- package/src/s3.ts +64 -64
- package/src/sigv4.ts +51 -51
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.
|
|
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
|
+
}
|