@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.
- package/LICENSE +21 -0
- package/README.md +161 -2
- package/dist/Asset.d.ts +174 -0
- package/dist/Asset.d.ts.map +1 -0
- package/dist/Asset.js +156 -0
- package/dist/Asset.js.map +1 -0
- package/dist/AssetBucket.d.ts +73 -0
- package/dist/AssetBucket.d.ts.map +1 -0
- package/dist/AssetBucket.js +19 -0
- package/dist/AssetBucket.js.map +1 -0
- package/dist/AssetDelivery.d.ts +23 -0
- package/dist/AssetDelivery.d.ts.map +1 -0
- package/dist/AssetDelivery.js +12 -0
- package/dist/AssetDelivery.js.map +1 -0
- package/dist/AssetSigning.d.ts +109 -0
- package/dist/AssetSigning.d.ts.map +1 -0
- package/dist/AssetSigning.js +93 -0
- package/dist/AssetSigning.js.map +1 -0
- package/dist/alchemy/r2.d.ts +40 -0
- package/dist/alchemy/r2.d.ts.map +1 -0
- package/dist/alchemy/r2.js +84 -0
- package/dist/alchemy/r2.js.map +1 -0
- package/dist/delivery/local.d.ts +17 -0
- package/dist/delivery/local.d.ts.map +1 -0
- package/dist/delivery/local.js +20 -0
- package/dist/delivery/local.js.map +1 -0
- package/dist/filesystem.d.ts +15 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/filesystem.js +90 -0
- package/dist/filesystem.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/bytes.d.ts +3 -0
- package/dist/internal/bytes.d.ts.map +1 -0
- package/dist/internal/bytes.js +11 -0
- package/dist/internal/bytes.js.map +1 -0
- package/dist/internal/http.d.ts +17 -0
- package/dist/internal/http.d.ts.map +1 -0
- package/dist/internal/http.js +38 -0
- package/dist/internal/http.js.map +1 -0
- package/dist/internal/sigv4.d.ts +15 -0
- package/dist/internal/sigv4.d.ts.map +1 -0
- package/dist/internal/sigv4.js +52 -0
- package/dist/internal/sigv4.js.map +1 -0
- package/dist/internal/url.d.ts +3 -0
- package/dist/internal/url.d.ts.map +1 -0
- package/dist/internal/url.js +3 -0
- package/dist/internal/url.js.map +1 -0
- package/dist/memory.d.ts +11 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +34 -0
- package/dist/memory.js.map +1 -0
- package/dist/s3.d.ts +42 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +67 -0
- package/dist/s3.js.map +1 -0
- package/dist/signing/local.d.ts +27 -0
- package/dist/signing/local.d.ts.map +1 -0
- package/dist/signing/local.js +128 -0
- package/dist/signing/local.js.map +1 -0
- package/dist/testing/AssetBucketContract.d.ts +18 -0
- package/dist/testing/AssetBucketContract.d.ts.map +1 -0
- package/dist/testing/AssetBucketContract.js +124 -0
- package/dist/testing/AssetBucketContract.js.map +1 -0
- package/dist/testing/AssetSigningContract.d.ts +18 -0
- package/dist/testing/AssetSigningContract.d.ts.map +1 -0
- package/dist/testing/AssetSigningContract.js +132 -0
- package/dist/testing/AssetSigningContract.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +103 -3
- package/src/Asset.ts +443 -0
- package/src/AssetBucket.ts +82 -0
- package/src/AssetDelivery.ts +41 -0
- package/src/AssetSigning.ts +206 -0
- package/src/alchemy/r2.ts +151 -0
- package/src/delivery/local.ts +41 -0
- package/src/filesystem.ts +166 -0
- package/src/index.ts +3 -0
- package/src/internal/bytes.ts +12 -0
- package/src/internal/http.ts +54 -0
- package/src/internal/sigv4.ts +84 -0
- package/src/internal/url.ts +2 -0
- package/src/memory.ts +65 -0
- package/src/s3.ts +138 -0
- package/src/signing/local.ts +210 -0
- package/src/testing/AssetBucketContract.ts +215 -0
- package/src/testing/AssetSigningContract.ts +254 -0
- package/src/testing/index.ts +3 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type * as Asset from "./Asset.ts";
|
|
2
|
+
import type * as AssetBucket from "./AssetBucket.ts";
|
|
3
|
+
|
|
4
|
+
import { ByteSize, Context, Duration, Effect, Option, Schema, SchemaGetter } from "effect";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A request a client sends to storage itself, such as a browser uploading a
|
|
8
|
+
* file without passing it through the app. A schema, so APIs can return it.
|
|
9
|
+
*/
|
|
10
|
+
export const SignedRequest = Schema.Struct({
|
|
11
|
+
method: Schema.Literals(["PUT", "GET"]),
|
|
12
|
+
url: Schema.String,
|
|
13
|
+
/**
|
|
14
|
+
* Headers the client must send as given: they are part of the signature.
|
|
15
|
+
* Uploads use only `uploadHeaders`.
|
|
16
|
+
*/
|
|
17
|
+
headers: Schema.Record(Schema.String, Schema.String),
|
|
18
|
+
expiresAt: Schema.DateTimeUtc,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type SignedRequest = typeof SignedRequest.Type;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The headers a signed upload may carry. Browsers send a CORS preflight
|
|
25
|
+
* before uploading with them to storage on another origin, so the bucket's
|
|
26
|
+
* CORS rules must allow them, not only `content-type`:
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* { allowedOrigins: [appOrigin], allowedMethods: ["PUT"], allowedHeaders: [...AssetSigning.uploadHeaders] }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export const uploadHeaders = ["content-type", "if-none-match", "cache-control"] as const;
|
|
33
|
+
|
|
34
|
+
export interface SignPutInput {
|
|
35
|
+
readonly key: string;
|
|
36
|
+
readonly contentType: string;
|
|
37
|
+
/** The exact length the upload must have. */
|
|
38
|
+
readonly contentLength: ByteSize.ByteSize;
|
|
39
|
+
/** Stored with the object; the client must send it as given. */
|
|
40
|
+
readonly cacheControl: string;
|
|
41
|
+
readonly expiresIn: Duration.Duration;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SignGetInput {
|
|
45
|
+
readonly key: string;
|
|
46
|
+
readonly expiresIn: Duration.Duration;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Signs requests that clients send to a bucket's storage directly. Storage
|
|
51
|
+
* must refuse a signed request that differs from what was signed: another
|
|
52
|
+
* method, key, content type, length or cache control, or one sent after it
|
|
53
|
+
* expires. Signed uploads create an object and never replace one, so a URL
|
|
54
|
+
* replayed after its upload is refused.
|
|
55
|
+
*/
|
|
56
|
+
export interface Signing {
|
|
57
|
+
readonly signPut: (
|
|
58
|
+
input: SignPutInput,
|
|
59
|
+
) => Effect.Effect<SignedRequest, AssetBucket.AssetBucketError>;
|
|
60
|
+
readonly signGet: (
|
|
61
|
+
input: SignGetInput,
|
|
62
|
+
) => Effect.Effect<SignedRequest, AssetBucket.AssetBucketError>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The service a bucket's `Signing` is provided as. */
|
|
66
|
+
export interface SigningOf<Self> {
|
|
67
|
+
readonly "~@kutur-kit/assets/AssetSigning": Self;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The key a bucket's `Signing` is provided and required by. */
|
|
71
|
+
export const of = <Self, Id extends string>(
|
|
72
|
+
bucket: AssetBucket.Tag<Self, Id>,
|
|
73
|
+
): Context.Service<SigningOf<Self>, Signing> =>
|
|
74
|
+
Context.Service<SigningOf<Self>, Signing>(`@kutur-kit/assets/AssetSigning/${bucket.id}`);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The stored object does not match the upload it should hold: it is missing,
|
|
78
|
+
* or its size or content type differs from what the client declared.
|
|
79
|
+
*/
|
|
80
|
+
export class UploadNotConfirmed extends Schema.TaggedError<
|
|
81
|
+
UploadNotConfirmed,
|
|
82
|
+
{ readonly brand: unique symbol }
|
|
83
|
+
>()(
|
|
84
|
+
"AssetUploadNotConfirmed",
|
|
85
|
+
{ asset: Schema.String, reason: Schema.Literals(["missing", "size", "contentType"]) },
|
|
86
|
+
{ httpApiStatus: 409 },
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
/** An upload a client declares before sending it: its content type and size. */
|
|
90
|
+
export interface Upload<Type extends Asset.MediaType> {
|
|
91
|
+
readonly contentType: Type;
|
|
92
|
+
readonly size: ByteSize.ByteSize;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A size in a JSON body: a whole number of bytes.
|
|
96
|
+
const Size = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(
|
|
97
|
+
Schema.decodeTo(Schema.ByteSize, {
|
|
98
|
+
decode: SchemaGetter.transform((size: number) => ByteSize.bytes(size)),
|
|
99
|
+
encode: SchemaGetter.transform((size: ByteSize.ByteSize) => Number(size)),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
/** The schema of an upload declaration, a struct apps can extend with its `fields`. */
|
|
104
|
+
export type UploadSchema<Type extends Asset.MediaType> = Schema.Struct<{
|
|
105
|
+
readonly contentType: Schema.Codec<Type, string>;
|
|
106
|
+
readonly size: typeof Size;
|
|
107
|
+
}>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decodes an upload a client declares, such as a request body, with a content
|
|
111
|
+
* type the asset accepts. Sizes over its `maxSize` are refused by
|
|
112
|
+
* `presignPut`, as `Asset.TooLarge`. Extend it for requests that carry more:
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* Schema.Struct({ ...AssetSigning.Upload(ProjectFile).fields, name: Schema.String })
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export const Upload = <Type extends Asset.MediaType, K, B>(
|
|
119
|
+
asset: Asset.Asset<string, Type, K, B>,
|
|
120
|
+
): UploadSchema<Type> => Schema.Struct({ contentType: asset.ContentType, size: Size });
|
|
121
|
+
|
|
122
|
+
export interface SignOptions {
|
|
123
|
+
/** How long the URL stays valid. 15 minutes by default. */
|
|
124
|
+
readonly expiresIn?: Duration.Input | undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const expiry = (options: SignOptions | undefined) =>
|
|
128
|
+
Duration.fromInputUnsafe(options?.expiresIn ?? "15 minutes");
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Signs a URL the client uploads the object to, bound to its content type,
|
|
132
|
+
* size and the asset's cache control.
|
|
133
|
+
*/
|
|
134
|
+
export const presignPut = <Type extends Asset.MediaType, K, B>(
|
|
135
|
+
asset: Asset.Asset<string, Type, K, B>,
|
|
136
|
+
key: K,
|
|
137
|
+
upload: Upload<Type>,
|
|
138
|
+
options?: SignOptions,
|
|
139
|
+
): Effect.Effect<
|
|
140
|
+
SignedRequest,
|
|
141
|
+
Asset.InvalidKey | Asset.UnsupportedContentType | Asset.TooLarge | AssetBucket.AssetBucketError,
|
|
142
|
+
SigningOf<B>
|
|
143
|
+
> =>
|
|
144
|
+
Effect.gen(function* () {
|
|
145
|
+
const stored = yield* asset.storedKey(key);
|
|
146
|
+
|
|
147
|
+
yield* asset.check({ contentType: upload.contentType, contentLength: upload.size });
|
|
148
|
+
|
|
149
|
+
const signing = yield* of(asset.bucket);
|
|
150
|
+
|
|
151
|
+
return yield* signing.signPut({
|
|
152
|
+
key: stored,
|
|
153
|
+
contentType: upload.contentType,
|
|
154
|
+
contentLength: upload.size,
|
|
155
|
+
cacheControl: asset.cacheControl,
|
|
156
|
+
expiresIn: expiry(options),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** Signs a URL the client downloads the object from. */
|
|
161
|
+
export const presignGet = <Type extends Asset.MediaType, K, B>(
|
|
162
|
+
asset: Asset.Asset<string, Type, K, B>,
|
|
163
|
+
key: K,
|
|
164
|
+
options?: SignOptions,
|
|
165
|
+
): Effect.Effect<SignedRequest, Asset.InvalidKey | AssetBucket.AssetBucketError, SigningOf<B>> =>
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const stored = yield* asset.storedKey(key);
|
|
168
|
+
const signing = yield* of(asset.bucket);
|
|
169
|
+
|
|
170
|
+
return yield* signing.signGet({ key: stored, expiresIn: expiry(options) });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Checks that a client's upload arrived as declared, before the app records
|
|
175
|
+
* it: the object exists with the declared size and content type.
|
|
176
|
+
*/
|
|
177
|
+
export const confirm = <Type extends Asset.MediaType, K, B>(
|
|
178
|
+
asset: Asset.Asset<string, Type, K, B>,
|
|
179
|
+
key: K,
|
|
180
|
+
upload: Upload<Type>,
|
|
181
|
+
): Effect.Effect<
|
|
182
|
+
Asset.Info<Type>,
|
|
183
|
+
Asset.InvalidKey | UploadNotConfirmed | AssetBucket.AssetBucketError,
|
|
184
|
+
B
|
|
185
|
+
> =>
|
|
186
|
+
Effect.gen(function* () {
|
|
187
|
+
const stored = yield* asset.storedKey(key);
|
|
188
|
+
const bucket = yield* asset.bucket;
|
|
189
|
+
const info = yield* bucket.head(stored);
|
|
190
|
+
|
|
191
|
+
const reason = Option.match(info, {
|
|
192
|
+
onNone: () => Option.some("missing" as const),
|
|
193
|
+
onSome: ({ size, contentType }) =>
|
|
194
|
+
size !== upload.size
|
|
195
|
+
? Option.some("size" as const)
|
|
196
|
+
: contentType === upload.contentType
|
|
197
|
+
? Option.none()
|
|
198
|
+
: Option.some("contentType" as const),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return yield* Option.match(reason, {
|
|
202
|
+
onNone: () => Effect.succeed({ size: upload.size, contentType: upload.contentType }),
|
|
203
|
+
onSome: (because) =>
|
|
204
|
+
Effect.fail(new UploadNotConfirmed({ asset: asset.id, reason: because })),
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type * as AssetSigning from "../AssetSigning.ts";
|
|
2
|
+
import type { Bucket, BucketCorsRule } from "alchemy/Cloudflare/R2/Bucket";
|
|
3
|
+
import type { PutOptions, R2Object } from "alchemy/Cloudflare/R2/BucketTypes";
|
|
4
|
+
import type { WorkerEnvironment } from "alchemy/Cloudflare/Workers/Worker";
|
|
5
|
+
|
|
6
|
+
import * as Credentials from "@distilled.cloud/aws/Credentials";
|
|
7
|
+
import { ReadWriteBucket } from "alchemy/Cloudflare/R2/ReadWriteBucket";
|
|
8
|
+
import { S3Credentials } from "alchemy/Cloudflare/R2/S3Credentials";
|
|
9
|
+
import { RuntimeContext } from "alchemy/RuntimeContext";
|
|
10
|
+
import { ByteSize, Effect, Layer, Option, Stream } from "effect";
|
|
11
|
+
|
|
12
|
+
import * as AssetBucket from "../AssetBucket.ts";
|
|
13
|
+
import * as AssetSigningModule from "../AssetSigning.ts";
|
|
14
|
+
import { concat } from "../internal/bytes.ts";
|
|
15
|
+
import * as SigV4 from "../internal/sigv4.ts";
|
|
16
|
+
|
|
17
|
+
/** An R2 bucket resource, or the Effect that declares it. */
|
|
18
|
+
export type R2Bucket<R = never> = Bucket | Effect.Effect<Bucket, never, R>;
|
|
19
|
+
|
|
20
|
+
// Alchemy marks binding methods as callable only while handling a request.
|
|
21
|
+
// `Bucket` methods carry no requirements, so the marker is satisfied here,
|
|
22
|
+
// as alchemy does where it implements other interfaces. The methods read
|
|
23
|
+
// nothing from it, but they fail if called while a Worker is constructed.
|
|
24
|
+
const handling = <A, E>(effect: Effect.Effect<A, E, RuntimeContext>): Effect.Effect<A, E> =>
|
|
25
|
+
Effect.provide(effect, RuntimeContext.phantom);
|
|
26
|
+
|
|
27
|
+
/** The HTTP metadata R2 stores with an object and serves with it. */
|
|
28
|
+
type HttpMetadata = Exclude<NonNullable<PutOptions["httpMetadata"]>, Headers>;
|
|
29
|
+
|
|
30
|
+
const infoOf = (object: R2Object): AssetBucket.Info => ({
|
|
31
|
+
size: ByteSize.bytes(object.size),
|
|
32
|
+
contentType: object.httpMetadata?.contentType ?? "application/octet-stream",
|
|
33
|
+
cacheControl: object.httpMetadata?.cacheControl,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Backs a bucket with an R2 bucket through alchemy's `ReadWriteBucket`.
|
|
38
|
+
* Provide its implementation for where the app runs: `ReadWriteBucketBinding`
|
|
39
|
+
* in a Worker, `ReadWriteBucketHttp` elsewhere alchemy deploys. Content type
|
|
40
|
+
* and cache control are stored as the object's HTTP metadata, which R2
|
|
41
|
+
* serves on its public domains.
|
|
42
|
+
*
|
|
43
|
+
* Bodies are collected before they are sent, so a failing body sends
|
|
44
|
+
* nothing. Uploads through the app are small by `maxSize`; larger files go
|
|
45
|
+
* to storage directly, through `signing`.
|
|
46
|
+
*/
|
|
47
|
+
export const layer = <Self, Id extends string, R = never>(
|
|
48
|
+
bucket: AssetBucket.Tag<Self, Id>,
|
|
49
|
+
r2: R2Bucket<R>,
|
|
50
|
+
): Layer.Layer<Self, never, ReadWriteBucket | R> =>
|
|
51
|
+
Layer.effect(
|
|
52
|
+
bucket,
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const client = yield* ReadWriteBucket(r2);
|
|
55
|
+
|
|
56
|
+
const fail = (operation: AssetBucket.AssetBucketError["operation"], key: string) =>
|
|
57
|
+
Effect.mapError(
|
|
58
|
+
(cause: unknown) =>
|
|
59
|
+
new AssetBucket.AssetBucketError({ operation, bucket: bucket.id, key, cause }),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return bucket.of({
|
|
63
|
+
put: ({ key, contentType, cacheControl, body }) =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const bytes = concat(yield* Stream.runCollect(body));
|
|
66
|
+
const httpMetadata: HttpMetadata = { contentType };
|
|
67
|
+
|
|
68
|
+
if (cacheControl !== undefined) httpMetadata.cacheControl = cacheControl;
|
|
69
|
+
|
|
70
|
+
const object = yield* handling(client.put(key, bytes, { httpMetadata })).pipe(
|
|
71
|
+
fail("put", key),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return { ...infoOf(object), contentType, cacheControl };
|
|
75
|
+
}),
|
|
76
|
+
get: (key) =>
|
|
77
|
+
handling(client.get(key)).pipe(
|
|
78
|
+
Effect.map((object) =>
|
|
79
|
+
object === null
|
|
80
|
+
? Option.none()
|
|
81
|
+
: Option.some({
|
|
82
|
+
info: infoOf(object),
|
|
83
|
+
body: Stream.mapError(
|
|
84
|
+
object.body,
|
|
85
|
+
(cause) =>
|
|
86
|
+
new AssetBucket.AssetBucketError({
|
|
87
|
+
operation: "get",
|
|
88
|
+
bucket: bucket.id,
|
|
89
|
+
key,
|
|
90
|
+
cause,
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
fail("get", key),
|
|
96
|
+
),
|
|
97
|
+
head: (key) =>
|
|
98
|
+
handling(client.head(key)).pipe(
|
|
99
|
+
Effect.map((object) => (object === null ? Option.none() : Option.some(infoOf(object)))),
|
|
100
|
+
fail("head", key),
|
|
101
|
+
),
|
|
102
|
+
delete: (key) => handling(client.delete(key)).pipe(fail("delete", key)),
|
|
103
|
+
});
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Signs URLs for an R2 bucket from a Worker, with the S3 credentials alchemy
|
|
109
|
+
* derives for it: a token scoped to the bucket when deployed, the local S3
|
|
110
|
+
* endpoint under `alchemy dev`. Signed as `S3Assets.signing` signs: uploads
|
|
111
|
+
* are bound to their content type, length and cache control, and write once.
|
|
112
|
+
*
|
|
113
|
+
* Outside a Worker, use `S3Assets.signing` with `S3Assets.r2Endpoint` and
|
|
114
|
+
* the S3 keys of an R2 API token.
|
|
115
|
+
*/
|
|
116
|
+
export const signing = <Self, Id extends string, R = never>(
|
|
117
|
+
bucket: AssetBucket.Tag<Self, Id>,
|
|
118
|
+
r2: R2Bucket<R>,
|
|
119
|
+
): Layer.Layer<AssetSigning.SigningOf<Self>, never, WorkerEnvironment | R> =>
|
|
120
|
+
Layer.effect(
|
|
121
|
+
AssetSigningModule.of(bucket),
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
const credentials = handling(yield* S3Credentials(r2, { access: "read-write" }));
|
|
124
|
+
|
|
125
|
+
return yield* SigV4.make(
|
|
126
|
+
bucket.id,
|
|
127
|
+
Effect.map(credentials, ({ endpoint, bucketName }) => ({ endpoint, bucket: bucketName })),
|
|
128
|
+
).pipe(
|
|
129
|
+
Effect.provideService(
|
|
130
|
+
Credentials.Credentials,
|
|
131
|
+
Effect.map(credentials, ({ accessKeyId, secretAccessKey, region }) =>
|
|
132
|
+
Credentials.fromAwsCredentialIdentity({ accessKeyId, secretAccessKey }, region),
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The CORS rule an R2 bucket needs for browsers on `origins` to upload to it
|
|
141
|
+
* with signed URLs: `PUT` with `AssetSigning.uploadHeaders`.
|
|
142
|
+
*
|
|
143
|
+
* ```ts
|
|
144
|
+
* Cloudflare.R2.Bucket("Uploads", { cors: [AssetsR2.corsRule(["https://app.example.com"])] })
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export const corsRule = (origins: ReadonlyArray<string>): BucketCorsRule => ({
|
|
148
|
+
allowedMethods: ["PUT"],
|
|
149
|
+
allowedOrigins: [...origins],
|
|
150
|
+
allowedHeaders: [...AssetSigningModule.uploadHeaders],
|
|
151
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type * as AssetBucket from "../AssetBucket.ts";
|
|
2
|
+
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
5
|
+
|
|
6
|
+
import { mount, respond, target } from "../internal/http.ts";
|
|
7
|
+
|
|
8
|
+
export interface Options {
|
|
9
|
+
/**
|
|
10
|
+
* Where `routes` are mounted, such as `/cdn/public` or
|
|
11
|
+
* `http://localhost:3000/cdn/public`. Give `AssetDelivery.layer` the same
|
|
12
|
+
* base URL, so the public URLs it makes reach the routes.
|
|
13
|
+
*/
|
|
14
|
+
readonly baseUrl: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Serves every object of a bucket under `baseUrl` without a signature, as a
|
|
19
|
+
* CDN serves a public bucket, for development and tests. Objects are served
|
|
20
|
+
* with the cache control stored with them, as R2 and S3 serve it.
|
|
21
|
+
*/
|
|
22
|
+
export const routes = <Self, Id extends string>(
|
|
23
|
+
bucket: AssetBucket.Tag<Self, Id>,
|
|
24
|
+
options: Options,
|
|
25
|
+
) => {
|
|
26
|
+
const { prefix, path } = mount(options.baseUrl);
|
|
27
|
+
|
|
28
|
+
return HttpRouter.add(
|
|
29
|
+
"GET",
|
|
30
|
+
path,
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const { key } = yield* target(prefix);
|
|
33
|
+
const store = yield* bucket;
|
|
34
|
+
|
|
35
|
+
return yield* Option.match(key, {
|
|
36
|
+
onNone: () => Effect.succeed(HttpServerResponse.empty({ status: 404 })),
|
|
37
|
+
onSome: (value) => respond(store, value),
|
|
38
|
+
});
|
|
39
|
+
}).pipe(Effect.orDie),
|
|
40
|
+
);
|
|
41
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
|
|
2
|
+
|
|
3
|
+
import * as AssetBucket from "./AssetBucket.ts";
|
|
4
|
+
|
|
5
|
+
export interface Options {
|
|
6
|
+
/** The directory holding the bucket's files. It is created when needed. */
|
|
7
|
+
readonly directory: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// What a stored object's record holds. `file` names the body's file, a new
|
|
11
|
+
// one for each write, so a replacement is visible only once its record is.
|
|
12
|
+
const Entry = Schema.Struct({
|
|
13
|
+
key: Schema.String,
|
|
14
|
+
contentType: Schema.String,
|
|
15
|
+
cacheControl: Schema.optional(Schema.String),
|
|
16
|
+
size: Schema.ByteSize,
|
|
17
|
+
file: Schema.String,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const EntryJson = Schema.fromJsonString(Schema.toCodecJson(Entry));
|
|
21
|
+
|
|
22
|
+
const infoOf = (entry: typeof Entry.Type): AssetBucket.Info => ({
|
|
23
|
+
size: entry.size,
|
|
24
|
+
contentType: entry.contentType,
|
|
25
|
+
cacheControl: entry.cacheControl,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
class BodyFailure<E> {
|
|
29
|
+
constructor(readonly error: E) {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hex = (bytes: ArrayBuffer) =>
|
|
33
|
+
Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Backs a bucket with files under a directory, for development and tests
|
|
37
|
+
* without object storage. Keys are stored by their SHA-256, so any key fits
|
|
38
|
+
* any file system: case-insensitive ones and Windows included. Writes are
|
|
39
|
+
* atomic for one process; concurrent writers to one key may leave unused
|
|
40
|
+
* files behind.
|
|
41
|
+
*/
|
|
42
|
+
export const layer = <Self, Id extends string>(
|
|
43
|
+
bucket: AssetBucket.Tag<Self, Id>,
|
|
44
|
+
options: Options,
|
|
45
|
+
): Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path> =>
|
|
46
|
+
Layer.effect(
|
|
47
|
+
bucket,
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const fs = yield* FileSystem.FileSystem;
|
|
50
|
+
const path = yield* Path.Path;
|
|
51
|
+
|
|
52
|
+
const failure =
|
|
53
|
+
(operation: AssetBucket.AssetBucketError["operation"], key: string) => (cause: unknown) =>
|
|
54
|
+
new AssetBucket.AssetBucketError({ operation, bucket: bucket.id, key, cause });
|
|
55
|
+
|
|
56
|
+
// Two hex characters of the hash shard the directory, as Git does.
|
|
57
|
+
const locate = (key: string) =>
|
|
58
|
+
Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(key))).pipe(
|
|
59
|
+
Effect.map((digest) => {
|
|
60
|
+
const hash = hex(digest);
|
|
61
|
+
const directory = path.join(options.directory, hash.slice(0, 2));
|
|
62
|
+
|
|
63
|
+
return { directory, hash, record: path.join(directory, `${hash}.json`) };
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const read = (key: string, operation: AssetBucket.AssetBucketError["operation"]) =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const { directory, record } = yield* locate(key);
|
|
70
|
+
|
|
71
|
+
const json = yield* fs.readFileString(record).pipe(
|
|
72
|
+
Effect.asSome,
|
|
73
|
+
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeedNone),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (Option.isNone(json)) return Option.none();
|
|
77
|
+
|
|
78
|
+
const entry = yield* Schema.decodeEffect(EntryJson)(json.value);
|
|
79
|
+
|
|
80
|
+
return Option.some({ entry, file: path.join(directory, entry.file) });
|
|
81
|
+
}).pipe(Effect.mapError(failure(operation, key)));
|
|
82
|
+
|
|
83
|
+
return bucket.of({
|
|
84
|
+
put: ({ key, contentType, cacheControl, body }) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const { directory, hash, record } = yield* locate(key).pipe(
|
|
87
|
+
Effect.tap(({ directory }) => fs.makeDirectory(directory, { recursive: true })),
|
|
88
|
+
Effect.mapError(failure("put", key)),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const previous = yield* read(key, "put");
|
|
92
|
+
const version = crypto.randomUUID();
|
|
93
|
+
const file = `${hash}.${version}`;
|
|
94
|
+
const target = path.join(directory, file);
|
|
95
|
+
|
|
96
|
+
// Body errors pass through the sink, so they are wrapped to keep
|
|
97
|
+
// them apart from the file system's.
|
|
98
|
+
const size = yield* Stream.run(
|
|
99
|
+
Stream.mapError(body, (error) => new BodyFailure(error)),
|
|
100
|
+
fs.sink(target),
|
|
101
|
+
).pipe(
|
|
102
|
+
Effect.andThen(fs.stat(target)),
|
|
103
|
+
Effect.mapError((error) =>
|
|
104
|
+
error instanceof BodyFailure ? error.error : failure("put", key)(error),
|
|
105
|
+
),
|
|
106
|
+
Effect.map((info) => info.size),
|
|
107
|
+
Effect.onError(() => Effect.ignore(fs.remove(target, { force: true }))),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const entry = { key, contentType, cacheControl, size, file };
|
|
111
|
+
const staged = `${record}.${version}`;
|
|
112
|
+
|
|
113
|
+
yield* Schema.encodeEffect(EntryJson)(entry).pipe(
|
|
114
|
+
Effect.flatMap((json) => fs.writeFileString(staged, json)),
|
|
115
|
+
Effect.andThen(fs.rename(staged, record)),
|
|
116
|
+
Effect.onError(() =>
|
|
117
|
+
Effect.ignore(
|
|
118
|
+
Effect.all([
|
|
119
|
+
fs.remove(staged, { force: true }),
|
|
120
|
+
fs.remove(target, { force: true }),
|
|
121
|
+
]),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
Effect.mapError(failure("put", key)),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// The replaced body is no longer referenced. Leaving it on failure
|
|
128
|
+
// costs only disk space.
|
|
129
|
+
yield* Option.match(previous, {
|
|
130
|
+
onNone: () => Effect.void,
|
|
131
|
+
onSome: ({ file }) => Effect.ignore(fs.remove(file, { force: true })),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return { size: entry.size, contentType, cacheControl };
|
|
135
|
+
}),
|
|
136
|
+
get: (key) =>
|
|
137
|
+
Effect.map(
|
|
138
|
+
read(key, "get"),
|
|
139
|
+
Option.map(({ entry, file }) => ({
|
|
140
|
+
info: infoOf(entry),
|
|
141
|
+
body: Stream.mapError(fs.stream(file), failure("get", key)),
|
|
142
|
+
})),
|
|
143
|
+
),
|
|
144
|
+
head: (key) =>
|
|
145
|
+
Effect.map(
|
|
146
|
+
read(key, "head"),
|
|
147
|
+
Option.map(({ entry }) => infoOf(entry)),
|
|
148
|
+
),
|
|
149
|
+
delete: (key) =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const stored = yield* read(key, "delete");
|
|
152
|
+
|
|
153
|
+
if (Option.isNone(stored)) return;
|
|
154
|
+
|
|
155
|
+
const { record } = yield* locate(key);
|
|
156
|
+
|
|
157
|
+
yield* fs
|
|
158
|
+
.remove(record, { force: true })
|
|
159
|
+
.pipe(
|
|
160
|
+
Effect.andThen(fs.remove(stored.value.file, { force: true })),
|
|
161
|
+
Effect.mapError(failure("delete", key)),
|
|
162
|
+
);
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
}),
|
|
166
|
+
);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Joins chunks into one array. */
|
|
2
|
+
export const concat = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => {
|
|
3
|
+
const bytes = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0));
|
|
4
|
+
let offset = 0;
|
|
5
|
+
|
|
6
|
+
for (const chunk of chunks) {
|
|
7
|
+
bytes.set(chunk, offset);
|
|
8
|
+
offset += chunk.length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return bytes;
|
|
12
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type * as AssetBucket from "../AssetBucket.ts";
|
|
2
|
+
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
5
|
+
|
|
6
|
+
/** The routes' prefix and wildcard path under a base URL, absolute or not. */
|
|
7
|
+
export const mount = (baseUrl: string) => {
|
|
8
|
+
const prefix = new URL(baseUrl, "http://localhost").pathname.replace(/\/$/u, "");
|
|
9
|
+
|
|
10
|
+
return { prefix, path: `/${prefix.slice(1)}/*` as const };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** The current request's key under the prefix, and its query; no key for a malformed path. */
|
|
14
|
+
export const target = (prefix: string) =>
|
|
15
|
+
Effect.map(HttpServerRequest.HttpServerRequest, (request) => {
|
|
16
|
+
const url = new URL(request.url, "http://localhost");
|
|
17
|
+
|
|
18
|
+
const key = Option.liftThrowable(() =>
|
|
19
|
+
url.pathname
|
|
20
|
+
.slice(prefix.length + 1)
|
|
21
|
+
.split("/")
|
|
22
|
+
.map(decodeURIComponent)
|
|
23
|
+
.join("/"),
|
|
24
|
+
)();
|
|
25
|
+
|
|
26
|
+
return { request, key, query: url.searchParams };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Objects are served from the app's origin in development. Even a file a
|
|
30
|
+
// browser would read as something else must not be sniffed or run.
|
|
31
|
+
const inert = {
|
|
32
|
+
"x-content-type-options": "nosniff",
|
|
33
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Serves a stored object with its type, length and cache control, or 404. */
|
|
37
|
+
export const respond = (bucket: AssetBucket.Bucket, key: string) =>
|
|
38
|
+
Effect.map(
|
|
39
|
+
bucket.get(key),
|
|
40
|
+
Option.match({
|
|
41
|
+
onNone: () => HttpServerResponse.empty({ status: 404 }),
|
|
42
|
+
onSome: ({ info, body }) => {
|
|
43
|
+
const response = HttpServerResponse.stream(body, {
|
|
44
|
+
contentType: info.contentType,
|
|
45
|
+
contentLength: Number(info.size),
|
|
46
|
+
headers: inert,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return info.cacheControl === undefined
|
|
50
|
+
? response
|
|
51
|
+
: HttpServerResponse.setHeader(response, "cache-control", info.cacheControl);
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
);
|