@temporalio/external-storage-s3 1.21.0
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 -0
- package/README.md +159 -0
- package/lib/client.d.ts +30 -0
- package/lib/client.js +8 -0
- package/lib/client.js.map +1 -0
- package/lib/driver.d.ts +52 -0
- package/lib/driver.js +184 -0
- package/lib/driver.js.map +1 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +9 -0
- package/lib/index.js.map +1 -0
- package/package.json +52 -0
- package/src/client.ts +32 -0
- package/src/driver.ts +244 -0
- package/src/index.ts +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021-2025 Temporal Technologies Inc. All rights reserved.
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Amazon S3 External Storage Driver for the Temporal TypeScript SDK
|
|
2
|
+
|
|
3
|
+
> ⚠️ **This package is experimental and may be subject to change.** ⚠️
|
|
4
|
+
|
|
5
|
+
`@temporalio/external-storage-s3` stores and retrieves Temporal payloads in Amazon S3 via the [External Storage](https://docs.temporal.io/external-storage) feature.
|
|
6
|
+
|
|
7
|
+
This package has no AWS dependency: it defines the driver and the `S3StorageDriverClient` interface, and you supply the S3 client. Use the companion [`@temporalio/external-storage-s3-aws-sdk`](../external-storage-s3-aws-sdk) package for an [`@aws-sdk/client-s3`](https://www.npmjs.com/package/@aws-sdk/client-s3)-backed client, or implement the interface yourself.
|
|
8
|
+
|
|
9
|
+
## Using the AWS SDK client
|
|
10
|
+
|
|
11
|
+
Install the adapter package alongside this one:
|
|
12
|
+
|
|
13
|
+
npm install @temporalio/external-storage-s3 @temporalio/external-storage-s3-aws-sdk @aws-sdk/client-s3
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
17
|
+
import { S3StorageDriver } from '@temporalio/external-storage-s3';
|
|
18
|
+
import { AwsSdkS3StorageDriverClient } from '@temporalio/external-storage-s3-aws-sdk';
|
|
19
|
+
|
|
20
|
+
const s3Client = new S3Client({ region: 'us-east-1' });
|
|
21
|
+
const driver = new S3StorageDriver({
|
|
22
|
+
client: new AwsSdkS3StorageDriverClient(s3Client),
|
|
23
|
+
bucket: 'my-temporal-payloads',
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Register the resulting driver with the SDK's External Storage configuration so the
|
|
28
|
+
client and worker offload eligible payloads to it.
|
|
29
|
+
|
|
30
|
+
## Custom S3 client implementations
|
|
31
|
+
|
|
32
|
+
To use a different S3 library, implement `S3StorageDriverClient`.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import type { S3StorageDriverClient } from '@temporalio/external-storage-s3';
|
|
36
|
+
|
|
37
|
+
const myClient: S3StorageDriverClient = {
|
|
38
|
+
async putObject(bucket, key, data, options) {
|
|
39
|
+
/* ... */
|
|
40
|
+
},
|
|
41
|
+
async objectExists(bucket, key, options) {
|
|
42
|
+
/* ... */
|
|
43
|
+
return false;
|
|
44
|
+
},
|
|
45
|
+
async getObject(bucket, key, options) {
|
|
46
|
+
/* ... */
|
|
47
|
+
return new Uint8Array();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Dynamic bucket selection
|
|
53
|
+
|
|
54
|
+
Pass a callable as `bucket` to choose the destination per payload:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const driver = new S3StorageDriver({
|
|
58
|
+
client: new AwsSdkS3StorageDriverClient(s3Client),
|
|
59
|
+
bucket: (_context, payload) => ((payload.data?.length ?? 0) > 10 * 1024 * 1024 ? 'large-payloads' : 'small-payloads'),
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Required IAM permissions
|
|
64
|
+
|
|
65
|
+
The credentials used by your S3 client must have these S3 permissions on the target bucket and its objects:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"Effect": "Allow",
|
|
70
|
+
"Action": ["s3:PutObject", "s3:GetObject"],
|
|
71
|
+
"Resource": "arn:aws:s3:::my-temporal-payloads/*"
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`s3:PutObject` is required by components that store payloads (typically the client and worker sending workflow/activity inputs); `s3:GetObject` is required by components that retrieve them. Components that only retrieve do not need `s3:PutObject`, and vice versa.
|
|
76
|
+
|
|
77
|
+
## S3 Storage Key Specification
|
|
78
|
+
|
|
79
|
+
All Temporal S3 drivers generate S3 keys in a consistent manner.
|
|
80
|
+
|
|
81
|
+
### Key format
|
|
82
|
+
|
|
83
|
+
Workflow key:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
v0/ns/{namespace}/wt/{workflow-type}/wi/{workflow-id}/ri/{run-id}/d/{hash-algorithm}/{hex-digest}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Activity key:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
v0/ns/{namespace}/at/{activity-type}/ai/{activity-id}/ri/{run-id}/d/{hash-algorithm}/{hex-digest}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Fallback key (unknown target):
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
v0/d/{hash-algorithm}/{hex-digest}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- If no namespace, workflow, or activity information is available, the fallback is used.
|
|
102
|
+
- Dynamic path segments are percent-encoded (rules below).
|
|
103
|
+
- Missing values (including a missing `run-id`) are encoded as `null`.
|
|
104
|
+
- `hex-digest` is lower-case SHA-256 hex (64 characters).
|
|
105
|
+
|
|
106
|
+
### Percent-encoding rules
|
|
107
|
+
|
|
108
|
+
The Temporal SDKs escape anything that isn't listed in S3's safe character set: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
|
|
109
|
+
|
|
110
|
+
Safe Characters:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
Alphanumeric characters
|
|
114
|
+
0-9
|
|
115
|
+
a-z
|
|
116
|
+
A-Z
|
|
117
|
+
|
|
118
|
+
Special characters
|
|
119
|
+
Exclamation point (!)
|
|
120
|
+
Hyphen (-)
|
|
121
|
+
Underscore (_)
|
|
122
|
+
Period (.)
|
|
123
|
+
Asterisk (*)
|
|
124
|
+
Single quotation mark (')
|
|
125
|
+
Opening parenthesis (()
|
|
126
|
+
Closing parenthesis ())
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Examples
|
|
130
|
+
|
|
131
|
+
Workflow key example:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
input:
|
|
135
|
+
namespace=payments prod
|
|
136
|
+
workflow-type=ChargeWorkflow
|
|
137
|
+
workflow-id=order+123=abc
|
|
138
|
+
run-id=3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31
|
|
139
|
+
hash-algorithm=sha256
|
|
140
|
+
hex-digest=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
|
141
|
+
|
|
142
|
+
output:
|
|
143
|
+
v0/ns/payments%20prod/wt/ChargeWorkflow/wi/order%2B123%3Dabc/ri/3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31/d/sha256/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Activity key example:
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
input:
|
|
150
|
+
namespace=payments prod
|
|
151
|
+
activity-type=Capture/Charge
|
|
152
|
+
activity-id=activity id+42
|
|
153
|
+
run-id=9e1d1fd9-2f8a-4c40-93e2-731f31b9268b
|
|
154
|
+
hash-algorithm=sha256
|
|
155
|
+
hex-digest=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
|
156
|
+
|
|
157
|
+
output:
|
|
158
|
+
v0/ns/payments%20prod/at/Capture%2FCharge/ai/activity%20id%2B42/ri/9e1d1fd9-2f8a-4c40-93e2-731f31b9268b/d/sha256/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
|
159
|
+
```
|
package/lib/client.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 object operations used by {@link S3StorageDriver}.
|
|
3
|
+
*
|
|
4
|
+
* @experimental
|
|
5
|
+
*/
|
|
6
|
+
/** Per-request options. */
|
|
7
|
+
export interface S3RequestOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Aborts the in-flight request.
|
|
10
|
+
*/
|
|
11
|
+
abortSignal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* S3 driver client operations
|
|
15
|
+
*
|
|
16
|
+
* @experimental
|
|
17
|
+
*/
|
|
18
|
+
export interface S3StorageDriverClient {
|
|
19
|
+
/** Upload `data` to the given bucket and key. */
|
|
20
|
+
putObject(bucket: string, key: string, data: Uint8Array, options?: S3RequestOptions): Promise<void>;
|
|
21
|
+
/** Resolve `true` if an object exists at the given bucket and key. */
|
|
22
|
+
objectExists(bucket: string, key: string, options?: S3RequestOptions): Promise<boolean>;
|
|
23
|
+
/** Download and resolve the bytes stored at the given bucket and key. */
|
|
24
|
+
getObject(bucket: string, key: string, options?: S3RequestOptions): Promise<Uint8Array>;
|
|
25
|
+
/**
|
|
26
|
+
* Optional client-specific diagnostic metadata (e.g. region) that the driver
|
|
27
|
+
* appends to error messages to help diagnose common misconfigurations.
|
|
28
|
+
*/
|
|
29
|
+
describe?(): Record<string, string>;
|
|
30
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":";AAAA;;;;GAIG"}
|
package/lib/driver.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Payload } from '@temporalio/common';
|
|
2
|
+
import { StorageDriverClaim, type StorageDriver, type StorageDriverStoreContext, type StorageDriverRetrieveContext } from '@temporalio/common/lib/converter/extstore';
|
|
3
|
+
import type { S3StorageDriverClient } from './client';
|
|
4
|
+
/** Picks the destination bucket for a given payload. Enables dynamic per-payload routing. */
|
|
5
|
+
export type BucketSelector = (context: StorageDriverStoreContext, payload: Payload) => string;
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for an {@link S3StorageDriver}.
|
|
8
|
+
*
|
|
9
|
+
* @experimental
|
|
10
|
+
*/
|
|
11
|
+
export interface S3StorageDriverOptions {
|
|
12
|
+
/**
|
|
13
|
+
* An {@link S3StorageDriverClient} that performs the underlying requests,
|
|
14
|
+
* e.g. an `AwsSdkS3StorageDriverClient`.
|
|
15
|
+
*/
|
|
16
|
+
client: S3StorageDriverClient;
|
|
17
|
+
/** Static bucket name or a {@link BucketSelector}. */
|
|
18
|
+
bucket: string | BucketSelector;
|
|
19
|
+
/**
|
|
20
|
+
* Per-instance routing name written into the wire format. Defaults to
|
|
21
|
+
* `"aws.s3driver"`. Override only when registering multiple S3 drivers with
|
|
22
|
+
* distinct configurations under the same `ExternalStorage.drivers` list.
|
|
23
|
+
*/
|
|
24
|
+
driverName?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Hard upper limit, in bytes, on the serialized size of a single payload.
|
|
27
|
+
* Defaults to 52428800 (50 MiB). Stores larger than this throw.
|
|
28
|
+
*/
|
|
29
|
+
maxPayloadSize?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Stores and retrieves Temporal payloads in Amazon S3.
|
|
33
|
+
*
|
|
34
|
+
* Payloads are content-addressed by a SHA-256 hash of their serialized bytes. S3 keys
|
|
35
|
+
* are created using the namespace, workflow/activity type, other metadata from the store
|
|
36
|
+
* context. Keys adhere to S3's safe-character set.
|
|
37
|
+
*
|
|
38
|
+
* @experimental
|
|
39
|
+
*/
|
|
40
|
+
export declare class S3StorageDriver implements StorageDriver {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly type = "aws.s3driver";
|
|
43
|
+
private readonly client;
|
|
44
|
+
private readonly bucket;
|
|
45
|
+
private readonly maxPayloadSize;
|
|
46
|
+
constructor(options: S3StorageDriverOptions);
|
|
47
|
+
store(context: StorageDriverStoreContext, payloads: Payload[]): Promise<StorageDriverClaim[]>;
|
|
48
|
+
retrieve(context: StorageDriverRetrieveContext, claims: StorageDriverClaim[]): Promise<Payload[]>;
|
|
49
|
+
private storePayload;
|
|
50
|
+
private uploadIfAbsent;
|
|
51
|
+
private retrievePayload;
|
|
52
|
+
}
|
package/lib/driver.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.S3StorageDriver = void 0;
|
|
27
|
+
const node_crypto_1 = require("node:crypto");
|
|
28
|
+
const proto = __importStar(require("@temporalio/proto"));
|
|
29
|
+
const common_1 = require("@temporalio/common");
|
|
30
|
+
const extstore_1 = require("@temporalio/common/lib/converter/extstore");
|
|
31
|
+
const PayloadProto = proto.temporal.api.common.v1.Payload;
|
|
32
|
+
const DRIVER_TYPE = 'aws.s3driver';
|
|
33
|
+
const DEFAULT_MAX_PAYLOAD_SIZE = 50 * 1024 * 1024;
|
|
34
|
+
/** Key segment written when a context value is empty or absent. */
|
|
35
|
+
const NULL_SEGMENT = 'null';
|
|
36
|
+
/**
|
|
37
|
+
* Percent-encodes a key segment, escaping everything outside S3's safe-character
|
|
38
|
+
* set (alphanumerics and `! - _ . * ' ( )`). An empty or absent value becomes the
|
|
39
|
+
* literal `null`.
|
|
40
|
+
*/
|
|
41
|
+
function encodeKeySegment(value) {
|
|
42
|
+
if (!value)
|
|
43
|
+
return NULL_SEGMENT;
|
|
44
|
+
return encodeURIComponent(value).replace(/~/g, '%7E');
|
|
45
|
+
}
|
|
46
|
+
function buildContextSegments(target) {
|
|
47
|
+
if (target?.kind === 'workflow') {
|
|
48
|
+
return (`/ns/${encodeKeySegment(target.namespace)}` +
|
|
49
|
+
`/wt/${encodeKeySegment(target.type)}` +
|
|
50
|
+
`/wi/${encodeKeySegment(target.id)}` +
|
|
51
|
+
`/ri/${encodeKeySegment(target.runId)}`);
|
|
52
|
+
}
|
|
53
|
+
if (target?.kind === 'activity') {
|
|
54
|
+
return (`/ns/${encodeKeySegment(target.namespace)}` +
|
|
55
|
+
`/at/${encodeKeySegment(target.type)}` +
|
|
56
|
+
`/ai/${encodeKeySegment(target.id)}` +
|
|
57
|
+
`/ri/${encodeKeySegment(target.runId)}`);
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Formats `describe()` output as ", k=v, k=v" for error messages. Returns an
|
|
63
|
+
* empty string when the client reports no metadata or `describe` itself throws.
|
|
64
|
+
*/
|
|
65
|
+
function formatClientContext(client) {
|
|
66
|
+
let info;
|
|
67
|
+
try {
|
|
68
|
+
info = client.describe?.() ?? {};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
const entries = Object.entries(info);
|
|
74
|
+
return entries.map(([k, v]) => `, ${k}=${v}`).join('');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Runs the per-payload operations concurrently. On the first failure, aborts the
|
|
78
|
+
* shared signal so in-flight sibling requests are cancelled, then waits for them
|
|
79
|
+
* to settle before propagating the original error.
|
|
80
|
+
*/
|
|
81
|
+
async function runAllWithAbortOnError(external, makeTasks) {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const signal = external ? AbortSignal.any([external, controller.signal]) : controller.signal;
|
|
84
|
+
const tasks = makeTasks(signal);
|
|
85
|
+
try {
|
|
86
|
+
return await Promise.all(tasks);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
controller.abort();
|
|
90
|
+
await Promise.allSettled(tasks);
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Stores and retrieves Temporal payloads in Amazon S3.
|
|
96
|
+
*
|
|
97
|
+
* Payloads are content-addressed by a SHA-256 hash of their serialized bytes. S3 keys
|
|
98
|
+
* are created using the namespace, workflow/activity type, other metadata from the store
|
|
99
|
+
* context. Keys adhere to S3's safe-character set.
|
|
100
|
+
*
|
|
101
|
+
* @experimental
|
|
102
|
+
*/
|
|
103
|
+
class S3StorageDriver {
|
|
104
|
+
name;
|
|
105
|
+
type = DRIVER_TYPE;
|
|
106
|
+
client;
|
|
107
|
+
bucket;
|
|
108
|
+
maxPayloadSize;
|
|
109
|
+
constructor(options) {
|
|
110
|
+
const { client, bucket, driverName = DRIVER_TYPE, maxPayloadSize = DEFAULT_MAX_PAYLOAD_SIZE } = options;
|
|
111
|
+
if (!Number.isFinite(maxPayloadSize) || maxPayloadSize <= 0) {
|
|
112
|
+
throw new common_1.ValueError(`maxPayloadSize must be a positive finite number, got ${String(maxPayloadSize)}`);
|
|
113
|
+
}
|
|
114
|
+
this.client = client;
|
|
115
|
+
this.bucket = typeof bucket === 'string' ? () => bucket : bucket;
|
|
116
|
+
this.name = driverName;
|
|
117
|
+
this.maxPayloadSize = maxPayloadSize;
|
|
118
|
+
}
|
|
119
|
+
async store(context, payloads) {
|
|
120
|
+
const contextSegments = buildContextSegments(context.target);
|
|
121
|
+
return runAllWithAbortOnError(context.abortSignal, (signal) => {
|
|
122
|
+
const uploads = new Map();
|
|
123
|
+
return payloads.map((payload) => this.storePayload(context, payload, contextSegments, signal, uploads));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async retrieve(context, claims) {
|
|
127
|
+
return runAllWithAbortOnError(context.abortSignal, (signal) => claims.map((claim) => this.retrievePayload(claim, signal)));
|
|
128
|
+
}
|
|
129
|
+
async storePayload(context, payload, contextSegments, abortSignal, uploads) {
|
|
130
|
+
const bucket = this.bucket(context, payload);
|
|
131
|
+
const payloadBytes = PayloadProto.encode(payload).finish();
|
|
132
|
+
if (payloadBytes.length > this.maxPayloadSize) {
|
|
133
|
+
throw new common_1.ValueError(`Payload size ${payloadBytes.length} bytes exceeds the configured maxPayloadSize of ${this.maxPayloadSize} bytes`);
|
|
134
|
+
}
|
|
135
|
+
const hashValue = (0, node_crypto_1.createHash)('sha256').update(payloadBytes).digest('hex');
|
|
136
|
+
const key = `v0${contextSegments}/d/sha256/${hashValue}`;
|
|
137
|
+
try {
|
|
138
|
+
const dedupeKey = `${bucket} ${key}`;
|
|
139
|
+
let upload = uploads.get(dedupeKey);
|
|
140
|
+
if (!upload) {
|
|
141
|
+
upload = this.uploadIfAbsent(bucket, key, payloadBytes, abortSignal);
|
|
142
|
+
uploads.set(dedupeKey, upload);
|
|
143
|
+
}
|
|
144
|
+
await upload;
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
throw new Error(`S3StorageDriver store failed [bucket=${bucket}, key=${key}${formatClientContext(this.client)}]`, { cause: err });
|
|
148
|
+
}
|
|
149
|
+
return new extstore_1.StorageDriverClaim({ bucket, key, hashAlgorithm: 'sha256', hashValue });
|
|
150
|
+
}
|
|
151
|
+
async uploadIfAbsent(bucket, key, payloadBytes, abortSignal) {
|
|
152
|
+
if (!(await this.client.objectExists(bucket, key, { abortSignal }))) {
|
|
153
|
+
await this.client.putObject(bucket, key, payloadBytes, { abortSignal });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async retrievePayload(claim, abortSignal) {
|
|
157
|
+
const { bucket, key, hashAlgorithm, hashValue: expectedHash } = claim.claimData;
|
|
158
|
+
if (!bucket || !key) {
|
|
159
|
+
throw new common_1.ValueError(`S3StorageDriver claim is missing required location information: ` + `claimData must contain 'bucket' and 'key'`);
|
|
160
|
+
}
|
|
161
|
+
if (!hashAlgorithm || !expectedHash) {
|
|
162
|
+
throw new common_1.ValueError(`S3StorageDriver claim is missing required content hash information [bucket=${bucket}, key=${key}]: ` +
|
|
163
|
+
`claimData must contain 'hashAlgorithm' and 'hashValue'`);
|
|
164
|
+
}
|
|
165
|
+
if (hashAlgorithm !== 'sha256') {
|
|
166
|
+
throw new common_1.ValueError(`S3StorageDriver unsupported hash algorithm [bucket=${bucket}, key=${key}]: expected sha256, got ${hashAlgorithm}`);
|
|
167
|
+
}
|
|
168
|
+
let payloadBytes;
|
|
169
|
+
try {
|
|
170
|
+
payloadBytes = await this.client.getObject(bucket, key, { abortSignal });
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
throw new Error(`S3StorageDriver retrieve failed [bucket=${bucket}, key=${key}${formatClientContext(this.client)}]`, { cause: err });
|
|
174
|
+
}
|
|
175
|
+
const actualHash = (0, node_crypto_1.createHash)('sha256').update(payloadBytes).digest('hex');
|
|
176
|
+
if (actualHash !== expectedHash) {
|
|
177
|
+
throw new common_1.ValueError(`S3StorageDriver integrity check failed [bucket=${bucket}, key=${key}]: ` +
|
|
178
|
+
`expected ${hashAlgorithm}:${expectedHash}, got ${hashAlgorithm}:${actualHash}`);
|
|
179
|
+
}
|
|
180
|
+
return PayloadProto.decode(payloadBytes);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
exports.S3StorageDriver = S3StorageDriver;
|
|
184
|
+
//# sourceMappingURL=driver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driver.js","sourceRoot":"","sources":["../src/driver.ts"],"names":[],"mappings":"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"}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @experimental The External Storage S3 driver is an experimental feature and may be subject to change.
|
|
3
|
+
*/
|
|
4
|
+
export { S3StorageDriver } from './driver';
|
|
5
|
+
export type { S3StorageDriverOptions, BucketSelector } from './driver';
|
|
6
|
+
export type { S3StorageDriverClient, S3RequestOptions } from './client';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.S3StorageDriver = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* @experimental The External Storage S3 driver is an experimental feature and may be subject to change.
|
|
6
|
+
*/
|
|
7
|
+
var driver_1 = require("./driver");
|
|
8
|
+
Object.defineProperty(exports, "S3StorageDriver", { enumerable: true, get: function () { return driver_1.S3StorageDriver; } });
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA;;GAEG;AACH,mCAA2C;AAAlC,yGAAA,eAAe,OAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@temporalio/external-storage-s3",
|
|
3
|
+
"version": "1.21.0",
|
|
4
|
+
"description": "Amazon S3 external storage driver for the Temporal TypeScript SDK",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "./lib/index.d.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"temporal",
|
|
9
|
+
"workflow",
|
|
10
|
+
"external storage",
|
|
11
|
+
"payload",
|
|
12
|
+
"s3",
|
|
13
|
+
"aws"
|
|
14
|
+
],
|
|
15
|
+
"author": "Temporal Technologies Inc. <sdk@temporal.io>",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"ava": {
|
|
18
|
+
"timeout": "60s"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@temporalio/common": "1.21.0",
|
|
22
|
+
"@temporalio/proto": "1.21.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"ava": "^5.3.1"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">= 20.3.0"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/temporalio/sdk-typescript/issues"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/temporalio/sdk-typescript.git",
|
|
36
|
+
"directory": "contrib/external-storage-s3"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/temporalio/sdk-typescript/tree/main/contrib/external-storage-s3",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"src",
|
|
44
|
+
"lib",
|
|
45
|
+
"!src/__tests__",
|
|
46
|
+
"!lib/__tests__"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc --build",
|
|
50
|
+
"test": "ava ./lib/__tests__/test-*.js"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 object operations used by {@link S3StorageDriver}.
|
|
3
|
+
*
|
|
4
|
+
* @experimental
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Per-request options. */
|
|
8
|
+
export interface S3RequestOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Aborts the in-flight request.
|
|
11
|
+
*/
|
|
12
|
+
abortSignal?: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* S3 driver client operations
|
|
17
|
+
*
|
|
18
|
+
* @experimental
|
|
19
|
+
*/
|
|
20
|
+
export interface S3StorageDriverClient {
|
|
21
|
+
/** Upload `data` to the given bucket and key. */
|
|
22
|
+
putObject(bucket: string, key: string, data: Uint8Array, options?: S3RequestOptions): Promise<void>;
|
|
23
|
+
/** Resolve `true` if an object exists at the given bucket and key. */
|
|
24
|
+
objectExists(bucket: string, key: string, options?: S3RequestOptions): Promise<boolean>;
|
|
25
|
+
/** Download and resolve the bytes stored at the given bucket and key. */
|
|
26
|
+
getObject(bucket: string, key: string, options?: S3RequestOptions): Promise<Uint8Array>;
|
|
27
|
+
/**
|
|
28
|
+
* Optional client-specific diagnostic metadata (e.g. region) that the driver
|
|
29
|
+
* appends to error messages to help diagnose common misconfigurations.
|
|
30
|
+
*/
|
|
31
|
+
describe?(): Record<string, string>;
|
|
32
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import * as proto from '@temporalio/proto';
|
|
3
|
+
import { ValueError } from '@temporalio/common';
|
|
4
|
+
import type { Payload } from '@temporalio/common';
|
|
5
|
+
import {
|
|
6
|
+
StorageDriverClaim,
|
|
7
|
+
type StorageDriver,
|
|
8
|
+
type StorageDriverStoreContext,
|
|
9
|
+
type StorageDriverRetrieveContext,
|
|
10
|
+
type StorageDriverTargetInfo,
|
|
11
|
+
} from '@temporalio/common/lib/converter/extstore';
|
|
12
|
+
import type { S3StorageDriverClient } from './client';
|
|
13
|
+
|
|
14
|
+
const PayloadProto = proto.temporal.api.common.v1.Payload;
|
|
15
|
+
|
|
16
|
+
const DRIVER_TYPE = 'aws.s3driver';
|
|
17
|
+
const DEFAULT_MAX_PAYLOAD_SIZE = 50 * 1024 * 1024;
|
|
18
|
+
/** Key segment written when a context value is empty or absent. */
|
|
19
|
+
const NULL_SEGMENT = 'null';
|
|
20
|
+
|
|
21
|
+
/** Picks the destination bucket for a given payload. Enables dynamic per-payload routing. */
|
|
22
|
+
export type BucketSelector = (context: StorageDriverStoreContext, payload: Payload) => string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Configuration for an {@link S3StorageDriver}.
|
|
26
|
+
*
|
|
27
|
+
* @experimental
|
|
28
|
+
*/
|
|
29
|
+
export interface S3StorageDriverOptions {
|
|
30
|
+
/**
|
|
31
|
+
* An {@link S3StorageDriverClient} that performs the underlying requests,
|
|
32
|
+
* e.g. an `AwsSdkS3StorageDriverClient`.
|
|
33
|
+
*/
|
|
34
|
+
client: S3StorageDriverClient;
|
|
35
|
+
/** Static bucket name or a {@link BucketSelector}. */
|
|
36
|
+
bucket: string | BucketSelector;
|
|
37
|
+
/**
|
|
38
|
+
* Per-instance routing name written into the wire format. Defaults to
|
|
39
|
+
* `"aws.s3driver"`. Override only when registering multiple S3 drivers with
|
|
40
|
+
* distinct configurations under the same `ExternalStorage.drivers` list.
|
|
41
|
+
*/
|
|
42
|
+
driverName?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Hard upper limit, in bytes, on the serialized size of a single payload.
|
|
45
|
+
* Defaults to 52428800 (50 MiB). Stores larger than this throw.
|
|
46
|
+
*/
|
|
47
|
+
maxPayloadSize?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Percent-encodes a key segment, escaping everything outside S3's safe-character
|
|
52
|
+
* set (alphanumerics and `! - _ . * ' ( )`). An empty or absent value becomes the
|
|
53
|
+
* literal `null`.
|
|
54
|
+
*/
|
|
55
|
+
function encodeKeySegment(value: string | undefined): string {
|
|
56
|
+
if (!value) return NULL_SEGMENT;
|
|
57
|
+
return encodeURIComponent(value).replace(/~/g, '%7E');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildContextSegments(target: StorageDriverTargetInfo | undefined): string {
|
|
61
|
+
if (target?.kind === 'workflow') {
|
|
62
|
+
return (
|
|
63
|
+
`/ns/${encodeKeySegment(target.namespace)}` +
|
|
64
|
+
`/wt/${encodeKeySegment(target.type)}` +
|
|
65
|
+
`/wi/${encodeKeySegment(target.id)}` +
|
|
66
|
+
`/ri/${encodeKeySegment(target.runId)}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (target?.kind === 'activity') {
|
|
70
|
+
return (
|
|
71
|
+
`/ns/${encodeKeySegment(target.namespace)}` +
|
|
72
|
+
`/at/${encodeKeySegment(target.type)}` +
|
|
73
|
+
`/ai/${encodeKeySegment(target.id)}` +
|
|
74
|
+
`/ri/${encodeKeySegment(target.runId)}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Formats `describe()` output as ", k=v, k=v" for error messages. Returns an
|
|
82
|
+
* empty string when the client reports no metadata or `describe` itself throws.
|
|
83
|
+
*/
|
|
84
|
+
function formatClientContext(client: S3StorageDriverClient): string {
|
|
85
|
+
let info: Record<string, string>;
|
|
86
|
+
try {
|
|
87
|
+
info = client.describe?.() ?? {};
|
|
88
|
+
} catch {
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
const entries = Object.entries(info);
|
|
92
|
+
return entries.map(([k, v]) => `, ${k}=${v}`).join('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Runs the per-payload operations concurrently. On the first failure, aborts the
|
|
97
|
+
* shared signal so in-flight sibling requests are cancelled, then waits for them
|
|
98
|
+
* to settle before propagating the original error.
|
|
99
|
+
*/
|
|
100
|
+
async function runAllWithAbortOnError<T>(
|
|
101
|
+
external: AbortSignal | undefined,
|
|
102
|
+
makeTasks: (signal: AbortSignal) => Promise<T>[]
|
|
103
|
+
): Promise<T[]> {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const signal = external ? AbortSignal.any([external, controller.signal]) : controller.signal;
|
|
106
|
+
const tasks = makeTasks(signal);
|
|
107
|
+
try {
|
|
108
|
+
return await Promise.all(tasks);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
controller.abort();
|
|
111
|
+
await Promise.allSettled(tasks);
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Stores and retrieves Temporal payloads in Amazon S3.
|
|
118
|
+
*
|
|
119
|
+
* Payloads are content-addressed by a SHA-256 hash of their serialized bytes. S3 keys
|
|
120
|
+
* are created using the namespace, workflow/activity type, other metadata from the store
|
|
121
|
+
* context. Keys adhere to S3's safe-character set.
|
|
122
|
+
*
|
|
123
|
+
* @experimental
|
|
124
|
+
*/
|
|
125
|
+
export class S3StorageDriver implements StorageDriver {
|
|
126
|
+
readonly name: string;
|
|
127
|
+
readonly type = DRIVER_TYPE;
|
|
128
|
+
private readonly client: S3StorageDriverClient;
|
|
129
|
+
private readonly bucket: BucketSelector;
|
|
130
|
+
private readonly maxPayloadSize: number;
|
|
131
|
+
|
|
132
|
+
constructor(options: S3StorageDriverOptions) {
|
|
133
|
+
const { client, bucket, driverName = DRIVER_TYPE, maxPayloadSize = DEFAULT_MAX_PAYLOAD_SIZE } = options;
|
|
134
|
+
if (!Number.isFinite(maxPayloadSize) || maxPayloadSize <= 0) {
|
|
135
|
+
throw new ValueError(`maxPayloadSize must be a positive finite number, got ${String(maxPayloadSize)}`);
|
|
136
|
+
}
|
|
137
|
+
this.client = client;
|
|
138
|
+
this.bucket = typeof bucket === 'string' ? () => bucket : bucket;
|
|
139
|
+
this.name = driverName;
|
|
140
|
+
this.maxPayloadSize = maxPayloadSize;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async store(context: StorageDriverStoreContext, payloads: Payload[]): Promise<StorageDriverClaim[]> {
|
|
144
|
+
const contextSegments = buildContextSegments(context.target);
|
|
145
|
+
return runAllWithAbortOnError(context.abortSignal, (signal) => {
|
|
146
|
+
const uploads = new Map<string, Promise<void>>();
|
|
147
|
+
return payloads.map((payload) => this.storePayload(context, payload, contextSegments, signal, uploads));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async retrieve(context: StorageDriverRetrieveContext, claims: StorageDriverClaim[]): Promise<Payload[]> {
|
|
152
|
+
return runAllWithAbortOnError(context.abortSignal, (signal) =>
|
|
153
|
+
claims.map((claim) => this.retrievePayload(claim, signal))
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async storePayload(
|
|
158
|
+
context: StorageDriverStoreContext,
|
|
159
|
+
payload: Payload,
|
|
160
|
+
contextSegments: string,
|
|
161
|
+
abortSignal: AbortSignal,
|
|
162
|
+
uploads: Map<string, Promise<void>>
|
|
163
|
+
): Promise<StorageDriverClaim> {
|
|
164
|
+
const bucket = this.bucket(context, payload);
|
|
165
|
+
|
|
166
|
+
const payloadBytes = PayloadProto.encode(payload).finish();
|
|
167
|
+
if (payloadBytes.length > this.maxPayloadSize) {
|
|
168
|
+
throw new ValueError(
|
|
169
|
+
`Payload size ${payloadBytes.length} bytes exceeds the configured maxPayloadSize of ${this.maxPayloadSize} bytes`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const hashValue = createHash('sha256').update(payloadBytes).digest('hex');
|
|
174
|
+
const key = `v0${contextSegments}/d/sha256/${hashValue}`;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const dedupeKey = `${bucket} ${key}`;
|
|
178
|
+
let upload = uploads.get(dedupeKey);
|
|
179
|
+
if (!upload) {
|
|
180
|
+
upload = this.uploadIfAbsent(bucket, key, payloadBytes, abortSignal);
|
|
181
|
+
uploads.set(dedupeKey, upload);
|
|
182
|
+
}
|
|
183
|
+
await upload;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`S3StorageDriver store failed [bucket=${bucket}, key=${key}${formatClientContext(this.client)}]`,
|
|
187
|
+
{ cause: err }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return new StorageDriverClaim({ bucket, key, hashAlgorithm: 'sha256', hashValue });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async uploadIfAbsent(
|
|
195
|
+
bucket: string,
|
|
196
|
+
key: string,
|
|
197
|
+
payloadBytes: Uint8Array,
|
|
198
|
+
abortSignal: AbortSignal
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
if (!(await this.client.objectExists(bucket, key, { abortSignal }))) {
|
|
201
|
+
await this.client.putObject(bucket, key, payloadBytes, { abortSignal });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async retrievePayload(claim: StorageDriverClaim, abortSignal: AbortSignal): Promise<Payload> {
|
|
206
|
+
const { bucket, key, hashAlgorithm, hashValue: expectedHash } = claim.claimData;
|
|
207
|
+
if (!bucket || !key) {
|
|
208
|
+
throw new ValueError(
|
|
209
|
+
`S3StorageDriver claim is missing required location information: ` + `claimData must contain 'bucket' and 'key'`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (!hashAlgorithm || !expectedHash) {
|
|
213
|
+
throw new ValueError(
|
|
214
|
+
`S3StorageDriver claim is missing required content hash information [bucket=${bucket}, key=${key}]: ` +
|
|
215
|
+
`claimData must contain 'hashAlgorithm' and 'hashValue'`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (hashAlgorithm !== 'sha256') {
|
|
219
|
+
throw new ValueError(
|
|
220
|
+
`S3StorageDriver unsupported hash algorithm [bucket=${bucket}, key=${key}]: expected sha256, got ${hashAlgorithm}`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let payloadBytes: Uint8Array;
|
|
225
|
+
try {
|
|
226
|
+
payloadBytes = await this.client.getObject(bucket, key, { abortSignal });
|
|
227
|
+
} catch (err) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`S3StorageDriver retrieve failed [bucket=${bucket}, key=${key}${formatClientContext(this.client)}]`,
|
|
230
|
+
{ cause: err }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const actualHash = createHash('sha256').update(payloadBytes).digest('hex');
|
|
235
|
+
if (actualHash !== expectedHash) {
|
|
236
|
+
throw new ValueError(
|
|
237
|
+
`S3StorageDriver integrity check failed [bucket=${bucket}, key=${key}]: ` +
|
|
238
|
+
`expected ${hashAlgorithm}:${expectedHash}, got ${hashAlgorithm}:${actualHash}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return PayloadProto.decode(payloadBytes);
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @experimental The External Storage S3 driver is an experimental feature and may be subject to change.
|
|
3
|
+
*/
|
|
4
|
+
export { S3StorageDriver } from './driver';
|
|
5
|
+
export type { S3StorageDriverOptions, BucketSelector } from './driver';
|
|
6
|
+
export type { S3StorageDriverClient, S3RequestOptions } from './client';
|