@grest-ts/http 0.0.47 → 0.0.49
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 +321 -128
- package/dist/src/_dedupCheck.js +1 -1
- package/dist/src/client/GGContextKeySynchronizer.d.ts +11 -0
- package/dist/src/client/GGContextKeySynchronizer.d.ts.map +1 -0
- package/dist/src/client/GGContextKeySynchronizer.js +26 -0
- package/dist/src/client/GGContextKeySynchronizer.js.map +1 -0
- package/dist/src/client/GGHttpSchema.createClient.d.ts +8 -0
- package/dist/src/client/GGHttpSchema.createClient.d.ts.map +1 -1
- package/dist/src/client/GGHttpSchema.createClient.js +2 -1
- package/dist/src/client/GGHttpSchema.createClient.js.map +1 -1
- package/dist/src/index-browser.d.ts +4 -0
- package/dist/src/index-browser.d.ts.map +1 -1
- package/dist/src/index-browser.js +4 -0
- package/dist/src/index-browser.js.map +1 -1
- package/dist/src/index-node.d.ts +9 -1
- package/dist/src/index-node.d.ts.map +1 -1
- package/dist/src/index-node.js +17 -5
- package/dist/src/index-node.js.map +1 -1
- package/dist/src/rpc/GGHttpRouteRPC.d.ts +4 -0
- package/dist/src/rpc/GGHttpRouteRPC.d.ts.map +1 -1
- package/dist/src/rpc/GGHttpRouteRPC.js +6 -0
- package/dist/src/rpc/GGHttpRouteRPC.js.map +1 -1
- package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts +4 -3
- package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts.map +1 -1
- package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js +9 -2
- package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js.map +1 -1
- package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts +3 -3
- package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts.map +1 -1
- package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js +13 -16
- package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js.map +1 -1
- package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts +1 -0
- package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts.map +1 -1
- package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js +4 -0
- package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js.map +1 -1
- package/dist/src/schema/GGCookie.d.ts +50 -0
- package/dist/src/schema/GGCookie.d.ts.map +1 -0
- package/dist/src/schema/GGCookie.js +130 -0
- package/dist/src/schema/GGCookie.js.map +1 -0
- package/dist/src/schema/GGHeader.d.ts +23 -0
- package/dist/src/schema/GGHeader.d.ts.map +1 -0
- package/dist/src/schema/GGHeader.js +41 -0
- package/dist/src/schema/GGHeader.js.map +1 -0
- package/dist/src/schema/GGHttpPermissionsChecker.d.ts +9 -0
- package/dist/src/schema/GGHttpPermissionsChecker.d.ts.map +1 -0
- package/dist/src/schema/GGHttpPermissionsChecker.js +29 -0
- package/dist/src/schema/GGHttpPermissionsChecker.js.map +1 -0
- package/dist/src/schema/GGHttpSchema.d.ts +14 -52
- package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
- package/dist/src/schema/GGHttpSchema.js.map +1 -1
- package/dist/src/schema/GGWireContextKey.d.ts +30 -0
- package/dist/src/schema/GGWireContextKey.d.ts.map +1 -0
- package/dist/src/schema/GGWireContextKey.js +36 -0
- package/dist/src/schema/GGWireContextKey.js.map +1 -0
- package/dist/src/schema/GGWireContextKey.node.d.ts +36 -0
- package/dist/src/schema/GGWireContextKey.node.d.ts.map +1 -0
- package/dist/src/schema/GGWireContextKey.node.js +83 -0
- package/dist/src/schema/GGWireContextKey.node.js.map +1 -0
- package/dist/src/schema/httpSchema.d.ts +2 -4
- package/dist/src/schema/httpSchema.d.ts.map +1 -1
- package/dist/src/schema/httpSchema.js +0 -48
- package/dist/src/schema/httpSchema.js.map +1 -1
- package/dist/src/server/GGHttp.d.ts +2 -26
- package/dist/src/server/GGHttp.d.ts.map +1 -1
- package/dist/src/server/GGHttp.js +0 -15
- package/dist/src/server/GGHttp.js.map +1 -1
- package/dist/src/server/GGHttpSchema.startServer.d.ts +2 -11
- package/dist/src/server/GGHttpSchema.startServer.d.ts.map +1 -1
- package/dist/src/server/GGHttpSchema.startServer.js +14 -28
- package/dist/src/server/GGHttpSchema.startServer.js.map +1 -1
- package/dist/src/server/GGHttpServer.d.ts +45 -14
- package/dist/src/server/GGHttpServer.d.ts.map +1 -1
- package/dist/src/server/GGHttpServer.js +61 -87
- package/dist/src/server/GGHttpServer.js.map +1 -1
- package/dist/src/server/applyRequestMiddleware.d.ts +11 -0
- package/dist/src/server/applyRequestMiddleware.d.ts.map +1 -0
- package/dist/src/server/applyRequestMiddleware.js +38 -0
- package/dist/src/server/applyRequestMiddleware.js.map +1 -0
- package/dist/src/server/applyResponseMiddleware.d.ts +12 -0
- package/dist/src/server/applyResponseMiddleware.d.ts.map +1 -0
- package/dist/src/server/applyResponseMiddleware.js +25 -0
- package/dist/src/server/applyResponseMiddleware.js.map +1 -0
- package/dist/src/server/checkers/checkPermissionsAtStart.d.ts +39 -0
- package/dist/src/server/checkers/checkPermissionsAtStart.d.ts.map +1 -0
- package/dist/src/server/checkers/checkPermissionsAtStart.js +82 -0
- package/dist/src/server/checkers/checkPermissionsAtStart.js.map +1 -0
- package/dist/src/server/checkers/checkWireConflicts.d.ts +18 -0
- package/dist/src/server/checkers/checkWireConflicts.d.ts.map +1 -0
- package/dist/src/server/checkers/checkWireConflicts.js +43 -0
- package/dist/src/server/checkers/checkWireConflicts.js.map +1 -0
- package/dist/src/server/checkers/checkWiresImplemented.d.ts +11 -0
- package/dist/src/server/checkers/checkWiresImplemented.d.ts.map +1 -0
- package/dist/src/server/checkers/checkWiresImplemented.js +27 -0
- package/dist/src/server/checkers/checkWiresImplemented.js.map +1 -0
- package/dist/src/tsconfig.json +2 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +14 -11
- package/src/_dedupCheck.ts +1 -1
- package/src/client/GGContextKeySynchronizer.test.ts +106 -0
- package/src/client/GGContextKeySynchronizer.ts +42 -0
- package/src/client/GGHttpSchema.createClient.ts +10 -1
- package/src/index-browser.ts +4 -0
- package/src/index-node.ts +18 -5
- package/src/rpc/GGHttpRouteRPC.ts +8 -0
- package/src/rpc/RpcRequest/GGRpcRequestBuilder.ts +11 -4
- package/src/rpc/RpcRequest/GGRpcRequestParser.ts +16 -17
- package/src/rpc/RpcResponse/GGRpcResponseBuilder.ts +5 -0
- package/src/schema/GGCookie.ts +151 -0
- package/src/schema/GGHeader.ts +48 -0
- package/src/schema/GGHttpPermissionsChecker.test.ts +125 -0
- package/src/schema/GGHttpPermissionsChecker.ts +35 -0
- package/src/schema/GGHttpSchema.ts +15 -63
- package/src/schema/GGWireContextKey.node.ts +119 -0
- package/src/schema/GGWireContextKey.ts +46 -0
- package/src/schema/httpSchema.ts +4 -57
- package/src/server/GGHttp.ts +3 -34
- package/src/server/GGHttpSchema.startServer.ts +16 -38
- package/src/server/GGHttpServer.ts +90 -97
- package/src/server/applyRequestMiddleware.ts +40 -0
- package/src/server/applyResponseMiddleware.ts +27 -0
- package/src/server/checkers/checkPermissionsAtStart.ts +112 -0
- package/src/server/checkers/checkWireConflicts.ts +65 -0
- package/src/server/checkers/checkWiresImplemented.ts +42 -0
- package/src/tsconfig.json +2 -1
- package/dist/src/server/GG_PERMISSIONS.d.ts +0 -15
- package/dist/src/server/GG_PERMISSIONS.d.ts.map +0 -1
- package/dist/src/server/GG_PERMISSIONS.js +0 -15
- package/dist/src/server/GG_PERMISSIONS.js.map +0 -1
- package/src/server/GG_PERMISSIONS.ts +0 -15
package/README.md
CHANGED
|
@@ -168,169 +168,237 @@ export const MyApiContract = new GGContractClass("MyApi", {
|
|
|
168
168
|
|
|
169
169
|
## Permissions
|
|
170
170
|
|
|
171
|
-
Contract methods may declare a `permission`. The framework gates every request against the declared permission **before the handler runs**, so missing or wrong scopes can never reach service code.
|
|
171
|
+
Contract methods may declare a `permission`. The framework gates every request against the declared permission **before the handler runs**, so missing or wrong scopes can never reach service code. The caller's scopes come from the **wires** the schema `.use()`s (see "Authentication & Context" below) — a smart wire verifies the credential and exposes the caller's grants via its `permissions()` resolver. There is no separate `.usePermissions(...)` step and no auth-middleware chain; the schema's wires ARE the source of scopes.
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
Declarations are opt-in but *infectious*: once any route on the server declares a `permission`, every route on the server must declare one — the runtime refuses to start otherwise (see "Hard guarantee" below).
|
|
174
174
|
|
|
175
175
|
```typescript
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
// contract: declare the permission a route requires
|
|
177
|
+
export const ItemApiContract = new GGContractClass("ItemApi", {
|
|
178
|
+
list: { success: IsArray(IsItem), errors: [SERVER_ERROR], permission: GG_NO_PERMISSIONS },
|
|
179
|
+
delete: { input: IsItemIdParam, errors: [NOT_AUTHORIZED, FORBIDDEN, SERVER_ERROR], permission: ItemPermission.DELETE },
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// schema: .use() the wire that authenticates + resolves scopes for these routes
|
|
183
|
+
export const ItemApi = httpSchema(ItemApiContract)
|
|
184
|
+
.pathPrefix("api/items")
|
|
185
|
+
.use(USER_TOKEN_WIRE)
|
|
186
|
+
.routes({ list: GGRpc.GET("list"), delete: GGRpc.DELETE("delete/:id") })
|
|
180
187
|
```
|
|
181
188
|
|
|
182
|
-
|
|
189
|
+
The gate reads the method's `permission`, collects the caller's grants from every wire on the schema (each wire's `permissions()`), pools them into one set, checks `satisfies(method.permission, pooledGrants)`, and throws `NOT_AUTHORIZED` (a wire couldn't authenticate) or `FORBIDDEN` (authenticated, wrong scopes).
|
|
190
|
+
|
|
191
|
+
**Multi-wire composition (AND across sources).** A schema may `.use()` more than one auth wire (e.g. a user wire AND an org wire). Each wire's server handler runs at the request boundary, so **every** wire must authenticate — any one throwing rejects the request. That is the AND across sources. For scope checks the grants from all wires are **pooled** into one set, and the method's `permission` must be satisfied by that union; grants are matched by string against the pool, with no per-wire ownership of permission strings. A route that declares a non-public `permission` on a schema that `.use()`s **no** permission-resolving wire can never be satisfied (its pooled grants are always empty), so it fails at startup — see the hard guarantee below.
|
|
183
192
|
|
|
184
|
-
**Hard guarantee.** At server start the framework walks every HTTP / WS route registered on the `GGHttpServer
|
|
193
|
+
**Hard guarantee.** At server start the framework walks every HTTP / WS route registered on the `GGHttpServer`, before it accepts traffic. Two guarantees hold:
|
|
185
194
|
|
|
186
|
-
-
|
|
187
|
-
-
|
|
195
|
+
- **Wires are implemented (always).** A `.use()`d wire that was never implemented (no `.define(...).create(deps)` in `compose()`) fails the start — a listed wire must work or the request fails loud. This runs regardless of whether any permission is declared.
|
|
196
|
+
- **Permission coverage (strict mode).** If any route declared a permission, **strict mode** is on for the whole server: every route must declare `permission` — use `GG_NO_PERMISSIONS` for intentionally public ones. Routes that omit it fail the start and are listed by name.
|
|
188
197
|
|
|
189
|
-
The check is per-server (HTTP routes + WS schemas on the same `GGHttpServer`), so a permission declared anywhere is infectious across sibling chains. The only way to opt out is to declare nothing — projects with no auth pay zero ceremony, but the moment one route opts in, the framework forces consistency.
|
|
198
|
+
The coverage check is per-server (HTTP routes + WS schemas on the same `GGHttpServer`), so a permission declared anywhere is infectious across sibling chains. The only way to opt out is to declare nothing — projects with no auth pay zero ceremony, but the moment one route opts in, the framework forces consistency.
|
|
190
199
|
|
|
191
|
-
**What this does not cover.** The permission gates *endpoint access* — "is this caller allowed to invoke this method at all?" It does not handle *resource access* — "can this caller edit *this specific* post?" That check still belongs in the handler
|
|
200
|
+
**What this does not cover.** The permission gates *endpoint access* — "is this caller allowed to invoke this method at all?" It does not handle *resource access* — "can this caller edit *this specific* post?" That check still belongs in the handler, which reads the app's own durable principal (minted by the wire's `process()`) — not a framework scope bag.
|
|
192
201
|
|
|
193
202
|
## Authentication & Context
|
|
194
203
|
|
|
195
|
-
|
|
204
|
+
Authentication and per-request context ride on **wires**. A wire (`GGHeader` / `GGCookie`)
|
|
205
|
+
*is* a context key and a transport middleware at once: it reads a value off the inbound
|
|
206
|
+
request and attaches to a schema with a single `.use(WIRE)`. The schema declares the wire
|
|
207
|
+
once and every method on it parses (and, for credentials, verifies) that wire — you can't
|
|
208
|
+
forget it on a method, and "is this endpoint protected?" is readable off the schema.
|
|
209
|
+
|
|
210
|
+
There are two tiers, decided by whether the wire is `.define()`d server-side:
|
|
211
|
+
|
|
212
|
+
- **Smart (credential) wire** — verified. Holds the raw inbound credential, which is
|
|
213
|
+
**ephemeral**: verified inside the server handler's `process()`, then cleared *before*
|
|
214
|
+
the route handler runs. Handlers read a durable principal the handler minted, never the
|
|
215
|
+
token. Requires `.define()` (server) and `.create(deps)` (per runtime).
|
|
216
|
+
- **Ambient wire** — never `.define()`d. The parsed value lands in the wire and persists
|
|
217
|
+
through the handler (read via `WIRE.get()`). For non-credential headers where absent →
|
|
218
|
+
`undefined` is fine.
|
|
219
|
+
|
|
220
|
+
### Smart wire — token auth (the common case)
|
|
221
|
+
|
|
222
|
+
The wire and its identity types live in the shared `api/`; the verification handler and the
|
|
223
|
+
durable principal live **server-side only** (handlers/services read it; the client never sees it).
|
|
196
224
|
|
|
197
225
|
```typescript
|
|
198
|
-
// auth/UserAuth.ts
|
|
199
|
-
import {
|
|
200
|
-
import { IsObject, IsString } from "@grest-ts/schema"
|
|
226
|
+
// api/auth/UserAuth.ts (shared)
|
|
227
|
+
import { GGHeader } from "@grest-ts/http"
|
|
228
|
+
import { IsArray, IsEnum, IsObject, IsString } from "@grest-ts/schema"
|
|
201
229
|
|
|
202
|
-
export
|
|
203
|
-
export
|
|
230
|
+
export enum UserPermission { CAN_EDIT = "CAN_EDIT" }
|
|
231
|
+
export const IsUserPermission = IsEnum(UserPermission)
|
|
204
232
|
|
|
205
233
|
export const IsUserId = IsString.brand("UserId")
|
|
206
|
-
export type tUserId = typeof IsUserId.infer
|
|
207
|
-
|
|
208
234
|
export const IsUser = IsObject({
|
|
209
235
|
id: IsUserId,
|
|
210
236
|
username: IsString,
|
|
211
|
-
email: IsString
|
|
237
|
+
email: IsString,
|
|
238
|
+
permissions: IsArray(IsUserPermission),
|
|
212
239
|
})
|
|
213
240
|
export type User = typeof IsUser.infer
|
|
214
241
|
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
export type UserAuthContext = typeof IsUserAuthContext.infer
|
|
220
|
-
|
|
221
|
-
// Define the header schema
|
|
222
|
-
const HEADER_AUTHORIZATION = "authorization"
|
|
223
|
-
const HeaderType = IsObject({
|
|
224
|
-
[HEADER_AUTHORIZATION]: IsString.orUndefined
|
|
225
|
-
})
|
|
242
|
+
// SMART wire: parses `Authorization: Bearer <jwt>`. The raw token is ephemeral — readable
|
|
243
|
+
// only inside the server handler's process(), then cleared before the route handler runs.
|
|
244
|
+
export const USER_TOKEN_WIRE = new GGHeader("authorization", { scheme: "bearer" })
|
|
245
|
+
```
|
|
226
246
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
247
|
+
```typescript
|
|
248
|
+
// server/auth/UserAuthHandler.ts (server-only)
|
|
249
|
+
import { GGContextKey } from "@grest-ts/context"
|
|
250
|
+
import { NOT_AUTHORIZED } from "@grest-ts/schema"
|
|
251
|
+
import { deepFreeze } from "@grest-ts/common"
|
|
252
|
+
import { IsUser, USER_TOKEN_WIRE } from "../../api/auth/UserAuth"
|
|
253
|
+
import type { UserService } from "../services/UserService"
|
|
254
|
+
|
|
255
|
+
// The durable principal — server-only. USER_DATA IS the User (it owns its permissions).
|
|
256
|
+
// Deep-frozen on set() so a handler can't mutate it to escalate.
|
|
257
|
+
export const USER_DATA = new GGContextKey("userData", IsUser)
|
|
258
|
+
|
|
259
|
+
// .define() attaches the verification handler. Process-global, frozen once (a second
|
|
260
|
+
// .define() throws). The returned registration is .create()d once per runtime in compose().
|
|
261
|
+
export const USER_TOKEN_WIRE_HANDLER = USER_TOKEN_WIRE.define((users: UserService) => ({
|
|
262
|
+
process: async () => {
|
|
263
|
+
// USER_TOKEN_WIRE.get() returns the raw bearer token — readable ONLY here.
|
|
264
|
+
const user = await users.verifyAccessToken(USER_TOKEN_WIRE.get())
|
|
265
|
+
if (!user) throw new NOT_AUTHORIZED({ debugMessage: "invalid token" })
|
|
266
|
+
USER_DATA.set(deepFreeze(user))
|
|
236
267
|
},
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
268
|
+
// The caller's grants, read off the durable principal — feeds the per-method gate.
|
|
269
|
+
permissions: async () => USER_DATA.get()!.permissions,
|
|
240
270
|
}))
|
|
241
271
|
```
|
|
242
272
|
|
|
243
|
-
|
|
273
|
+
`.use()` the wire on every schema whose routes need it, and `.create()` its handler once in
|
|
274
|
+
the runtime's `compose()`:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
// api/UserApi.ts
|
|
278
|
+
export const UserApi = httpSchema(UserApiContract)
|
|
279
|
+
.pathPrefix("api/users")
|
|
280
|
+
.use(USER_TOKEN_WIRE) // every route here verifies the token
|
|
281
|
+
.routes({ me: GGRpc.GET("me"), updateProfile: GGRpc.PUT("profile") })
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
// server/AppRuntime.ts
|
|
286
|
+
protected compose(): void {
|
|
287
|
+
const userService = new UserService(tokenEngine)
|
|
288
|
+
|
|
289
|
+
// Bind the wire's deps into THIS runtime's scope — once per runtime (a fresh test
|
|
290
|
+
// worker / restart gets its own scope). Deps must be async-context-bound + stateless.
|
|
291
|
+
USER_TOKEN_WIRE_HANDLER.create(userService)
|
|
292
|
+
|
|
293
|
+
new GGHttp(new GGHttpServer())
|
|
294
|
+
.http(UserApi, userService)
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
In a handler or service, read the durable principal — never the token:
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
export class UserService {
|
|
302
|
+
me = async (): Promise<User> => USER_DATA.get() // identity for this request
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Ambient wire — non-credential headers
|
|
307
|
+
|
|
308
|
+
For a header where absent → `undefined` is acceptable and there's nothing to verify, use a
|
|
309
|
+
bare wire (no `.define()`). The value lands in the wire and persists through the handler:
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
// shared api/
|
|
313
|
+
export const CLIENT_VERSION_WIRE = new GGHeader("x-client-version") // ambient
|
|
314
|
+
|
|
315
|
+
httpSchema(MyContract)
|
|
316
|
+
.use(CLIENT_VERSION_WIRE)
|
|
317
|
+
.routes({ ... })
|
|
318
|
+
|
|
319
|
+
// in a handler — read it directly:
|
|
320
|
+
const version = CLIENT_VERSION_WIRE.get() // string | undefined
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
When you need several headers folded into one structured context value, write a custom
|
|
324
|
+
`GGTransportMiddleware` (one shape for HTTP and WebSocket). The runtime calls only the
|
|
325
|
+
hooks each side needs: a client runs `update(outbound)` to write outbound headers; a server
|
|
326
|
+
runs `parse(inbound)` to read inbound headers. Declare the headers it touches via `headers`
|
|
327
|
+
so CORS + doc-gen pick them up. Use this for ambient context — **not** for credentials,
|
|
328
|
+
which belong on a smart wire so the token can't leak into handler code.
|
|
244
329
|
|
|
245
330
|
```typescript
|
|
246
331
|
// middleware/ClientInfoMiddleware.ts
|
|
247
|
-
import {
|
|
248
|
-
import { GGContextKey } from "@grest-ts/context"
|
|
332
|
+
import { GGTransportMiddleware, GGInbound, GGOutbound, GGContextKey } from "@grest-ts/context"
|
|
249
333
|
import { IsObject, IsString, IsLiteral } from "@grest-ts/schema"
|
|
250
334
|
|
|
251
|
-
export interface ClientInfo {
|
|
252
|
-
version: string
|
|
253
|
-
platform: 'web' | 'ios' | 'android'
|
|
254
|
-
}
|
|
335
|
+
export interface ClientInfo { version: string; platform: 'web' | 'ios' | 'android' }
|
|
255
336
|
|
|
256
337
|
export const GG_CLIENT_INFO = new GGContextKey<ClientInfo>('clientInfo', IsObject({
|
|
257
338
|
version: IsString,
|
|
258
|
-
platform: IsLiteral("web", "ios", "android")
|
|
339
|
+
platform: IsLiteral("web", "ios", "android"),
|
|
259
340
|
}))
|
|
260
341
|
|
|
261
|
-
export const ClientInfoMiddleware:
|
|
262
|
-
headers:
|
|
263
|
-
|
|
264
|
-
updateRequest(req: GGHttpRequest): void {
|
|
342
|
+
export const ClientInfoMiddleware: GGTransportMiddleware = {
|
|
343
|
+
headers: { 'x-client-version': IsString.orUndefined, 'x-client-platform': IsString.orUndefined },
|
|
344
|
+
update(outbound: GGOutbound): void {
|
|
265
345
|
const info = GG_CLIENT_INFO.get()
|
|
266
346
|
if (info) {
|
|
267
|
-
|
|
268
|
-
|
|
347
|
+
outbound.headers['x-client-version'] = info.version
|
|
348
|
+
outbound.headers['x-client-platform'] = info.platform
|
|
269
349
|
}
|
|
270
350
|
},
|
|
271
|
-
|
|
351
|
+
parse(inbound: GGInbound): void {
|
|
272
352
|
GG_CLIENT_INFO.set({
|
|
273
|
-
version:
|
|
274
|
-
platform: (
|
|
353
|
+
version: inbound.headers['x-client-version'] ?? 'unknown',
|
|
354
|
+
platform: (inbound.headers['x-client-platform'] ?? 'web') as ClientInfo['platform'],
|
|
275
355
|
})
|
|
276
|
-
}
|
|
356
|
+
},
|
|
277
357
|
}
|
|
358
|
+
|
|
359
|
+
httpSchema(MyContract).use(ClientInfoMiddleware).routes({ ... })
|
|
278
360
|
```
|
|
279
361
|
|
|
280
|
-
###
|
|
362
|
+
### Client — sending the credential
|
|
281
363
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
import { GG_INTL_LOCALE } from "@grest-ts/intl"
|
|
364
|
+
The browser app does **not** hand-write the outbound side. `GGAuthSession` (from
|
|
365
|
+
`@grest-ts/auth`) owns the token lifecycle (localStorage persistence, cross-tab refresh
|
|
366
|
+
dedup, proactive refresh) and configures the wires it holds itself:
|
|
286
367
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
errors: [NOT_AUTHORIZED, SERVER_ERROR]
|
|
291
|
-
},
|
|
292
|
-
create: {
|
|
293
|
-
input: IsCreateRequest,
|
|
294
|
-
success: IsItem,
|
|
295
|
-
errors: [NOT_AUTHORIZED, VALIDATION_ERROR, SERVER_ERROR]
|
|
296
|
-
}
|
|
297
|
-
})
|
|
368
|
+
```typescript
|
|
369
|
+
import { GGAuthSession } from "@grest-ts/auth"
|
|
370
|
+
import { USER_TOKEN_WIRE } from "../../api/auth/UserAuth"
|
|
298
371
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
.pathPrefix("api/items")
|
|
302
|
-
.useHeader(GG_INTL_LOCALE) // Use codec from context key
|
|
303
|
-
.useHeader(GG_USER_AUTH) // Use codec from context key
|
|
304
|
-
.use(ClientInfoMiddleware) // Use middleware object
|
|
305
|
-
.routes({
|
|
306
|
-
list: GGRpc.GET("list"),
|
|
307
|
-
create: GGRpc.POST("create")
|
|
308
|
-
})
|
|
372
|
+
export const session = GGAuthSession
|
|
373
|
+
.withToken(USER_TOKEN_WIRE, { refresh: api.authApi.refresh, localStorageKey: "auth" })
|
|
309
374
|
```
|
|
310
375
|
|
|
376
|
+
For the no-session case (a static service-to-service API key), the underlying primitive is
|
|
377
|
+
`WIRE.defineClient({ value: () => API_KEY })`.
|
|
378
|
+
|
|
311
379
|
### Public API (No Auth)
|
|
312
380
|
|
|
381
|
+
A public route simply lives on a schema that does **not** `.use()` an auth wire — "what's
|
|
382
|
+
public?" is answered by looking at the schema, not by reading handler code. (There is no
|
|
383
|
+
"optional auth": anonymous and authenticated variants live on separate schemas.)
|
|
384
|
+
|
|
313
385
|
```typescript
|
|
314
386
|
export const PublicApiContract = new GGContractClass("PublicApi", {
|
|
315
|
-
status: {
|
|
316
|
-
|
|
317
|
-
errors: [SERVER_ERROR]
|
|
318
|
-
},
|
|
319
|
-
login: {
|
|
320
|
-
input: IsLoginRequest,
|
|
321
|
-
success: IsLoginResponse,
|
|
322
|
-
errors: [VALIDATION_ERROR, SERVER_ERROR]
|
|
323
|
-
}
|
|
387
|
+
status: { success: IsStatusResponse, errors: [SERVER_ERROR], permission: GG_NO_PERMISSIONS },
|
|
388
|
+
login: { input: IsLoginRequest, success: IsLoginResponse, errors: [VALIDATION_ERROR, SERVER_ERROR], permission: GG_NO_PERMISSIONS },
|
|
324
389
|
})
|
|
325
390
|
|
|
326
391
|
export const PublicApi = httpSchema(PublicApiContract)
|
|
327
|
-
.pathPrefix("pub")
|
|
392
|
+
.pathPrefix("pub") // no .use(WIRE) → public
|
|
328
393
|
.routes({
|
|
329
394
|
status: GGRpc.GET("status"),
|
|
330
|
-
login: GGRpc.POST("login")
|
|
395
|
+
login: GGRpc.POST("login"),
|
|
331
396
|
})
|
|
332
397
|
```
|
|
333
398
|
|
|
399
|
+
(`permission: GG_NO_PERMISSIONS` is only needed once any route on the server declares a
|
|
400
|
+
permission — see "Permissions" → strict mode.)
|
|
401
|
+
|
|
334
402
|
## Error Types
|
|
335
403
|
|
|
336
404
|
### Declaring Errors in Contract
|
|
@@ -376,7 +444,7 @@ export class MyService {
|
|
|
376
444
|
const item = await this.findItem(request.id)
|
|
377
445
|
if (!item) throw new NOT_FOUND()
|
|
378
446
|
|
|
379
|
-
const user =
|
|
447
|
+
const user = USER_DATA.get()
|
|
380
448
|
if (item.ownerId !== user.id) throw new FORBIDDEN()
|
|
381
449
|
|
|
382
450
|
return this.updateItem(item, request)
|
|
@@ -388,21 +456,24 @@ export class MyService {
|
|
|
388
456
|
|
|
389
457
|
### Using GGHttp (Fluent API)
|
|
390
458
|
|
|
459
|
+
Auth wiring is **not** a chain on `GGHttp` — the schema carries the wire (`.use(WIRE)`) and
|
|
460
|
+
the runtime binds the wire's deps once via `WIRE_HANDLER.create(deps)`. `GGHttp.use(...)` is
|
|
461
|
+
reserved for *ambient* middleware (e.g. client-info, trace) that should apply to every API
|
|
462
|
+
registered on that builder.
|
|
463
|
+
|
|
391
464
|
```typescript
|
|
392
465
|
import { GGHttp, GGHttpServer } from "@grest-ts/http"
|
|
393
466
|
|
|
394
467
|
protected compose(): void {
|
|
395
468
|
const httpServer = new GGHttpServer()
|
|
396
469
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
.http(StatusApi, {
|
|
400
|
-
status: async () => ({ status: true })
|
|
401
|
-
})
|
|
470
|
+
// Bind each used wire's handler into this runtime's scope (once per runtime).
|
|
471
|
+
USER_TOKEN_WIRE_HANDLER.create(userService)
|
|
402
472
|
|
|
403
473
|
new GGHttp(httpServer)
|
|
404
|
-
.use(
|
|
405
|
-
.http(
|
|
474
|
+
.use(ClientInfoMiddleware) // ambient middleware for every API below (optional)
|
|
475
|
+
.http(PublicApi, publicService) // no wire on its schema → public
|
|
476
|
+
.http(MyApi, myService) // MyApi schema .use(USER_TOKEN_WIRE)
|
|
406
477
|
.http(UserAuthApi, userService)
|
|
407
478
|
}
|
|
408
479
|
```
|
|
@@ -437,38 +508,36 @@ protected compose(): void {
|
|
|
437
508
|
|
|
438
509
|
### CORS Headers
|
|
439
510
|
|
|
440
|
-
CORS `Access-Control-Allow-Headers` are auto-discovered from
|
|
441
|
-
Only `Content-Type` is included by default (it's set by the framework's
|
|
442
|
-
All other headers — including `Authorization` — are registered automatically
|
|
443
|
-
|
|
511
|
+
CORS `Access-Control-Allow-Headers` are auto-discovered from the wires and middleware a
|
|
512
|
+
schema `.use()`s. Only `Content-Type` is included by default (it's set by the framework's
|
|
513
|
+
RPC layer). All other headers — including `Authorization` — are registered automatically
|
|
514
|
+
from each wire/middleware's declared header names.
|
|
444
515
|
|
|
445
|
-
|
|
516
|
+
A `GGHeader` wire carries its own header name, so `.use()`ing it is enough — no extra CORS
|
|
517
|
+
wiring:
|
|
446
518
|
|
|
447
519
|
```typescript
|
|
448
|
-
const
|
|
449
|
-
"x-org-token": IsString.orUndefined // Auto-discovered as CORS header
|
|
450
|
-
})
|
|
451
|
-
GG_ORG_TOKEN.addCodec("http", HeaderType.codecTo(...))
|
|
520
|
+
export const ORG_TOKEN_WIRE = new GGHeader("x-org-token", {}) // smart wire, custom header
|
|
452
521
|
|
|
453
522
|
httpSchema(Contract)
|
|
454
|
-
.
|
|
523
|
+
.use(ORG_TOKEN_WIRE) // "x-org-token" added to CORS Allow-Headers automatically
|
|
455
524
|
.routes({ ... })
|
|
456
525
|
```
|
|
457
526
|
|
|
458
|
-
|
|
459
|
-
|
|
527
|
+
A custom middleware declares the headers it reads via `headers` and the response headers it
|
|
528
|
+
sets via `responseHeaders` — both are `Record<string, GGSchema<string | undefined>>`,
|
|
529
|
+
keyed by header name. The names feed CORS auto-discovery; omit a field when not applicable:
|
|
460
530
|
|
|
461
531
|
```typescript
|
|
462
|
-
export const MyMiddleware:
|
|
463
|
-
headers:
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
parseRequest(req) { ... }
|
|
532
|
+
export const MyMiddleware: GGTransportMiddleware = {
|
|
533
|
+
headers: { 'x-custom-header': IsString.orUndefined },
|
|
534
|
+
update(outbound) { ... },
|
|
535
|
+
parse(inbound) { ... }
|
|
467
536
|
}
|
|
468
537
|
```
|
|
469
538
|
|
|
470
539
|
Codecs also declare `responseHeaders`. For example, `GGFileDownload` declares
|
|
471
|
-
`
|
|
540
|
+
`Content-Disposition` which is automatically added to `Access-Control-Expose-Headers`.
|
|
472
541
|
|
|
473
542
|
You can also register headers manually on the server:
|
|
474
543
|
|
|
@@ -478,6 +547,124 @@ httpServer.registerCorsHeaders(['x-custom-header'])
|
|
|
478
547
|
httpServer.registerCorsExposeHeaders(['x-custom-response-header'])
|
|
479
548
|
```
|
|
480
549
|
|
|
550
|
+
## Cookies (httpOnly sessions)
|
|
551
|
+
|
|
552
|
+
An httpOnly session cookie is a value the browser stores and re-sends automatically
|
|
553
|
+
that JavaScript cannot read (so XSS can't steal it). It is **server-minted**: the
|
|
554
|
+
server emits `Set-Cookie`, the browser stores and resends it, and JS never touches it.
|
|
555
|
+
|
|
556
|
+
A cookie is a **`GGCookie` wire** — it IS its own context key. Its name is the cookie
|
|
557
|
+
name. Attach it with one `.use(SESSION)` on the schema; read it with `SESSION.get()` in any
|
|
558
|
+
handler; write it with the static `GGCookie.setCookie(SESSION, …)` / `clearCookie(SESSION)`
|
|
559
|
+
from a route that declared `.updatesCookie(SESSION)`:
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
// shared api/
|
|
563
|
+
import {GGRpc, httpSchema, GGCookie} from "@grest-ts/http"
|
|
564
|
+
|
|
565
|
+
// The wire's name IS the cookie name. No cookie policy in the shared API.
|
|
566
|
+
export const SESSION = new GGCookie("session")
|
|
567
|
+
|
|
568
|
+
export const AuthApi = httpSchema(AuthContract)
|
|
569
|
+
.use(SESSION)
|
|
570
|
+
.pathPrefix("api/auth")
|
|
571
|
+
.routes({
|
|
572
|
+
login: GGRpc.POST("login").updatesCookie(SESSION), // may write the cookie
|
|
573
|
+
logout: GGRpc.POST("logout").updatesCookie(SESSION), // may write (clear)
|
|
574
|
+
me: GGRpc.GET("me"), // read-only -> no declaration
|
|
575
|
+
})
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
import {GGCookie} from "@grest-ts/http"
|
|
580
|
+
import {SESSION} from "../../api/AuthApi"
|
|
581
|
+
|
|
582
|
+
export class AuthService {
|
|
583
|
+
login = async (input: LoginRequest): Promise<User> => {
|
|
584
|
+
const {user, token} = await this.verify(input)
|
|
585
|
+
// Write rules live HERE, at the set site. Safe defaults (HttpOnly, Secure,
|
|
586
|
+
// SameSite=Lax, Path=/) are applied; pass only what differs:
|
|
587
|
+
GGCookie.setCookie(SESSION, token, {maxAgeSec: input.remember ? 60*60*24*30 : 60*60})
|
|
588
|
+
return user
|
|
589
|
+
}
|
|
590
|
+
logout = async (): Promise<void> => { GGCookie.clearCookie(SESSION) } // -> Max-Age=0 clear
|
|
591
|
+
me = async (): Promise<User> => this.fromSession(SESSION.get()) // read the cookie
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
**Read vs write.** Reading is implicit — `SESSION.get()` works on any route whose schema
|
|
596
|
+
`.use()`d the cookie. **Writing is explicit**: only a route that declared
|
|
597
|
+
`.updatesCookie(SESSION)` may write it. A `GGCookie.setCookie(SESSION, …)` call from a route
|
|
598
|
+
that *didn't* declare it throws `SERVER_ERROR`, so a deep service function can't silently
|
|
599
|
+
mint or change someone's session — the capability is visible at the API boundary. The
|
|
600
|
+
inbound value stays set-once: the written value never touches `SESSION.get()`.
|
|
601
|
+
|
|
602
|
+
**Emit-on-write.** `setCookie`/`clearCookie` schedule a pending Set-Cookie that the wire
|
|
603
|
+
flushes onto the response: `setCookie(SESSION, token)` → `Set-Cookie`;
|
|
604
|
+
`clearCookie(SESSION)` (or `setCookie(SESSION, undefined)`) → `Max-Age=0` clear; a route
|
|
605
|
+
that writes nothing emits nothing.
|
|
606
|
+
|
|
607
|
+
**Write rules live at the `setCookie(value, options)` call site**, never in the shared API —
|
|
608
|
+
only the cookie name (needed to *read* it on every route) is on the schema. Options:
|
|
609
|
+
`httpOnly`, `secure`, `sameSite`, `path`, `domain`, `maxAgeSec`. Safe defaults (`HttpOnly`,
|
|
610
|
+
`Secure`, `SameSite=Lax`, `Path=/`) are applied by the serializer, so `setCookie(SESSION,
|
|
611
|
+
token)` is safe; pass only deviations. `SameSite=None` forces `Secure`; name/path/domain are
|
|
612
|
+
validated (no CR/LF/`;`). A scoped cookie repeats `path`/`domain` on clear so deletion matches:
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
GGCookie.setCookie(SESSION, token, {path: "/api", domain: ".example.com", sameSite: "none", maxAgeSec: WEEK})
|
|
616
|
+
GGCookie.clearCookie(SESSION, {path: "/api", domain: ".example.com"}) // clear must match scope
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
**Domain scope is a security boundary.** The default is **host-only** (no `Domain`):
|
|
620
|
+
the cookie is sent only to the exact host that set it. `domain: ".example.com"` sends
|
|
621
|
+
it to **every** subdomain, so *any* subdomain — including user-controlled or untrusted
|
|
622
|
+
content — can read it. Never share a parent domain with untrusted subdomains; host
|
|
623
|
+
untrusted/user content on a **separate registrable domain** instead.
|
|
624
|
+
|
|
625
|
+
**Cross-site cookies.** A `SameSite=Lax` cookie is not sent on cross-site requests.
|
|
626
|
+
For a cross-site setup (browser and API on different sites) use `sameSite: "none"` +
|
|
627
|
+
`secure: true` **and** credentialed CORS on the server (below) with an exact-origin
|
|
628
|
+
allowlist — never a wildcard.
|
|
629
|
+
|
|
630
|
+
### Credentialed CORS (cross-origin cookies)
|
|
631
|
+
|
|
632
|
+
Cross-origin cookie requests require the response to echo the **specific** request
|
|
633
|
+
Origin plus `Access-Control-Allow-Credentials: true` — browsers reject the wildcard
|
|
634
|
+
`*` when credentials are involved. Opt in per server (default off keeps the
|
|
635
|
+
permissive `*`):
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
new GGHttpServer({
|
|
639
|
+
cors: {
|
|
640
|
+
origins: ["https://app.example.com"], // exact list, or (origin) => boolean
|
|
641
|
+
credentials: true,
|
|
642
|
+
},
|
|
643
|
+
})
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
Only an allowlisted Origin is echoed (never `*`), with `Vary: Origin`. Never reflect
|
|
647
|
+
an arbitrary origin with credentials — the allowlist is the gate.
|
|
648
|
+
|
|
649
|
+
### Client: send cookies
|
|
650
|
+
|
|
651
|
+
The browser attaches cross-origin cookies only when the caller opts in:
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
const client = AuthApi.createClient({url: "https://api.example.com", credentials: "include"})
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
Same-origin calls and the default need nothing.
|
|
658
|
+
|
|
659
|
+
### WebSocket cookies
|
|
660
|
+
|
|
661
|
+
The same `GGCookie` wire works on a WebSocket schema, read-only: a browser auto-attaches
|
|
662
|
+
the httpOnly cookie to the WS **upgrade** request, and the runtime fills `inbound.cookie`
|
|
663
|
+
from that real upgrade header (never from the spoofable in-band handshake message). `.use()`
|
|
664
|
+
the same `SESSION` wire on the WS schema and read it at handshake with `SESSION.get()` — no
|
|
665
|
+
client code, no ticket dance. There is no `Set-Cookie` over a socket, so the wire only reads
|
|
666
|
+
there (`.updatesCookie` is an HTTP route concept). See `@grest-ts/websocket` → "Cookies".
|
|
667
|
+
|
|
481
668
|
## HTTP Client
|
|
482
669
|
|
|
483
670
|
### Creating Clients
|
|
@@ -589,10 +776,14 @@ export const NotificationApiContract = defineSocketContract("NotificationApi", {
|
|
|
589
776
|
|
|
590
777
|
export const NotificationApi = webSocketSchema(NotificationApiContract)
|
|
591
778
|
.path("ws/notifications")
|
|
592
|
-
.use(
|
|
779
|
+
.use(USER_TOKEN_WIRE) // same wire as the HTTP schemas — verified at handshake
|
|
593
780
|
.done()
|
|
594
781
|
```
|
|
595
782
|
|
|
783
|
+
The wire works identically on WebSocket: it reads + verifies the credential off the upgrade
|
|
784
|
+
handshake (the `.define()` handler runs once at connect). See `@grest-ts/websocket` for the
|
|
785
|
+
full WS auth + permissions model.
|
|
786
|
+
|
|
596
787
|
### WebSocket Server Handler
|
|
597
788
|
|
|
598
789
|
```typescript
|
|
@@ -602,7 +793,7 @@ export class NotificationService {
|
|
|
602
793
|
private connections = new Map<string, Set<WebSocketOutgoing<any>>>()
|
|
603
794
|
|
|
604
795
|
handleConnection = (incoming: WebSocketIncoming<any>, outgoing: WebSocketOutgoing<any>): void => {
|
|
605
|
-
const user =
|
|
796
|
+
const user = USER_DATA.get() // durable principal minted by the wire at handshake
|
|
606
797
|
|
|
607
798
|
// Track connection
|
|
608
799
|
if (!this.connections.has(user.id)) {
|
|
@@ -643,8 +834,10 @@ import { GGHttpServer } from "@grest-ts/http"
|
|
|
643
834
|
protected compose(): void {
|
|
644
835
|
const httpServer = new GGHttpServer()
|
|
645
836
|
|
|
837
|
+
// One wire handler covers every schema that .use()s it — HTTP and WS alike.
|
|
838
|
+
USER_TOKEN_WIRE_HANDLER.create(userService)
|
|
839
|
+
|
|
646
840
|
new GGHttp(httpServer)
|
|
647
|
-
.use(new UserContextMiddleware(userService))
|
|
648
841
|
.http(MyApi, myService)
|
|
649
842
|
|
|
650
843
|
// Register WebSocket on the same HTTP server
|
package/dist/src/_dedupCheck.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
const __KEY = Symbol.for("grest-ts:dedup:@grest-ts/http");
|
|
12
12
|
const __slot = globalThis;
|
|
13
13
|
const __existing = __slot[__KEY];
|
|
14
|
-
const __here = { path: import.meta.url, version: "0.0.
|
|
14
|
+
const __here = { path: import.meta.url, version: "0.0.49" };
|
|
15
15
|
// Same path + same version means the same physical package is being
|
|
16
16
|
// re-evaluated by a different ESM runner (e.g. vitest projects share one
|
|
17
17
|
// process but each project has its own module cache). That isn't a true
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { GGContextKey } from "@grest-ts/context";
|
|
2
|
+
export interface GGKeyController {
|
|
3
|
+
isStale: () => boolean;
|
|
4
|
+
recover: () => Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare class GGContextKeySynchronizer {
|
|
7
|
+
static provide(key: GGContextKey<any>, controller: GGKeyController): void;
|
|
8
|
+
static waitFor(key: GGContextKey<any>): Promise<void>;
|
|
9
|
+
static clear(key: GGContextKey<any>): void;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=GGContextKeySynchronizer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GGContextKeySynchronizer.d.ts","sourceRoot":"","sources":["../../../src/client/GGContextKeySynchronizer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAC,MAAM,mBAAmB,CAAA;AAG9C,MAAM,WAAW,eAAe;IAC5B,OAAO,EAAE,MAAM,OAAO,CAAA;IACtB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC/B;AAYD,qBAAa,wBAAwB;IAEjC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,YAAY,CAAC,GAAG,CAAC,EAAE,UAAU,EAAE,eAAe,GAAG,IAAI;WAS5D,OAAO,CAAC,GAAG,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAS3D,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,IAAI;CAG7C"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { GGContextKey } from "@grest-ts/context";
|
|
2
|
+
import { IsAny } from "@grest-ts/schema";
|
|
3
|
+
const GG_SYNC_CONTROLLERS = new GGContextKey("sync:controllers", IsAny);
|
|
4
|
+
export class GGContextKeySynchronizer {
|
|
5
|
+
static provide(key, controller) {
|
|
6
|
+
let registry = GG_SYNC_CONTROLLERS.get();
|
|
7
|
+
if (registry === undefined) {
|
|
8
|
+
registry = new Map();
|
|
9
|
+
GG_SYNC_CONTROLLERS.set(registry);
|
|
10
|
+
}
|
|
11
|
+
registry.set(key.name, { controller, inflight: undefined });
|
|
12
|
+
}
|
|
13
|
+
static async waitFor(key) {
|
|
14
|
+
const entry = GG_SYNC_CONTROLLERS.get()?.get(key.name);
|
|
15
|
+
if (entry === undefined || !entry.controller.isStale())
|
|
16
|
+
return;
|
|
17
|
+
entry.inflight ??= entry.controller.recover().finally(() => {
|
|
18
|
+
entry.inflight = undefined;
|
|
19
|
+
});
|
|
20
|
+
await entry.inflight;
|
|
21
|
+
}
|
|
22
|
+
static clear(key) {
|
|
23
|
+
GG_SYNC_CONTROLLERS.get()?.delete(key.name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=GGContextKeySynchronizer.js.map
|