Skip to content

Commit 2f945b0

Browse files
committed
feat(extstore): add aws-sdk s3 driver package.
1 parent 288e97b commit 2f945b0

9 files changed

Lines changed: 731 additions & 18 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# AWS SDK Client for the Temporal S3 External Storage Driver
2+
3+
> ⚠️ **This package is experimental and may be subject to change.** ⚠️
4+
5+
`@temporalio/external-storage-s3-aws-sdk` provides an [`@aws-sdk/client-s3`](https://www.npmjs.com/package/@aws-sdk/client-s3)-backed `S3StorageDriverClient` for [`@temporalio/external-storage-s3`](../external-storage-s3).
6+
7+
`@aws-sdk/client-s3` is a peer dependency, so the driver uses the same `S3Client` (and version) your application already configures.
8+
9+
## Usage
10+
11+
npm install @temporalio/external-storage-s3 @temporalio/external-storage-s3-aws-sdk @aws-sdk/client-s3
12+
13+
```ts
14+
import { S3Client } from '@aws-sdk/client-s3';
15+
import { S3StorageDriver } from '@temporalio/external-storage-s3';
16+
import { AwsSdkS3StorageDriverClient } from '@temporalio/external-storage-s3-aws-sdk';
17+
18+
const s3Client = new S3Client({ region: 'us-east-1' });
19+
const driver = new S3StorageDriver({
20+
client: new AwsSdkS3StorageDriverClient(s3Client),
21+
bucket: 'my-temporal-payloads',
22+
});
23+
```
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@temporalio/external-storage-s3-aws-sdk",
3+
"version": "1.17.2",
4+
"description": "AWS SDK (@aws-sdk/client-s3) client for the Temporal S3 external storage driver",
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+
"scripts": {
18+
"build": "tsc --build",
19+
"test": "ava ./lib/__tests__/test-*.js"
20+
},
21+
"ava": {
22+
"timeout": "60s"
23+
},
24+
"dependencies": {
25+
"@temporalio/external-storage-s3": "workspace:*"
26+
},
27+
"peerDependencies": {
28+
"@aws-sdk/client-s3": "^3.0.0"
29+
},
30+
"devDependencies": {
31+
"@aws-sdk/client-s3": "^3.0.0",
32+
"ava": "^5.3.1"
33+
},
34+
"engines": {
35+
"node": ">= 20.3.0"
36+
},
37+
"bugs": {
38+
"url": "https://github.com/temporalio/sdk-typescript/issues"
39+
},
40+
"repository": {
41+
"type": "git",
42+
"url": "git+https://github.com/temporalio/sdk-typescript.git",
43+
"directory": "contrib/external-storage-s3-aws-sdk"
44+
},
45+
"homepage": "https://github.com/temporalio/sdk-typescript/tree/main/contrib/external-storage-s3-aws-sdk",
46+
"publishConfig": {
47+
"access": "public"
48+
},
49+
"files": [
50+
"src",
51+
"lib",
52+
"!src/__tests__",
53+
"!lib/__tests__"
54+
]
55+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import test from 'ava';
2+
import type { S3Client } from '@aws-sdk/client-s3';
3+
import { AwsSdkS3StorageDriverClient } from '../aws-sdk-client';
4+
5+
/** Minimal S3Client stand-in: only `send` and `config.region` are exercised. */
6+
function fakeS3Client(send: (command: unknown) => Promise<unknown>, region?: string): S3Client {
7+
return { send, config: { region } } as unknown as S3Client;
8+
}
9+
10+
test('objectExists maps a NotFound error to false', async (t) => {
11+
const client = new AwsSdkS3StorageDriverClient(
12+
fakeS3Client(() => Promise.reject(Object.assign(new Error('not found'), { name: 'NotFound' })))
13+
);
14+
t.false(await client.objectExists('b', 'k'));
15+
});
16+
17+
test('objectExists maps a 404 status to false', async (t) => {
18+
const client = new AwsSdkS3StorageDriverClient(
19+
fakeS3Client(() => Promise.reject(Object.assign(new Error('nope'), { $metadata: { httpStatusCode: 404 } })))
20+
);
21+
t.false(await client.objectExists('b', 'k'));
22+
});
23+
24+
test('objectExists rethrows non-404 errors', async (t) => {
25+
const client = new AwsSdkS3StorageDriverClient(
26+
fakeS3Client(() => Promise.reject(Object.assign(new Error('denied'), { $metadata: { httpStatusCode: 403 } })))
27+
);
28+
await t.throwsAsync(() => client.objectExists('b', 'k'), { message: 'denied' });
29+
});
30+
31+
test('objectExists returns true when the head succeeds', async (t) => {
32+
const client = new AwsSdkS3StorageDriverClient(fakeS3Client(() => Promise.resolve({})));
33+
t.true(await client.objectExists('b', 'k'));
34+
});
35+
36+
test('getObject reads the response body as bytes', async (t) => {
37+
const bytes = new Uint8Array([1, 2, 3]);
38+
const client = new AwsSdkS3StorageDriverClient(fakeS3Client(() => Promise.resolve({ Body: { transformToByteArray: async () => bytes } })));
39+
t.deepEqual(await client.getObject('b', 'k'), bytes);
40+
});
41+
42+
test('getObject throws when the response has no body', async (t) => {
43+
const client = new AwsSdkS3StorageDriverClient(fakeS3Client(() => Promise.resolve({})));
44+
await t.throwsAsync(() => client.getObject('b', 'k'), { message: /empty body/ });
45+
});
46+
47+
test('describe surfaces a plain-string region', (t) => {
48+
const client = new AwsSdkS3StorageDriverClient(fakeS3Client(() => Promise.resolve({}), 'us-west-2'));
49+
t.deepEqual(client.describe?.(), { clientRegion: 'us-west-2' });
50+
});
51+
52+
test('describe omits region when unavailable', (t) => {
53+
const client = new AwsSdkS3StorageDriverClient(fakeS3Client(() => Promise.resolve({})));
54+
t.deepEqual(client.describe?.(), {});
55+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
2+
import type { S3Client as AwsS3Client } from '@aws-sdk/client-s3';
3+
import type { S3StorageDriverClient, S3RequestOptions } from '@temporalio/external-storage-s3';
4+
5+
/**
6+
* An {@link S3StorageDriverClient} backed by an `@aws-sdk/client-s3` `S3Client`,
7+
* for use with `S3StorageDriver`.
8+
*
9+
* @experimental
10+
*/
11+
export class AwsSdkS3StorageDriverClient implements S3StorageDriverClient {
12+
constructor(private readonly client: AwsS3Client) {}
13+
14+
describe(): Record<string, string> {
15+
// v3 normalizes `region` to an async provider, so it is usually unavailable
16+
// synchronously here; surface it only when it was supplied as a plain string.
17+
const region = this.client.config?.region;
18+
return typeof region === 'string' && region ? { clientRegion: region } : {};
19+
}
20+
21+
async objectExists(bucket: string, key: string, options?: S3RequestOptions): Promise<boolean> {
22+
try {
23+
await this.client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }), { abortSignal: options?.abortSignal });
24+
return true;
25+
} catch (err) {
26+
if (isNotFound(err)) {
27+
return false;
28+
}
29+
throw err;
30+
}
31+
}
32+
33+
async putObject(bucket: string, key: string, data: Uint8Array, options?: S3RequestOptions): Promise<void> {
34+
await this.client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: data }), {
35+
abortSignal: options?.abortSignal,
36+
});
37+
}
38+
39+
async getObject(bucket: string, key: string, options?: S3RequestOptions): Promise<Uint8Array> {
40+
const response = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }), {
41+
abortSignal: options?.abortSignal,
42+
});
43+
if (!response.Body) {
44+
throw new Error(`S3 GetObject returned an empty body [bucket=${bucket}, key=${key}]`);
45+
}
46+
return response.Body.transformToByteArray();
47+
}
48+
}
49+
50+
function isNotFound(err: unknown): boolean {
51+
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
52+
return e?.name === 'NotFound' || e?.$metadata?.httpStatusCode === 404;
53+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* @experimental The External Storage S3 driver is an experimental feature and may be subject to change.
3+
*/
4+
export { AwsSdkS3StorageDriverClient } from './aws-sdk-client';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "./lib",
5+
"rootDir": "./src"
6+
},
7+
"references": [{ "path": "../external-storage-s3" }],
8+
"include": ["./src/**/*.ts"]
9+
}

0 commit comments

Comments
 (0)