@grest-ts/http 0.0.46 → 0.0.48

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.
Files changed (63) hide show
  1. package/README.md +125 -0
  2. package/dist/src/_dedupCheck.js +1 -1
  3. package/dist/src/client/GGHttpSchema.createClient.d.ts +8 -0
  4. package/dist/src/client/GGHttpSchema.createClient.d.ts.map +1 -1
  5. package/dist/src/client/GGHttpSchema.createClient.js +2 -1
  6. package/dist/src/client/GGHttpSchema.createClient.js.map +1 -1
  7. package/dist/src/index-browser.d.ts +2 -0
  8. package/dist/src/index-browser.d.ts.map +1 -1
  9. package/dist/src/index-browser.js +2 -0
  10. package/dist/src/index-browser.js.map +1 -1
  11. package/dist/src/index-node.d.ts +3 -0
  12. package/dist/src/index-node.d.ts.map +1 -1
  13. package/dist/src/index-node.js +3 -0
  14. package/dist/src/index-node.js.map +1 -1
  15. package/dist/src/rpc/GGHttpRouteRPC.d.ts +4 -0
  16. package/dist/src/rpc/GGHttpRouteRPC.d.ts.map +1 -1
  17. package/dist/src/rpc/GGHttpRouteRPC.js +6 -0
  18. package/dist/src/rpc/GGHttpRouteRPC.js.map +1 -1
  19. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts +1 -0
  20. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts.map +1 -1
  21. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js +4 -0
  22. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js.map +1 -1
  23. package/dist/src/schema/GGHttpSchema.d.ts +13 -0
  24. package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
  25. package/dist/src/schema/GGHttpSchema.js.map +1 -1
  26. package/dist/src/schema/cookieMiddleware.d.ts +71 -0
  27. package/dist/src/schema/cookieMiddleware.d.ts.map +1 -0
  28. package/dist/src/schema/cookieMiddleware.js +126 -0
  29. package/dist/src/schema/cookieMiddleware.js.map +1 -0
  30. package/dist/src/schema/headerBinding.d.ts +35 -0
  31. package/dist/src/schema/headerBinding.d.ts.map +1 -0
  32. package/dist/src/schema/headerBinding.js +49 -0
  33. package/dist/src/schema/headerBinding.js.map +1 -0
  34. package/dist/src/schema/httpSchema.d.ts +0 -2
  35. package/dist/src/schema/httpSchema.d.ts.map +1 -1
  36. package/dist/src/schema/httpSchema.js +0 -48
  37. package/dist/src/schema/httpSchema.js.map +1 -1
  38. package/dist/src/server/GGHttpSchema.startServer.d.ts.map +1 -1
  39. package/dist/src/server/GGHttpSchema.startServer.js +4 -1
  40. package/dist/src/server/GGHttpSchema.startServer.js.map +1 -1
  41. package/dist/src/server/GGHttpServer.d.ts +29 -0
  42. package/dist/src/server/GGHttpServer.d.ts.map +1 -1
  43. package/dist/src/server/GGHttpServer.js +35 -8
  44. package/dist/src/server/GGHttpServer.js.map +1 -1
  45. package/dist/src/server/applyResponseMiddleware.d.ts +12 -0
  46. package/dist/src/server/applyResponseMiddleware.d.ts.map +1 -0
  47. package/dist/src/server/applyResponseMiddleware.js +25 -0
  48. package/dist/src/server/applyResponseMiddleware.js.map +1 -0
  49. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  50. package/package.json +10 -10
  51. package/src/_dedupCheck.ts +1 -1
  52. package/src/client/GGHttpSchema.createClient.ts +10 -1
  53. package/src/index-browser.ts +2 -0
  54. package/src/index-node.ts +3 -0
  55. package/src/rpc/GGHttpRouteRPC.ts +8 -0
  56. package/src/rpc/RpcResponse/GGRpcResponseBuilder.ts +5 -1
  57. package/src/schema/GGHttpSchema.ts +15 -0
  58. package/src/schema/cookieMiddleware.ts +164 -0
  59. package/src/schema/headerBinding.ts +65 -0
  60. package/src/schema/httpSchema.ts +2 -55
  61. package/src/server/GGHttpSchema.startServer.ts +4 -1
  62. package/src/server/GGHttpServer.ts +57 -8
  63. package/src/server/applyResponseMiddleware.ts +27 -0
@@ -29,6 +29,7 @@ export type GGHttpTransport = (
29
29
  headers: Record<string, string>
30
30
  body: string | FormData | undefined
31
31
  signal: AbortSignal
32
+ credentials?: "omit" | "same-origin" | "include"
32
33
  }
33
34
  ) => Promise<Response>
34
35
 
@@ -36,6 +37,13 @@ export interface GGHttpClientConfig {
36
37
  url?: string;
37
38
  timeout?: number;
38
39
  noValidation?: boolean
40
+ /**
41
+ * Cross-origin credentials mode for the default fetch transport. Default
42
+ * (unset → fetch's "same-origin") is unchanged; pass "include" so the browser
43
+ * attaches/stores cross-origin cookies. Ignored by custom transports unless
44
+ * they read it.
45
+ */
46
+ credentials?: "omit" | "same-origin" | "include"
39
47
  /**
40
48
  * Override the wire-layer call. Defaults to `fetch`. When provided,
41
49
  * service discovery is skipped (the transport is presumed to know how
@@ -111,7 +119,8 @@ export function createClient<TContract extends GGContractApiDefinition, TContext
111
119
  method: fetchRequest.method,
112
120
  signal: controller.signal,
113
121
  headers: fetchRequest.headers,
114
- body: fetchRequest.body
122
+ body: fetchRequest.body,
123
+ credentials: config.credentials
115
124
  }).finally(() => clearTimeout(timeoutId));
116
125
  const resData = await wireFormat.parseResponse(wireResponse);
117
126
 
@@ -2,6 +2,8 @@ import "./_dedupCheck";
2
2
  // API Schema
3
3
  export * from "./schema/GGHttpSchema";
4
4
  export * from "./schema/httpSchema";
5
+ export * from "./schema/cookieMiddleware";
6
+ export * from "./schema/headerBinding";
5
7
  export * from "./rpc/GGHttpRouteRPC";
6
8
  export * from "./rpc/RpcRequest/GGRpcRequestBuilder";
7
9
  export * from "./rpc/RpcResponse/GGRpcResponseParser";
package/src/index-node.ts CHANGED
@@ -18,6 +18,8 @@ export * from "./server/GG_PERMISSIONS";
18
18
  // API Schema
19
19
  export * from "./schema/GGHttpSchema";
20
20
  export * from "./schema/httpSchema";
21
+ export * from "./schema/cookieMiddleware";
22
+ export * from "./schema/headerBinding";
21
23
  export * from "./rpc/GGHttpRouteRPC";
22
24
  export * from "./rpc/openApiSuccessResponse";
23
25
  export * from "./rpc/openApiHelpers";
@@ -31,6 +33,7 @@ export * from "./server/GGHttp";
31
33
  export * from "./server/GGHttp";
32
34
  export * from "./server/GGHttpServer";
33
35
  export * from "./server/GG_HTTP_SERVER";
36
+ export * from "./server/applyResponseMiddleware";
34
37
 
35
38
  // Client
36
39
  export * from "./client/GGHttpSchema.createClient";
@@ -2,6 +2,7 @@ import {HttpMethod} from "@grest-ts/common"
2
2
  import {ClientHttpRouteToRpcTransformClientCodec, ClientHttpRouteToRpcTransformClientConfig, ClientHttpRouteToRpcTransformServerCodec, ClientHttpRouteToRpcTransformServerConfig, GGHttpCodec, GGHttpCodecOpenApiConfig} from "../schema/GGHttpSchema"
3
3
  import {GGRpcRequestBuilder} from "./RpcRequest/GGRpcRequestBuilder";
4
4
  import {GGRpcResponseParser} from "./RpcResponse/GGRpcResponseParser";
5
+ import type {GGContextKey} from "@grest-ts/context";
5
6
  import type {OpenAPIV3_1} from "openapi-types";
6
7
  import {GGSchema} from "@grest-ts/schema";
7
8
  import {buildRpcSuccessResponses} from "./openApiSuccessResponse";
@@ -28,6 +29,7 @@ class GGHttpRpcCodec implements GGHttpCodec {
28
29
  public readonly path: string
29
30
  public readonly deprecated: boolean | undefined
30
31
  public readonly responseHeaders: Record<string, GGSchema<string | undefined>> = {}
32
+ public readonly updatesCookies: GGContextKey<string | undefined>[] = []
31
33
 
32
34
  constructor(method: HttpMethod, path: string, deprecated?: boolean) {
33
35
  this.method = method
@@ -35,6 +37,12 @@ class GGHttpRpcCodec implements GGHttpCodec {
35
37
  this.deprecated = deprecated
36
38
  }
37
39
 
40
+ /** Permit this route to set/clear the given cookie context key(s). Reading needs no declaration. */
41
+ public updatesCookie(...keys: GGContextKey<string | undefined>[]): this {
42
+ this.updatesCookies.push(...keys)
43
+ return this
44
+ }
45
+
38
46
  public createForClient(config: ClientHttpRouteToRpcTransformClientConfig): ClientHttpRouteToRpcTransformClientCodec {
39
47
  return {
40
48
  createRequest: new GGRpcRequestBuilder(this.method, this.path, config).createRequest,
@@ -1,14 +1,17 @@
1
1
  import type http from "http";
2
2
  import {ANY_ERROR, ERROR, GGContractExecutor, GGContractMethod, GGDebugData, GGErrorData, GGSchema, OK} from "@grest-ts/schema";
3
- import {ClientHttpRouteToRpcTransformServerConfig} from "../../schema/GGHttpSchema";
3
+ import {ClientHttpRouteToRpcTransformServerConfig, GGHttpTransportMiddleware} from "../../schema/GGHttpSchema";
4
+ import {applyResponseMiddleware} from "../../server/applyResponseMiddleware";
4
5
 
5
6
 
6
7
  export class GGRpcResponseBuilder {
7
8
 
8
9
  protected readonly contract: GGContractMethod
10
+ private readonly apiMiddlewares: readonly GGHttpTransportMiddleware[]
9
11
 
10
12
  constructor(config: ClientHttpRouteToRpcTransformServerConfig) {
11
13
  this.contract = config.contract;
14
+ this.apiMiddlewares = config.apiMiddlewares;
12
15
  }
13
16
 
14
17
  public sendResponse = async (res: http.ServerResponse, rpcResult: ERROR<string, unknown> | OK<unknown>): Promise<void> => {
@@ -25,6 +28,7 @@ export class GGRpcResponseBuilder {
25
28
  json = this.makeError(GGContractExecutor.getResponseSchema(this.contract, rpcResult), rpcResult);
26
29
  statusCode = rpcResult.statusCode;
27
30
  }
31
+ applyResponseMiddleware(res, this.apiMiddlewares);
28
32
  res.writeHead(statusCode, {
29
33
  'Content-Type': 'application/json',
30
34
  'Content-Length': Buffer.byteLength(json)
@@ -2,6 +2,7 @@ import {ERROR, ERROR_JSON, GGContractApiDefinition, GGContractClass, GGContractM
2
2
  import type {HttpMethod} from "@grest-ts/common";
3
3
  import type http from "http";
4
4
  import type {GGHttpServerMiddleware} from "../server/GGHttpSchema.startServer";
5
+ import type {GGContextKey} from "@grest-ts/context";
5
6
  import type {OpenAPIV3_1} from "openapi-types";
6
7
 
7
8
  export class GGHttpSchema<TContract extends GGContractApiDefinition, TContext> {
@@ -114,6 +115,13 @@ export interface GGHttpCodec {
114
115
  */
115
116
  readonly responseHeaders: Record<string, GGSchema<string | undefined>>;
116
117
 
118
+ /**
119
+ * Cookie context keys this route is permitted to modify (set/clear), declared via
120
+ * GGRpc.*(...).updatesCookie(key). A handler that changes a cookie its route did not
121
+ * declare is a SERVER_ERROR. Reading a cookie needs no declaration.
122
+ */
123
+ readonly updatesCookies?: readonly GGContextKey<string | undefined>[];
124
+
117
125
  createForClient(config: ClientHttpRouteToRpcTransformClientConfig): ClientHttpRouteToRpcTransformClientCodec
118
126
 
119
127
  createForServer(config: ClientHttpRouteToRpcTransformServerConfig): ClientHttpRouteToRpcTransformServerCodec
@@ -156,6 +164,13 @@ export interface GGHttpTransportMiddleware {
156
164
  */
157
165
  readonly responseHeaders: Record<string, GGSchema<string | undefined>>;
158
166
 
167
+ /**
168
+ * Cookies this middleware reads, mapped to their value schemas. Emitted as `in: cookie`
169
+ * parameters in OpenAPI so cookie-bound inputs are documented. Absent for header/plain
170
+ * middlewares.
171
+ */
172
+ readonly cookieParams?: Record<string, GGSchema<string | undefined>>;
173
+
159
174
  /**
160
175
  * Client-side: modify outgoing request (add headers, etc.)
161
176
  */
@@ -0,0 +1,164 @@
1
+ import {GGContextKey} from "@grest-ts/context"
2
+ import {IsAny, IsString, SERVER_ERROR, type GGSchema} from "@grest-ts/schema"
3
+ import type {GGHttpRequest, GGHttpResponse, GGHttpTransportMiddleware} from "./GGHttpSchema"
4
+
5
+ /**
6
+ * Per-request set of context-key names the current route is permitted to write a cookie
7
+ * for (declared via GGRpc.*(...).updatesCookie(key)). Populated by the server route
8
+ * handler; read by setCookie() to reject undeclared cookie writes.
9
+ */
10
+ export const GG_COOKIE_WRITES = new GGContextKey<Set<string>>("cookie:writes", IsAny as unknown as GGSchema<Set<string>>)
11
+
12
+ /**
13
+ * Cookie write rules — passed to setCookie(key, value, options). They live at the write
14
+ * call site (the handler that mints the cookie), never in the shared API.
15
+ */
16
+ export interface CookieOptions {
17
+ httpOnly?: boolean
18
+ secure?: boolean
19
+ sameSite?: "lax" | "strict" | "none"
20
+ path?: string
21
+ domain?: string
22
+ maxAgeSec?: number
23
+ }
24
+
25
+ const SAFE_DEFAULTS = {httpOnly: true, secure: true, sameSite: "lax", path: "/"} as const
26
+
27
+ interface PendingCookie {
28
+ value: string | undefined
29
+ options?: CookieOptions
30
+ }
31
+
32
+ /**
33
+ * Per-request Set-Cookie writes scheduled via setCookie()/clearCookie(), keyed by the
34
+ * context key's name. Flushed onto the response by the matching cookie() binding.
35
+ */
36
+ const GG_COOKIE_PENDING = new GGContextKey<Map<string, PendingCookie>>("cookie:pending", IsAny as unknown as GGSchema<Map<string, PendingCookie>>)
37
+
38
+ // Reject anything that could break out of the Set-Cookie line / inject attributes.
39
+ function assertCookieSafe(label: string, value: string): void {
40
+ if (/[\x00-\x1f;]/.test(value)) {
41
+ throw new Error(`Cookie ${label} contains illegal characters (control chars or ';'): ${JSON.stringify(value)}`)
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Read a single named cookie out of a raw `Cookie` header value. Shared by the HTTP
47
+ * binding (parseRequest) and the WebSocket binding (parseHandshake). A malformed
48
+ * percent-encoding (e.g. "sid=%") must not crash — fall back to the raw value.
49
+ */
50
+ export function readCookie(rawCookieHeader: string | undefined, name: string): string | undefined {
51
+ if (typeof rawCookieHeader !== "string") return undefined
52
+ for (const part of rawCookieHeader.split(";")) {
53
+ const eq = part.indexOf("=")
54
+ if (eq === -1) continue
55
+ if (part.slice(0, eq).trim() === name) {
56
+ const rawValue = part.slice(eq + 1).trim()
57
+ try {
58
+ return decodeURIComponent(rawValue)
59
+ } catch {
60
+ return rawValue
61
+ }
62
+ }
63
+ }
64
+ return undefined
65
+ }
66
+
67
+ function serializeSetCookie(name: string, value: string | undefined, options?: CookieOptions): string {
68
+ const o = {...SAFE_DEFAULTS, ...options}
69
+ const secure = o.sameSite === "none" ? true : o.secure
70
+ const sameSite = `${o.sameSite.charAt(0).toUpperCase()}${o.sameSite.slice(1)}`
71
+ const attrs = `; Path=${o.path}${o.domain ? `; Domain=${o.domain}` : ""}; SameSite=${sameSite}${secure ? "; Secure" : ""}${o.httpOnly ? "; HttpOnly" : ""}`
72
+ return value
73
+ ? `${name}=${encodeURIComponent(value)}${o.maxAgeSec !== undefined ? `; Max-Age=${Math.trunc(o.maxAgeSec)}` : ""}${attrs}`
74
+ : `${name}=; Max-Age=0${attrs}`
75
+ }
76
+
77
+ /**
78
+ * Local structural copy of GGWebSocketHandshakeContext (defined in @grest-ts/websocket).
79
+ * http must not import websocket; cookie()'s WS half is typed against this shape and
80
+ * matches GGWebSocketMiddleware structurally at the webSocketSchema.use() call site.
81
+ * `upgradeHeaders` is the real browser upgrade request (carries httpOnly cookies); the
82
+ * in-band `headers` map cannot spoof it.
83
+ */
84
+ interface WsHandshakeContextLike {
85
+ headers: Record<string, string>
86
+ upgradeHeaders?: Record<string, string>
87
+ queryArgs: Record<string, string>
88
+ }
89
+
90
+ export interface GGCookieBinding extends GGHttpTransportMiddleware {
91
+ /** The cookie this binding reads, mapped to its value schema — emitted as an `in: cookie` doc param. */
92
+ readonly cookieParams: Record<string, GGSchema<string | undefined>>
93
+ parseHandshake(ctx: WsHandshakeContextLike): void
94
+ }
95
+
96
+ /**
97
+ * Bind a context key to a cookie, read-only on the wire. Reads ONLY the named cookie —
98
+ * never a header — from the incoming HTTP Cookie (parseRequest) or the real WS upgrade
99
+ * Cookie (parseHandshake), into key.get(). The cookie's wire name defaults to the key's
100
+ * name. To write the cookie, call setCookie(key, …) from a route that declared
101
+ * .updatesCookie(key); the matching binding flushes it as Set-Cookie (HTTP only — a
102
+ * WebSocket has no response to set a cookie on).
103
+ */
104
+ export function cookie(key: GGContextKey<string | undefined>, opts?: {name?: string; schema?: GGSchema<string | undefined>}): GGCookieBinding {
105
+ const cookieName = opts?.name ?? key.name
106
+ assertCookieSafe("name", cookieName)
107
+ return {
108
+ headers: {},
109
+ responseHeaders: {},
110
+ cookieParams: {[cookieName]: opts?.schema ?? IsString.orUndefined},
111
+ parseRequest(req: GGHttpRequest): void {
112
+ const raw = req.headers?.["cookie"]
113
+ const value = readCookie(typeof raw === "string" ? raw : undefined, cookieName)
114
+ if (value !== undefined) key.set(value)
115
+ },
116
+ updateResponse(res: GGHttpResponse): void {
117
+ const pending = GG_COOKIE_PENDING.get()?.get(key.name)
118
+ if (pending === undefined) return
119
+ const line = serializeSetCookie(cookieName, pending.value, pending.options)
120
+ const existing = res.headers["set-cookie"]
121
+ const arr = existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]
122
+ arr.push(line)
123
+ res.headers["set-cookie"] = arr
124
+ },
125
+ parseHandshake(ctx: WsHandshakeContextLike): void {
126
+ const value = readCookie(ctx.upgradeHeaders?.["cookie"], cookieName)
127
+ if (value !== undefined) key.set(value)
128
+ },
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Schedule a Set-Cookie on the response (HTTP only). The value never touches key.get() —
134
+ * inbound reads stay set-once. The route must declare .updatesCookie(key); an undeclared
135
+ * call is invalid usage and throws SERVER_ERROR. The matching cookie() binding serializes
136
+ * the pending write with these options (HttpOnly/Secure/SameSite/Path/Domain/Max-Age).
137
+ */
138
+ export function setCookie(key: GGContextKey<string | undefined>, value: string | undefined, options?: CookieOptions): void {
139
+ if (options) {
140
+ if (options.maxAgeSec !== undefined && !Number.isFinite(options.maxAgeSec)) {
141
+ throw new Error(`setCookie("${key.name}"): maxAgeSec must be a finite number, got ${options.maxAgeSec}.`)
142
+ }
143
+ if (options.path !== undefined) assertCookieSafe("path", options.path)
144
+ if (options.domain !== undefined) assertCookieSafe("domain", options.domain)
145
+ }
146
+ if (!GG_COOKIE_WRITES.get()?.has(key.name)) {
147
+ throw new SERVER_ERROR({debugMessage: `setCookie("${key.name}") was called, but this route did not declare .updatesCookie(<key>). Only routes that declare it may set or clear the cookie.`})
148
+ }
149
+ let pending = GG_COOKIE_PENDING.get()
150
+ if (pending === undefined) {
151
+ pending = new Map()
152
+ GG_COOKIE_PENDING.set(pending)
153
+ }
154
+ pending.set(key.name, {value, options})
155
+ }
156
+
157
+ /**
158
+ * Clear the cookie — schedules a Set-Cookie with Max-Age=0. Pass path/domain to match a
159
+ * scoped cookie (deletion only works when path+domain match what was set). Equivalent to
160
+ * setCookie(key, undefined, options), but reads clearer at logout-style call sites.
161
+ */
162
+ export function clearCookie(key: GGContextKey<string | undefined>, options?: CookieOptions): void {
163
+ setCookie(key, undefined, options)
164
+ }
@@ -0,0 +1,65 @@
1
+ import {GGContextKey} from "@grest-ts/context"
2
+ import {IsString, type GGSchema} from "@grest-ts/schema"
3
+ import type {GGHttpRequest, GGHttpTransportMiddleware} from "./GGHttpSchema"
4
+
5
+ /**
6
+ * Local structural copy of GGWebSocketHandshakeContext (defined in @grest-ts/websocket).
7
+ * http must not import websocket; header()'s WS half is typed against this shape and
8
+ * matches GGWebSocketMiddleware structurally at the webSocketSchema.use() call site.
9
+ * `headers` is the in-band handshake bag (client-set, spoofable — same trust level as an
10
+ * HTTP request header).
11
+ */
12
+ interface WsHandshakeContextLike {
13
+ headers: Record<string, string>
14
+ upgradeHeaders?: Record<string, string>
15
+ queryArgs: Record<string, string>
16
+ }
17
+
18
+ export interface GGHeaderBinding extends GGHttpTransportMiddleware {
19
+ parseHandshake(ctx: WsHandshakeContextLike): void
20
+ updateHandshake(ctx: WsHandshakeContextLike): void
21
+ }
22
+
23
+ const BEARER = "Bearer "
24
+
25
+ /**
26
+ * Bind a context key to a single named header, on both transports. Reads ONLY that header
27
+ * — never a cookie — from the HTTP request (parseRequest) or the in-band WS handshake
28
+ * (parseHandshake) into key.get(), and writes it from key.get() on the client (HTTP
29
+ * updateRequest / WS updateHandshake). `scheme: "bearer"` strips/adds the `Bearer ` prefix;
30
+ * default is verbatim. The wire name defaults to the key's name (lowercased — HTTP headers
31
+ * are case-insensitive). Client-attachable, so it is the service-to-service variant (a
32
+ * Node client can carry it; a browser cookie cannot).
33
+ */
34
+ export function header(key: GGContextKey<string | undefined>, opts?: {name?: string; scheme?: "bearer"; schema?: GGSchema<string | undefined>}): GGHeaderBinding {
35
+ const name = (opts?.name ?? key.name).toLowerCase()
36
+ const bearer = opts?.scheme === "bearer"
37
+ const unwrap = (raw: string | undefined): string | undefined => {
38
+ if (raw === undefined) return undefined
39
+ return bearer && raw.startsWith(BEARER) ? raw.slice(BEARER.length) : raw
40
+ }
41
+ const wrap = (value: string): string => bearer ? `${BEARER}${value}` : value
42
+ return {
43
+ headers: {[name]: opts?.schema ?? IsString.orUndefined},
44
+ responseHeaders: {},
45
+ parseRequest(req: GGHttpRequest): void {
46
+ const raw = req.headers?.[name]
47
+ const value = unwrap(typeof raw === "string" ? raw : Array.isArray(raw) ? raw[0] : undefined)
48
+ if (value !== undefined) key.set(value)
49
+ },
50
+ updateRequest(req: GGHttpRequest): void {
51
+ const value = key.get()
52
+ if (value === undefined) return
53
+ req.headers = req.headers ?? {}
54
+ req.headers[name] = wrap(value)
55
+ },
56
+ parseHandshake(ctx: WsHandshakeContextLike): void {
57
+ const value = unwrap(ctx.headers?.[name])
58
+ if (value !== undefined) key.set(value)
59
+ },
60
+ updateHandshake(ctx: WsHandshakeContextLike): void {
61
+ const value = key.get()
62
+ if (value !== undefined) ctx.headers[name] = wrap(value)
63
+ },
64
+ }
65
+ }
@@ -1,8 +1,6 @@
1
- import type http from "http";
2
1
  import {GGHttpCodec, GGHttpSchema} from "./GGHttpSchema";
3
- import {GGHttpRequest, GGHttpTransportMiddleware} from "./GGHttpSchema";
4
- import {GGContractApiDefinition, GGContractClass, GGSchema} from "@grest-ts/schema";
5
- import {GGContextKey} from "@grest-ts/context";
2
+ import {GGHttpTransportMiddleware} from "./GGHttpSchema";
3
+ import {GGContractApiDefinition, GGContractClass} from "@grest-ts/schema";
6
4
 
7
5
  /**
8
6
  * Create an HTTP API schema builder from a contract.
@@ -54,57 +52,6 @@ class GGHttpSchemaBuilder<TContract extends GGContractApiDefinition, TContext =
54
52
  return this as unknown as GGHttpSchemaBuilder<TContract, TContext | M>
55
53
  }
56
54
 
57
- useHeader<Input>(contextKey: GGContextKey<Input>): GGHttpSchemaBuilder<TContract, TContext | Input> {
58
- const codec = contextKey.getCodec("http");
59
- if (!codec) {
60
- throw new Error(`Context key '${contextKey.name}' does not have an 'http-header' codec registered.`);
61
- }
62
-
63
- // Build the typed header map from codec.inputSchema — the IsObject({headerName: schema})
64
- // that describes which headers this codec reads and what format each value has.
65
- const inputSchema = codec.inputSchema;
66
- const headers: Record<string, GGSchema<string | undefined>> = {};
67
- if (inputSchema) {
68
- const desc = inputSchema.toSchemaDescription();
69
- if (desc.node.kind === 'object') {
70
- for (const [k, fieldDesc] of Object.entries(desc.node.properties)) {
71
- headers[k] = fieldDesc.schema as GGSchema<string | undefined>;
72
- }
73
- }
74
- }
75
-
76
- const middleware: GGHttpTransportMiddleware = {
77
- headers,
78
- responseHeaders: {},
79
- updateRequest(req: GGHttpRequest) {
80
- const contextValue = contextKey.get();
81
- if (contextValue !== undefined) {
82
- const result = codec.decode(contextValue);
83
- if (result.success) {
84
- Object.assign(req.headers, result.value);
85
- }
86
- } else {
87
- const emptyResult = codec.decode({} as Input);
88
- if (emptyResult.success) {
89
- for (const key of Object.keys(emptyResult.value as object)) {
90
- delete req.headers[key];
91
- }
92
- }
93
- }
94
- },
95
- parseRequest(req: http.IncomingMessage) {
96
- const headers = req.headers as Record<string, string>;
97
- const result = codec.encode(headers);
98
- if (result.success) {
99
- contextKey.set(result.value);
100
- }
101
- }
102
- };
103
-
104
- this._middlewares.push(middleware);
105
- return this as unknown as GGHttpSchemaBuilder<TContract, TContext | Input>;
106
- }
107
-
108
55
  routes(mapping: { [K in keyof TContract]: GGHttpCodec }): GGHttpSchema<TContract, TContext> {
109
56
  return new GGHttpSchema(this._pathPrefix, this._contract, mapping, this._middlewares)
110
57
  }
@@ -12,6 +12,7 @@ import {GG_DISCOVERY} from "@grest-ts/discovery";
12
12
  import {GGContext, GGContextStore} from "@grest-ts/context";
13
13
  import {GG_TRACE} from "@grest-ts/trace";
14
14
  import {GG_HTTP_REQUEST} from "./GG_HTTP_REQUEST";
15
+ import {GG_COOKIE_WRITES} from "../schema/cookieMiddleware";
15
16
  import {GG_METRICS} from "@grest-ts/metrics";
16
17
  import {GGHttpMetrics} from "./GGHttpMetrics";
17
18
  import {GG_HTTP_SERVER} from "./GG_HTTP_SERVER";
@@ -127,11 +128,13 @@ function setupRoutes<TContract extends GGContractApiDefinition>(
127
128
  apiMiddlewares: apiMiddlewares,
128
129
  serverMiddlewares: config.middlewares
129
130
  })
131
+ const cookieWriteNames = new Set((codec.updatesCookies ?? []).map(k => k.name))
130
132
  server.registerRoute(codec.method, pathPrefix + codec.path, async (req: http.IncomingMessage, res: http.ServerResponse): Promise<void> => {
131
133
  scope.ensureEntered();
132
- return new GGContext("REQ", parentContext).run(async () => {
134
+ return new GGContext("REQ", parentContext, true).run(async () => {
133
135
  GG_TRACE.init();
134
136
  GG_HTTP_REQUEST.set({port: server.port, method: req.method, path: req.url});
137
+ GG_COOKIE_WRITES.set(cookieWriteNames);
135
138
  const startTime = performance.now()
136
139
  let rpcResult: ERROR<string, unknown> | OK<unknown>
137
140
  try {
@@ -17,9 +17,31 @@ type AnyWebSocketSchema = {
17
17
  connectPermission?: GGPermission;
18
18
  };
19
19
 
20
+ export interface GGCorsConfig {
21
+ /**
22
+ * Allowed cross-origin origins. Either an exact-match list or a predicate.
23
+ * Only a matching Origin is echoed back — an arbitrary origin is never
24
+ * reflected (that would be a credential-leak / CSRF hole with credentials on).
25
+ */
26
+ origins: string[] | ((origin: string) => boolean);
27
+ /**
28
+ * When true, adds Access-Control-Allow-Credentials: true so the browser will
29
+ * send/store cross-origin cookies. Requires an exact-origin echo (never `*`),
30
+ * which this enforces by construction.
31
+ */
32
+ credentials?: boolean;
33
+ }
34
+
20
35
  export interface GGHttpServerAdapterConfig {
21
36
  key?: GGLocatorKey<GGHttpServer>;
22
37
  port?: number;
38
+ /**
39
+ * Cross-origin policy. Omitted (default) keeps the permissive
40
+ * `Access-Control-Allow-Origin: *` with no credentials — backward compatible.
41
+ * When set, switches to allowlisted mode: only an Origin in `origins` is echoed
42
+ * (never `*`), and `credentials: true` enables cross-origin cookies.
43
+ */
44
+ cors?: GGCorsConfig;
23
45
  /**
24
46
  * If provided, the server listens over HTTPS using this cert+key. Both
25
47
  * must be PEM-encoded strings or Buffers. Leaving undefined keeps the
@@ -41,6 +63,7 @@ export class GGHttpServer {
41
63
  protected readonly scope: GGLocatorScope;
42
64
  protected readonly runtimeName: string;
43
65
  private readonly configuredPort: number;
66
+ private readonly cors?: GGCorsConfig;
44
67
 
45
68
  private readonly _onStart: Array<() => void> = [];
46
69
  private readonly _onTeardown: Array<() => void> = [];
@@ -75,6 +98,7 @@ export class GGHttpServer {
75
98
  this.runtimeName = GGLocator.getScope().serviceName;
76
99
  this.scope = GGLocator.getScope();
77
100
  this.configuredPort = config?.port ?? (process.env.PORT ? Number(process.env.PORT) : 0);
101
+ this.cors = config?.cors;
78
102
 
79
103
  GGLocator.getScope().setWithLifecycle(config?.key ?? GG_HTTP_SERVER, this, {
80
104
  type: GGLocatorServiceType.HTTP,
@@ -90,14 +114,8 @@ export class GGHttpServer {
90
114
  }
91
115
  this.activeRequests++;
92
116
  try {
93
- if (req.headers.origin) { // For browsers
94
- res.setHeader('Access-Control-Allow-Origin', '*');
95
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
96
- res.setHeader('Access-Control-Allow-Headers', this._corsHeadersCache);
97
- if (this._corsExposeHeadersCache) {
98
- res.setHeader('Access-Control-Expose-Headers', this._corsExposeHeadersCache);
99
- }
100
- }
117
+ const cors = corsResponseHeaders(req.headers.origin, this.cors, this._corsHeadersCache, this._corsExposeHeadersCache);
118
+ for (const name in cors) res.setHeader(name, cors[name]);
101
119
  if (req.method === 'OPTIONS') {
102
120
  res.writeHead(204);
103
121
  res.end();
@@ -313,3 +331,34 @@ export class GGHttpServer {
313
331
  }
314
332
 
315
333
  export type GGHttpRequestCallback = (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void>;
334
+
335
+ /**
336
+ * Computes CORS response headers for a request Origin. Pure (no req/res) so it is
337
+ * unit-testable. Default (no config) = permissive `*`, no credentials. With config =
338
+ * allowlisted: only a matching Origin is echoed (never `*`), `credentials` adds
339
+ * Allow-Credentials, and Vary: Origin is always set so caches key on the origin.
340
+ */
341
+ export function corsResponseHeaders(
342
+ origin: string | undefined,
343
+ cors: GGCorsConfig | undefined,
344
+ allowHeaders: string,
345
+ exposeHeaders: string
346
+ ): Record<string, string> {
347
+ if (!origin) return {};
348
+ const headers: Record<string, string> = {};
349
+ if (cors) {
350
+ // Never reflect the literal "null" origin (sandboxed iframe / file:// / opaque
351
+ // origins) with credentials — the classic null-origin credential-leak footgun.
352
+ const allowed = origin !== "null" && (Array.isArray(cors.origins) ? cors.origins.includes(origin) : cors.origins(origin));
353
+ headers['Vary'] = 'Origin';
354
+ if (!allowed) return headers;
355
+ headers['Access-Control-Allow-Origin'] = origin;
356
+ if (cors.credentials) headers['Access-Control-Allow-Credentials'] = 'true';
357
+ } else {
358
+ headers['Access-Control-Allow-Origin'] = '*';
359
+ }
360
+ headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
361
+ headers['Access-Control-Allow-Headers'] = allowHeaders;
362
+ if (exposeHeaders) headers['Access-Control-Expose-Headers'] = exposeHeaders;
363
+ return headers;
364
+ }
@@ -0,0 +1,27 @@
1
+ import type http from "http";
2
+ import type {GGHttpResponse, GGHttpTransportMiddleware} from "../schema/GGHttpSchema";
3
+
4
+ /**
5
+ * Runs the transport chain's server-side response hook and writes the collected
6
+ * headers onto the Node response. Applied via setHeader (not writeHead) so a
7
+ * string[] value emits one header line per element — the only way to send
8
+ * multiple Set-Cookie headers — and so the codec's later writeHead(Content-*)
9
+ * merges rather than clobbers. Must run inside the request GGContext so a
10
+ * middleware can read per-request staged state.
11
+ */
12
+ export function applyResponseMiddleware(
13
+ res: http.ServerResponse,
14
+ middlewares: readonly GGHttpTransportMiddleware[] | undefined
15
+ ): void {
16
+ if (!middlewares?.length || res.headersSent) return;
17
+ let response: GGHttpResponse | undefined;
18
+ for (const mw of middlewares) {
19
+ if (!mw.updateResponse) continue;
20
+ response ??= {headers: {}};
21
+ mw.updateResponse(response);
22
+ }
23
+ if (!response) return;
24
+ for (const name in response.headers) {
25
+ res.setHeader(name, response.headers[name]);
26
+ }
27
+ }