@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 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
+ ```
@@ -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,8 @@
1
+ "use strict";
2
+ /**
3
+ * S3 object operations used by {@link S3StorageDriver}.
4
+ *
5
+ * @experimental
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":";AAAA;;;;GAIG"}
@@ -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
@@ -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';