@ddd-ts-kit/s3 0.16.0 → 0.16.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/README.fr.md +211 -0
- package/README.md +135 -10
- package/package.json +4 -4
package/README.fr.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
[English](README.md) · **Français**
|
|
2
|
+
<!-- translation-of: README.md sha256:1ff5474228b48807 -->
|
|
3
|
+
|
|
4
|
+
# @ddd-ts-kit/s3
|
|
5
|
+
|
|
6
|
+
Le `FileStorage` du noyau sur tout fournisseur du protocole S3 : AWS S3, MinIO, OVHcloud Object
|
|
7
|
+
Storage, Scaleway, Cloudflare R2, Garage. Le contenu d'un fichier part en flux vers le bucket par
|
|
8
|
+
parties, jamais entier en mémoire et jamais dans la base de données ; l'agrégat garde le
|
|
9
|
+
`StoredFile` que renvoie `put`. Rien dans `domain/` ou `application/` n'importe ce *package* : les
|
|
10
|
+
gestionnaires (handlers) dépendent du port `FileStorage`, et la plateforme lie cet adaptateur.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
npm i @ddd-ts-kit/s3 @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Les trois *packages* du SDK AWS v3 officiel sont des dépendances *peer* optionnelles, écrites pour
|
|
17
|
+
la version 3.1150.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
Les cas d'usage parlent au port avec des clés comme `appointment-documents/2026/10/7f3c`,
|
|
22
|
+
générées par `TenantFileKeys`, le `FileKeys` du noyau : la zone, l'année, le mois, puis un
|
|
23
|
+
identifiant issu de l'`IdGenerator` (précédés de `tenants/<tenant>/` quand le travail en cours
|
|
24
|
+
désigne un tenant). L'adaptateur place son préfixe devant chaque clé
|
|
25
|
+
(`scheduling/appointment-documents/2026/10/7f3c` dans le bucket), et `FileStaging` place
|
|
26
|
+
`staging/` devant la clé d'un fichier qui attend sa validation
|
|
27
|
+
(`scheduling/staging/appointment-documents/2026/10/9a1b`). Les octets d'un fichier vont du client
|
|
28
|
+
au bucket, ou de l'API au bucket par parties, et la base de données ne garde que la référence.
|
|
29
|
+
|
|
30
|
+
## Racine de composition
|
|
31
|
+
|
|
32
|
+
<!-- checked sample -->
|
|
33
|
+
```ts
|
|
34
|
+
import type { FileStorage } from '@ddd-ts-kit/kernel';
|
|
35
|
+
import { S3FileStorage, s3ClientOf } from '@ddd-ts-kit/s3';
|
|
36
|
+
|
|
37
|
+
declare const config: {
|
|
38
|
+
readonly storage: {
|
|
39
|
+
readonly endpoint?: string;
|
|
40
|
+
readonly region: string;
|
|
41
|
+
readonly accessKeyId: string;
|
|
42
|
+
readonly secretAccessKey: string;
|
|
43
|
+
readonly bucket: string;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const { endpoint, region, accessKeyId, secretAccessKey, bucket } = config.storage;
|
|
48
|
+
|
|
49
|
+
export const files: FileStorage = new S3FileStorage({
|
|
50
|
+
client: s3ClientOf({
|
|
51
|
+
...(endpoint === undefined ? {} : { endpoint }), // omis pour AWS ; http://localhost:9000 pour MinIO
|
|
52
|
+
region, // 'auto' pour R2
|
|
53
|
+
accessKeyId,
|
|
54
|
+
secretAccessKey,
|
|
55
|
+
}),
|
|
56
|
+
bucket, // créé par le code d'infrastructure, jamais par l'application
|
|
57
|
+
prefix: 'scheduling/', // facultatif : plusieurs applications dans un seul bucket
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`s3ClientOf` active l'adressage par chemin (path-style) quand un endpoint est donné (MinIO et la
|
|
62
|
+
plupart des serveurs auto-hébergés en ont besoin) et ne calcule d'empreinte (checksum) que
|
|
63
|
+
lorsqu'une opération en exige une : par défaut, le SDK envoie une empreinte avec chaque requête,
|
|
64
|
+
ce que refusent plusieurs fournisseurs qui ne sont pas AWS, et ce qui lierait un lien d'envoi
|
|
65
|
+
signé à un corps vide. Un client construit à la main fonctionne aussi.
|
|
66
|
+
|
|
67
|
+
Les options de `S3FileStorage` :
|
|
68
|
+
|
|
69
|
+
| Option | Par défaut | |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `client` | requis | un `S3Client`, issu de `s3ClientOf` ou construit à la main |
|
|
72
|
+
| `bucket` | requis | il doit exister : l'adaptateur n'en crée jamais |
|
|
73
|
+
| `prefix` | aucun | une clé valide terminée par `/`, placée devant chaque clé ; le constructeur en refuse toute autre |
|
|
74
|
+
| `partSize` | 8 Mio | la taille de chaque partie d'un envoi, au moins 5 Mio : le maximum gardé en mémoire par envoi |
|
|
75
|
+
| `clock` | l'horloge du système | donne l'heure des `expiresAt` que renvoient les liens signés |
|
|
76
|
+
|
|
77
|
+
Un gestionnaire dépend du port, jamais du SDK :
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// À éviter : le gestionnaire parle S3, garde tout le fichier en mémoire et nomme le bucket
|
|
81
|
+
const body = Buffer.concat(await Array.fromAsync(rows));
|
|
82
|
+
await s3.send(new PutObjectCommand({ Bucket: 'acme-files', Key: `exports/${id}.csv`, Body: body }));
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
<!-- checked sample -->
|
|
86
|
+
```ts
|
|
87
|
+
import { FileKeys, FileStorage, type StoredFile } from '@ddd-ts-kit/kernel';
|
|
88
|
+
|
|
89
|
+
// Écrire : le port du noyau ; la plateforme y lie S3FileStorage, un test InMemoryFileStorage.
|
|
90
|
+
export class ExportAppointments {
|
|
91
|
+
constructor(
|
|
92
|
+
private readonly files: FileStorage,
|
|
93
|
+
private readonly keys: FileKeys,
|
|
94
|
+
) {}
|
|
95
|
+
|
|
96
|
+
async execute(csv: AsyncIterable<Uint8Array>): Promise<{ readonly file: StoredFile; readonly link: string }> {
|
|
97
|
+
const key = this.keys.next('appointment-exports');
|
|
98
|
+
|
|
99
|
+
// Part en flux vers le bucket par parties ; au-delà de 50 Mio, FILE_TOO_LARGE, et rien ne reste sous la clé.
|
|
100
|
+
const file = await this.files.put(key, csv, { mediaType: 'text/csv', maxBytes: 50 * 1_048_576 });
|
|
101
|
+
|
|
102
|
+
// Un GetObject présigné, calculé localement : aucune requête n'atteint le bucket.
|
|
103
|
+
const { url } = await this.files.signedDownloadUrl(key, { expiresInSeconds: 300, fileName: 'appointments.csv' });
|
|
104
|
+
|
|
105
|
+
return { file, link: url };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Ce qu'il fait
|
|
111
|
+
|
|
112
|
+
| Méthode du port | Sur le bucket |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `put(key, stream, { mediaType, maxBytes })` | un envoi *multipart* en parties de `partSize` (8 Mio par défaut, une seule en mémoire à la fois), ou un seul `PutObject` quand le contenu tient dans une partie ; le contenu est compté et haché au passage, et refusé avec `FILE_TOO_LARGE` au-delà de `maxBytes`, ce qui abandonne l'envoi et laisse l'objet précédent |
|
|
115
|
+
| `get`, `stat` | `GetObject` en flux, `HeadObject` ; `undefined` quand l'objet n'existe pas, un rejet quand le service est injoignable |
|
|
116
|
+
| `delete` | `DeleteObject`, idempotent |
|
|
117
|
+
| `copy`, `move` | `CopyObject`, fait par le service (et `DeleteObject` pour un déplacement), jusqu'à 5 Gio par objet |
|
|
118
|
+
| `list(prefix)` | `ListObjectsV2`, page après page |
|
|
119
|
+
| `signedDownloadUrl` | un `GetObject` présigné, servi avec `Content-Disposition: attachment`, avec le nom du fichier quand il est donné |
|
|
120
|
+
| `signedUploadUrl` | un `PutObject` présigné dont la signature couvre le `content-type` : un autre type de média est refusé par le service |
|
|
121
|
+
|
|
122
|
+
L'empreinte (`sha256:<hex>`) est renvoyée par `put`, calculée pendant que le contenu passe ; le
|
|
123
|
+
bucket ne la garde pas, si bien que `stat` n'en renvoie aucune. Un fichier envoyé par un lien signé
|
|
124
|
+
est mesuré, vérifié et haché par `FileStaging.confirm` du noyau, qui le relit.
|
|
125
|
+
|
|
126
|
+
Une clé que le port refuse (`../escape`) est refusée avec `INVALID_FILE_KEY`, et un lien demandé
|
|
127
|
+
pour plus d'une heure avec `INVALID_FILE_URL_EXPIRY`, avant toute requête. La signature ne
|
|
128
|
+
demande pas de requête non plus : un lien signé est calculé à partir des identifiants d'accès, et
|
|
129
|
+
seul le fournisseur le vérifie, quand le client l'utilise.
|
|
130
|
+
|
|
131
|
+

|
|
132
|
+
|
|
133
|
+
Le client envoie le fichier exactement comme le dit le lien, avec les en-têtes qu'il a reçus :
|
|
134
|
+
|
|
135
|
+
<!-- checked sample -->
|
|
136
|
+
```ts
|
|
137
|
+
import type { SignedUpload } from '@ddd-ts-kit/kernel';
|
|
138
|
+
|
|
139
|
+
// Dans le client : la signature couvre le content-type, les en-têtes sont donc envoyés tels quels.
|
|
140
|
+
export async function sendFile(link: SignedUpload, file: Blob): Promise<void> {
|
|
141
|
+
const response = await fetch(link.url, { method: link.method, headers: link.headers, body: file });
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error(`The storage refused the upload: ${response.status}`); // 403 une fois expiré, ou pour un autre type
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Limites
|
|
150
|
+
|
|
151
|
+
- Un lien d'envoi signé ne peut pas plafonner la taille de ce qui est envoyé (seule une politique
|
|
152
|
+
de POST présigné le peut) : son `maxBytes` n'est pas appliqué par le service, et
|
|
153
|
+
`FileStaging.confirm` mesure le fichier contre le plafond de l'autorisation d'envoi que le
|
|
154
|
+
serveur a gardée, et le supprime au-delà.
|
|
155
|
+
- La copie et le déplacement passent par `CopyObject`, limité à 5 Gio par objet par le protocole.
|
|
156
|
+
- Un envoi compte au plus 10 000 parties : avec des parties de 8 Mio, un fichier d'environ 78 Gio.
|
|
157
|
+
Augmentez `partSize` pour des fichiers plus gros.
|
|
158
|
+
- Les règles de cycle de vie, le versionnement, le verrouillage d'objet (object lock) et le
|
|
159
|
+
chiffrement du bucket sont des réglages du bucket, faits par le code d'infrastructure du
|
|
160
|
+
déploiement, pas par cet adaptateur.
|
|
161
|
+
|
|
162
|
+
## Tests
|
|
163
|
+
|
|
164
|
+
Le contrat du port est tenu contre un vrai serveur : `fileStorageContract` de
|
|
165
|
+
`@ddd-ts-kit/testing/contracts` (réexporté par `@ddd-ts-kit/s3/testing`) s'exécute sur MinIO dans
|
|
166
|
+
le *job* d'intégration du *pipeline*, avec la variable `DDD_TS_KIT_TEST_S3_ENDPOINT` définie ;
|
|
167
|
+
sans elle, les *specs* qui ont besoin d'un serveur sont ignorées. Le projet MinIO amont a cessé
|
|
168
|
+
de publier des images en 2025 : le *pipeline* exécute `pgsty/minio`, construit à partir des
|
|
169
|
+
mêmes sources, épinglé par *digest* (`pipeline/github/scripts/start-minio.sh`).
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
docker run -d --name minio -p 127.0.0.1:9000:9000 -e MINIO_ROOT_USER=ddd-ts-kit -e MINIO_ROOT_PASSWORD=ddd-ts-kit-secret pgsty/minio:RELEASE.2026-08-04T00-00-00Z server /data
|
|
173
|
+
DDD_TS_KIT_TEST_S3_ENDPOINT=http://localhost:9000 npx vitest run packages/s3
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Un projet tient sa propre liaison à la même suite, sur le MinIO de sa *stack* compose : chaque
|
|
177
|
+
test écrit sous un préfixe à lui, si bien que les tests partagent un seul bucket sans voir les
|
|
178
|
+
fichiers des autres.
|
|
179
|
+
|
|
180
|
+
<!-- checked sample -->
|
|
181
|
+
```ts
|
|
182
|
+
import { randomBytes } from 'node:crypto';
|
|
183
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
184
|
+
import { S3FileStorage, s3ClientOf } from '@ddd-ts-kit/s3';
|
|
185
|
+
import { fileStorageContract } from '@ddd-ts-kit/s3/testing';
|
|
186
|
+
|
|
187
|
+
const endpoint = process.env['S3_TEST_ENDPOINT'] ?? '';
|
|
188
|
+
|
|
189
|
+
// Ignoré sans serveur ; le bucket est créé au préalable, comme dans un déploiement.
|
|
190
|
+
fileStorageContract(
|
|
191
|
+
{ describe: describe.runIf(endpoint !== ''), it, expect, beforeEach },
|
|
192
|
+
{
|
|
193
|
+
name: 'S3FileStorage on MinIO',
|
|
194
|
+
createStorage: () =>
|
|
195
|
+
new S3FileStorage({
|
|
196
|
+
client: s3ClientOf({ endpoint, region: 'us-east-1', accessKeyId: 'minio', secretAccessKey: 'minio-secret' }),
|
|
197
|
+
bucket: 'acme-files-test',
|
|
198
|
+
prefix: `run-${randomBytes(6).toString('hex')}/`,
|
|
199
|
+
}),
|
|
200
|
+
// Les liens d'un vrai serveur expirent en temps réel.
|
|
201
|
+
elapse: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Un autre fournisseur
|
|
207
|
+
|
|
208
|
+
Google Cloud Storage et Azure Blob Storage parlent leurs propres protocoles. Un connecteur pour
|
|
209
|
+
l'un d'eux est une classe qui étend `FileStorage` du noyau et passe `fileStorageContract` contre
|
|
210
|
+
le vrai service ou son émulateur (fake-gcs-server, Azurite) : voir
|
|
211
|
+
[Connecteurs de stockage de fichiers](../../docs/tools/file-storage.fr.md).
|
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
**English** · [Français](README.fr.md)
|
|
2
|
+
|
|
1
3
|
# @ddd-ts-kit/s3
|
|
2
4
|
|
|
3
5
|
The kernel `FileStorage` on any provider of the S3 protocol: AWS S3, MinIO, OVHcloud Object
|
|
4
6
|
Storage, Scaleway, Cloudflare R2, Garage. The content of a file streams to the bucket in parts,
|
|
5
7
|
never whole in memory and never in the database; the aggregate keeps the `StoredFile` that `put`
|
|
6
|
-
|
|
8
|
+
returns. Nothing in `domain/` or `application/` imports this package: handlers depend on the
|
|
7
9
|
`FileStorage` port, and the platform binds this adapter.
|
|
8
10
|
|
|
9
11
|
```
|
|
@@ -12,19 +14,44 @@ npm i @ddd-ts-kit/s3 @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request
|
|
|
12
14
|
|
|
13
15
|
The three packages of the official AWS SDK v3 are optional peers, written against 3.1150.
|
|
14
16
|
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
The use cases speak to the port with keys such as `appointment-documents/2026/10/7f3c`,
|
|
20
|
+
generated by `TenantFileKeys`, the `FileKeys` of the kernel: the area, the year, the month, then
|
|
21
|
+
an id from the `IdGenerator` (and `tenants/<tenant>/` before them when the work names a tenant).
|
|
22
|
+
The adapter puts its prefix before every key (`scheduling/appointment-documents/2026/10/7f3c` in
|
|
23
|
+
the bucket), and `FileStaging` puts `staging/` before the key of a file that waits for its commit
|
|
24
|
+
(`scheduling/staging/appointment-documents/2026/10/9a1b`). The bytes of a file go from the
|
|
25
|
+
client to the bucket, or from the API to the bucket in parts, and the database keeps only the
|
|
26
|
+
reference.
|
|
27
|
+
|
|
15
28
|
## Composition root
|
|
16
29
|
|
|
30
|
+
<!-- checked sample -->
|
|
17
31
|
```ts
|
|
32
|
+
import type { FileStorage } from '@ddd-ts-kit/kernel';
|
|
18
33
|
import { S3FileStorage, s3ClientOf } from '@ddd-ts-kit/s3';
|
|
19
34
|
|
|
20
|
-
const
|
|
35
|
+
declare const config: {
|
|
36
|
+
readonly storage: {
|
|
37
|
+
readonly endpoint?: string;
|
|
38
|
+
readonly region: string;
|
|
39
|
+
readonly accessKeyId: string;
|
|
40
|
+
readonly secretAccessKey: string;
|
|
41
|
+
readonly bucket: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const { endpoint, region, accessKeyId, secretAccessKey, bucket } = config.storage;
|
|
46
|
+
|
|
47
|
+
export const files: FileStorage = new S3FileStorage({
|
|
21
48
|
client: s3ClientOf({
|
|
22
|
-
endpoint:
|
|
23
|
-
region
|
|
24
|
-
accessKeyId
|
|
25
|
-
secretAccessKey
|
|
49
|
+
...(endpoint === undefined ? {} : { endpoint }), // left out for AWS; http://localhost:9000 for MinIO
|
|
50
|
+
region, // 'auto' for R2
|
|
51
|
+
accessKeyId,
|
|
52
|
+
secretAccessKey,
|
|
26
53
|
}),
|
|
27
|
-
bucket
|
|
54
|
+
bucket, // created by the infrastructure code, never by the application
|
|
28
55
|
prefix: 'scheduling/', // optional: several applications in one bucket
|
|
29
56
|
});
|
|
30
57
|
```
|
|
@@ -34,11 +61,54 @@ servers need it) and computes checksums only when an operation requires one: the
|
|
|
34
61
|
SDK sends a checksum with every request, which several providers that are not AWS refuse, and
|
|
35
62
|
which would bind a signed upload link to an empty body. A client built by hand works too.
|
|
36
63
|
|
|
64
|
+
The options of `S3FileStorage`:
|
|
65
|
+
|
|
66
|
+
| Option | Default | |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `client` | required | an `S3Client`, from `s3ClientOf` or built by hand |
|
|
69
|
+
| `bucket` | required | it must exist: the adapter never creates one |
|
|
70
|
+
| `prefix` | none | a valid key ending with `/`, put before every key; the constructor refuses another |
|
|
71
|
+
| `partSize` | 8 MiB | the size of each part of an upload, at least 5 MiB: the most held in memory per upload |
|
|
72
|
+
| `clock` | the system clock | gives the `expiresAt` the signed links return |
|
|
73
|
+
|
|
74
|
+
A handler depends on the port, never on the SDK:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// Instead of: the handler speaks S3, holds the whole file in memory, and names the bucket
|
|
78
|
+
const body = Buffer.concat(await Array.fromAsync(rows));
|
|
79
|
+
await s3.send(new PutObjectCommand({ Bucket: 'acme-files', Key: `exports/${id}.csv`, Body: body }));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
<!-- checked sample -->
|
|
83
|
+
```ts
|
|
84
|
+
import { FileKeys, FileStorage, type StoredFile } from '@ddd-ts-kit/kernel';
|
|
85
|
+
|
|
86
|
+
// Write: the port of the kernel; the platform binds S3FileStorage to it, a test InMemoryFileStorage.
|
|
87
|
+
export class ExportAppointments {
|
|
88
|
+
constructor(
|
|
89
|
+
private readonly files: FileStorage,
|
|
90
|
+
private readonly keys: FileKeys,
|
|
91
|
+
) {}
|
|
92
|
+
|
|
93
|
+
async execute(csv: AsyncIterable<Uint8Array>): Promise<{ readonly file: StoredFile; readonly link: string }> {
|
|
94
|
+
const key = this.keys.next('appointment-exports');
|
|
95
|
+
|
|
96
|
+
// Streams to the bucket in parts; beyond 50 MiB, FILE_TOO_LARGE, and nothing is left under the key.
|
|
97
|
+
const file = await this.files.put(key, csv, { mediaType: 'text/csv', maxBytes: 50 * 1_048_576 });
|
|
98
|
+
|
|
99
|
+
// A presigned GetObject, computed locally: no request reaches the bucket.
|
|
100
|
+
const { url } = await this.files.signedDownloadUrl(key, { expiresInSeconds: 300, fileName: 'appointments.csv' });
|
|
101
|
+
|
|
102
|
+
return { file, link: url };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
37
107
|
## What it does
|
|
38
108
|
|
|
39
109
|
| Port method | On the bucket |
|
|
40
110
|
|---|---|
|
|
41
|
-
| `put(key, stream, { mediaType, maxBytes })` | a multipart upload of `partSize` parts (8 MiB by default, one in memory at a time); the content is counted and hashed on its way, and refused with `FILE_TOO_LARGE` beyond `maxBytes`, which aborts the upload and leaves the previous object |
|
|
111
|
+
| `put(key, stream, { mediaType, maxBytes })` | a multipart upload of `partSize` parts (8 MiB by default, one in memory at a time), or a single `PutObject` when the content fits in one part; the content is counted and hashed on its way, and refused with `FILE_TOO_LARGE` beyond `maxBytes`, which aborts the upload and leaves the previous object |
|
|
42
112
|
| `get`, `stat` | `GetObject` as a stream, `HeadObject`; `undefined` when the object does not exist, a rejection when the service cannot be reached |
|
|
43
113
|
| `delete` | `DeleteObject`, idempotent |
|
|
44
114
|
| `copy`, `move` | `CopyObject` done by the service (and `DeleteObject` for a move), up to 5 GiB per object |
|
|
@@ -46,16 +116,41 @@ which would bind a signed upload link to an empty body. A client built by hand w
|
|
|
46
116
|
| `signedDownloadUrl` | a presigned `GetObject` answered with `Content-Disposition: attachment`, with the file name when one is given |
|
|
47
117
|
| `signedUploadUrl` | a presigned `PutObject` whose signature covers the `content-type`: another media type is refused by the service |
|
|
48
118
|
|
|
49
|
-
The checksum (`sha256:<hex>`) is
|
|
50
|
-
bucket does not keep it, so `stat` does not
|
|
119
|
+
The checksum (`sha256:<hex>`) is returned by `put`, computed while the content streams; the
|
|
120
|
+
bucket does not keep it, so `stat` does not return one. A file uploaded through a signed link
|
|
51
121
|
is measured, checked and hashed by `FileStaging.confirm` of the kernel, which reads it back.
|
|
52
122
|
|
|
123
|
+
A key the port refuses (`../escape`) is refused with `INVALID_FILE_KEY`, and a link asked for
|
|
124
|
+
more than an hour with `INVALID_FILE_URL_EXPIRY`, before any request. Signing needs no request
|
|
125
|
+
either: a signed link is computed from the credentials, and only the provider checks it, when
|
|
126
|
+
the client uses it.
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
130
|
+
The client sends the file exactly as the link says, with the headers it was given:
|
|
131
|
+
|
|
132
|
+
<!-- checked sample -->
|
|
133
|
+
```ts
|
|
134
|
+
import type { SignedUpload } from '@ddd-ts-kit/kernel';
|
|
135
|
+
|
|
136
|
+
// In the client: the signature covers the content-type, so the headers are sent as they are.
|
|
137
|
+
export async function sendFile(link: SignedUpload, file: Blob): Promise<void> {
|
|
138
|
+
const response = await fetch(link.url, { method: link.method, headers: link.headers, body: file });
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new Error(`The storage refused the upload: ${response.status}`); // 403 once expired, or for another type
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
53
146
|
## Limits
|
|
54
147
|
|
|
55
148
|
- A signed upload link cannot cap the size of what is sent (only a presigned POST policy can):
|
|
56
149
|
its `maxBytes` is not enforced by the service, and `FileStaging.confirm` measures the file
|
|
57
150
|
against the cap of the grant the server kept and discards it beyond.
|
|
58
151
|
- Copy and move go through `CopyObject`, limited to 5 GiB per object by the protocol.
|
|
152
|
+
- An upload has at most 10 000 parts: with parts of 8 MiB, a file of about 78 GiB. Raise
|
|
153
|
+
`partSize` for larger files.
|
|
59
154
|
- Lifecycle rules, versioning, object lock and the encryption of the bucket are settings of the
|
|
60
155
|
bucket, made by the infrastructure code of the deployment, not by this adapter.
|
|
61
156
|
|
|
@@ -73,6 +168,36 @@ docker run -d --name minio -p 127.0.0.1:9000:9000 -e MINIO_ROOT_USER=ddd-ts-kit
|
|
|
73
168
|
DDD_TS_KIT_TEST_S3_ENDPOINT=http://localhost:9000 npx vitest run packages/s3
|
|
74
169
|
```
|
|
75
170
|
|
|
171
|
+
A project holds its own binding to the same suite, on the MinIO of its compose stack: each test
|
|
172
|
+
writes under a prefix of its own, so the tests share one bucket without seeing each other's
|
|
173
|
+
files.
|
|
174
|
+
|
|
175
|
+
<!-- checked sample -->
|
|
176
|
+
```ts
|
|
177
|
+
import { randomBytes } from 'node:crypto';
|
|
178
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
179
|
+
import { S3FileStorage, s3ClientOf } from '@ddd-ts-kit/s3';
|
|
180
|
+
import { fileStorageContract } from '@ddd-ts-kit/s3/testing';
|
|
181
|
+
|
|
182
|
+
const endpoint = process.env['S3_TEST_ENDPOINT'] ?? '';
|
|
183
|
+
|
|
184
|
+
// Skipped without a server; the bucket is created beforehand, as in a deployment.
|
|
185
|
+
fileStorageContract(
|
|
186
|
+
{ describe: describe.runIf(endpoint !== ''), it, expect, beforeEach },
|
|
187
|
+
{
|
|
188
|
+
name: 'S3FileStorage on MinIO',
|
|
189
|
+
createStorage: () =>
|
|
190
|
+
new S3FileStorage({
|
|
191
|
+
client: s3ClientOf({ endpoint, region: 'us-east-1', accessKeyId: 'minio', secretAccessKey: 'minio-secret' }),
|
|
192
|
+
bucket: 'acme-files-test',
|
|
193
|
+
prefix: `run-${randomBytes(6).toString('hex')}/`,
|
|
194
|
+
}),
|
|
195
|
+
// The links of a real server expire in real time.
|
|
196
|
+
elapse: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
```
|
|
200
|
+
|
|
76
201
|
## Another provider
|
|
77
202
|
|
|
78
203
|
Google Cloud Storage and Azure Blob Storage speak their own protocols. A connector for one of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ddd-ts-kit/s3",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Adapter of the kernel FileStorage port on the S3 protocol (AWS S3, MinIO, OVHcloud, Scaleway, Cloudflare R2): streamed uploads, signed links, copy, move, listing.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -30,13 +30,13 @@
|
|
|
30
30
|
"dist"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ddd-ts-kit/kernel": "0.16.
|
|
33
|
+
"@ddd-ts-kit/kernel": "0.16.1"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"@aws-sdk/client-s3": "^3.1150.0",
|
|
37
37
|
"@aws-sdk/lib-storage": "^3.1150.0",
|
|
38
38
|
"@aws-sdk/s3-request-presigner": "^3.1150.0",
|
|
39
|
-
"@ddd-ts-kit/testing": "0.16.
|
|
39
|
+
"@ddd-ts-kit/testing": "0.16.1"
|
|
40
40
|
},
|
|
41
41
|
"peerDependenciesMeta": {
|
|
42
42
|
"@aws-sdk/client-s3": {
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@aws-sdk/client-s3": "3.1150.0",
|
|
57
57
|
"@aws-sdk/lib-storage": "3.1150.0",
|
|
58
58
|
"@aws-sdk/s3-request-presigner": "3.1150.0",
|
|
59
|
-
"@ddd-ts-kit/testing": "0.16.
|
|
59
|
+
"@ddd-ts-kit/testing": "0.16.1"
|
|
60
60
|
}
|
|
61
61
|
}
|