@visiolab/effect-firebase-functions 0.0.0-stage → 0.3.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 +228 -2
- package/dist/Functions.d.mts +339 -0
- package/dist/Functions.mjs +213 -0
- package/dist/Functions.mjs.map +1 -0
- package/dist/_virtual/_rolldown/runtime.mjs +12 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/internal/callable.mjs +104 -0
- package/dist/internal/callable.mjs.map +1 -0
- package/dist/internal/documentTrigger.mjs +110 -0
- package/dist/internal/documentTrigger.mjs.map +1 -0
- package/dist/internal/kinds.mjs +19 -0
- package/dist/internal/kinds.mjs.map +1 -0
- package/dist/internal/member.mjs +12 -0
- package/dist/internal/member.mjs.map +1 -0
- package/dist/internal/runtime.mjs +79 -0
- package/dist/internal/runtime.mjs.map +1 -0
- package/dist/internal/serverSpan.mjs +187 -0
- package/dist/internal/serverSpan.mjs.map +1 -0
- package/dist/logger/CloudLogging.d.mts +92 -0
- package/dist/logger/CloudLogging.mjs +146 -0
- package/dist/logger/CloudLogging.mjs.map +1 -0
- package/dist/logger/index.d.mts +2 -0
- package/dist/logger/index.mjs +1 -0
- package/package.json +64 -3
- package/src/Functions.ts +482 -0
- package/src/index.ts +8 -0
- package/src/internal/callable.ts +156 -0
- package/src/internal/documentTrigger.ts +188 -0
- package/src/internal/kinds.ts +36 -0
- package/src/internal/member.ts +21 -0
- package/src/internal/runtime.ts +110 -0
- package/src/internal/serverSpan.ts +203 -0
- package/src/logger/CloudLogging.ts +152 -0
- package/src/logger/index.ts +8 -0
package/src/Functions.ts
ADDED
|
@@ -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,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
|
+
};
|