effect-vercel 0.4.0 → 0.6.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 +61 -1
- package/dist/Functions.d.ts +14 -0
- package/dist/Functions.js +34 -0
- package/dist/Functions.js.map +1 -0
- package/dist/PgClient.d.ts +17 -0
- package/dist/PgClient.js +119 -0
- package/dist/PgClient.js.map +1 -0
- package/dist/RuntimeCache.js +1 -1
- package/package.json +13 -4
- package/src/Functions.ts +34 -0
- package/src/PgClient.ts +134 -0
package/README.md
CHANGED
|
@@ -11,7 +11,9 @@ import * as AiGateway from "effect-vercel/AiGateway"
|
|
|
11
11
|
| ------------------------------------ | ------------------------------------------------------------------------------- |
|
|
12
12
|
| `effect-vercel/AiGateway` | [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) for Effect AI |
|
|
13
13
|
| `effect-vercel/AiGatewayCredentials` | How AI Gateway requests authenticate |
|
|
14
|
+
| `effect-vercel/Functions` | Bindings for `@vercel/functions`: `waitUntil` |
|
|
14
15
|
| `effect-vercel/Oidc` | The OIDC token Vercel issues to a deployment |
|
|
16
|
+
| `effect-vercel/PgClient` | `@effect/sql-pg` pool that doesn't leave connections open on frozen instances |
|
|
15
17
|
| `effect-vercel/RuntimeCache` | [Runtime Cache](https://vercel.com/docs/runtime-cache) for Effect `Persistence` |
|
|
16
18
|
|
|
17
19
|
## Install
|
|
@@ -19,7 +21,8 @@ import * as AiGateway from "effect-vercel/AiGateway"
|
|
|
19
21
|
```sh
|
|
20
22
|
pnpm add effect-vercel effect
|
|
21
23
|
pnpm add @effect/ai-anthropic # only for effect-vercel/AiGateway
|
|
22
|
-
pnpm add @vercel/functions # only for effect-vercel/RuntimeCache
|
|
24
|
+
pnpm add @vercel/functions # only for effect-vercel/Functions and RuntimeCache
|
|
25
|
+
pnpm add @effect/sql-pg # only for effect-vercel/PgClient
|
|
23
26
|
```
|
|
24
27
|
|
|
25
28
|
## AI Gateway
|
|
@@ -120,6 +123,63 @@ unset. It fails with an `OidcError` when neither is set.
|
|
|
120
123
|
It reads the token on every run, since it rotates per request. It does not refresh an
|
|
121
124
|
expired local token; re-run `vercel env pull`.
|
|
122
125
|
|
|
126
|
+
## Functions
|
|
127
|
+
|
|
128
|
+
`Functions.waitUntil(fiber)` keeps the Function running until `fiber` ends, as `waitUntil`
|
|
129
|
+
from `@vercel/functions` does for a promise. Use it to finish work after the response is sent.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import * as Functions from "effect-vercel/Functions"
|
|
133
|
+
import { Effect } from "effect"
|
|
134
|
+
|
|
135
|
+
const handler = Effect.gen(function* () {
|
|
136
|
+
yield* Effect.forkDetach(sendReceipt).pipe(Effect.tap(Functions.waitUntil))
|
|
137
|
+
return new Response("OK")
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- Fork with `Effect.forkDetach`, or into a scope that outlives the request. A child fiber is
|
|
142
|
+
interrupted when the request fiber ends, whatever `waitUntil` says.
|
|
143
|
+
- The fiber keeps the request's services. Acquire resources it needs inside its own
|
|
144
|
+
`Effect.scoped`, since the request's resources may be released first.
|
|
145
|
+
- Failures are not reported. Handle or log them in the forked effect.
|
|
146
|
+
- Vercel stops the work when the Function reaches its maximum duration.
|
|
147
|
+
- Off Vercel, `waitUntil` does nothing and the fiber runs in the background.
|
|
148
|
+
|
|
149
|
+
## Postgres
|
|
150
|
+
|
|
151
|
+
`PgClient.layer` is `PgClient.layer` from `@effect/sql-pg` that keeps the Function instance up
|
|
152
|
+
until the pool has closed its connections.
|
|
153
|
+
|
|
154
|
+
Vercel freezes an instance once no request or `waitUntil` promise is left. A frozen instance
|
|
155
|
+
keeps its sockets open, so a pooler like PgBouncer still counts them against `max_client_conn`.
|
|
156
|
+
During a deploy many instances freeze at once, which can use up the limit. `attachDatabasePool`
|
|
157
|
+
from `@vercel/functions` handles this for node-postgres pools by waiting out the idle timeout
|
|
158
|
+
after each release. It doesn't support the Effect pool, which also closes an idle connection up
|
|
159
|
+
to twice its `idleTimeout` after its last use.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import * as PgClient from "effect-vercel/PgClient"
|
|
163
|
+
import { Redacted } from "effect"
|
|
164
|
+
|
|
165
|
+
const SqlLive = PgClient.layer({
|
|
166
|
+
url: Redacted.make(process.env.DATABASE_URL!),
|
|
167
|
+
maxConnections: 3,
|
|
168
|
+
idleTimeout: "5 seconds",
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- It takes the same config as `@effect/sql-pg`, except `stream`: it opens each socket itself
|
|
173
|
+
and counts it until it closes. While a socket is open, one `waitUntil` promise keeps the
|
|
174
|
+
instance up.
|
|
175
|
+
- Each time a request takes a connection from the pool, the promise moves to that request, so a
|
|
176
|
+
busy instance never keeps a finished request open. The wait also ends a second before the
|
|
177
|
+
request's deadline.
|
|
178
|
+
- It moves the promise from `onAcquire`. An `onAcquire` in your config still runs, after.
|
|
179
|
+
- Keep `idleTimeout` short. The instance stays up until the pool closes its last idle
|
|
180
|
+
connection.
|
|
181
|
+
- Off Vercel, it behaves like the plain layer.
|
|
182
|
+
|
|
123
183
|
## Runtime Cache
|
|
124
184
|
|
|
125
185
|
`RuntimeCache.layer` provides Effect's `Persistence`, stored in the Vercel Runtime Cache.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect, Fiber } from "effect";
|
|
2
|
+
//#region src/Functions.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Keeps the Function running until `fiber` ends, as `waitUntil` from
|
|
5
|
+
* `@vercel/functions` does for a promise. Off Vercel it does nothing.
|
|
6
|
+
*
|
|
7
|
+
* The fiber must outlive the request fiber, so fork it with
|
|
8
|
+
* `Effect.forkDetach` or into a scope that outlives the request. A child
|
|
9
|
+
* fiber is interrupted when the request fiber ends. Failures are not
|
|
10
|
+
* reported, so handle them in the forked effect.
|
|
11
|
+
*/
|
|
12
|
+
export declare const waitUntil: <A, E>(fiber: Fiber.Fiber<A, E>) => Effect.Effect<void>;
|
|
13
|
+
//#endregion
|
|
14
|
+
//# sourceMappingURL=Functions.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { waitUntil as waitUntil$1 } from "@vercel/functions";
|
|
3
|
+
//#region src/Functions.ts
|
|
4
|
+
/**
|
|
5
|
+
* Effect bindings for `@vercel/functions`.
|
|
6
|
+
*
|
|
7
|
+
* `waitUntil` keeps the Function running until a fiber ends, so work forked
|
|
8
|
+
* during a request can finish after the response is sent.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import * as Functions from "effect-vercel/Functions"
|
|
13
|
+
* import { Effect } from "effect"
|
|
14
|
+
*
|
|
15
|
+
* const handler = Effect.gen(function* () {
|
|
16
|
+
* yield* Effect.forkDetach(sendReceipt).pipe(Effect.tap(Functions.waitUntil))
|
|
17
|
+
* return new Response("OK")
|
|
18
|
+
* })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Keeps the Function running until `fiber` ends, as `waitUntil` from
|
|
23
|
+
* `@vercel/functions` does for a promise. Off Vercel it does nothing.
|
|
24
|
+
*
|
|
25
|
+
* The fiber must outlive the request fiber, so fork it with
|
|
26
|
+
* `Effect.forkDetach` or into a scope that outlives the request. A child
|
|
27
|
+
* fiber is interrupted when the request fiber ends. Failures are not
|
|
28
|
+
* reported, so handle them in the forked effect.
|
|
29
|
+
*/
|
|
30
|
+
const waitUntil = (fiber) => Effect.sync(() => waitUntil$1(new Promise((resolve) => fiber.addObserver(() => resolve()))));
|
|
31
|
+
//#endregion
|
|
32
|
+
export { waitUntil };
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=Functions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Functions.js","names":["vercelWaitUntil"],"sources":["../src/Functions.ts"],"sourcesContent":["/**\n * Effect bindings for `@vercel/functions`.\n *\n * `waitUntil` keeps the Function running until a fiber ends, so work forked\n * during a request can finish after the response is sent.\n *\n * @example\n * ```ts\n * import * as Functions from \"effect-vercel/Functions\"\n * import { Effect } from \"effect\"\n *\n * const handler = Effect.gen(function* () {\n * yield* Effect.forkDetach(sendReceipt).pipe(Effect.tap(Functions.waitUntil))\n * return new Response(\"OK\")\n * })\n * ```\n */\n\nimport { waitUntil as vercelWaitUntil } from \"@vercel/functions\"\nimport { Effect, type Fiber } from \"effect\"\n\n/**\n * Keeps the Function running until `fiber` ends, as `waitUntil` from\n * `@vercel/functions` does for a promise. Off Vercel it does nothing.\n *\n * The fiber must outlive the request fiber, so fork it with\n * `Effect.forkDetach` or into a scope that outlives the request. A child\n * fiber is interrupted when the request fiber ends. Failures are not\n * reported, so handle them in the forked effect.\n */\nexport const waitUntil = <A, E>(fiber: Fiber.Fiber<A, E>): Effect.Effect<void> =>\n Effect.sync(() =>\n vercelWaitUntil(new Promise<void>((resolve) => fiber.addObserver(() => resolve()))),\n )\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAa,aAAmB,UAC9B,OAAO,WACLA,YAAgB,IAAI,SAAe,YAAY,MAAM,kBAAkB,QAAQ,CAAC,CAAC,CAAC,CACpF"}
|
|
@@ -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
|
package/dist/PgClient.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Duration, Effect, Layer, Redacted } from "effect";
|
|
2
|
+
import * as Net from "node:net";
|
|
3
|
+
import { PgClient } from "@effect/sql-pg";
|
|
4
|
+
import { constVoid } from "effect/Function";
|
|
5
|
+
//#region src/PgClient.ts
|
|
6
|
+
/**
|
|
7
|
+
* `@effect/sql-pg` for Vercel Functions: a pool that keeps the instance up
|
|
8
|
+
* while it has a connection open.
|
|
9
|
+
*
|
|
10
|
+
* Vercel freezes an instance once no request or `waitUntil` promise is left.
|
|
11
|
+
* A frozen instance keeps its sockets, so a pooler like PgBouncer keeps
|
|
12
|
+
* counting them as clients until the instance wakes up or dies. With many
|
|
13
|
+
* instances, as during a deploy, they fill up `max_client_conn`.
|
|
14
|
+
* `attachDatabasePool` from `@vercel/functions` waits out the idle timeout
|
|
15
|
+
* after each release, but it only supports node-postgres-like pools, and the
|
|
16
|
+
* Effect pool closes idle connections up to twice its `idleTimeout` after
|
|
17
|
+
* their last use. So this layer counts the pool's sockets and waits until
|
|
18
|
+
* the last one closes.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import * as PgClient from "effect-vercel/PgClient"
|
|
23
|
+
* import { Redacted } from "effect"
|
|
24
|
+
*
|
|
25
|
+
* const SqlLive = PgClient.layer({
|
|
26
|
+
* url: Redacted.make(process.env.DATABASE_URL!),
|
|
27
|
+
* idleTimeout: "5 seconds",
|
|
28
|
+
* })
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Vercel exposes the current request's context on a well-known global symbol.
|
|
33
|
+
* `@vercel/functions` reads `waitUntil` and the deadline from there. Off
|
|
34
|
+
* Vercel, or outside a request, there is no `waitUntil`.
|
|
35
|
+
*/
|
|
36
|
+
const REQUEST_CONTEXT = Symbol.for("@vercel/request-context");
|
|
37
|
+
const currentRequest = () => {
|
|
38
|
+
return globalThis[REQUEST_CONTEXT]?.get?.();
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Keeps the instance up while a tracked socket is open. One `waitUntil`
|
|
42
|
+
* promise holds it, and `follow` moves it to the request running now, as
|
|
43
|
+
* `attachDatabasePool` does, so a busy instance never holds a finished
|
|
44
|
+
* request open. The wait also ends a second before the request's deadline.
|
|
45
|
+
* Outside a request, `follow` keeps the wait where it is.
|
|
46
|
+
*/
|
|
47
|
+
const makeKeepAlive = () => {
|
|
48
|
+
let open = 0;
|
|
49
|
+
let release = constVoid;
|
|
50
|
+
const follow = () => {
|
|
51
|
+
const request = currentRequest();
|
|
52
|
+
if (open === 0 || request?.waitUntil === void 0) return;
|
|
53
|
+
release();
|
|
54
|
+
const cutoff = Date.parse(request.deadline ?? "") - 1e3;
|
|
55
|
+
request.waitUntil(new Promise((resolve) => {
|
|
56
|
+
const timer = Number.isNaN(cutoff) ? void 0 : setTimeout(resolve, cutoff - Date.now());
|
|
57
|
+
release = () => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
resolve();
|
|
60
|
+
};
|
|
61
|
+
}));
|
|
62
|
+
};
|
|
63
|
+
const track = (socket) => {
|
|
64
|
+
open++;
|
|
65
|
+
follow();
|
|
66
|
+
socket.once("close", () => {
|
|
67
|
+
if (--open === 0) release();
|
|
68
|
+
});
|
|
69
|
+
return socket;
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
follow,
|
|
73
|
+
track
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Opens the TCP or Unix socket `@effect/sql-pg` would open for `config`.
|
|
78
|
+
* It still runs TLS and the startup on it.
|
|
79
|
+
*/
|
|
80
|
+
const connect = (config) => {
|
|
81
|
+
const url = config.url && new URL(Redacted.value(config.url));
|
|
82
|
+
const param = (name) => url?.searchParams.getAll(name).at(-1);
|
|
83
|
+
const urlHost = url?.hostname && decodeURIComponent(url.hostname.replace(/^\[(.*)\]$/, "$1"));
|
|
84
|
+
const host = config.host ?? param("host") ?? (urlHost || "localhost");
|
|
85
|
+
const port = config.port ?? Number(param("port") ?? (url?.port || 5432));
|
|
86
|
+
const path = config.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : void 0);
|
|
87
|
+
if (path !== void 0) return Net.connect({ path });
|
|
88
|
+
return Net.connect({
|
|
89
|
+
host,
|
|
90
|
+
port,
|
|
91
|
+
noDelay: true,
|
|
92
|
+
...config.keepAlive !== void 0 && {
|
|
93
|
+
keepAlive: true,
|
|
94
|
+
keepAliveInitialDelay: Duration.toMillis(Duration.fromInputUnsafe(config.keepAlive))
|
|
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
|
+
const layer = (config) => Layer.suspend(() => {
|
|
108
|
+
const keepAlive = makeKeepAlive();
|
|
109
|
+
const follow = Effect.sync(keepAlive.follow);
|
|
110
|
+
return PgClient.layer({
|
|
111
|
+
...config,
|
|
112
|
+
stream: () => keepAlive.track(connect(config)),
|
|
113
|
+
onAcquire: config.onAcquire ? Effect.andThen(follow, config.onAcquire) : follow
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
//#endregion
|
|
117
|
+
export { layer };
|
|
118
|
+
|
|
119
|
+
//# sourceMappingURL=PgClient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PgClient.js","names":["holder"],"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\"\n\n/**\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\nconst 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\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCA,MAAM,kBAAkB,OAAO,IAAI,yBAAyB;AAO5D,MAAM,uBAAmD;CAIvD,OAAOA,WAAO,gBAAgB,EAAE,MAAM;AACxC;;;;;;;;AASA,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"}
|
package/dist/RuntimeCache.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Duration, Effect, Layer } from "effect";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
2
|
import { getCache } from "@vercel/functions";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
4
|
import { Persistence } from "effect/persistence";
|
|
5
5
|
//#region src/RuntimeCache.ts
|
|
6
6
|
/**
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-vercel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Effect integrations for Vercel: AI Gateway, OIDC, Runtime Cache",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Effect integrations for Vercel: AI Gateway, OIDC, Postgres pools, Runtime Cache, waitUntil",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-gateway",
|
|
7
7
|
"effect",
|
|
8
8
|
"effect-ai",
|
|
9
9
|
"oidc",
|
|
10
|
+
"postgres",
|
|
10
11
|
"runtime-cache",
|
|
11
|
-
"vercel"
|
|
12
|
+
"vercel",
|
|
13
|
+
"wait-until"
|
|
12
14
|
],
|
|
13
15
|
"homepage": "https://github.com/duailibe/effect-vercel#readme",
|
|
14
16
|
"bugs": {
|
|
@@ -29,7 +31,9 @@
|
|
|
29
31
|
"exports": {
|
|
30
32
|
"./AiGateway": "./dist/AiGateway.js",
|
|
31
33
|
"./AiGatewayCredentials": "./dist/AiGatewayCredentials.js",
|
|
34
|
+
"./Functions": "./dist/Functions.js",
|
|
32
35
|
"./Oidc": "./dist/Oidc.js",
|
|
36
|
+
"./PgClient": "./dist/PgClient.js",
|
|
33
37
|
"./RuntimeCache": "./dist/RuntimeCache.js",
|
|
34
38
|
"./package.json": "./package.json"
|
|
35
39
|
},
|
|
@@ -48,10 +52,11 @@
|
|
|
48
52
|
"devDependencies": {
|
|
49
53
|
"@effect/ai-anthropic": "^4.0.0",
|
|
50
54
|
"@effect/language-service": "^0.87.3",
|
|
55
|
+
"@effect/sql-pg": "^4.0.6",
|
|
51
56
|
"@effect/vitest": "^4.0.0",
|
|
52
57
|
"@types/node": "^24",
|
|
53
58
|
"@vercel/functions": "^3.9.9",
|
|
54
|
-
"effect": "^4.0.
|
|
59
|
+
"effect": "^4.0.6",
|
|
55
60
|
"oxfmt": "^0.68.0",
|
|
56
61
|
"oxlint": "^1.83.0",
|
|
57
62
|
"tsdown": "^0.23.0",
|
|
@@ -60,6 +65,7 @@
|
|
|
60
65
|
},
|
|
61
66
|
"peerDependencies": {
|
|
62
67
|
"@effect/ai-anthropic": "^4.0.0",
|
|
68
|
+
"@effect/sql-pg": "^4.0.6",
|
|
63
69
|
"@vercel/functions": "^3.9.9",
|
|
64
70
|
"effect": "^4.0.0"
|
|
65
71
|
},
|
|
@@ -67,6 +73,9 @@
|
|
|
67
73
|
"@effect/ai-anthropic": {
|
|
68
74
|
"optional": true
|
|
69
75
|
},
|
|
76
|
+
"@effect/sql-pg": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
70
79
|
"@vercel/functions": {
|
|
71
80
|
"optional": true
|
|
72
81
|
}
|
package/src/Functions.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect bindings for `@vercel/functions`.
|
|
3
|
+
*
|
|
4
|
+
* `waitUntil` keeps the Function running until a fiber ends, so work forked
|
|
5
|
+
* during a request can finish after the response is sent.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import * as Functions from "effect-vercel/Functions"
|
|
10
|
+
* import { Effect } from "effect"
|
|
11
|
+
*
|
|
12
|
+
* const handler = Effect.gen(function* () {
|
|
13
|
+
* yield* Effect.forkDetach(sendReceipt).pipe(Effect.tap(Functions.waitUntil))
|
|
14
|
+
* return new Response("OK")
|
|
15
|
+
* })
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { waitUntil as vercelWaitUntil } from "@vercel/functions"
|
|
20
|
+
import { Effect, type Fiber } from "effect"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Keeps the Function running until `fiber` ends, as `waitUntil` from
|
|
24
|
+
* `@vercel/functions` does for a promise. Off Vercel it does nothing.
|
|
25
|
+
*
|
|
26
|
+
* The fiber must outlive the request fiber, so fork it with
|
|
27
|
+
* `Effect.forkDetach` or into a scope that outlives the request. A child
|
|
28
|
+
* fiber is interrupted when the request fiber ends. Failures are not
|
|
29
|
+
* reported, so handle them in the forked effect.
|
|
30
|
+
*/
|
|
31
|
+
export const waitUntil = <A, E>(fiber: Fiber.Fiber<A, E>): Effect.Effect<void> =>
|
|
32
|
+
Effect.sync(() =>
|
|
33
|
+
vercelWaitUntil(new Promise<void>((resolve) => fiber.addObserver(() => resolve()))),
|
|
34
|
+
)
|
package/src/PgClient.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Vercel exposes the current request's context on a well-known global symbol.
|
|
34
|
+
* `@vercel/functions` reads `waitUntil` and the deadline from there. Off
|
|
35
|
+
* Vercel, or outside a request, there is no `waitUntil`.
|
|
36
|
+
*/
|
|
37
|
+
const REQUEST_CONTEXT = Symbol.for("@vercel/request-context")
|
|
38
|
+
|
|
39
|
+
type RequestContext = {
|
|
40
|
+
readonly waitUntil?: (promise: Promise<unknown>) => void
|
|
41
|
+
readonly deadline?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const currentRequest = (): RequestContext | undefined => {
|
|
45
|
+
const holder = globalThis as typeof globalThis & {
|
|
46
|
+
[REQUEST_CONTEXT]?: { get?: () => RequestContext | undefined }
|
|
47
|
+
}
|
|
48
|
+
return holder[REQUEST_CONTEXT]?.get?.()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Keeps the instance up while a tracked socket is open. One `waitUntil`
|
|
53
|
+
* promise holds it, and `follow` moves it to the request running now, as
|
|
54
|
+
* `attachDatabasePool` does, so a busy instance never holds a finished
|
|
55
|
+
* request open. The wait also ends a second before the request's deadline.
|
|
56
|
+
* Outside a request, `follow` keeps the wait where it is.
|
|
57
|
+
*/
|
|
58
|
+
const makeKeepAlive = () => {
|
|
59
|
+
let open = 0
|
|
60
|
+
let release = constVoid
|
|
61
|
+
|
|
62
|
+
const follow = () => {
|
|
63
|
+
const request = currentRequest()
|
|
64
|
+
if (open === 0 || request?.waitUntil === undefined) return
|
|
65
|
+
release()
|
|
66
|
+
const cutoff = Date.parse(request.deadline ?? "") - 1_000
|
|
67
|
+
request.waitUntil(
|
|
68
|
+
new Promise<void>((resolve) => {
|
|
69
|
+
const timer = Number.isNaN(cutoff) ? undefined : setTimeout(resolve, cutoff - Date.now())
|
|
70
|
+
release = () => {
|
|
71
|
+
clearTimeout(timer)
|
|
72
|
+
resolve()
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const track = (socket: Net.Socket) => {
|
|
79
|
+
open++
|
|
80
|
+
follow()
|
|
81
|
+
socket.once("close", () => {
|
|
82
|
+
if (--open === 0) release()
|
|
83
|
+
})
|
|
84
|
+
return socket
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { follow, track }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `PgClient.PgPoolConfig` without `stream`, which this module sets. */
|
|
91
|
+
export type Config<R = never> = Omit<PgClient.PgPoolConfig<R>, "stream">
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Opens the TCP or Unix socket `@effect/sql-pg` would open for `config`.
|
|
95
|
+
* It still runs TLS and the startup on it.
|
|
96
|
+
*/
|
|
97
|
+
const connect = (config: Config<unknown>): Net.Socket => {
|
|
98
|
+
const url = config.url && new URL(Redacted.value(config.url))
|
|
99
|
+
const param = (name: string) => url?.searchParams.getAll(name).at(-1)
|
|
100
|
+
const urlHost = url?.hostname && decodeURIComponent(url.hostname.replace(/^\[(.*)\]$/, "$1"))
|
|
101
|
+
const host = config.host ?? param("host") ?? (urlHost || "localhost")
|
|
102
|
+
const port = config.port ?? Number(param("port") ?? (url?.port || 5432))
|
|
103
|
+
const path = config.path ?? (host.startsWith("/") ? `${host}/.s.PGSQL.${port}` : undefined)
|
|
104
|
+
if (path !== undefined) return Net.connect({ path })
|
|
105
|
+
return Net.connect({
|
|
106
|
+
host,
|
|
107
|
+
port,
|
|
108
|
+
noDelay: true,
|
|
109
|
+
...(config.keepAlive !== undefined && {
|
|
110
|
+
keepAlive: true,
|
|
111
|
+
keepAliveInitialDelay: Duration.toMillis(Duration.fromInputUnsafe(config.keepAlive)),
|
|
112
|
+
}),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `PgClient.layer` from `@effect/sql-pg` that keeps the Function instance up
|
|
118
|
+
* until the pool has closed every connection, or the request's deadline
|
|
119
|
+
* comes. Off Vercel it behaves like the plain layer.
|
|
120
|
+
*
|
|
121
|
+
* It opens each socket itself, through the `stream` option, and moves the
|
|
122
|
+
* wait to the request that takes each connection, through `onAcquire`. An
|
|
123
|
+
* `onAcquire` in `config` still runs, after that.
|
|
124
|
+
*/
|
|
125
|
+
export const layer = <R = never>(config: Config<R>) =>
|
|
126
|
+
Layer.suspend(() => {
|
|
127
|
+
const keepAlive = makeKeepAlive()
|
|
128
|
+
const follow = Effect.sync(keepAlive.follow)
|
|
129
|
+
return PgClient.layer({
|
|
130
|
+
...config,
|
|
131
|
+
stream: () => keepAlive.track(connect(config)),
|
|
132
|
+
onAcquire: config.onAcquire ? Effect.andThen(follow, config.onAcquire) : follow,
|
|
133
|
+
})
|
|
134
|
+
})
|