@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.21.0",
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
- "@aws-sdk/client-s3": "^3.1045.0",
76
- "@aws-sdk/lib-storage": "^3.1045.0",
77
- "@aws-sdk/s3-request-presigner": "^3.1045.0",
78
- "@cosmicdrift/kumiko-dispatcher-live": "0.14.0",
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 @aws-sdk/lib-storage's Upload-class fuer echtes
18
- // multipart-streaming. S3 created dabei eine Multipart-Upload-Session mit
19
- // einer Upload-ID; bei normaler Completion wird sie via Complete-
20
- // MultipartUpload geschlossen.
21
- //
22
- // **Edge-Case bei Worker-Abort:** wenn der Export-Worker mid-write gecancelt
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
- // Minimal config surface everything the SDK needs, nothing framework-
49
- // specific. Apps wire this into `buildServer({ files: { storageProvider } })`
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. Keeping it testable
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
- credentials: {
83
- accessKeyId: config.accessKeyId,
84
- secretAccessKey: config.secretAccessKey,
85
- },
60
+ accessKeyId: config.accessKeyId,
61
+ secretAccessKey: config.secretAccessKey,
62
+ bucket: config.bucket,
86
63
  ...(config.endpoint !== undefined && { endpoint: config.endpoint }),
87
- forcePathStyle: resolveForcePathStyle(config),
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.send(
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 @aws-sdk/lib-storage.Upload
104
- // der Source-AsyncIterable wird chunk-weise zu S3 hochgeladen,
105
- // niemals alles im Memory aggregiert. lib-storage handled
106
- // automatisch chunking (5MB-Parts default), parallel-uploads
107
- // (4 concurrent default), und retry bei Part-Failures.
108
- //
109
- // Memory-Footprint: ~5MB pro in-flight-part × 4 concurrent =
110
- // ~20MB Heap-Bound, unabhaengig von der Total-Bundle-Size. Macht
111
- // 1GB+ Bundles moeglich ohne OOM.
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
- await upload.done();
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
- const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key }));
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
- // S3 GetObject.Body ist ein StreamingBlobPayloadOutputTypes auf
142
- // node ist das ein Readable-Stream der bereits AsyncIterable<Buffer>
143
- // ist. Wir wrappen lazy: erst beim ersten chunk-pull wird der
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 response = await client.send(
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.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
117
+ await client.delete(key);
167
118
  },
168
119
 
169
120
  async exists(key): Promise<boolean> {
170
- try {
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
- // ResponseContentDisposition is the S3 mechanism for overriding the
191
- // Content-Disposition header on the presigned GET — the browser sees
192
- // the original filename instead of the UUID storage key.
193
- const command = new GetObjectCommand({
194
- Bucket: config.bucket,
195
- Key: key,
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
- ResponseContentDisposition: options.contentDisposition,
136
+ contentDisposition: options.contentDisposition,
198
137
  }),
199
138
  });
200
- return presign(client, command, { expiresIn: expiresInSeconds });
201
139
  },
202
140
  };
203
141
  }