@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.
- package/README.md +125 -0
- package/dist/src/_dedupCheck.js +1 -1
- 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 +2 -0
- package/dist/src/index-browser.d.ts.map +1 -1
- package/dist/src/index-browser.js +2 -0
- package/dist/src/index-browser.js.map +1 -1
- package/dist/src/index-node.d.ts +3 -0
- package/dist/src/index-node.d.ts.map +1 -1
- package/dist/src/index-node.js +3 -0
- 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/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/GGHttpSchema.d.ts +13 -0
- package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
- package/dist/src/schema/GGHttpSchema.js.map +1 -1
- package/dist/src/schema/cookieMiddleware.d.ts +71 -0
- package/dist/src/schema/cookieMiddleware.d.ts.map +1 -0
- package/dist/src/schema/cookieMiddleware.js +126 -0
- package/dist/src/schema/cookieMiddleware.js.map +1 -0
- package/dist/src/schema/headerBinding.d.ts +35 -0
- package/dist/src/schema/headerBinding.d.ts.map +1 -0
- package/dist/src/schema/headerBinding.js +49 -0
- package/dist/src/schema/headerBinding.js.map +1 -0
- package/dist/src/schema/httpSchema.d.ts +0 -2
- 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/GGHttpSchema.startServer.d.ts.map +1 -1
- package/dist/src/server/GGHttpSchema.startServer.js +4 -1
- package/dist/src/server/GGHttpSchema.startServer.js.map +1 -1
- package/dist/src/server/GGHttpServer.d.ts +29 -0
- package/dist/src/server/GGHttpServer.d.ts.map +1 -1
- package/dist/src/server/GGHttpServer.js +35 -8
- package/dist/src/server/GGHttpServer.js.map +1 -1
- 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/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/_dedupCheck.ts +1 -1
- package/src/client/GGHttpSchema.createClient.ts +10 -1
- package/src/index-browser.ts +2 -0
- package/src/index-node.ts +3 -0
- package/src/rpc/GGHttpRouteRPC.ts +8 -0
- package/src/rpc/RpcResponse/GGRpcResponseBuilder.ts +5 -1
- package/src/schema/GGHttpSchema.ts +15 -0
- package/src/schema/cookieMiddleware.ts +164 -0
- package/src/schema/headerBinding.ts +65 -0
- package/src/schema/httpSchema.ts +2 -55
- package/src/server/GGHttpSchema.startServer.ts +4 -1
- package/src/server/GGHttpServer.ts +57 -8
- 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
|
|
package/src/index-browser.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/schema/httpSchema.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import type http from "http";
|
|
2
1
|
import {GGHttpCodec, GGHttpSchema} from "./GGHttpSchema";
|
|
3
|
-
import {
|
|
4
|
-
import {GGContractApiDefinition, GGContractClass
|
|
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
|
-
|
|
94
|
-
|
|
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
|
+
}
|