effect-vercel 0.5.0 → 0.7.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/README.md CHANGED
@@ -13,6 +13,8 @@ import * as AiGateway from "effect-vercel/AiGateway"
13
13
  | `effect-vercel/AiGatewayCredentials` | How AI Gateway requests authenticate |
14
14
  | `effect-vercel/Functions` | Bindings for `@vercel/functions`: `waitUntil` |
15
15
  | `effect-vercel/Oidc` | The OIDC token Vercel issues to a deployment |
16
+ | `effect-vercel/Otlp` | Keeps a Function running until its OTLP telemetry is exported |
17
+ | `effect-vercel/PgClient` | `@effect/sql-pg` pool that doesn't leave connections open on frozen instances |
16
18
  | `effect-vercel/RuntimeCache` | [Runtime Cache](https://vercel.com/docs/runtime-cache) for Effect `Persistence` |
17
19
 
18
20
  ## Install
@@ -21,6 +23,7 @@ import * as AiGateway from "effect-vercel/AiGateway"
21
23
  pnpm add effect-vercel effect
22
24
  pnpm add @effect/ai-anthropic # only for effect-vercel/AiGateway
23
25
  pnpm add @vercel/functions # only for effect-vercel/Functions and RuntimeCache
26
+ pnpm add @effect/sql-pg # only for effect-vercel/PgClient
24
27
  ```
25
28
 
26
29
  ## AI Gateway
@@ -144,6 +147,92 @@ const handler = Effect.gen(function* () {
144
147
  - Vercel stops the work when the Function reaches its maximum duration.
145
148
  - Off Vercel, `waitUntil` does nothing and the fiber runs in the background.
146
149
 
150
+ ## OTLP
151
+
152
+ Effect's OTLP exporters buffer spans, logs and metrics and export them on an interval. Vercel can
153
+ suspend the instance once the response is sent, so the interval may never fire and the telemetry
154
+ is lost. `effect-vercel/Otlp` flushes the exporters when each request ends and keeps the Function
155
+ running until the flush is done.
156
+
157
+ For an `HttpRouter` app, add the `flushAfterRequest` middleware next to your routes. Provide the
158
+ OTLP layer with `Layer.provideMerge`, so its tracer and logger reach each request:
159
+
160
+ ```ts
161
+ import * as VercelOtlp from "effect-vercel/Otlp"
162
+ import { Layer } from "effect"
163
+ import { FetchHttpClient, HttpRouter } from "effect/http"
164
+ import { Otlp } from "effect/observability"
165
+
166
+ const { handler } = HttpRouter.toWebHandler(
167
+ Layer.merge(Routes, VercelOtlp.flushAfterRequest).pipe(
168
+ Layer.provideMerge(Otlp.layerJson({ baseUrl, resource })),
169
+ Layer.provide(FetchHttpClient.layer),
170
+ ),
171
+ )
172
+ ```
173
+
174
+ For another entry point, such as a queue consumer, run `VercelOtlp.flushOnClose` at the start of
175
+ the request in a scope that closes when the request ends:
176
+
177
+ ```ts
178
+ // `services` holds the app's services, including the OTLP layer's Flusher.
179
+ const runSync = Effect.runSyncWith(services)
180
+ const scope = runSync(Scope.make())
181
+ runSync(VercelOtlp.flushOnClose.pipe(Scope.provide(scope)))
182
+
183
+ // When the request ends:
184
+ runSync(Scope.close(scope, Exit.void))
185
+ ```
186
+
187
+ - The flush starts when the request's scope closes. For an `HttpRouter` app, that is after the
188
+ response body is sent, so telemetry recorded while a body streams is included.
189
+ - Every request flushes, sampled or not, since logs and metrics are exported either way. Spans and
190
+ logs are sent only when some are buffered. Metrics send a snapshot on every flush.
191
+ - The Function waits for the flush for at most 2 seconds.
192
+ - Both need the `OtlpExporter.Flusher` that the OTLP layers provide, so leaving them out fails to
193
+ compile.
194
+ - They register the wait with Vercel when the request starts, while its context is current. Run
195
+ `flushOnClose` first in its scope, so finalizers added later, such as ending a span, run before
196
+ the flush.
197
+ - `flush` can't wait for an export the interval already started, and skips exporters disabled
198
+ after a failure.
199
+ - Off Vercel, or outside a request, they do nothing and the exporters' interval sends the
200
+ telemetry.
201
+
202
+ ## Postgres
203
+
204
+ `PgClient.layer` is `PgClient.layer` from `@effect/sql-pg` that keeps the Function instance up
205
+ until the pool has closed its connections.
206
+
207
+ Vercel freezes an instance once no request or `waitUntil` promise is left. A frozen instance
208
+ keeps its sockets open, so a pooler like PgBouncer still counts them against `max_client_conn`.
209
+ During a deploy many instances freeze at once, which can use up the limit. `attachDatabasePool`
210
+ from `@vercel/functions` handles this for node-postgres pools by waiting out the idle timeout
211
+ after each release. It doesn't support the Effect pool, which also closes an idle connection up
212
+ to twice its `idleTimeout` after its last use.
213
+
214
+ ```ts
215
+ import * as PgClient from "effect-vercel/PgClient"
216
+ import { Redacted } from "effect"
217
+
218
+ const SqlLive = PgClient.layer({
219
+ url: Redacted.make(process.env.DATABASE_URL!),
220
+ maxConnections: 3,
221
+ idleTimeout: "5 seconds",
222
+ })
223
+ ```
224
+
225
+ - It takes the same config as `@effect/sql-pg`, except `stream`: it opens each socket itself
226
+ and counts it until it closes. While a socket is open, one `waitUntil` promise keeps the
227
+ instance up.
228
+ - Each time a request takes a connection from the pool, the promise moves to that request, so a
229
+ busy instance never keeps a finished request open. The wait also ends a second before the
230
+ request's deadline.
231
+ - It moves the promise from `onAcquire`. An `onAcquire` in your config still runs, after.
232
+ - Keep `idleTimeout` short. The instance stays up until the pool closes its last idle
233
+ connection.
234
+ - Off Vercel, it behaves like the plain layer.
235
+
147
236
  ## Runtime Cache
148
237
 
149
238
  `RuntimeCache.layer` provides Effect's `Persistence`, stored in the Vercel Runtime Cache.
package/dist/Otlp.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { Effect, Layer, Scope } from "effect";
2
+ import { HttpRouter } from "effect/http";
3
+ import { OtlpExporter } from "effect/observability";
4
+ //#region src/Otlp.d.ts
5
+ /**
6
+ * When the current scope closes, flushes the OTLP exporters and keeps the
7
+ * Function running until the flush ends, for at most 2 seconds.
8
+ *
9
+ * It registers the wait with Vercel right away, so run it while the request
10
+ * is current. Run it first in the scope: finalizers added later, such as
11
+ * ending a span, run before the flush. Off Vercel, or outside a request, it
12
+ * does nothing, and the exporters' interval sends the telemetry.
13
+ *
14
+ * Use `flushAfterRequest` for an `HttpRouter` app. Use this one when the
15
+ * request has another entry point, with a scope that closes when it ends.
16
+ */
17
+ export declare const flushOnClose: Effect.Effect<void, never, OtlpExporter.Flusher | Scope.Scope>;
18
+ /**
19
+ * Global `HttpRouter` middleware that runs `flushOnClose` in each request's
20
+ * scope. That scope closes after the response body is sent, so telemetry
21
+ * recorded while a body streams is in the flush.
22
+ */
23
+ export declare const flushAfterRequest: Layer.Layer<never, never, HttpRouter.HttpRouter | OtlpExporter.Flusher>;
24
+ //#endregion
25
+ //# sourceMappingURL=Otlp.d.ts.map
package/dist/Otlp.js ADDED
@@ -0,0 +1,59 @@
1
+ import { t as currentRequest } from "./requestContext-CEUKYTim.js";
2
+ import { Effect } from "effect";
3
+ import { HttpRouter } from "effect/http";
4
+ import { OtlpExporter } from "effect/observability";
5
+ //#region src/Otlp.ts
6
+ /**
7
+ * Keeps a Vercel Function running until its OTLP telemetry is exported.
8
+ *
9
+ * Effect's OTLP exporters buffer spans, logs and metrics and export them on
10
+ * an interval. Vercel can suspend the instance once the response is sent, so
11
+ * the interval may never fire and the telemetry is lost. These flush the
12
+ * exporters when a request ends and keep the Function running until the
13
+ * flush is done.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import * as VercelOtlp from "effect-vercel/Otlp"
18
+ * import { Layer } from "effect"
19
+ * import { FetchHttpClient, HttpRouter } from "effect/http"
20
+ * import { Otlp } from "effect/observability"
21
+ *
22
+ * const { handler } = HttpRouter.toWebHandler(
23
+ * Layer.merge(Routes, VercelOtlp.flushAfterRequest).pipe(
24
+ * Layer.provideMerge(Otlp.layerJson({ baseUrl, resource })),
25
+ * Layer.provide(FetchHttpClient.layer),
26
+ * ),
27
+ * )
28
+ * ```
29
+ */
30
+ const flushOnCloseWith = (flusher) => Effect.suspend(() => {
31
+ const request = currentRequest();
32
+ if (request?.waitUntil === void 0) return Effect.void;
33
+ const { promise, resolve } = Promise.withResolvers();
34
+ request.waitUntil(promise);
35
+ return Effect.addFinalizer(() => flusher.flush.pipe(Effect.timeoutOption("2 seconds"), Effect.ensuring(Effect.sync(resolve)), Effect.forkDetach));
36
+ }).pipe(Effect.uninterruptible);
37
+ /**
38
+ * When the current scope closes, flushes the OTLP exporters and keeps the
39
+ * Function running until the flush ends, for at most 2 seconds.
40
+ *
41
+ * It registers the wait with Vercel right away, so run it while the request
42
+ * is current. Run it first in the scope: finalizers added later, such as
43
+ * ending a span, run before the flush. Off Vercel, or outside a request, it
44
+ * does nothing, and the exporters' interval sends the telemetry.
45
+ *
46
+ * Use `flushAfterRequest` for an `HttpRouter` app. Use this one when the
47
+ * request has another entry point, with a scope that closes when it ends.
48
+ */
49
+ const flushOnClose = Effect.flatMap(OtlpExporter.Flusher, flushOnCloseWith);
50
+ /**
51
+ * Global `HttpRouter` middleware that runs `flushOnClose` in each request's
52
+ * scope. That scope closes after the response body is sent, so telemetry
53
+ * recorded while a body streams is in the flush.
54
+ */
55
+ const flushAfterRequest = HttpRouter.middleware(Effect.map(OtlpExporter.Flusher, (flusher) => (httpEffect) => Effect.andThen(flushOnCloseWith(flusher), httpEffect)), { global: true });
56
+ //#endregion
57
+ export { flushAfterRequest, flushOnClose };
58
+
59
+ //# sourceMappingURL=Otlp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Otlp.js","names":[],"sources":["../src/Otlp.ts"],"sourcesContent":["/**\n * Keeps a Vercel Function running until its OTLP telemetry is exported.\n *\n * Effect's OTLP exporters buffer spans, logs and metrics and export them on\n * an interval. Vercel can suspend the instance once the response is sent, so\n * the interval may never fire and the telemetry is lost. These flush the\n * exporters when a request ends and keep the Function running until the\n * flush is done.\n *\n * @example\n * ```ts\n * import * as VercelOtlp from \"effect-vercel/Otlp\"\n * import { Layer } from \"effect\"\n * import { FetchHttpClient, HttpRouter } from \"effect/http\"\n * import { Otlp } from \"effect/observability\"\n *\n * const { handler } = HttpRouter.toWebHandler(\n * Layer.merge(Routes, VercelOtlp.flushAfterRequest).pipe(\n * Layer.provideMerge(Otlp.layerJson({ baseUrl, resource })),\n * Layer.provide(FetchHttpClient.layer),\n * ),\n * )\n * ```\n */\n\nimport { Effect, type Layer, type Scope } from \"effect\"\nimport { HttpRouter } from \"effect/http\"\nimport { OtlpExporter } from \"effect/observability\"\nimport { currentRequest } from \"./internal/requestContext.js\"\n\nconst flushOnCloseWith = (flusher: OtlpExporter.Flusher[\"Service\"]) =>\n Effect.suspend(() => {\n const request = currentRequest()\n if (request?.waitUntil === undefined) return Effect.void\n const { promise, resolve } = Promise.withResolvers<void>()\n request.waitUntil(promise)\n // Forked, so a streamed body ends without waiting for the export.\n return Effect.addFinalizer(() =>\n flusher.flush.pipe(\n Effect.timeoutOption(\"2 seconds\"),\n Effect.ensuring(Effect.sync(resolve)),\n Effect.forkDetach,\n ),\n )\n }).pipe(\n // An interrupt before the finalizer is added would leave the wait open.\n Effect.uninterruptible,\n )\n\n/**\n * When the current scope closes, flushes the OTLP exporters and keeps the\n * Function running until the flush ends, for at most 2 seconds.\n *\n * It registers the wait with Vercel right away, so run it while the request\n * is current. Run it first in the scope: finalizers added later, such as\n * ending a span, run before the flush. Off Vercel, or outside a request, it\n * does nothing, and the exporters' interval sends the telemetry.\n *\n * Use `flushAfterRequest` for an `HttpRouter` app. Use this one when the\n * request has another entry point, with a scope that closes when it ends.\n */\nexport const flushOnClose: Effect.Effect<void, never, OtlpExporter.Flusher | Scope.Scope> =\n Effect.flatMap(OtlpExporter.Flusher, flushOnCloseWith)\n\n/**\n * Global `HttpRouter` middleware that runs `flushOnClose` in each request's\n * scope. That scope closes after the response body is sent, so telemetry\n * recorded while a body streams is in the flush.\n */\nexport const flushAfterRequest: Layer.Layer<\n never,\n never,\n HttpRouter.HttpRouter | OtlpExporter.Flusher\n> = HttpRouter.middleware(\n Effect.map(\n OtlpExporter.Flusher,\n (flusher) => (httpEffect) => Effect.andThen(flushOnCloseWith(flusher), httpEffect),\n ),\n { global: true },\n)\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,oBAAoB,YACxB,OAAO,cAAc;CACnB,MAAM,UAAU,eAAe;CAC/B,IAAI,SAAS,cAAc,KAAA,GAAW,OAAO,OAAO;CACpD,MAAM,EAAE,SAAS,YAAY,QAAQ,cAAoB;CACzD,QAAQ,UAAU,OAAO;CAEzB,OAAO,OAAO,mBACZ,QAAQ,MAAM,KACZ,OAAO,cAAc,WAAW,GAChC,OAAO,SAAS,OAAO,KAAK,OAAO,CAAC,GACpC,OAAO,UACT,CACF;AACF,CAAC,CAAC,CAAC,KAED,OAAO,eACT;;;;;;;;;;;;;AAcF,MAAa,eACX,OAAO,QAAQ,aAAa,SAAS,gBAAgB;;;;;;AAOvD,MAAa,oBAIT,WAAW,WACb,OAAO,IACL,aAAa,UACZ,aAAa,eAAe,OAAO,QAAQ,iBAAiB,OAAO,GAAG,UAAU,CACnF,GACA,EAAE,QAAQ,KAAK,CACjB"}
@@ -0,0 +1,17 @@
1
+ import { Layer } from "effect";
2
+ import { PgClient } from "@effect/sql-pg";
3
+ //#region src/PgClient.d.ts
4
+ /** `PgClient.PgPoolConfig` without `stream`, which this module sets. */
5
+ export type Config<R = never> = Omit<PgClient.PgPoolConfig<R>, "stream">;
6
+ /**
7
+ * `PgClient.layer` from `@effect/sql-pg` that keeps the Function instance up
8
+ * until the pool has closed every connection, or the request's deadline
9
+ * comes. Off Vercel it behaves like the plain layer.
10
+ *
11
+ * It opens each socket itself, through the `stream` option, and moves the
12
+ * wait to the request that takes each connection, through `onAcquire`. An
13
+ * `onAcquire` in `config` still runs, after that.
14
+ */
15
+ export declare const layer: <R = never>(config: Config<R>) => Layer.Layer<PgClient.PgClient | import("effect/sql/SqlClient").SqlClient, import("effect/sql/SqlError").SqlError, Exclude<R, import("effect/Scope").Scope | import("effect/reactivity/Reactivity").Reactivity>>;
16
+ //#endregion
17
+ //# sourceMappingURL=PgClient.d.ts.map
@@ -0,0 +1,111 @@
1
+ import { t as currentRequest } from "./requestContext-CEUKYTim.js";
2
+ import { Duration, Effect, Layer, Redacted } from "effect";
3
+ import * as Net from "node:net";
4
+ import { PgClient } from "@effect/sql-pg";
5
+ import { constVoid } from "effect/Function";
6
+ //#region src/PgClient.ts
7
+ /**
8
+ * `@effect/sql-pg` for Vercel Functions: a pool that keeps the instance up
9
+ * while it has a connection open.
10
+ *
11
+ * Vercel freezes an instance once no request or `waitUntil` promise is left.
12
+ * A frozen instance keeps its sockets, so a pooler like PgBouncer keeps
13
+ * counting them as clients until the instance wakes up or dies. With many
14
+ * instances, as during a deploy, they fill up `max_client_conn`.
15
+ * `attachDatabasePool` from `@vercel/functions` waits out the idle timeout
16
+ * after each release, but it only supports node-postgres-like pools, and the
17
+ * Effect pool closes idle connections up to twice its `idleTimeout` after
18
+ * their last use. So this layer counts the pool's sockets and waits until
19
+ * the last one closes.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import * as PgClient from "effect-vercel/PgClient"
24
+ * import { Redacted } from "effect"
25
+ *
26
+ * const SqlLive = PgClient.layer({
27
+ * url: Redacted.make(process.env.DATABASE_URL!),
28
+ * idleTimeout: "5 seconds",
29
+ * })
30
+ * ```
31
+ */
32
+ /**
33
+ * Keeps the instance up while a tracked socket is open. One `waitUntil`
34
+ * promise holds it, and `follow` moves it to the request running now, as
35
+ * `attachDatabasePool` does, so a busy instance never holds a finished
36
+ * request open. The wait also ends a second before the request's deadline.
37
+ * Outside a request, `follow` keeps the wait where it is.
38
+ */
39
+ const makeKeepAlive = () => {
40
+ let open = 0;
41
+ let release = constVoid;
42
+ const follow = () => {
43
+ const request = currentRequest();
44
+ if (open === 0 || request?.waitUntil === void 0) return;
45
+ release();
46
+ const cutoff = Date.parse(request.deadline ?? "") - 1e3;
47
+ request.waitUntil(new Promise((resolve) => {
48
+ const timer = Number.isNaN(cutoff) ? void 0 : setTimeout(resolve, cutoff - Date.now());
49
+ release = () => {
50
+ clearTimeout(timer);
51
+ resolve();
52
+ };
53
+ }));
54
+ };
55
+ const track = (socket) => {
56
+ open++;
57
+ follow();
58
+ socket.once("close", () => {
59
+ if (--open === 0) release();
60
+ });
61
+ return socket;
62
+ };
63
+ return {
64
+ follow,
65
+ track
66
+ };
67
+ };
68
+ /**
69
+ * Opens the TCP or Unix socket `@effect/sql-pg` would open for `config`.
70
+ * It still runs TLS and the startup on it.
71
+ */
72
+ const connect = (config) => {
73
+ const url = config.url && new URL(Redacted.value(config.url));
74
+ const param = (name) => url?.searchParams.getAll(name).at(-1);
75
+ const urlHost = url?.hostname && decodeURIComponent(url.hostname.replace(/^\[(.*)\]$/, "$1"));
76
+ const host = config.host ?? param("host") ?? (urlHost || "localhost");
77
+ const port = config.port ?? Number(param("port") ?? (url?.port || 5432));
78
+ const path = config.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : void 0);
79
+ if (path !== void 0) return Net.connect({ path });
80
+ return Net.connect({
81
+ host,
82
+ port,
83
+ noDelay: true,
84
+ ...config.keepAlive !== void 0 && {
85
+ keepAlive: true,
86
+ keepAliveInitialDelay: Duration.toMillis(Duration.fromInputUnsafe(config.keepAlive))
87
+ }
88
+ });
89
+ };
90
+ /**
91
+ * `PgClient.layer` from `@effect/sql-pg` that keeps the Function instance up
92
+ * until the pool has closed every connection, or the request's deadline
93
+ * comes. Off Vercel it behaves like the plain layer.
94
+ *
95
+ * It opens each socket itself, through the `stream` option, and moves the
96
+ * wait to the request that takes each connection, through `onAcquire`. An
97
+ * `onAcquire` in `config` still runs, after that.
98
+ */
99
+ const layer = (config) => Layer.suspend(() => {
100
+ const keepAlive = makeKeepAlive();
101
+ const follow = Effect.sync(keepAlive.follow);
102
+ return PgClient.layer({
103
+ ...config,
104
+ stream: () => keepAlive.track(connect(config)),
105
+ onAcquire: config.onAcquire ? Effect.andThen(follow, config.onAcquire) : follow
106
+ });
107
+ });
108
+ //#endregion
109
+ export { layer };
110
+
111
+ //# sourceMappingURL=PgClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PgClient.js","names":[],"sources":["../src/PgClient.ts"],"sourcesContent":["/**\n * `@effect/sql-pg` for Vercel Functions: a pool that keeps the instance up\n * while it has a connection open.\n *\n * Vercel freezes an instance once no request or `waitUntil` promise is left.\n * A frozen instance keeps its sockets, so a pooler like PgBouncer keeps\n * counting them as clients until the instance wakes up or dies. With many\n * instances, as during a deploy, they fill up `max_client_conn`.\n * `attachDatabasePool` from `@vercel/functions` waits out the idle timeout\n * after each release, but it only supports node-postgres-like pools, and the\n * Effect pool closes idle connections up to twice its `idleTimeout` after\n * their last use. So this layer counts the pool's sockets and waits until\n * the last one closes.\n *\n * @example\n * ```ts\n * import * as PgClient from \"effect-vercel/PgClient\"\n * import { Redacted } from \"effect\"\n *\n * const SqlLive = PgClient.layer({\n * url: Redacted.make(process.env.DATABASE_URL!),\n * idleTimeout: \"5 seconds\",\n * })\n * ```\n */\n\nimport * as Net from \"node:net\"\nimport { PgClient } from \"@effect/sql-pg\"\nimport { Duration, Effect, Layer, Redacted } from \"effect\"\nimport { constVoid } from \"effect/Function\"\nimport { currentRequest } from \"./internal/requestContext.js\"\n\n/**\n * Keeps the instance up while a tracked socket is open. One `waitUntil`\n * promise holds it, and `follow` moves it to the request running now, as\n * `attachDatabasePool` does, so a busy instance never holds a finished\n * request open. The wait also ends a second before the request's deadline.\n * Outside a request, `follow` keeps the wait where it is.\n */\nconst makeKeepAlive = () => {\n let open = 0\n let release = constVoid\n\n const follow = () => {\n const request = currentRequest()\n if (open === 0 || request?.waitUntil === undefined) return\n release()\n const cutoff = Date.parse(request.deadline ?? \"\") - 1_000\n request.waitUntil(\n new Promise<void>((resolve) => {\n const timer = Number.isNaN(cutoff) ? undefined : setTimeout(resolve, cutoff - Date.now())\n release = () => {\n clearTimeout(timer)\n resolve()\n }\n }),\n )\n }\n\n const track = (socket: Net.Socket) => {\n open++\n follow()\n socket.once(\"close\", () => {\n if (--open === 0) release()\n })\n return socket\n }\n\n return { follow, track }\n}\n\n/** `PgClient.PgPoolConfig` without `stream`, which this module sets. */\nexport type Config<R = never> = Omit<PgClient.PgPoolConfig<R>, \"stream\">\n\n/**\n * Opens the TCP or Unix socket `@effect/sql-pg` would open for `config`.\n * It still runs TLS and the startup on it.\n */\nconst connect = (config: Config<unknown>): Net.Socket => {\n const url = config.url && new URL(Redacted.value(config.url))\n const param = (name: string) => url?.searchParams.getAll(name).at(-1)\n const urlHost = url?.hostname && decodeURIComponent(url.hostname.replace(/^\\[(.*)\\]$/, \"$1\"))\n const host = config.host ?? param(\"host\") ?? (urlHost || \"localhost\")\n const port = config.port ?? Number(param(\"port\") ?? (url?.port || 5432))\n const path = config.path ?? (host.startsWith(\"/\") ? `${host}/.s.PGSQL.${port}` : undefined)\n if (path !== undefined) return Net.connect({ path })\n return Net.connect({\n host,\n port,\n noDelay: true,\n ...(config.keepAlive !== undefined && {\n keepAlive: true,\n keepAliveInitialDelay: Duration.toMillis(Duration.fromInputUnsafe(config.keepAlive)),\n }),\n })\n}\n\n/**\n * `PgClient.layer` from `@effect/sql-pg` that keeps the Function instance up\n * until the pool has closed every connection, or the request's deadline\n * comes. Off Vercel it behaves like the plain layer.\n *\n * It opens each socket itself, through the `stream` option, and moves the\n * wait to the request that takes each connection, through `onAcquire`. An\n * `onAcquire` in `config` still runs, after that.\n */\nexport const layer = <R = never>(config: Config<R>) =>\n Layer.suspend(() => {\n const keepAlive = makeKeepAlive()\n const follow = Effect.sync(keepAlive.follow)\n return PgClient.layer({\n ...config,\n stream: () => keepAlive.track(connect(config)),\n onAcquire: config.onAcquire ? Effect.andThen(follow, config.onAcquire) : follow,\n })\n })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuCA,MAAM,sBAAsB;CAC1B,IAAI,OAAO;CACX,IAAI,UAAU;CAEd,MAAM,eAAe;EACnB,MAAM,UAAU,eAAe;EAC/B,IAAI,SAAS,KAAK,SAAS,cAAc,KAAA,GAAW;EACpD,QAAQ;EACR,MAAM,SAAS,KAAK,MAAM,QAAQ,YAAY,EAAE,IAAI;EACpD,QAAQ,UACN,IAAI,SAAe,YAAY;GAC7B,MAAM,QAAQ,OAAO,MAAM,MAAM,IAAI,KAAA,IAAY,WAAW,SAAS,SAAS,KAAK,IAAI,CAAC;GACxF,gBAAgB;IACd,aAAa,KAAK;IAClB,QAAQ;GACV;EACF,CAAC,CACH;CACF;CAEA,MAAM,SAAS,WAAuB;EACpC;EACA,OAAO;EACP,OAAO,KAAK,eAAe;GACzB,IAAI,EAAE,SAAS,GAAG,QAAQ;EAC5B,CAAC;EACD,OAAO;CACT;CAEA,OAAO;EAAE;EAAQ;CAAM;AACzB;;;;;AASA,MAAM,WAAW,WAAwC;CACvD,MAAM,MAAM,OAAO,OAAO,IAAI,IAAI,SAAS,MAAM,OAAO,GAAG,CAAC;CAC5D,MAAM,SAAS,SAAiB,KAAK,aAAa,OAAO,IAAI,CAAC,CAAC,GAAG,EAAE;CACpE,MAAM,UAAU,KAAK,YAAY,mBAAmB,IAAI,SAAS,QAAQ,cAAc,IAAI,CAAC;CAC5F,MAAM,OAAO,OAAO,QAAQ,MAAM,MAAM,MAAM,WAAW;CACzD,MAAM,OAAO,OAAO,QAAQ,OAAO,MAAM,MAAM,MAAM,KAAK,QAAQ,KAAK;CACvE,MAAM,OAAO,OAAO,SAAS,KAAK,WAAW,GAAG,IAAI,GAAG,KAAK,YAAY,SAAS,KAAA;CACjF,IAAI,SAAS,KAAA,GAAW,OAAO,IAAI,QAAQ,EAAE,KAAK,CAAC;CACnD,OAAO,IAAI,QAAQ;EACjB;EACA;EACA,SAAS;EACT,GAAI,OAAO,cAAc,KAAA,KAAa;GACpC,WAAW;GACX,uBAAuB,SAAS,SAAS,SAAS,gBAAgB,OAAO,SAAS,CAAC;EACrF;CACF,CAAC;AACH;;;;;;;;;;AAWA,MAAa,SAAoB,WAC/B,MAAM,cAAc;CAClB,MAAM,YAAY,cAAc;CAChC,MAAM,SAAS,OAAO,KAAK,UAAU,MAAM;CAC3C,OAAO,SAAS,MAAM;EACpB,GAAG;EACH,cAAc,UAAU,MAAM,QAAQ,MAAM,CAAC;EAC7C,WAAW,OAAO,YAAY,OAAO,QAAQ,QAAQ,OAAO,SAAS,IAAI;CAC3E,CAAC;AACH,CAAC"}
@@ -0,0 +1,14 @@
1
+ //#region src/internal/requestContext.ts
2
+ /**
3
+ * Vercel exposes the current request's context on a well-known global symbol.
4
+ * `@vercel/functions` reads `waitUntil` and the deadline from there. Off
5
+ * Vercel, or outside a request, there is no `waitUntil`.
6
+ */
7
+ const REQUEST_CONTEXT = Symbol.for("@vercel/request-context");
8
+ const currentRequest = () => {
9
+ return globalThis[REQUEST_CONTEXT]?.get?.();
10
+ };
11
+ //#endregion
12
+ export { currentRequest as t };
13
+
14
+ //# sourceMappingURL=requestContext-CEUKYTim.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"requestContext-CEUKYTim.js","names":["holder"],"sources":["../src/internal/requestContext.ts"],"sourcesContent":["/**\n * Vercel exposes the current request's context on a well-known global symbol.\n * `@vercel/functions` reads `waitUntil` and the deadline from there. Off\n * Vercel, or outside a request, there is no `waitUntil`.\n */\nconst REQUEST_CONTEXT = Symbol.for(\"@vercel/request-context\")\n\ntype RequestContext = {\n readonly waitUntil?: (promise: Promise<unknown>) => void\n readonly deadline?: string\n}\n\nexport const currentRequest = (): RequestContext | undefined => {\n const holder = globalThis as typeof globalThis & {\n [REQUEST_CONTEXT]?: { get?: () => RequestContext | undefined }\n }\n return holder[REQUEST_CONTEXT]?.get?.()\n}\n"],"mappings":";;;;;;AAKA,MAAM,kBAAkB,OAAO,IAAI,yBAAyB;AAO5D,MAAa,uBAAmD;CAI9D,OAAOA,WAAO,gBAAgB,EAAE,MAAM;AACxC"}
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "effect-vercel",
3
- "version": "0.5.0",
4
- "description": "Effect integrations for Vercel: AI Gateway, OIDC, Runtime Cache, waitUntil",
3
+ "version": "0.7.0",
4
+ "description": "Effect integrations for Vercel: AI Gateway, OIDC, OTLP flushing, Postgres pools, Runtime Cache, waitUntil",
5
5
  "keywords": [
6
6
  "ai-gateway",
7
7
  "effect",
8
8
  "effect-ai",
9
9
  "oidc",
10
+ "opentelemetry",
11
+ "otlp",
12
+ "postgres",
10
13
  "runtime-cache",
11
14
  "vercel",
12
15
  "wait-until"
@@ -32,6 +35,8 @@
32
35
  "./AiGatewayCredentials": "./dist/AiGatewayCredentials.js",
33
36
  "./Functions": "./dist/Functions.js",
34
37
  "./Oidc": "./dist/Oidc.js",
38
+ "./Otlp": "./dist/Otlp.js",
39
+ "./PgClient": "./dist/PgClient.js",
35
40
  "./RuntimeCache": "./dist/RuntimeCache.js",
36
41
  "./package.json": "./package.json"
37
42
  },
@@ -48,12 +53,13 @@
48
53
  "prepublishOnly": "pnpm check && pnpm test && pnpm build"
49
54
  },
50
55
  "devDependencies": {
51
- "@effect/ai-anthropic": "^4.0.0",
56
+ "@effect/ai-anthropic": "^4.1.0",
52
57
  "@effect/language-service": "^0.87.3",
53
- "@effect/vitest": "^4.0.0",
58
+ "@effect/sql-pg": "^4.1.0",
59
+ "@effect/vitest": "^4.1.0",
54
60
  "@types/node": "^24",
55
61
  "@vercel/functions": "^3.9.9",
56
- "effect": "^4.0.0",
62
+ "effect": "^4.1.0",
57
63
  "oxfmt": "^0.68.0",
58
64
  "oxlint": "^1.83.0",
59
65
  "tsdown": "^0.23.0",
@@ -62,13 +68,17 @@
62
68
  },
63
69
  "peerDependencies": {
64
70
  "@effect/ai-anthropic": "^4.0.0",
71
+ "@effect/sql-pg": "^4.0.6",
65
72
  "@vercel/functions": "^3.9.9",
66
- "effect": "^4.0.0"
73
+ "effect": "^4.1.0"
67
74
  },
68
75
  "peerDependenciesMeta": {
69
76
  "@effect/ai-anthropic": {
70
77
  "optional": true
71
78
  },
79
+ "@effect/sql-pg": {
80
+ "optional": true
81
+ },
72
82
  "@vercel/functions": {
73
83
  "optional": true
74
84
  }
package/src/Otlp.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Keeps a Vercel Function running until its OTLP telemetry is exported.
3
+ *
4
+ * Effect's OTLP exporters buffer spans, logs and metrics and export them on
5
+ * an interval. Vercel can suspend the instance once the response is sent, so
6
+ * the interval may never fire and the telemetry is lost. These flush the
7
+ * exporters when a request ends and keep the Function running until the
8
+ * flush is done.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import * as VercelOtlp from "effect-vercel/Otlp"
13
+ * import { Layer } from "effect"
14
+ * import { FetchHttpClient, HttpRouter } from "effect/http"
15
+ * import { Otlp } from "effect/observability"
16
+ *
17
+ * const { handler } = HttpRouter.toWebHandler(
18
+ * Layer.merge(Routes, VercelOtlp.flushAfterRequest).pipe(
19
+ * Layer.provideMerge(Otlp.layerJson({ baseUrl, resource })),
20
+ * Layer.provide(FetchHttpClient.layer),
21
+ * ),
22
+ * )
23
+ * ```
24
+ */
25
+
26
+ import { Effect, type Layer, type Scope } from "effect"
27
+ import { HttpRouter } from "effect/http"
28
+ import { OtlpExporter } from "effect/observability"
29
+ import { currentRequest } from "./internal/requestContext.js"
30
+
31
+ const flushOnCloseWith = (flusher: OtlpExporter.Flusher["Service"]) =>
32
+ Effect.suspend(() => {
33
+ const request = currentRequest()
34
+ if (request?.waitUntil === undefined) return Effect.void
35
+ const { promise, resolve } = Promise.withResolvers<void>()
36
+ request.waitUntil(promise)
37
+ // Forked, so a streamed body ends without waiting for the export.
38
+ return Effect.addFinalizer(() =>
39
+ flusher.flush.pipe(
40
+ Effect.timeoutOption("2 seconds"),
41
+ Effect.ensuring(Effect.sync(resolve)),
42
+ Effect.forkDetach,
43
+ ),
44
+ )
45
+ }).pipe(
46
+ // An interrupt before the finalizer is added would leave the wait open.
47
+ Effect.uninterruptible,
48
+ )
49
+
50
+ /**
51
+ * When the current scope closes, flushes the OTLP exporters and keeps the
52
+ * Function running until the flush ends, for at most 2 seconds.
53
+ *
54
+ * It registers the wait with Vercel right away, so run it while the request
55
+ * is current. Run it first in the scope: finalizers added later, such as
56
+ * ending a span, run before the flush. Off Vercel, or outside a request, it
57
+ * does nothing, and the exporters' interval sends the telemetry.
58
+ *
59
+ * Use `flushAfterRequest` for an `HttpRouter` app. Use this one when the
60
+ * request has another entry point, with a scope that closes when it ends.
61
+ */
62
+ export const flushOnClose: Effect.Effect<void, never, OtlpExporter.Flusher | Scope.Scope> =
63
+ Effect.flatMap(OtlpExporter.Flusher, flushOnCloseWith)
64
+
65
+ /**
66
+ * Global `HttpRouter` middleware that runs `flushOnClose` in each request's
67
+ * scope. That scope closes after the response body is sent, so telemetry
68
+ * recorded while a body streams is in the flush.
69
+ */
70
+ export const flushAfterRequest: Layer.Layer<
71
+ never,
72
+ never,
73
+ HttpRouter.HttpRouter | OtlpExporter.Flusher
74
+ > = HttpRouter.middleware(
75
+ Effect.map(
76
+ OtlpExporter.Flusher,
77
+ (flusher) => (httpEffect) => Effect.andThen(flushOnCloseWith(flusher), httpEffect),
78
+ ),
79
+ { global: true },
80
+ )
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `@effect/sql-pg` for Vercel Functions: a pool that keeps the instance up
3
+ * while it has a connection open.
4
+ *
5
+ * Vercel freezes an instance once no request or `waitUntil` promise is left.
6
+ * A frozen instance keeps its sockets, so a pooler like PgBouncer keeps
7
+ * counting them as clients until the instance wakes up or dies. With many
8
+ * instances, as during a deploy, they fill up `max_client_conn`.
9
+ * `attachDatabasePool` from `@vercel/functions` waits out the idle timeout
10
+ * after each release, but it only supports node-postgres-like pools, and the
11
+ * Effect pool closes idle connections up to twice its `idleTimeout` after
12
+ * their last use. So this layer counts the pool's sockets and waits until
13
+ * the last one closes.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import * as PgClient from "effect-vercel/PgClient"
18
+ * import { Redacted } from "effect"
19
+ *
20
+ * const SqlLive = PgClient.layer({
21
+ * url: Redacted.make(process.env.DATABASE_URL!),
22
+ * idleTimeout: "5 seconds",
23
+ * })
24
+ * ```
25
+ */
26
+
27
+ import * as Net from "node:net"
28
+ import { PgClient } from "@effect/sql-pg"
29
+ import { Duration, Effect, Layer, Redacted } from "effect"
30
+ import { constVoid } from "effect/Function"
31
+ import { currentRequest } from "./internal/requestContext.js"
32
+
33
+ /**
34
+ * Keeps the instance up while a tracked socket is open. One `waitUntil`
35
+ * promise holds it, and `follow` moves it to the request running now, as
36
+ * `attachDatabasePool` does, so a busy instance never holds a finished
37
+ * request open. The wait also ends a second before the request's deadline.
38
+ * Outside a request, `follow` keeps the wait where it is.
39
+ */
40
+ const makeKeepAlive = () => {
41
+ let open = 0
42
+ let release = constVoid
43
+
44
+ const follow = () => {
45
+ const request = currentRequest()
46
+ if (open === 0 || request?.waitUntil === undefined) return
47
+ release()
48
+ const cutoff = Date.parse(request.deadline ?? "") - 1_000
49
+ request.waitUntil(
50
+ new Promise<void>((resolve) => {
51
+ const timer = Number.isNaN(cutoff) ? undefined : setTimeout(resolve, cutoff - Date.now())
52
+ release = () => {
53
+ clearTimeout(timer)
54
+ resolve()
55
+ }
56
+ }),
57
+ )
58
+ }
59
+
60
+ const track = (socket: Net.Socket) => {
61
+ open++
62
+ follow()
63
+ socket.once("close", () => {
64
+ if (--open === 0) release()
65
+ })
66
+ return socket
67
+ }
68
+
69
+ return { follow, track }
70
+ }
71
+
72
+ /** `PgClient.PgPoolConfig` without `stream`, which this module sets. */
73
+ export type Config<R = never> = Omit<PgClient.PgPoolConfig<R>, "stream">
74
+
75
+ /**
76
+ * Opens the TCP or Unix socket `@effect/sql-pg` would open for `config`.
77
+ * It still runs TLS and the startup on it.
78
+ */
79
+ const connect = (config: Config<unknown>): Net.Socket => {
80
+ const url = config.url && new URL(Redacted.value(config.url))
81
+ const param = (name: string) => url?.searchParams.getAll(name).at(-1)
82
+ const urlHost = url?.hostname && decodeURIComponent(url.hostname.replace(/^\[(.*)\]$/, "$1"))
83
+ const host = config.host ?? param("host") ?? (urlHost || "localhost")
84
+ const port = config.port ?? Number(param("port") ?? (url?.port || 5432))
85
+ const path = config.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : undefined)
86
+ if (path !== undefined) return Net.connect({ path })
87
+ return Net.connect({
88
+ host,
89
+ port,
90
+ noDelay: true,
91
+ ...(config.keepAlive !== undefined && {
92
+ keepAlive: true,
93
+ keepAliveInitialDelay: Duration.toMillis(Duration.fromInputUnsafe(config.keepAlive)),
94
+ }),
95
+ })
96
+ }
97
+
98
+ /**
99
+ * `PgClient.layer` from `@effect/sql-pg` that keeps the Function instance up
100
+ * until the pool has closed every connection, or the request's deadline
101
+ * comes. Off Vercel it behaves like the plain layer.
102
+ *
103
+ * It opens each socket itself, through the `stream` option, and moves the
104
+ * wait to the request that takes each connection, through `onAcquire`. An
105
+ * `onAcquire` in `config` still runs, after that.
106
+ */
107
+ export const layer = <R = never>(config: Config<R>) =>
108
+ Layer.suspend(() => {
109
+ const keepAlive = makeKeepAlive()
110
+ const follow = Effect.sync(keepAlive.follow)
111
+ return PgClient.layer({
112
+ ...config,
113
+ stream: () => keepAlive.track(connect(config)),
114
+ onAcquire: config.onAcquire ? Effect.andThen(follow, config.onAcquire) : follow,
115
+ })
116
+ })
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Vercel exposes the current request's context on a well-known global symbol.
3
+ * `@vercel/functions` reads `waitUntil` and the deadline from there. Off
4
+ * Vercel, or outside a request, there is no `waitUntil`.
5
+ */
6
+ const REQUEST_CONTEXT = Symbol.for("@vercel/request-context")
7
+
8
+ type RequestContext = {
9
+ readonly waitUntil?: (promise: Promise<unknown>) => void
10
+ readonly deadline?: string
11
+ }
12
+
13
+ export const currentRequest = (): RequestContext | undefined => {
14
+ const holder = globalThis as typeof globalThis & {
15
+ [REQUEST_CONTEXT]?: { get?: () => RequestContext | undefined }
16
+ }
17
+ return holder[REQUEST_CONTEXT]?.get?.()
18
+ }