@visiolab/effect-firebase-functions 0.0.0-stage → 0.3.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.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Deploys Functions, of any kind, as Cloud Functions for Firebase, which plain
3
+ * `firebase deploy` deploys. {@link make} deploys a Function in no group under
4
+ * its name, and {@link group} deploys each member of a Function Group as
5
+ * `<group>-<name>`. Each dispatches on the Function's kind: a Callable is
6
+ * served with `onCall`, and a Document Trigger with the `onDocument*`
7
+ * provider of its change, such as `onDocumentCreated`.
8
+ *
9
+ * Each export is built at once and synchronously, and builds nothing: the
10
+ * Layer that implements it is built on the first call or event, so discovery
11
+ * and deploy never run your setup. Every Layer is built through one memo map
12
+ * per process, so a Layer shared by several exports is built once, and
13
+ * concurrent first calls share one build.
14
+ *
15
+ * Where it differs from a plain `firebase-functions` function:
16
+ *
17
+ * - Finalizers of the Layer don't run when the instance shuts down. Release
18
+ * what must be released within the call.
19
+ * - `minInstances` keeps an instance alive, but its Layer is still built on the
20
+ * first call.
21
+ * - Call `setGlobalOptions` before any function is defined, as Firebase
22
+ * requires.
23
+ *
24
+ * Every call or event logs through `CloudLogging.layer`, provided beneath the
25
+ * Layer you serve: Effect logs land in Cloud Logging as structured entries
26
+ * tied to the current span, so concurrent calls on one instance each log under
27
+ * their own trace. A `Logger.layer` in the Layer replaces it. Logs of a first
28
+ * call's Layer build are written once the build is done, when the spans they
29
+ * were logged under have their final ids. Entries carry no timestamp, so
30
+ * Cloud Logging stamps them with the time the build finished. Entries carry
31
+ * no uid; add one in the handler with `Effect.annotateLogs`.
32
+ *
33
+ * Its span's parent is the trace context Firebase reads from the request or
34
+ * event. With none, as in the emulator, the span is a root span. The span is
35
+ * always on, and is exported only when the Layer provides a `Tracer`.
36
+ *
37
+ * **Callables**
38
+ *
39
+ * A Callable's Payload and success value cross the wire as
40
+ * `Schema.toCodecJson` output, so a `Date` arrives as a `Date`.
41
+ *
42
+ * Every call runs in a `kind: "server"` span named `CallableFunction.<deployed
43
+ * name>`, such as `CallableFunction.users-create`, with the attributes
44
+ * `rpc.system: "firebase_callable"`, `rpc.method` (the Callable's name),
45
+ * `rpc.service` (the group, for a member) and `faas.name` (the deployed name).
46
+ * The span covers the Payload decoding, the handler and the encoding of the
47
+ * answer, and the Layer build on the call that triggers it. Spans the handler
48
+ * or the build open are its children. The trace context comes from the
49
+ * `X-Cloud-Trace-Context` header, else `traceparent`. The client's span of
50
+ * the same call is not linked to it, so the two land in separate traces. The
51
+ * span carries no uid; add one in the handler with `Effect.annotateCurrentSpan`.
52
+ *
53
+ * **Document Triggers**
54
+ *
55
+ * A Document Trigger listens to the `(default)` database, at the path
56
+ * pattern of its collection. Each event runs in a `kind: "server"` span named
57
+ * `DocumentTrigger.<deployed name>`. Firebase delivers each event at least
58
+ * once, so make the handler idempotent, keyed on the event's `id`.
59
+ *
60
+ * @example Deploying a Callable
61
+ * ```ts
62
+ * import { Effect, Schema } from "effect";
63
+ * import { Callable } from "@visiolab/effect-firebase/functions";
64
+ * import { Functions } from "@visiolab/effect-firebase-functions";
65
+ *
66
+ * const Greet = Callable.make("greet", {
67
+ * payload: { name: Schema.String },
68
+ * success: Schema.String,
69
+ * });
70
+ *
71
+ * const GreetLive = Callable.toLayer(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
72
+ *
73
+ * export const { greet } = Functions.make(Greet, GreetLive, { region: "europe-west1" });
74
+ * ```
75
+ *
76
+ * @stability unstable
77
+ * @since 0.3.0
78
+ */
79
+ import type { Layer } from "effect";
80
+ import type * as Callable from "@visiolab/effect-firebase/functions/Callable";
81
+ import type * as DocumentTrigger from "@visiolab/effect-firebase/functions/DocumentTrigger";
82
+ import type * as FirebaseFunction from "@visiolab/effect-firebase/functions/FirebaseFunction";
83
+ import type * as FunctionGroup from "@visiolab/effect-firebase/functions/FunctionGroup";
84
+ import type { CloudFunction } from "firebase-functions/v2";
85
+ import type * as Firestore from "firebase-functions/v2/firestore";
86
+ import type * as Https from "firebase-functions/v2/https";
87
+ import * as kinds from "./internal/kinds.ts";
88
+
89
+ /**
90
+ * The value `onCall` returns for a Callable: what firebase-tools discovers and
91
+ * deploys.
92
+ *
93
+ * @see {@link Export} for the export of a Function of any kind
94
+ *
95
+ * @stability unstable
96
+ * @category models
97
+ * @since 0.3.0
98
+ */
99
+ export type CallableExport = Https.CallableFunction<unknown, Promise<unknown>>;
100
+
101
+ /**
102
+ * The value an `onDocument*` provider returns for a Document Trigger, by the
103
+ * change it runs on: what firebase-tools discovers and deploys.
104
+ *
105
+ * **Details**
106
+ *
107
+ * A `created` trigger is exported with `onDocumentCreated`, a `deleted` one
108
+ * with `onDocumentDeleted`, an `updated` one with `onDocumentUpdated` and a
109
+ * `written` one with `onDocumentWritten`. The type is the `CloudFunction` of
110
+ * the event that provider hands over.
111
+ *
112
+ * @see {@link Export} for the export of a Function of any kind
113
+ *
114
+ * @stability unstable
115
+ * @category type-level
116
+ * @since 0.3.0
117
+ */
118
+ export type DocumentTriggerExport<T extends DocumentTrigger.Any> = CloudFunction<
119
+ Firestore.FirestoreEvent<DocumentTriggerEventData[T["event"]]>
120
+ >;
121
+
122
+ /**
123
+ * The data of the event each `onDocument*` provider hands over, by the change
124
+ * it runs on.
125
+ */
126
+ interface DocumentTriggerEventData {
127
+ readonly created: Firestore.QueryDocumentSnapshot | undefined;
128
+ readonly deleted: Firestore.QueryDocumentSnapshot | undefined;
129
+ readonly updated: Firestore.Change<Firestore.QueryDocumentSnapshot> | undefined;
130
+ readonly written: Firestore.Change<Firestore.DocumentSnapshot> | undefined;
131
+ }
132
+
133
+ /**
134
+ * The deploy options of a Document Trigger: Firebase's `DocumentOptions`
135
+ * without what the library derives from the trigger. That leaves the options
136
+ * every Function shares, such as `region`, `memory` and `timeoutSeconds`,
137
+ * plus `retry`.
138
+ *
139
+ * **Details**
140
+ *
141
+ * The `document` path pattern comes from the trigger's collection, and the
142
+ * database is always `(default)`, the one `Firestore` reads and writes, so
143
+ * `document`, `database` and `namespace` are left out, as are the event
144
+ * filter options.
145
+ *
146
+ * With `retry: true`, Firebase delivers a failed event again until it
147
+ * succeeds or its retry window ends. Without it, a failed event is dropped.
148
+ * The library adds no retry of its own.
149
+ *
150
+ * @stability unstable
151
+ * @category models
152
+ * @since 0.3.0
153
+ */
154
+ export type DocumentTriggerOptions = Omit<
155
+ Firestore.DocumentOptions,
156
+ | "document"
157
+ | "database"
158
+ | "namespace"
159
+ | "eventType"
160
+ | "eventFilters"
161
+ | "eventFilterPathPatterns"
162
+ | "channel"
163
+ >;
164
+
165
+ /**
166
+ * The export of a Function, by its kind: a {@link CallableExport} for a
167
+ * Callable, a {@link DocumentTriggerExport} for a Document Trigger.
168
+ *
169
+ * @stability unstable
170
+ * @category type-level
171
+ * @since 0.3.0
172
+ */
173
+ export type Export<F extends FirebaseFunction.Any> = F extends Callable.Any
174
+ ? CallableExport
175
+ : F extends DocumentTrigger.Any
176
+ ? DocumentTriggerExport<F>
177
+ : never;
178
+
179
+ /**
180
+ * The deploy options of a Function, by its kind: Firebase's `CallableOptions`
181
+ * for a Callable, passed to `onCall` unchanged, and
182
+ * {@link DocumentTriggerOptions} for a Document Trigger.
183
+ *
184
+ * @stability unstable
185
+ * @category type-level
186
+ * @since 0.3.0
187
+ */
188
+ export type Options<F extends FirebaseFunction.Any> = F extends Callable.Any
189
+ ? Https.CallableOptions
190
+ : F extends DocumentTrigger.Any
191
+ ? DocumentTriggerOptions
192
+ : never;
193
+
194
+ /**
195
+ * What serving a Function under the handler key `H` requires, by its kind.
196
+ * For a Callable: its handler, the implementation of each of its middleware,
197
+ * and what its middleware chain still requires. For a Document Trigger: its
198
+ * handler.
199
+ *
200
+ * @stability unstable
201
+ * @category type-level
202
+ * @since 0.3.0
203
+ */
204
+ export type Services<F extends FirebaseFunction.Any, H> = F extends Callable.Any
205
+ ? Callable.ChainServices<F, H>
206
+ : F extends DocumentTrigger.Any
207
+ ? H
208
+ : never;
209
+
210
+ /**
211
+ * What serving every member of a {@link FunctionGroup.FunctionGroup}
212
+ * requires: what each member requires, by its kind, under its handler key in
213
+ * the group.
214
+ *
215
+ * @stability unstable
216
+ * @category type-level
217
+ * @since 0.3.0
218
+ */
219
+ export type GroupServices<G extends FunctionGroup.Any> =
220
+ FunctionGroup.Names<G> extends infer Name
221
+ ? Name extends string
222
+ ? Services<FunctionGroup.Member<G, Name>, FirebaseFunction.Handler<G["identifier"], Name>>
223
+ : never
224
+ : never;
225
+
226
+ /**
227
+ * The exports of {@link make}: one export under the Function's name.
228
+ *
229
+ * @stability unstable
230
+ * @category models
231
+ * @since 0.3.0
232
+ */
233
+ export type Exports<F extends FirebaseFunction.Any> = { readonly [K in F["name"]]: Export<F> };
234
+
235
+ /**
236
+ * The exports of {@link group}: one export per member, keyed by the member's
237
+ * name, under the group's name.
238
+ *
239
+ * @stability unstable
240
+ * @category models
241
+ * @since 0.3.0
242
+ */
243
+ export type GroupExports<G extends FunctionGroup.Any> = {
244
+ readonly [K in G["identifier"]]: {
245
+ readonly [N in FunctionGroup.Names<G>]: Export<FunctionGroup.Member<G, N>>;
246
+ };
247
+ };
248
+
249
+ /**
250
+ * The options of {@link group}: each member's {@link Options}, typed by its
251
+ * kind and keyed by its name. Members left out get none.
252
+ *
253
+ * @stability unstable
254
+ * @category models
255
+ * @since 0.3.0
256
+ */
257
+ export type GroupOptions<G extends FunctionGroup.Any> = {
258
+ readonly [N in FunctionGroup.Names<G>]?: Options<FunctionGroup.Member<G, N>>;
259
+ };
260
+
261
+ /**
262
+ * Deploys a Function in no group, whatever its kind.
263
+ *
264
+ * **When to use**
265
+ *
266
+ * Use to export a Function in no group. Destructure the result into the
267
+ * module's exports, so the export's name is the Function's.
268
+ *
269
+ * **Details**
270
+ *
271
+ * `live` must provide what the Function's kind needs and leave nothing
272
+ * required. It may provide more. Give each export a Layer of its own where you
273
+ * can: a cold instance then builds only what its own function needs.
274
+ *
275
+ * `options` are typed by the Function's kind (see {@link Options}).
276
+ *
277
+ * For a Callable, `live` provides its handler, from `Callable.toLayer`, and
278
+ * the implementation of each of its middleware, such as
279
+ * `Caller.Authenticated.layer`. `Functions` provides no middleware itself.
280
+ * `options` is Firebase's `CallableOptions`, and the export is exactly what
281
+ * `onCall` returns, so its `__endpoint` and `run` work as for any `onCall`
282
+ * function.
283
+ *
284
+ * For a Document Trigger, `live` provides its handler, from
285
+ * `DocumentTrigger.toLayer`. `options` are {@link DocumentTriggerOptions}:
286
+ * the shared options plus `retry`. The export is what the provider of the
287
+ * trigger's change (`onDocumentCreated`, `onDocumentUpdated`,
288
+ * `onDocumentDeleted` or `onDocumentWritten`) returns for the `(default)`
289
+ * database and the path pattern of the trigger's collection. Each event is
290
+ * converted into the trigger's event, its data read into Firestore Values as
291
+ * a read does, a side that doesn't exist as a `Missing` snapshot, and run
292
+ * through `DocumentTrigger.serve`, in a `kind: "server"` span named
293
+ * `DocumentTrigger.<deployed name>` with `faas.name` and
294
+ * `faas.trigger: "datasource"`, logging through `CloudLogging.layer`. Any
295
+ * failure, whether a typed error, a defect, an interrupt or a Layer that
296
+ * fails to build, is logged at Error in that span and makes the function
297
+ * throw, so Firebase delivers the event again when `retry` is set. A Layer
298
+ * that failed to build is built again on the next event.
299
+ *
300
+ * Each call runs the Callable's middleware chain, the one `Callable.handler`
301
+ * runs, with Firebase's request provided as the portable `CallableRequest`.
302
+ * Its `rawRequest` is Firebase's Express request as Effect's
303
+ * `HttpServerRequest`, with the caller's IP address as `remoteAddress`. Its
304
+ * body streams Firebase's buffered bytes without copying them. The Payload is
305
+ * decoded inside the chain, after the middleware.
306
+ *
307
+ * A failed call answers with an `HttpsError`:
308
+ *
309
+ * - A declared error, or an error of a middleware, is sent with its schema's
310
+ * code, the error's `message` (else its `_tag`) and the error encoded with
311
+ * `Schema.toCodecJson` as `details`, so the client decodes it back into its
312
+ * class. A middleware that rejects the call answers before the Payload is
313
+ * decoded.
314
+ * - A Payload that doesn't decode is answered with `invalid-argument`, a
315
+ * generic message and no details. It is logged at Debug.
316
+ * - A defect, or a success or error value that doesn't encode, is answered
317
+ * with a bare `internal`. Its Cause is logged at Error, with the logger of
318
+ * `live` if it provides one.
319
+ * - A Layer that fails to build answers the call with a bare `internal` and
320
+ * is logged at Error. The next call builds it again.
321
+ * - An interrupt of the handler is answered with `unavailable`. When the
322
+ * client disconnects, the handler is interrupted and the call is answered
323
+ * with `cancelled`, as far as the platform reports the disconnect.
324
+ *
325
+ * @example Exporting a Callable
326
+ * ```ts
327
+ * import { Effect } from "effect";
328
+ * import { Callable } from "@visiolab/effect-firebase/functions";
329
+ * import { Functions } from "@visiolab/effect-firebase-functions";
330
+ *
331
+ * const Ping = Callable.make("ping");
332
+ *
333
+ * export const { ping } = Functions.make(Ping, Callable.toLayer(Ping, () => Effect.void));
334
+ * ```
335
+ *
336
+ * @example Exporting a Document Trigger
337
+ * ```ts
338
+ * import { Effect, Schema } from "effect";
339
+ * import { Collection, FirestoreModel } from "@visiolab/effect-firebase/firestore";
340
+ * import { DocumentTrigger } from "@visiolab/effect-firebase/functions";
341
+ * import { Functions } from "@visiolab/effect-firebase-functions";
342
+ *
343
+ * class Post extends FirestoreModel.Class<Post>("Post")({ title: Schema.String }) {}
344
+ *
345
+ * const OnPostCreated = DocumentTrigger.created("onPostCreated", Collection.make("posts", Post));
346
+ *
347
+ * export const { onPostCreated } = Functions.make(
348
+ * OnPostCreated,
349
+ * DocumentTrigger.toLayer(OnPostCreated, (event) =>
350
+ * Effect.flatMap(event.snapshot.data, (post) => Effect.log(`New post: ${post.title}`)),
351
+ * ),
352
+ * { retry: true },
353
+ * );
354
+ * ```
355
+ *
356
+ * @see {@link group} for deploying the members of a Function Group
357
+ * @see {@link Callable.serve} for serving a Callable from a transport of your own
358
+ * @see {@link Callable.handler} for running a Callable's handler in process
359
+ * @see {@link DocumentTrigger.serve} for running a Document Trigger's event in process
360
+ * @see {@link CallableClient.make} for calling a Callable from the client
361
+ *
362
+ * @stability unstable
363
+ * @category constructors
364
+ * @since 0.3.0
365
+ */
366
+ export const make = <F extends FirebaseFunction.Any, E>(
367
+ fn: F,
368
+ live: Layer.Layer<Services<F, FirebaseFunction.HandlerFor<F>>, E>,
369
+ options?: Options<F>,
370
+ ): Exports<F> =>
371
+ ({
372
+ [fn.name]: kinds.deploy(undefined, fn, live, options),
373
+ }) as Exports<F>;
374
+
375
+ /**
376
+ * Deploys the members of a Function Group, each as a function of its own,
377
+ * whatever their kinds.
378
+ *
379
+ * **When to use**
380
+ *
381
+ * Use to export a Function Group. Destructure the result into the module's
382
+ * exports, so `export const { users } = Functions.group(Users, Live)` deploys
383
+ * `users-create`, `users-delete` and so on.
384
+ *
385
+ * **Details**
386
+ *
387
+ * `live` must provide what every member needs, by its kind, and leave nothing
388
+ * required. For a Callable member, that is its handler, from
389
+ * `Callable.toLayer(Users, name, handler)`, and the implementation of each of
390
+ * its middleware. A handler from `Callable.toLayer` of the Callable on its
391
+ * own doesn't serve it in the group. For a Document Trigger member, that is
392
+ * its handler, from `DocumentTrigger.toLayer(Posts, name, handler)`. `live`
393
+ * may provide more, such as the handlers of other groups.
394
+ *
395
+ * Each member is its own function, with a worker of its own. A worker builds
396
+ * `live` on the first call to its own function, and never for another
397
+ * member's. One `live` per group is fine. Split it when members need very
398
+ * different services, so a cold instance builds only what its function needs.
399
+ *
400
+ * `options` holds each member's {@link Options}, keyed by the member's name
401
+ * and typed by its kind. A Callable member's are Firebase's `CallableOptions`,
402
+ * passed to its `onCall` unchanged. A Document Trigger member's are
403
+ * {@link DocumentTriggerOptions}: the shared options plus `retry`. The group
404
+ * has no options of its own: set those once with `setGlobalOptions`.
405
+ *
406
+ * Each export is what the member's kind deploys, and fails as one from
407
+ * {@link make} does.
408
+ *
409
+ * @example Exporting a Function Group
410
+ * ```ts
411
+ * import { Effect, Layer, Schema } from "effect";
412
+ * import { Callable, FunctionGroup } from "@visiolab/effect-firebase/functions";
413
+ * import { Functions } from "@visiolab/effect-firebase-functions";
414
+ *
415
+ * const CreateUser = Callable.make("create", {
416
+ * payload: { name: Schema.String },
417
+ * success: Schema.String,
418
+ * });
419
+ * const DeleteUser = Callable.make("delete", { payload: { id: Schema.String } });
420
+ *
421
+ * class Users extends FunctionGroup.make("users").add(CreateUser, DeleteUser) {}
422
+ *
423
+ * const UsersLive = Layer.mergeAll(
424
+ * Callable.toLayer(Users, "create", ({ name }) => Effect.succeed(name)),
425
+ * Callable.toLayer(Users, "delete", () => Effect.void),
426
+ * );
427
+ *
428
+ * // Deploys `users-create` and `users-delete`.
429
+ * export const { users } = Functions.group(Users, UsersLive, {
430
+ * delete: { region: "europe-west1" },
431
+ * });
432
+ * ```
433
+ *
434
+ * @example Exporting a group of a Callable and a Document Trigger
435
+ * ```ts
436
+ * import { Effect, Layer, Schema } from "effect";
437
+ * import { Collection, FirestoreModel } from "@visiolab/effect-firebase/firestore";
438
+ * import { Callable, DocumentTrigger, FunctionGroup } from "@visiolab/effect-firebase/functions";
439
+ * import { Functions } from "@visiolab/effect-firebase-functions";
440
+ *
441
+ * class Post extends FirestoreModel.Class<Post>("Post")({ title: Schema.String }) {}
442
+ *
443
+ * const Publish = Callable.make("publish", { payload: { id: Schema.String } });
444
+ * const OnCreated = DocumentTrigger.created("onCreated", Collection.make("posts", Post));
445
+ *
446
+ * class Posts extends FunctionGroup.make("posts").add(Publish, OnCreated) {}
447
+ *
448
+ * // Deploys `posts-publish` and `posts-onCreated`.
449
+ * export const { posts } = Functions.group(
450
+ * Posts,
451
+ * Layer.mergeAll(
452
+ * Callable.toLayer(Posts, "publish", () => Effect.void),
453
+ * DocumentTrigger.toLayer(Posts, "onCreated", (event) => Effect.log(event.ref.id)),
454
+ * ),
455
+ * { publish: { cors: true }, onCreated: { retry: true } },
456
+ * );
457
+ * ```
458
+ *
459
+ * @see {@link make} for deploying a Function in no group
460
+ * @see {@link FunctionGroup.make} for declaring the group
461
+ * @see {@link CallableClient.make} for calling the group's Callables from the client
462
+ *
463
+ * @stability unstable
464
+ * @category constructors
465
+ * @since 0.3.0
466
+ */
467
+ export const group = <G extends FunctionGroup.Any, E>(
468
+ group: G,
469
+ live: Layer.Layer<GroupServices<G>, E>,
470
+ options?: GroupOptions<G>,
471
+ ): GroupExports<G> => {
472
+ const exports: Record<string, unknown> = {};
473
+ for (const [name, fn] of Object.entries(group.functions)) {
474
+ exports[name] = kinds.deploy(
475
+ group,
476
+ fn,
477
+ live,
478
+ (options as Record<string, object | undefined> | undefined)?.[name],
479
+ );
480
+ }
481
+ return { [group.identifier]: exports } as GroupExports<G>;
482
+ };
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Deploys Functions, of any kind, as Cloud Functions for Firebase, which
3
+ * plain `firebase deploy` deploys.
4
+ *
5
+ * @stability unstable
6
+ * @since 0.3.0
7
+ */
8
+ export * as Functions from "./Functions.ts";
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The Callable kind's runtime: serves one Callable as an `onCall` export.
3
+ *
4
+ * @internal
5
+ */
6
+ import { Cause, Effect, Exit, type Layer, Option } from "effect";
7
+ import * as HttpServerRequest from "effect/http/HttpServerRequest";
8
+ import * as Callable from "@visiolab/effect-firebase/functions/Callable";
9
+ import * as CallableError from "@visiolab/effect-firebase/functions/CallableError";
10
+ import type * as CallableRequest from "@visiolab/effect-firebase/functions/CallableRequest";
11
+ import type * as FunctionGroup from "@visiolab/effect-firebase/functions/FunctionGroup";
12
+ import * as Https from "firebase-functions/v2/https";
13
+ import * as Member from "./member.ts";
14
+ import * as Runtime from "./runtime.ts";
15
+
16
+ const internalError = () => new Https.HttpsError("internal", "INTERNAL");
17
+
18
+ /**
19
+ * Firebase's Express request as Effect's `HttpServerRequest`: the same method,
20
+ * URL, headers and body, with the caller's address as `remoteAddress`. The
21
+ * body streams Firebase's bytes rather than copying them, as a `Buffer` body
22
+ * would.
23
+ */
24
+ const toHttpServerRequest = (raw: Https.Request): HttpServerRequest.HttpServerRequest => {
25
+ const headers: Array<[string, string]> = [];
26
+ for (const [name, value] of Object.entries(raw.headers)) {
27
+ if (value === undefined) continue;
28
+ headers.push([name, Array.isArray(value) ? value.join(", ") : value]);
29
+ }
30
+ const method = raw.method.toUpperCase();
31
+ const bytes = raw.rawBody;
32
+ const init: RequestInit =
33
+ // A GET or HEAD request can't have a body. Callables are POSTs.
34
+ method === "GET" || method === "HEAD"
35
+ ? { method, headers }
36
+ : {
37
+ method,
38
+ headers,
39
+ body: new ReadableStream<Uint8Array>({
40
+ start(controller) {
41
+ controller.enqueue(bytes);
42
+ controller.close();
43
+ },
44
+ }),
45
+ // Required of a stream body.
46
+ duplex: "half",
47
+ };
48
+ return HttpServerRequest.fromWeb(
49
+ new Request(`${raw.protocol}://${raw.get("host") ?? "localhost"}${raw.originalUrl}`, init),
50
+ ).modify({ remoteAddress: Option.fromNullishOr(raw.ip) });
51
+ };
52
+
53
+ /**
54
+ * The portable `CallableRequest` of a call, from what `firebase-functions`
55
+ * hands over.
56
+ */
57
+ const toCallableRequest = (request: Https.CallableRequest): CallableRequest.CallableRequest => ({
58
+ auth: request.auth,
59
+ app: request.app,
60
+ instanceIdToken: request.instanceIdToken,
61
+ data: request.data,
62
+ rawRequest: toHttpServerRequest(request.rawRequest),
63
+ });
64
+
65
+ /**
66
+ * The answer `Callable.serve` already chose for a failed call, if `cause`
67
+ * carries one.
68
+ */
69
+ const answerOf = <E>(cause: Cause.Cause<E>): CallableError.ResponseError | undefined => {
70
+ const failure = cause.reasons.find(Cause.isFailReason)?.error;
71
+ return failure instanceof CallableError.CallableError && failure.reason._tag === "ResponseError"
72
+ ? failure.reason
73
+ : undefined;
74
+ };
75
+
76
+ /**
77
+ * Logs the Cause of a call that failed unexpectedly: a defect, or a failure
78
+ * that isn't an answer. Answers and interrupts aren't logged.
79
+ */
80
+ const logUnexpected =
81
+ (message: string) =>
82
+ <E>(cause: Cause.Cause<E>) =>
83
+ answerOf(cause) !== undefined || Cause.hasInterruptsOnly(cause)
84
+ ? Effect.void
85
+ : Effect.logError(message, cause);
86
+
87
+ /**
88
+ * The `HttpsError` a failed call answers with. A failure is already the
89
+ * answer `Callable.serve` chose. An interrupt is `cancelled` when the client
90
+ * went away and `unavailable` otherwise. Anything else, logged in the call's
91
+ * span by {@link logUnexpected}, is a bare `internal`.
92
+ */
93
+ const toHttpsError = (cause: Cause.Cause<unknown>, aborted: boolean) => {
94
+ const answer = answerOf(cause);
95
+ if (answer !== undefined)
96
+ return new Https.HttpsError(answer.code, answer.message, answer.details);
97
+ if (Cause.hasInterruptsOnly(cause)) {
98
+ return aborted
99
+ ? new Https.HttpsError("cancelled", "CANCELLED")
100
+ : new Https.HttpsError("unavailable", "UNAVAILABLE");
101
+ }
102
+ return internalError();
103
+ };
104
+
105
+ /**
106
+ * `Callable.serve` of one Callable, with its requirements erased, since the
107
+ * public signatures check that `live` meets them.
108
+ */
109
+ type Serve = Effect.Effect<
110
+ (request: CallableRequest.CallableRequest) => Effect.Effect<unknown, CallableError.CallableError>,
111
+ never,
112
+ unknown
113
+ >;
114
+
115
+ /**
116
+ * What `onCall` returns for a Callable.
117
+ *
118
+ * @internal
119
+ */
120
+ export type Export = Https.CallableFunction<unknown, Promise<unknown>>;
121
+
122
+ /**
123
+ * The Callable Function of `callable` in `group`, or in no group: an `onCall`
124
+ * export that runs `Callable.serve` under the deployed name and span
125
+ * attributes of `Callable.deployment`. It builds `live` on its own first call.
126
+ *
127
+ * @internal
128
+ */
129
+ export const deploy = <R, E>(
130
+ group: FunctionGroup.Any | undefined,
131
+ callable: Callable.Any,
132
+ live: Layer.Layer<R, E>,
133
+ options: Https.CallableOptions = {},
134
+ ): Export => {
135
+ const deployment = Member.call(group, callable, Callable.deployment);
136
+ const serve = Member.call<Serve>(group, callable, Callable.serve);
137
+ const run = Runtime.make(live, {
138
+ span: `CallableFunction.${deployment.name}`,
139
+ attributes: deployment.attributes,
140
+ onBuildFailure: logUnexpected("The Layer of a Callable failed to build"),
141
+ });
142
+ return Https.onCall<unknown, Promise<unknown>>(options, async (request, response) => {
143
+ const signal = response?.signal;
144
+ const callRequest = toCallableRequest(request);
145
+ const exit = await run(
146
+ serve.pipe(
147
+ Effect.flatMap((run) => run(callRequest)),
148
+ // Logged with the services of `live`, so a logger it provides is used.
149
+ Effect.onError(logUnexpected("A Callable failed")),
150
+ ),
151
+ signal,
152
+ );
153
+ if (Exit.isSuccess(exit)) return exit.value;
154
+ throw toHttpsError(exit.cause, signal?.aborted === true);
155
+ });
156
+ };