@cosmicdrift/kumiko-bundled-features 0.21.0 → 0.21.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/package.json +5 -8
- package/src/files-provider-s3/s3-provider.ts +52 -114
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -72,13 +72,10 @@
|
|
|
72
72
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"@
|
|
76
|
-
"@
|
|
77
|
-
"@
|
|
78
|
-
"@cosmicdrift/kumiko-
|
|
79
|
-
"@cosmicdrift/kumiko-framework": "0.14.0",
|
|
80
|
-
"@cosmicdrift/kumiko-renderer": "0.14.0",
|
|
81
|
-
"@cosmicdrift/kumiko-renderer-web": "0.14.0",
|
|
75
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.21.0",
|
|
76
|
+
"@cosmicdrift/kumiko-framework": "0.21.0",
|
|
77
|
+
"@cosmicdrift/kumiko-renderer": "0.21.0",
|
|
78
|
+
"@cosmicdrift/kumiko-renderer-web": "0.21.0",
|
|
82
79
|
"@mollie/api-client": "^4.5.0",
|
|
83
80
|
"@node-rs/argon2": "^2.0.2",
|
|
84
81
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -1,29 +1,15 @@
|
|
|
1
|
-
import { Readable } from "node:stream";
|
|
2
|
-
import {
|
|
3
|
-
DeleteObjectCommand,
|
|
4
|
-
GetObjectCommand,
|
|
5
|
-
HeadObjectCommand,
|
|
6
|
-
PutObjectCommand,
|
|
7
|
-
S3Client,
|
|
8
|
-
} from "@aws-sdk/client-s3";
|
|
9
|
-
import { Upload } from "@aws-sdk/lib-storage";
|
|
10
|
-
import { getSignedUrl as presign } from "@aws-sdk/s3-request-presigner";
|
|
11
1
|
import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-framework/files";
|
|
12
2
|
|
|
13
3
|
// =============================================================================
|
|
14
4
|
// Operator-Pflicht-Setup (Multipart-Upload-Cleanup)
|
|
15
5
|
// =============================================================================
|
|
16
6
|
//
|
|
17
|
-
// `writeStream` nutzt
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// wird (Pod-Restart, K8s-OOM-Kill, Process-Signal), bleibt die Multipart-
|
|
24
|
-
// Upload-Session in S3 OFFEN. S3 behaelt die bereits hochgeladenen Parts
|
|
25
|
-
// und berechnet Storage-Kosten dafuer — bis sie manuell oder via Lifecycle-
|
|
26
|
-
// Rule abgebrochen werden.
|
|
7
|
+
// `writeStream` nutzt Bun's S3-Writer fuer echtes multipart-streaming. S3
|
|
8
|
+
// created dabei eine Multipart-Upload-Session mit einer Upload-ID; bei
|
|
9
|
+
// normaler Completion wird sie geschlossen. Wird der Export-Worker mid-write
|
|
10
|
+
// gecancelt (Pod-Restart, K8s-OOM-Kill, Process-Signal), bleibt die Session
|
|
11
|
+
// in S3 OFFEN und berechnet Storage-Kosten fuer die bereits hochgeladenen
|
|
12
|
+
// Parts — bis sie via Lifecycle-Rule abgebrochen werden.
|
|
27
13
|
//
|
|
28
14
|
// **Pflicht-Operator-Setup auf jedem Bucket:**
|
|
29
15
|
//
|
|
@@ -39,16 +25,9 @@ import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-
|
|
|
39
25
|
// AWS-CLI: `aws s3api put-bucket-lifecycle-configuration --bucket <name>
|
|
40
26
|
// --lifecycle-configuration file://lifecycle.json`. Hetzner Object Storage
|
|
41
27
|
// + R2 + Minio supporten dieselbe Syntax.
|
|
42
|
-
//
|
|
43
|
-
// **Code-side abort()** fuer graceful Worker-Shutdown ist follow-up. Das
|
|
44
|
-
// braucht Worker-Cancel-Semantik (AbortSignal-Propagation durch r.job),
|
|
45
|
-
// die im framework noch nicht existiert. Bis dahin ist die Lifecycle-
|
|
46
|
-
// Rule die einzige Garantie gegen Storage-Leakage.
|
|
47
28
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// the same way they'd pass createLocalProvider in dev.
|
|
51
|
-
//
|
|
29
|
+
const STREAM_PART_SIZE = 5 * 1024 * 1024;
|
|
30
|
+
|
|
52
31
|
// `endpoint` + `forcePathStyle` are the R2/Minio knobs: AWS-S3 uses
|
|
53
32
|
// virtual-host-style URLs (bucket.s3.region.amazonaws.com), Minio and many
|
|
54
33
|
// S3-compat providers need path-style (endpoint/bucket/key). Default
|
|
@@ -67,8 +46,7 @@ export type S3ProviderConfig = {
|
|
|
67
46
|
|
|
68
47
|
// Exported for unit testing — the branch logic (explicit override vs.
|
|
69
48
|
// auto-detect from endpoint) is small but load-bearing: Minio/R2 break
|
|
70
|
-
// silently if the virtual-host-style is picked.
|
|
71
|
-
// without constructing an S3Client means the rule stays honest.
|
|
49
|
+
// silently if the virtual-host-style is picked.
|
|
72
50
|
export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
|
|
73
51
|
// Explicit override wins; otherwise: custom endpoint → path-style
|
|
74
52
|
// (that's the shape every non-AWS S3-compatible provider expects),
|
|
@@ -77,85 +55,58 @@ export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
|
|
|
77
55
|
}
|
|
78
56
|
|
|
79
57
|
export function createS3Provider(config: S3ProviderConfig): FileStorageProvider {
|
|
80
|
-
const client = new S3Client({
|
|
58
|
+
const client = new Bun.S3Client({
|
|
81
59
|
region: config.region,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
},
|
|
60
|
+
accessKeyId: config.accessKeyId,
|
|
61
|
+
secretAccessKey: config.secretAccessKey,
|
|
62
|
+
bucket: config.bucket,
|
|
86
63
|
...(config.endpoint !== undefined && { endpoint: config.endpoint }),
|
|
87
|
-
forcePathStyle
|
|
64
|
+
// Bun's virtualHostedStyle is the inverse of the AWS-SDK forcePathStyle
|
|
65
|
+
// knob this config exposes: path-style ⇔ virtualHostedStyle=false.
|
|
66
|
+
virtualHostedStyle: !resolveForcePathStyle(config),
|
|
88
67
|
});
|
|
89
68
|
|
|
90
69
|
return {
|
|
91
70
|
async write(key, data, mimeType): Promise<void> {
|
|
92
|
-
await client.
|
|
93
|
-
new PutObjectCommand({
|
|
94
|
-
Bucket: config.bucket,
|
|
95
|
-
Key: key,
|
|
96
|
-
Body: data,
|
|
97
|
-
...(mimeType !== undefined && { ContentType: mimeType }),
|
|
98
|
-
}),
|
|
99
|
-
);
|
|
71
|
+
await client.write(key, data, mimeType !== undefined ? { type: mimeType } : undefined);
|
|
100
72
|
},
|
|
101
73
|
|
|
102
74
|
async writeStream(key, source, options): Promise<void> {
|
|
103
|
-
// Echtes multipart-streaming via
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Readable.from(source) adapiert AsyncIterable → node:Readable —
|
|
114
|
-
// lib-storage's Body-Type akzeptiert Web-ReadableStream + node-
|
|
115
|
-
// Readable, nicht direkt AsyncIterable. Adapter ist zero-copy.
|
|
116
|
-
const body = Readable.from(source);
|
|
117
|
-
const upload = new Upload({
|
|
118
|
-
client,
|
|
119
|
-
params: {
|
|
120
|
-
Bucket: config.bucket,
|
|
121
|
-
Key: key,
|
|
122
|
-
Body: body,
|
|
123
|
-
...(options?.mimeType !== undefined && { ContentType: options.mimeType }),
|
|
124
|
-
},
|
|
75
|
+
// Echtes multipart-streaming via Bun's S3-Writer — der Source-
|
|
76
|
+
// AsyncIterable wird chunk-weise hochgeladen, niemals alles im Memory.
|
|
77
|
+
// Wir flushen sobald ein Part voll ist, damit der Heap-Footprint auf
|
|
78
|
+
// ~ein Part begrenzt bleibt, unabhaengig von der Total-Bundle-Size —
|
|
79
|
+
// macht 1GB+ Exports ohne OOM moeglich.
|
|
80
|
+
const writer = client.file(key).writer({
|
|
81
|
+
...(options?.mimeType !== undefined && { type: options.mimeType }),
|
|
82
|
+
retry: 3,
|
|
83
|
+
queueSize: 4,
|
|
84
|
+
partSize: STREAM_PART_SIZE,
|
|
125
85
|
});
|
|
126
|
-
|
|
86
|
+
let buffered = 0;
|
|
87
|
+
for await (const chunk of source) {
|
|
88
|
+
// write() returns a Promise when the writer applies backpressure —
|
|
89
|
+
// awaiting it bounds the in-flight queue instead of buffering ahead.
|
|
90
|
+
buffered += await writer.write(chunk);
|
|
91
|
+
if (buffered >= STREAM_PART_SIZE) {
|
|
92
|
+
await writer.flush();
|
|
93
|
+
buffered = 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await writer.end();
|
|
127
97
|
},
|
|
128
98
|
|
|
129
99
|
async read(key): Promise<Uint8Array> {
|
|
130
|
-
|
|
131
|
-
if (!response.Body) {
|
|
132
|
-
throw new Error(`s3_read_empty_body: ${key}`);
|
|
133
|
-
}
|
|
134
|
-
// transformToByteArray is the stream-to-bytes helper the v3 SDK ships
|
|
135
|
-
// with — avoids us reinventing a ReadableStream reader. Returns a
|
|
136
|
-
// Uint8Array, which is what FileStorageProvider.read() promises.
|
|
137
|
-
return response.Body.transformToByteArray();
|
|
100
|
+
return new Uint8Array(await client.file(key).arrayBuffer());
|
|
138
101
|
},
|
|
139
102
|
|
|
140
103
|
readStream(key): AsyncIterable<Uint8Array> {
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// GetObject-Request abgesetzt. Wenn der Key nicht existiert, faellt
|
|
145
|
-
// der Error genau dort (nicht beim readStream-Aufruf) — gleiches
|
|
146
|
-
// Lazy-Verhalten wie inmemory + local.
|
|
104
|
+
// Lazy: erst beim ersten chunk-pull wird der GET-Request abgesetzt.
|
|
105
|
+
// Existiert der Key nicht, faellt der Error genau dort (nicht beim
|
|
106
|
+
// readStream-Aufruf) — gleiches Lazy-Verhalten wie inmemory + local.
|
|
147
107
|
return {
|
|
148
108
|
async *[Symbol.asyncIterator]() {
|
|
149
|
-
const
|
|
150
|
-
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
|
151
|
-
);
|
|
152
|
-
if (!response.Body) {
|
|
153
|
-
throw new Error(`s3_read_empty_body: ${key}`);
|
|
154
|
-
}
|
|
155
|
-
// SdkStream is AsyncIterable<Buffer> on node. Buffer extends
|
|
156
|
-
// Uint8Array; cast sichert die Surface ohne neue runtime-deps.
|
|
157
|
-
const body = response.Body as AsyncIterable<Uint8Array>; // @cast-boundary engine-bridge
|
|
158
|
-
for await (const chunk of body) {
|
|
109
|
+
for await (const chunk of client.file(key).stream()) {
|
|
159
110
|
yield chunk;
|
|
160
111
|
}
|
|
161
112
|
},
|
|
@@ -163,23 +114,11 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
163
114
|
},
|
|
164
115
|
|
|
165
116
|
async delete(key): Promise<void> {
|
|
166
|
-
await client.
|
|
117
|
+
await client.delete(key);
|
|
167
118
|
},
|
|
168
119
|
|
|
169
120
|
async exists(key): Promise<boolean> {
|
|
170
|
-
|
|
171
|
-
await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
172
|
-
return true;
|
|
173
|
-
} catch (error) {
|
|
174
|
-
// S3 SDK throws either NotFound or a generic 404. Check both the
|
|
175
|
-
// `.name` property (newer SDKs) and the `$metadata.httpStatusCode`
|
|
176
|
-
// (what the SDK guarantees on every error).
|
|
177
|
-
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } }; // @cast-boundary error-details
|
|
178
|
-
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
throw error;
|
|
182
|
-
}
|
|
121
|
+
return client.exists(key);
|
|
183
122
|
},
|
|
184
123
|
|
|
185
124
|
async getSignedUrl(
|
|
@@ -187,17 +126,16 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
187
126
|
expiresInSeconds: number,
|
|
188
127
|
options?: SignedUrlOptions,
|
|
189
128
|
): Promise<string> {
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
129
|
+
// contentDisposition wird von Bun als response-content-disposition
|
|
130
|
+
// Query-Param signiert (Response-Override fuer den GET-Download) —
|
|
131
|
+
// der Browser sieht den Original-Dateinamen statt des UUID-Keys.
|
|
132
|
+
return client.presign(key, {
|
|
133
|
+
expiresIn: expiresInSeconds,
|
|
134
|
+
method: "GET",
|
|
196
135
|
...(options?.contentDisposition !== undefined && {
|
|
197
|
-
|
|
136
|
+
contentDisposition: options.contentDisposition,
|
|
198
137
|
}),
|
|
199
138
|
});
|
|
200
|
-
return presign(client, command, { expiresIn: expiresInSeconds });
|
|
201
139
|
},
|
|
202
140
|
};
|
|
203
141
|
}
|