Skip to content
Source
Sign inSign up
Open History to see the last commit

NativeClients.ts

193 lines7.8 KB

1import type * as Cloudflare from "@cloudflare/workers-types";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import { developerIdentity } from "./developerAuth.ts";
7​
8export const routePrefix = "/projects/t3code/native-clients/";
9​
10export class NativeClientsError extends Schema.TaggedError<NativeClientsError>()(
11 "NativeClientsError",
12 { status: Schema.Number, reason: Schema.String, cause: Schema.optional(Schema.Defect()) },
13) {}
14​
15export class Bindings extends Context.Service<
16 Bindings,
17 {
18 readonly CLIENTS: Cloudflare.R2Bucket;
19 readonly developerKeys: string;
20 readonly readerKeys: string;
21 readonly readLimit: Cloudflare.RateLimit;
22 readonly storeLimit: Cloudflare.RateLimit;
23 }
24>()("@t3tools/task-cache/NativeClients/Bindings") {}
25​
26export class NativeClients extends Context.Service<
27 NativeClients,
28 { readonly handle: (request: Request) => Effect.Effect<Response> }
29>()("@t3tools/task-cache/NativeClients") {}
30​
31const CompleteBody = Schema.fromJsonString(
32 Schema.Struct({
33 parts: Schema.Array(Schema.Struct({ partNumber: Schema.Number, etag: Schema.String })).check(
34 Schema.isMinLength(1),
35 Schema.isMaxLength(64),
36 ),
37 }),
38);
39const decodeComplete = Schema.decodeUnknownEffect(CompleteBody);
40​
41// <platform>/<variant>/<fingerprint>[/uploads[/<uploadId>[/<part>|/complete]]]
42const route =
43 /^((?:ios|android)\/[a-z0-9_-]{1,32}\/[a-f0-9]{40,64})(?:\/uploads(?:\/([^/]{1,1024})(?:\/(complete|[1-9][0-9]?))?)?)?$/;
44const digestPattern = /^[a-f0-9]{64}$/;
45​
46const make = Effect.gen(function* () {
47 const bindings = yield* Bindings;
48​
49 const r2 = <A>(run: () => Promise<A>) =>
50 Effect.tryPromise({
51 try: run,
52 catch: (cause) =>
53 new NativeClientsError({ status: 503, reason: "storage_unavailable", cause }),
54 });
55​
56 const limit = Effect.fn("NativeClients.limit")(function* (writing: boolean) {
57 const allowed = yield* r2(() =>
58 (writing ? bindings.storeLimit : bindings.readLimit).limit({
59 key: writing ? "t3code:native-store" : "t3code:native-read",
60 }),
61 );
62 if (!allowed.success)
63 return yield* new NativeClientsError({ status: 429, reason: "rate_limit" });
64 });
65​
66 const handle = Effect.fn("NativeClients.handle")(
67 function* (request: Request) {
68 const url = new URL(request.url);
69 const match = route.exec(url.pathname.slice(routePrefix.length));
70 if (!match || url.search || !url.pathname.startsWith(routePrefix))
71 return yield* new NativeClientsError({ status: 404, reason: "unknown_route" });
72 const [, key, uploadId, part] = match as unknown as [
73 string,
74 string,
75 string | undefined,
76 string | undefined,
77 ];
78 const operation =
79 uploadId === undefined
80 ? url.pathname.endsWith("/uploads")
81 ? "create"
82 : "download"
83 : part === undefined
84 ? "abort"
85 : part === "complete"
86 ? "complete"
87 : "part";
88 const method = {
89 download: "GET",
90 create: "POST",
91 part: "PUT",
92 complete: "POST",
93 abort: "DELETE",
94 }[operation];
95 yield* Effect.annotateCurrentSpan({ "native.operation": operation, "native.key": key });
96 if (request.method !== method)
97 return yield* new NativeClientsError({ status: 405, reason: "method_not_allowed" });
98 if (!request.headers.has("authorization"))
99 return yield* new NativeClientsError({ status: 401, reason: "missing_identity" });
100​
101 const developer = yield* developerIdentity(request, bindings.developerKeys);
102 const writing = operation !== "download";
103 if (writing) {
104 if (typeof developer !== "string")
105 return yield* new NativeClientsError({ status: 403, reason: "write_denied" });
106 } else if (
107 typeof developer !== "string" &&
108 typeof (yield* developerIdentity(request, bindings.readerKeys)) !== "string"
109 ) {
110 return yield* new NativeClientsError({ status: 403, reason: "read_denied" });
111 }
112 yield* limit(writing);
113​
114 if (operation === "download") {
115 const object = yield* r2(() => bindings.CLIENTS.get(key));
116 yield* Effect.annotateCurrentSpan({ "native.result": object ? "hit" : "miss" });
117 if (!object) return yield* new NativeClientsError({ status: 404, reason: "missing" });
118 return new Response(object.body as unknown as ReadableStream, {
119 headers: {
120 "Content-Type": "application/octet-stream",
121 "Content-Length": String(object.size),
122 "Cache-Control": "no-store",
123 // agent-device install-from-source picks the archive type from this name.
124 // iOS clients are uploaded as a tarred .app, Android ones as the bare APK.
125 "Content-Disposition": `attachment; filename="${key.split("/").at(-1)}.${key.startsWith("ios/") ? "tgz" : "apk"}"`,
126 "X-T3-Digest": object.customMetadata?.digest ?? "",
127 "X-T3-Build-Ms": object.customMetadata?.buildMs ?? "0",
128 },
129 });
130 }
131 if (operation === "create") {
132 const digest = request.headers.get("x-t3-digest") ?? "";
133 if (!digestPattern.test(digest))
134 return yield* new NativeClientsError({ status: 400, reason: "invalid_digest" });
135 if (yield* r2(() => bindings.CLIENTS.head(key))) {
136 yield* Effect.annotateCurrentSpan({ "native.result": "exists" });
137 return Response.json({ exists: true });
138 }
139 const buildMs = request.headers.get("x-t3-build-ms") ?? "";
140 const upload = yield* r2(() =>
141 bindings.CLIENTS.createMultipartUpload(key, {
142 customMetadata: {
143 digest,
144 writer: developer!,
145 buildMs: /^\d{1,10}$/.test(buildMs) ? buildMs : "0",
146 },
147 }),
148 );
149 return Response.json({ uploadId: upload.uploadId });
150 }
151​
152 const decodedId = yield* Effect.try({
153 try: () => decodeURIComponent(uploadId!),
154 catch: () => new NativeClientsError({ status: 400, reason: "invalid_upload_id" }),
155 });
156 const upload = bindings.CLIENTS.resumeMultipartUpload(key, decodedId);
157 if (operation === "abort") {
158 yield* r2(() => upload.abort());
159 return new Response(null, { status: 204 });
160 }
161 if (operation === "part") {
162 if (!request.body || !request.headers.has("content-length"))
163 return yield* new NativeClientsError({ status: 411, reason: "length_required" });
164 const uploaded = yield* r2(() =>
165 upload.uploadPart(Number(part), request.body as unknown as Cloudflare.ReadableStream),
166 );
167 return Response.json({ etag: uploaded.etag });
168 }
169 // A missing length would let text() buffer an unbounded body.
170 const length = request.headers.get("content-length");
171 if (length === null || !(Number(length) <= 64 * 1024))
172 return yield* new NativeClientsError({ status: 413, reason: "parts_too_large" });
173 const body = yield* decodeComplete(yield* r2(() => request.text())).pipe(
174 Effect.mapError(() => new NativeClientsError({ status: 400, reason: "invalid_parts" })),
175 );
176 yield* r2(() => upload.complete([...body.parts]));
177 yield* Effect.annotateCurrentSpan({ "native.result": "stored" });
178 return new Response(null, { status: 204 });
179 },
180 Effect.catchTags({
181 // The enclosing operation owns the span for this inline handler.
182 NativeClientsError: Effect.fnUntraced(function* (error) {
183 if (error.status >= 500)
184 yield* Effect.logWarning("Native client request failed", { reason: error.reason });
185 return Response.json({ error: error.reason }, { status: error.status });
186 }),
187 }),
188 );
189​
190 return NativeClients.of({ handle });
191});
192​
193export const layer = Layer.effect(NativeClients, make);