@kutur-kit/assets 0.0.0-stage → 0.1.0-alpha.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -2
  3. package/dist/Asset.d.ts +174 -0
  4. package/dist/Asset.d.ts.map +1 -0
  5. package/dist/Asset.js +156 -0
  6. package/dist/Asset.js.map +1 -0
  7. package/dist/AssetBucket.d.ts +73 -0
  8. package/dist/AssetBucket.d.ts.map +1 -0
  9. package/dist/AssetBucket.js +19 -0
  10. package/dist/AssetBucket.js.map +1 -0
  11. package/dist/AssetDelivery.d.ts +23 -0
  12. package/dist/AssetDelivery.d.ts.map +1 -0
  13. package/dist/AssetDelivery.js +12 -0
  14. package/dist/AssetDelivery.js.map +1 -0
  15. package/dist/AssetSigning.d.ts +109 -0
  16. package/dist/AssetSigning.d.ts.map +1 -0
  17. package/dist/AssetSigning.js +93 -0
  18. package/dist/AssetSigning.js.map +1 -0
  19. package/dist/alchemy/r2.d.ts +40 -0
  20. package/dist/alchemy/r2.d.ts.map +1 -0
  21. package/dist/alchemy/r2.js +84 -0
  22. package/dist/alchemy/r2.js.map +1 -0
  23. package/dist/delivery/local.d.ts +17 -0
  24. package/dist/delivery/local.d.ts.map +1 -0
  25. package/dist/delivery/local.js +20 -0
  26. package/dist/delivery/local.js.map +1 -0
  27. package/dist/filesystem.d.ts +15 -0
  28. package/dist/filesystem.d.ts.map +1 -0
  29. package/dist/filesystem.js +90 -0
  30. package/dist/filesystem.js.map +1 -0
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +3 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/internal/bytes.d.ts +3 -0
  36. package/dist/internal/bytes.d.ts.map +1 -0
  37. package/dist/internal/bytes.js +11 -0
  38. package/dist/internal/bytes.js.map +1 -0
  39. package/dist/internal/http.d.ts +17 -0
  40. package/dist/internal/http.d.ts.map +1 -0
  41. package/dist/internal/http.js +38 -0
  42. package/dist/internal/http.js.map +1 -0
  43. package/dist/internal/sigv4.d.ts +15 -0
  44. package/dist/internal/sigv4.d.ts.map +1 -0
  45. package/dist/internal/sigv4.js +52 -0
  46. package/dist/internal/sigv4.js.map +1 -0
  47. package/dist/internal/url.d.ts +3 -0
  48. package/dist/internal/url.d.ts.map +1 -0
  49. package/dist/internal/url.js +3 -0
  50. package/dist/internal/url.js.map +1 -0
  51. package/dist/memory.d.ts +11 -0
  52. package/dist/memory.d.ts.map +1 -0
  53. package/dist/memory.js +34 -0
  54. package/dist/memory.js.map +1 -0
  55. package/dist/s3.d.ts +42 -0
  56. package/dist/s3.d.ts.map +1 -0
  57. package/dist/s3.js +67 -0
  58. package/dist/s3.js.map +1 -0
  59. package/dist/signing/local.d.ts +27 -0
  60. package/dist/signing/local.d.ts.map +1 -0
  61. package/dist/signing/local.js +128 -0
  62. package/dist/signing/local.js.map +1 -0
  63. package/dist/testing/AssetBucketContract.d.ts +18 -0
  64. package/dist/testing/AssetBucketContract.d.ts.map +1 -0
  65. package/dist/testing/AssetBucketContract.js +124 -0
  66. package/dist/testing/AssetBucketContract.js.map +1 -0
  67. package/dist/testing/AssetSigningContract.d.ts +18 -0
  68. package/dist/testing/AssetSigningContract.d.ts.map +1 -0
  69. package/dist/testing/AssetSigningContract.js +132 -0
  70. package/dist/testing/AssetSigningContract.js.map +1 -0
  71. package/dist/testing/index.d.ts +3 -0
  72. package/dist/testing/index.d.ts.map +1 -0
  73. package/dist/testing/index.js +3 -0
  74. package/dist/testing/index.js.map +1 -0
  75. package/package.json +103 -3
  76. package/src/Asset.ts +443 -0
  77. package/src/AssetBucket.ts +82 -0
  78. package/src/AssetDelivery.ts +41 -0
  79. package/src/AssetSigning.ts +206 -0
  80. package/src/alchemy/r2.ts +151 -0
  81. package/src/delivery/local.ts +41 -0
  82. package/src/filesystem.ts +166 -0
  83. package/src/index.ts +3 -0
  84. package/src/internal/bytes.ts +12 -0
  85. package/src/internal/http.ts +54 -0
  86. package/src/internal/sigv4.ts +84 -0
  87. package/src/internal/url.ts +2 -0
  88. package/src/memory.ts +65 -0
  89. package/src/s3.ts +138 -0
  90. package/src/signing/local.ts +210 -0
  91. package/src/testing/AssetBucketContract.ts +215 -0
  92. package/src/testing/AssetSigningContract.ts +254 -0
  93. package/src/testing/index.ts +3 -0
package/src/Asset.ts ADDED
@@ -0,0 +1,443 @@
1
+ import type * as AssetBucket from "./AssetBucket.ts";
2
+
3
+ import { ByteSize, Effect, Option, Schema, SchemaGetter, Stream } from "effect";
4
+
5
+ /**
6
+ * A media type without parameters, such as `image/png`. Only the top-level
7
+ * type is checked, which catches typos without a registry of every subtype.
8
+ */
9
+ export type MediaType = `${
10
+ | "application"
11
+ | "audio"
12
+ | "font"
13
+ | "image"
14
+ | "model"
15
+ | "text"
16
+ | "video"}/${string}`;
17
+
18
+ // S3 and R2 limit keys to 1024 bytes, prefixes included. Keys are ASCII, so
19
+ // bytes are characters.
20
+ // https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
21
+ // https://developers.cloudflare.com/r2/platform/limits/
22
+ const maxKeyLength = 1024;
23
+
24
+ // S3's safe characters, without `*`, which Windows file names forbid. Keys made
25
+ // of these need no encoding in URLs or file system paths. S3 also advises
26
+ // against `.` and `..` segments, which tools resolve as relative paths.
27
+ // https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html#object-key-guidelines
28
+ // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
29
+ const segmentPattern = /^[0-9A-Za-z!\-_.'()]+$/;
30
+
31
+ // Stores that keep objects as files, such as RustFS, refuse longer segments,
32
+ // as file systems limit names to 255 bytes.
33
+ const maxSegmentLength = 255;
34
+
35
+ const isSegment = (segment: string) =>
36
+ segmentPattern.test(segment) &&
37
+ segment.length <= maxSegmentLength &&
38
+ segment !== "." &&
39
+ segment !== "..";
40
+
41
+ const portable = (prefix: string) =>
42
+ Schema.makeFilter<string>((key) => {
43
+ if (!key.split("/").every(isSegment))
44
+ return "segments must be non-empty, not `.` or `..`, at most 255 characters, and use only letters, digits and ! - _ . ' ( )";
45
+
46
+ if (prefix.length + key.length > maxKeyLength)
47
+ return `${prefix === "" ? "keys" : `keys with the prefix ${prefix}`} must fit in ${maxKeyLength} characters`;
48
+
49
+ return undefined;
50
+ });
51
+
52
+ /**
53
+ * A key that fits every store and URL without encoding: segments separated by
54
+ * `/`, each made of letters, digits and `! - _ . ' ( )`, not `.` or `..`, at
55
+ * most 255 characters each and 1024 in all. The asset id prefix also counts towards the limit,
56
+ * which each asset's `Key` checks.
57
+ */
58
+ export const Key = Schema.String.check(portable("")).pipe(Schema.brand("@kutur-kit/assets/Key"));
59
+
60
+ export type Key = typeof Key.Type;
61
+
62
+ /** Encodes an asset's keys to strings. Its encoded keys are checked like `Key`. */
63
+ export type KeySchema<K> = Schema.Codec<K, string>;
64
+
65
+ export interface Definition<Type extends MediaType = MediaType, K = Key> {
66
+ /** A bucket from `AssetBucket.Service`. */
67
+ readonly bucket: AssetBucket.Any;
68
+ /**
69
+ * The keys callers pass, such as a `UserId` or a `Schema.TemplateLiteral`
70
+ * of ids. `Key` by default.
71
+ */
72
+ readonly key?: KeySchema<K>;
73
+ /**
74
+ * Accepted media types, in lowercase and without parameters. There is no
75
+ * default: a bucket served from your domain must not accept arbitrary types
76
+ * such as `text/html`.
77
+ */
78
+ readonly contentTypes: readonly [Type, ...ReadonlyArray<Type>];
79
+ /**
80
+ * Such as `"5 MiB"` or a number of bytes. Checked while the body streams,
81
+ * before the store commits it.
82
+ */
83
+ readonly maxSize: ByteSize.Input;
84
+ /**
85
+ * The `Cache-Control` objects are stored and served with, such as
86
+ * `public, max-age=31536000, immutable` when each upload gets a new key.
87
+ * Public buckets, such as R2's on a custom domain, send it to CDNs and
88
+ * browsers.
89
+ *
90
+ * `no-cache` by default. Without one, a CDN applies its own default TTL,
91
+ * and browsers send `no-cache` with signed uploads anyway: they add it to
92
+ * requests with `If-None-Match`, which makes those write-once.
93
+ *
94
+ * CDN rules can override it, such as Cloudflare Cache Rules set to
95
+ * override origin or a CloudFront minimum TTL. Changing it here applies to
96
+ * new objects only: stored ones keep theirs.
97
+ */
98
+ readonly cacheControl?: string | undefined;
99
+ }
100
+
101
+ /** The keys an asset definition takes: its key schema's type, or `Key`. */
102
+ export type KeyOf<D> = D extends { readonly key: KeySchema<infer K> } ? K : Key;
103
+
104
+ export type ContentTypeOf<D extends Definition<MediaType, unknown>> = D["contentTypes"][number];
105
+
106
+ // Literal ids and content types are checked when compiling; `make` checks
107
+ // again at runtime, for values typed as plain strings. The message types
108
+ // surface in the compiler error.
109
+
110
+ /** An id that is one key segment, or a message naming the problem. */
111
+ export type CheckId<Id extends string> = Id extends "" | "." | ".." | `${string}/${string}`
112
+ ? "Asset ids must be one key segment, without `/` and not `.` or `..`"
113
+ : Id;
114
+
115
+ /** A content type in lowercase without parameters, or a message naming the problem. */
116
+ export type CheckContentType<Type extends string> = Type extends
117
+ | `${string};${string}`
118
+ | `${string} ${string}`
119
+ ? "Asset content types must have no parameters, such as `; charset=utf-8`"
120
+ : Type extends `${string}${UppercaseLetter}${string}`
121
+ ? "Asset content types must be lowercase"
122
+ : Type;
123
+
124
+ // Spelled out rather than compared with Lowercase<Type>, which also rejects
125
+ // types with a `${string}` part.
126
+ type UppercaseLetter = Uppercase<
127
+ | "a"
128
+ | "b"
129
+ | "c"
130
+ | "d"
131
+ | "e"
132
+ | "f"
133
+ | "g"
134
+ | "h"
135
+ | "i"
136
+ | "j"
137
+ | "k"
138
+ | "l"
139
+ | "m"
140
+ | "n"
141
+ | "o"
142
+ | "p"
143
+ | "q"
144
+ | "r"
145
+ | "s"
146
+ | "t"
147
+ | "u"
148
+ | "v"
149
+ | "w"
150
+ | "x"
151
+ | "y"
152
+ | "z"
153
+ >;
154
+
155
+ export class InvalidKey extends Schema.TaggedError<InvalidKey, { readonly brand: unique symbol }>()(
156
+ "AssetInvalidKey",
157
+ { asset: Schema.String, reason: Schema.String },
158
+ { httpApiStatus: 400 },
159
+ ) {}
160
+
161
+ export class UnsupportedContentType extends Schema.TaggedError<
162
+ UnsupportedContentType,
163
+ { readonly brand: unique symbol }
164
+ >()(
165
+ "AssetUnsupportedContentType",
166
+ { asset: Schema.String, contentType: Schema.String },
167
+ { httpApiStatus: 415 },
168
+ ) {}
169
+
170
+ /** A body is longer or shorter than the `contentLength` declared for it. */
171
+ export class ContentLengthMismatch extends Schema.TaggedError<
172
+ ContentLengthMismatch,
173
+ { readonly brand: unique symbol }
174
+ >()(
175
+ "AssetContentLengthMismatch",
176
+ { asset: Schema.String, contentLength: Schema.ByteSize },
177
+ { httpApiStatus: 400 },
178
+ ) {}
179
+
180
+ export class TooLarge extends Schema.TaggedError<TooLarge, { readonly brand: unique symbol }>()(
181
+ "AssetTooLarge",
182
+ { asset: Schema.String, maxSize: Schema.ByteSize },
183
+ { httpApiStatus: 413 },
184
+ ) {}
185
+
186
+ /**
187
+ * A stored object's content type is not one the asset accepts, such as one
188
+ * written by other code or under an older definition.
189
+ */
190
+ export class UnexpectedContentType extends Schema.TaggedError<
191
+ UnexpectedContentType,
192
+ { readonly brand: unique symbol }
193
+ >()("AssetUnexpectedContentType", {
194
+ asset: Schema.String,
195
+ /** The key in the store, with the asset id prefix. */
196
+ key: Schema.String,
197
+ contentType: Schema.String,
198
+ }) {}
199
+
200
+ export interface Info<Type extends MediaType> extends AssetBucket.Info {
201
+ readonly contentType: Type;
202
+ }
203
+
204
+ export interface StoredObject<Type extends MediaType> extends AssetBucket.StoredObject {
205
+ readonly info: Info<Type>;
206
+ }
207
+
208
+ export type ReadError = InvalidKey | UnexpectedContentType | AssetBucket.AssetBucketError;
209
+
210
+ /** What `toLayer` helpers need of an asset. */
211
+ export interface Any {
212
+ readonly id: string;
213
+ readonly bucket: AssetBucket.Any;
214
+ }
215
+
216
+ /** The bucket service an asset requires. */
217
+ export type BucketOf<A> = A extends { readonly bucket: AssetBucket.Tag<infer B> } ? B : never;
218
+
219
+ export interface Asset<Id extends string, Type extends MediaType, K, B> {
220
+ readonly id: Id;
221
+ readonly bucket: AssetBucket.Tag<B>;
222
+ readonly contentTypes: ReadonlyArray<Type>;
223
+ readonly maxSize: ByteSize.ByteSize;
224
+ readonly cacheControl: string;
225
+ /**
226
+ * Decodes an untrusted key, such as a path parameter, to one the asset
227
+ * accepts, checking that the stored key is portable.
228
+ */
229
+ readonly Key: KeySchema<K>;
230
+ /**
231
+ * Decodes an untrusted content type, such as a request header, to one the
232
+ * asset accepts. Ignores case, whitespace and parameters such as `charset`.
233
+ */
234
+ readonly ContentType: Schema.Codec<Type, string>;
235
+ readonly put: <E>(
236
+ key: K,
237
+ body: Stream.Stream<Uint8Array, E>,
238
+ options: {
239
+ readonly contentType: Type;
240
+ /**
241
+ * The body's length, such as a request's `Content-Length`. A length
242
+ * over `maxSize` fails before the body is read, and the bucket can
243
+ * stream a body of known length.
244
+ */
245
+ readonly contentLength?: ByteSize.ByteSize | undefined;
246
+ },
247
+ ) => Effect.Effect<
248
+ Info<Type>,
249
+ | InvalidKey
250
+ | UnsupportedContentType
251
+ | TooLarge
252
+ | ContentLengthMismatch
253
+ | AssetBucket.AssetBucketError
254
+ | E,
255
+ B
256
+ >;
257
+ readonly get: (key: K) => Effect.Effect<Option.Option<StoredObject<Type>>, ReadError, B>;
258
+ readonly head: (key: K) => Effect.Effect<Option.Option<Info<Type>>, ReadError, B>;
259
+ readonly delete: (key: K) => Effect.Effect<void, InvalidKey | AssetBucket.AssetBucketError, B>;
260
+ /** The key the object is stored under in the bucket: `<id>/<encoded key>`, checked. */
261
+ readonly storedKey: (key: K) => Effect.Effect<string, InvalidKey>;
262
+ /**
263
+ * Checks a content type and length as `put` does, for uploads that do not
264
+ * pass through `put`, such as to presigned URLs.
265
+ */
266
+ readonly check: (upload: {
267
+ readonly contentType: Type;
268
+ readonly contentLength: ByteSize.ByteSize;
269
+ }) => Effect.Effect<void, UnsupportedContentType | TooLarge>;
270
+ }
271
+
272
+ const essence = (contentType: string) => contentType.replace(/;.*/su, "").trim().toLowerCase();
273
+
274
+ /**
275
+ * Defines a kind of asset. Objects are stored in the definition's bucket
276
+ * under `<id>/<key>`, so several kinds can share a bucket.
277
+ */
278
+ export const make = <const Id extends string, const D extends Definition<MediaType, unknown>, B>(
279
+ checkedId: CheckId<Id>,
280
+ definition: D & {
281
+ readonly bucket: AssetBucket.Tag<B>;
282
+ readonly contentTypes: ReadonlyArray<CheckContentType<ContentTypeOf<D>>>;
283
+ },
284
+ ): Asset<Id, ContentTypeOf<D>, KeyOf<D>, B> => {
285
+ type Type = ContentTypeOf<D>;
286
+
287
+ type K = KeyOf<D>;
288
+
289
+ // SAFETY: CheckId<Id> is Id itself for every id the check below accepts.
290
+ const id = checkedId as Id;
291
+
292
+ if (!isSegment(id))
293
+ throw new Error(`Asset id must be a single key segment: ${JSON.stringify(id)}`);
294
+
295
+ const maxSize = Option.getOrThrowWith(
296
+ ByteSize.fromInput(definition.maxSize),
297
+ () => new Error(`Asset ${id} maxSize is not a byte size: ${String(definition.maxSize)}`),
298
+ );
299
+
300
+ for (const contentType of definition.contentTypes)
301
+ if (essence(contentType) !== contentType)
302
+ throw new Error(
303
+ `Asset ${id} content types must be lowercase without parameters: ${JSON.stringify(contentType)}`,
304
+ );
305
+
306
+ const cacheControl = definition.cacheControl ?? "no-cache";
307
+
308
+ const accepted: ReadonlySet<string> = new Set(definition.contentTypes);
309
+
310
+ const isAccepted = (contentType: string): contentType is Type => accepted.has(contentType);
311
+
312
+ const ContentType: Schema.Codec<Type, string> = Schema.String.pipe(
313
+ Schema.decodeTo(Schema.String, {
314
+ decode: SchemaGetter.transform(essence),
315
+ encode: SchemaGetter.passthrough(),
316
+ }),
317
+ Schema.decodeTo(Schema.Literals(definition.contentTypes)),
318
+ );
319
+
320
+ // SAFETY: KeyOf<D> is the type of the definition's key schema, or Key without one.
321
+ const keySchema = (definition.key ?? Key) as KeySchema<K>;
322
+
323
+ const AssetKey: KeySchema<K> = Schema.String.check(portable(`${id}/`)).pipe(
324
+ Schema.decodeTo(keySchema),
325
+ );
326
+
327
+ const encodeKey = Schema.encodeEffect(AssetKey);
328
+
329
+ const locate = (key: K) =>
330
+ encodeKey(key).pipe(
331
+ Effect.mapError((error) => new InvalidKey({ asset: id, reason: error.message })),
332
+ Effect.map((encoded) => `${id}/${encoded}`),
333
+ );
334
+
335
+ // Types can be bypassed, so `put` checks again what the compiler checked.
336
+ const accept = (contentType: string) =>
337
+ isAccepted(contentType)
338
+ ? Effect.void
339
+ : Effect.fail(new UnsupportedContentType({ asset: id, contentType }));
340
+
341
+ const expect = <I extends AssetBucket.Info>(key: string, info: I) =>
342
+ isAccepted(info.contentType)
343
+ ? Effect.succeed({ ...info, contentType: info.contentType })
344
+ : Effect.fail(new UnexpectedContentType({ asset: id, key, contentType: info.contentType }));
345
+
346
+ const declared = (contentLength: ByteSize.ByteSize | undefined) =>
347
+ contentLength !== undefined && contentLength > maxSize
348
+ ? Effect.fail(new TooLarge({ asset: id, maxSize }))
349
+ : Effect.void;
350
+
351
+ // Fails a body over maxSize or its declared length as soon as it is, and
352
+ // one shorter than its declared length at its end, before the bucket commits.
353
+ const measure = <E>(
354
+ body: Stream.Stream<Uint8Array, E>,
355
+ contentLength: ByteSize.ByteSize | undefined,
356
+ ): Stream.Stream<Uint8Array, E | TooLarge | ContentLengthMismatch> =>
357
+ Stream.suspend(() => {
358
+ let size = 0n;
359
+
360
+ const check = (
361
+ chunk: Uint8Array,
362
+ ): Effect.Effect<Uint8Array, TooLarge | ContentLengthMismatch> => {
363
+ size += BigInt(chunk.length);
364
+
365
+ if (size > maxSize) return Effect.fail(new TooLarge({ asset: id, maxSize }));
366
+
367
+ return contentLength !== undefined && size > contentLength
368
+ ? Effect.fail(new ContentLengthMismatch({ asset: id, contentLength }))
369
+ : Effect.succeed(chunk);
370
+ };
371
+
372
+ const counted = Stream.mapEffect(body, check);
373
+
374
+ if (contentLength === undefined) return counted;
375
+
376
+ const complete = Effect.suspend(() =>
377
+ size === contentLength
378
+ ? Effect.void
379
+ : Effect.fail(new ContentLengthMismatch({ asset: id, contentLength })),
380
+ );
381
+
382
+ return Stream.concat(counted, Stream.fromEffectDrain(complete));
383
+ });
384
+
385
+ return {
386
+ id,
387
+ bucket: definition.bucket,
388
+ contentTypes: definition.contentTypes,
389
+ maxSize,
390
+ cacheControl,
391
+ Key: AssetKey,
392
+ ContentType,
393
+ put: (key, body, { contentType, contentLength }) =>
394
+ Effect.gen(function* () {
395
+ const stored = yield* locate(key);
396
+
397
+ yield* accept(contentType);
398
+ yield* declared(contentLength);
399
+
400
+ const bucket = yield* definition.bucket;
401
+
402
+ const info = yield* bucket.put({
403
+ key: stored,
404
+ contentType,
405
+ cacheControl,
406
+ body: measure(body, contentLength),
407
+ contentLength,
408
+ });
409
+
410
+ return { ...info, contentType };
411
+ }),
412
+ get: (key) =>
413
+ Effect.gen(function* () {
414
+ const stored = yield* locate(key);
415
+ const bucket = yield* definition.bucket;
416
+ const object = yield* bucket.get(stored);
417
+
418
+ if (Option.isNone(object)) return Option.none();
419
+
420
+ const info = yield* expect(stored, object.value.info);
421
+
422
+ return Option.some({ ...object.value, info });
423
+ }),
424
+ head: (key) =>
425
+ Effect.gen(function* () {
426
+ const stored = yield* locate(key);
427
+ const bucket = yield* definition.bucket;
428
+ const info = yield* bucket.head(stored);
429
+
430
+ return Option.isNone(info) ? Option.none() : Option.some(yield* expect(stored, info.value));
431
+ }),
432
+ delete: (key) =>
433
+ Effect.gen(function* () {
434
+ const stored = yield* locate(key);
435
+ const bucket = yield* definition.bucket;
436
+
437
+ yield* bucket.delete(stored);
438
+ }),
439
+ storedKey: locate,
440
+ check: ({ contentType, contentLength }) =>
441
+ Effect.andThen(accept(contentType), declared(contentLength)),
442
+ };
443
+ };
@@ -0,0 +1,82 @@
1
+ import { type ByteSize, Context, type Effect, type Option, Schema, type Stream } from "effect";
2
+
3
+ export interface Info {
4
+ readonly size: ByteSize.ByteSize;
5
+ readonly contentType: string;
6
+ /**
7
+ * The `Cache-Control` the object is served with, such as by a CDN in front
8
+ * of a public bucket. Absent when the object has none.
9
+ */
10
+ readonly cacheControl?: string | undefined;
11
+ }
12
+
13
+ export interface StoredObject {
14
+ readonly info: Info;
15
+ /** Implementations may stream from storage, so read it at most once. */
16
+ readonly body: Stream.Stream<Uint8Array, AssetBucketError>;
17
+ }
18
+
19
+ export interface PutInput<E> {
20
+ readonly key: string;
21
+ readonly contentType: string;
22
+ /** Stored with the object, to be served with it. */
23
+ readonly cacheControl?: string | undefined;
24
+ readonly body: Stream.Stream<Uint8Array, E>;
25
+ /**
26
+ * The body's exact length, when the caller knows it. Stores that need a
27
+ * length to stream, such as R2, would otherwise buffer the body.
28
+ */
29
+ readonly contentLength?: ByteSize.ByteSize | undefined;
30
+ }
31
+
32
+ export class AssetBucketError extends Schema.TaggedError<
33
+ AssetBucketError,
34
+ { readonly brand: unique symbol }
35
+ >()("AssetBucketError", {
36
+ operation: Schema.Literals(["put", "get", "head", "delete", "sign"]),
37
+ bucket: Schema.String,
38
+ key: Schema.String,
39
+ cause: Schema.Defect(),
40
+ }) {}
41
+
42
+ /**
43
+ * Object storage for one bucket. Implementations must keep keys apart,
44
+ * including a key and the keys nested under it, replace an object on `put`,
45
+ * keep its content type and cache control,
46
+ * leave the previous object in place when a body fails and treat deleting a
47
+ * missing object as success.
48
+ */
49
+ export interface Bucket {
50
+ readonly put: <E>(input: PutInput<E>) => Effect.Effect<Info, AssetBucketError | E>;
51
+ readonly get: (key: string) => Effect.Effect<Option.Option<StoredObject>, AssetBucketError>;
52
+ readonly head: (key: string) => Effect.Effect<Option.Option<Info>, AssetBucketError>;
53
+ readonly delete: (key: string) => Effect.Effect<void, AssetBucketError>;
54
+ }
55
+
56
+ /** A logical bucket: assets name it, and a layer per environment backs it. */
57
+ export interface Tag<Self, Id extends string = string> extends Context.Service<Self, Bucket> {
58
+ // The key in the instance type keeps bucket classes apart: without it,
59
+ // classes from `Service` are structurally equal, and providing one bucket
60
+ // would satisfy the requirement for every other.
61
+ new (_: never): { readonly key: `@kutur-kit/assets/AssetBucket/${Id}` };
62
+ readonly id: Id;
63
+ }
64
+
65
+ /** What asset definitions need of a bucket. Its `Tag` adds the service type. */
66
+ export interface Any {
67
+ readonly id: string;
68
+ }
69
+
70
+ /**
71
+ * Declares a logical bucket as a service:
72
+ *
73
+ * ```ts
74
+ * export class Public extends AssetBucket.Service<Public>()("public") {}
75
+ * ```
76
+ *
77
+ * The id names the bucket in errors and adapters; it is not a physical bucket name.
78
+ */
79
+ export const Service =
80
+ <Self>() =>
81
+ <const Id extends string>(id: Id): Tag<Self, Id> =>
82
+ Object.assign(Context.Service<Self, Bucket>()(`@kutur-kit/assets/AssetBucket/${id}`), { id });
@@ -0,0 +1,41 @@
1
+ import type * as Asset from "./Asset.ts";
2
+ import type * as AssetBucket from "./AssetBucket.ts";
3
+
4
+ import { Context, Effect, Layer } from "effect";
5
+
6
+ import { encodeKey } from "./internal/url.ts";
7
+
8
+ /** Public URLs for a bucket's objects, such as on a CDN. Only public buckets have them. */
9
+ export interface Delivery {
10
+ readonly url: (key: string) => string;
11
+ }
12
+
13
+ /** The service a bucket's `Delivery` is provided as. */
14
+ export interface DeliveryOf<Self> {
15
+ readonly "~@kutur-kit/assets/AssetDelivery": Self;
16
+ }
17
+
18
+ /** The key a bucket's `Delivery` is provided and required by. */
19
+ export const of = <Self, Id extends string>(
20
+ bucket: AssetBucket.Tag<Self, Id>,
21
+ ): Context.Service<DeliveryOf<Self>, Delivery> =>
22
+ Context.Service<DeliveryOf<Self>, Delivery>(`@kutur-kit/assets/AssetDelivery/${bucket.id}`);
23
+
24
+ /**
25
+ * Public URLs for a bucket's objects under a base URL, such as a CDN or custom
26
+ * domain in front of the storage: `${baseUrl}/${key}`.
27
+ */
28
+ export const layer = <Self, Id extends string>(
29
+ bucket: AssetBucket.Tag<Self, Id>,
30
+ options: { readonly baseUrl: string },
31
+ ): Layer.Layer<DeliveryOf<Self>> =>
32
+ Layer.succeed(of(bucket), { url: (key) => `${options.baseUrl}/${encodeKey(key)}` });
33
+
34
+ /** The object's public URL. */
35
+ export const url = <Type extends Asset.MediaType, K, B>(
36
+ asset: Asset.Asset<string, Type, K, B>,
37
+ key: K,
38
+ ): Effect.Effect<string, Asset.InvalidKey, DeliveryOf<B>> =>
39
+ Effect.flatMap(asset.storedKey(key), (stored) =>
40
+ of(asset.bucket).useSync((delivery) => delivery.url(stored)),
41
+ );