| 1 | import { createHash } from "node:crypto"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | import { CurrentRuntimeContext } from "../RuntimeContext.ts"; |
| 5 | import { isResource } from "../Resource.ts"; |
| 6 | import type { Bucket } from "./Bucket.ts"; |
| 7 | import { bindBackendEnvironment } from "./BackendConnection.ts"; |
| 8 | import { |
| 9 | ObjectDecodeError, |
| 10 | serializeObjectValue, |
| 11 | storageBodyBytes, |
| 12 | type Object, |
| 13 | } from "./Object.ts"; |
| 14 | import type { ReadObjectClient, ObjectValue } from "./ReadObject.ts"; |
| 15 | import type { WriteObjectClient } from "./WriteObject.ts"; |
| 16 | import { |
| 17 | makeStorageBinding, |
| 18 | StorageBindingError, |
| 19 | type StorageBindingOptions, |
| 20 | } from "./StorageBinding.ts"; |
| 21 | |
| 22 | const isBucket = (value: unknown): value is Bucket => |
| 23 | isResource(value) && value.Type === "Neon.Bucket"; |
| 24 | |
| 25 | export const makeReadObjectHttp = () => |
| 26 | Effect.gen(function* () { |
| 27 | const bind = yield* makeStorageBinding("storage:read"); |
| 28 | const runtime = yield* CurrentRuntimeContext; |
| 29 | if (!runtime) |
| 30 | return yield* Effect.die( |
| 31 | new StorageBindingError({ |
| 32 | message: "Object bindings require a Platform host", |
| 33 | }), |
| 34 | ); |
| 35 | return Effect.fn(function* <T>( |
| 36 | object: Object<T>, |
| 37 | options?: StorageBindingOptions, |
| 38 | ) { |
| 39 | const target = object.Props.bucket; |
| 40 | if (!isBucket(target)) |
| 41 | return yield* Effect.die( |
| 42 | new StorageBindingError({ |
| 43 | message: "Object bindings require a Neon.Bucket resource reference", |
| 44 | }), |
| 45 | ); |
| 46 | const bucket = yield* bind(target, options); |
| 47 | const name = yield* Effect.sync( |
| 48 | () => |
| 49 | `NEON_OBJECT_${createHash("sha256").update(object.FQN).digest("hex").slice(0, 24).toUpperCase()}_KEY`, |
| 50 | ); |
| 51 | yield* bindBackendEnvironment(`${object.FQN}:ReadObject`, { |
| 52 | [name]: object.key, |
| 53 | }); |
| 54 | const key = runtime.get<string>(name).pipe( |
| 55 | Effect.flatMap((key) => |
| 56 | key === undefined |
| 57 | ? Effect.fail( |
| 58 | new StorageBindingError({ |
| 59 | message: "Object key was not bound", |
| 60 | }), |
| 61 | ) |
| 62 | : Effect.succeed(key), |
| 63 | ), |
| 64 | ); |
| 65 | const bytes = Effect.gen(function* () { |
| 66 | const object = yield* bucket.get(yield* key); |
| 67 | return object ? yield* storageBodyBytes(object.Body) : undefined; |
| 68 | }); |
| 69 | const get = Effect.gen(function* () { |
| 70 | const body = yield* bytes; |
| 71 | if (body === undefined) return undefined; |
| 72 | if (!("value" in object.Props)) return body as ObjectValue<T>; |
| 73 | const parsed: unknown = yield* Effect.try({ |
| 74 | try: () => JSON.parse(new TextDecoder().decode(body)), |
| 75 | catch: () => |
| 76 | new ObjectDecodeError({ |
| 77 | message: "Stored object is not valid JSON", |
| 78 | }), |
| 79 | }); |
| 80 | if (!object.Props.schema) return parsed as ObjectValue<T>; |
| 81 | return (yield* Schema.decodeUnknownEffect(object.Props.schema)( |
| 82 | parsed, |
| 83 | ).pipe( |
| 84 | Effect.mapError( |
| 85 | () => |
| 86 | new ObjectDecodeError({ |
| 87 | message: "Stored JSON does not satisfy the object's schema", |
| 88 | }), |
| 89 | ), |
| 90 | )) as ObjectValue<T>; |
| 91 | }); |
| 92 | return { |
| 93 | get: () => get, |
| 94 | bytes: () => bytes, |
| 95 | } satisfies ReadObjectClient<T>; |
| 96 | }); |
| 97 | }); |
| 98 | |
| 99 | export const makeWriteObjectHttp = () => |
| 100 | Effect.gen(function* () { |
| 101 | const bind = yield* makeStorageBinding("storage:write"); |
| 102 | const runtime = yield* CurrentRuntimeContext; |
| 103 | if (!runtime) |
| 104 | return yield* Effect.die( |
| 105 | new StorageBindingError({ |
| 106 | message: "Object bindings require a Platform host", |
| 107 | }), |
| 108 | ); |
| 109 | return Effect.fn(function* <T>( |
| 110 | object: Object<T>, |
| 111 | options?: StorageBindingOptions, |
| 112 | ) { |
| 113 | const target = object.Props.bucket; |
| 114 | if (!isBucket(target)) |
| 115 | return yield* Effect.die( |
| 116 | new StorageBindingError({ |
| 117 | message: "Object bindings require a Neon.Bucket resource reference", |
| 118 | }), |
| 119 | ); |
| 120 | const bucket = yield* bind(target, options); |
| 121 | const name = yield* Effect.sync( |
| 122 | () => |
| 123 | `NEON_OBJECT_${createHash("sha256").update(object.FQN).digest("hex").slice(0, 24).toUpperCase()}_KEY`, |
| 124 | ); |
| 125 | yield* bindBackendEnvironment(`${object.FQN}:WriteObject`, { |
| 126 | [name]: object.key, |
| 127 | }); |
| 128 | const put = Effect.fn(function* ( |
| 129 | value: [T] extends [never] ? string | Uint8Array : T, |
| 130 | ) { |
| 131 | const key = yield* runtime.get<string>(name); |
| 132 | if (key === undefined) |
| 133 | return yield* new StorageBindingError({ |
| 134 | message: "Object key was not bound", |
| 135 | }); |
| 136 | const observed = yield* bucket.head(key); |
| 137 | const metadata = { |
| 138 | ContentType: observed?.ContentType, |
| 139 | CacheControl: observed?.CacheControl, |
| 140 | ContentDisposition: observed?.ContentDisposition, |
| 141 | ContentEncoding: observed?.ContentEncoding, |
| 142 | Metadata: observed?.Metadata, |
| 143 | }; |
| 144 | if (!("value" in object.Props)) { |
| 145 | return yield* bucket.put(key, value as string | Uint8Array, metadata); |
| 146 | } |
| 147 | const checked = object.Props.schema |
| 148 | ? yield* Schema.decodeUnknownEffect(object.Props.schema)(value).pipe( |
| 149 | Effect.mapError( |
| 150 | () => |
| 151 | new ObjectDecodeError({ |
| 152 | message: |
| 153 | "Written JSON does not satisfy the object's schema", |
| 154 | }), |
| 155 | ), |
| 156 | ) |
| 157 | : value; |
| 158 | return yield* bucket.put(key, yield* serializeObjectValue(checked), { |
| 159 | ...metadata, |
| 160 | ContentType: metadata.ContentType ?? "application/json", |
| 161 | }); |
| 162 | }); |
| 163 | return { put } satisfies WriteObjectClient<T>; |
| 164 | }); |
| 165 | }); |