@grest-ts/http 0.0.48 → 0.0.50

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 (127) hide show
  1. package/README.md +243 -175
  2. package/dist/src/_dedupCheck.js +1 -1
  3. package/dist/src/client/GGContextKeySynchronizer.d.ts +11 -0
  4. package/dist/src/client/GGContextKeySynchronizer.d.ts.map +1 -0
  5. package/dist/src/client/GGContextKeySynchronizer.js +26 -0
  6. package/dist/src/client/GGContextKeySynchronizer.js.map +1 -0
  7. package/dist/src/index-browser.d.ts +4 -2
  8. package/dist/src/index-browser.d.ts.map +1 -1
  9. package/dist/src/index-browser.js +4 -2
  10. package/dist/src/index-browser.js.map +1 -1
  11. package/dist/src/index-node.d.ts +8 -3
  12. package/dist/src/index-node.d.ts.map +1 -1
  13. package/dist/src/index-node.js +16 -7
  14. package/dist/src/index-node.js.map +1 -1
  15. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts +4 -3
  16. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts.map +1 -1
  17. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js +9 -2
  18. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js.map +1 -1
  19. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts +3 -3
  20. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.d.ts.map +1 -1
  21. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js +13 -16
  22. package/dist/src/rpc/RpcRequest/GGRpcRequestParser.js.map +1 -1
  23. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts +1 -1
  24. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.d.ts.map +1 -1
  25. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js +3 -3
  26. package/dist/src/rpc/RpcResponse/GGRpcResponseBuilder.js.map +1 -1
  27. package/dist/src/schema/GGCookie.d.ts +50 -0
  28. package/dist/src/schema/GGCookie.d.ts.map +1 -0
  29. package/dist/src/schema/GGCookie.js +130 -0
  30. package/dist/src/schema/GGCookie.js.map +1 -0
  31. package/dist/src/schema/GGHeader.d.ts +23 -0
  32. package/dist/src/schema/GGHeader.d.ts.map +1 -0
  33. package/dist/src/schema/GGHeader.js +41 -0
  34. package/dist/src/schema/GGHeader.js.map +1 -0
  35. package/dist/src/schema/GGHttpPermissionsChecker.d.ts +9 -0
  36. package/dist/src/schema/GGHttpPermissionsChecker.d.ts.map +1 -0
  37. package/dist/src/schema/GGHttpPermissionsChecker.js +29 -0
  38. package/dist/src/schema/GGHttpPermissionsChecker.js.map +1 -0
  39. package/dist/src/schema/GGHttpSchema.d.ts +8 -59
  40. package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
  41. package/dist/src/schema/GGHttpSchema.js.map +1 -1
  42. package/dist/src/schema/GGWireContextKey.d.ts +30 -0
  43. package/dist/src/schema/GGWireContextKey.d.ts.map +1 -0
  44. package/dist/src/schema/GGWireContextKey.js +36 -0
  45. package/dist/src/schema/GGWireContextKey.js.map +1 -0
  46. package/dist/src/schema/GGWireContextKey.node.d.ts +36 -0
  47. package/dist/src/schema/GGWireContextKey.node.d.ts.map +1 -0
  48. package/dist/src/schema/GGWireContextKey.node.js +83 -0
  49. package/dist/src/schema/GGWireContextKey.node.js.map +1 -0
  50. package/dist/src/schema/httpSchema.d.ts +2 -2
  51. package/dist/src/schema/httpSchema.d.ts.map +1 -1
  52. package/dist/src/schema/httpSchema.js.map +1 -1
  53. package/dist/src/server/GGHttp.d.ts +2 -26
  54. package/dist/src/server/GGHttp.d.ts.map +1 -1
  55. package/dist/src/server/GGHttp.js +0 -15
  56. package/dist/src/server/GGHttp.js.map +1 -1
  57. package/dist/src/server/GGHttpSchema.startServer.d.ts +2 -11
  58. package/dist/src/server/GGHttpSchema.startServer.d.ts.map +1 -1
  59. package/dist/src/server/GGHttpSchema.startServer.js +11 -28
  60. package/dist/src/server/GGHttpSchema.startServer.js.map +1 -1
  61. package/dist/src/server/GGHttpServer.d.ts +16 -14
  62. package/dist/src/server/GGHttpServer.d.ts.map +1 -1
  63. package/dist/src/server/GGHttpServer.js +26 -79
  64. package/dist/src/server/GGHttpServer.js.map +1 -1
  65. package/dist/src/server/applyRequestMiddleware.d.ts +11 -0
  66. package/dist/src/server/applyRequestMiddleware.d.ts.map +1 -0
  67. package/dist/src/server/applyRequestMiddleware.js +38 -0
  68. package/dist/src/server/applyRequestMiddleware.js.map +1 -0
  69. package/dist/src/server/applyResponseMiddleware.d.ts +2 -2
  70. package/dist/src/server/applyResponseMiddleware.d.ts.map +1 -1
  71. package/dist/src/server/applyResponseMiddleware.js +2 -2
  72. package/dist/src/server/applyResponseMiddleware.js.map +1 -1
  73. package/dist/src/server/checkers/checkPermissionsAtStart.d.ts +39 -0
  74. package/dist/src/server/checkers/checkPermissionsAtStart.d.ts.map +1 -0
  75. package/dist/src/server/checkers/checkPermissionsAtStart.js +82 -0
  76. package/dist/src/server/checkers/checkPermissionsAtStart.js.map +1 -0
  77. package/dist/src/server/checkers/checkWireConflicts.d.ts +18 -0
  78. package/dist/src/server/checkers/checkWireConflicts.d.ts.map +1 -0
  79. package/dist/src/server/checkers/checkWireConflicts.js +43 -0
  80. package/dist/src/server/checkers/checkWireConflicts.js.map +1 -0
  81. package/dist/src/server/checkers/checkWiresImplemented.d.ts +11 -0
  82. package/dist/src/server/checkers/checkWiresImplemented.d.ts.map +1 -0
  83. package/dist/src/server/checkers/checkWiresImplemented.js +27 -0
  84. package/dist/src/server/checkers/checkWiresImplemented.js.map +1 -0
  85. package/dist/src/tsconfig.json +2 -1
  86. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  87. package/package.json +14 -11
  88. package/src/_dedupCheck.ts +1 -1
  89. package/src/client/GGContextKeySynchronizer.test.ts +106 -0
  90. package/src/client/GGContextKeySynchronizer.ts +42 -0
  91. package/src/index-browser.ts +4 -2
  92. package/src/index-node.ts +17 -7
  93. package/src/rpc/RpcRequest/GGRpcRequestBuilder.ts +11 -4
  94. package/src/rpc/RpcRequest/GGRpcRequestParser.ts +16 -17
  95. package/src/rpc/RpcResponse/GGRpcResponseBuilder.ts +5 -4
  96. package/src/schema/GGCookie.ts +151 -0
  97. package/src/schema/GGHeader.ts +48 -0
  98. package/src/schema/GGHttpPermissionsChecker.test.ts +125 -0
  99. package/src/schema/GGHttpPermissionsChecker.ts +35 -0
  100. package/src/schema/GGHttpSchema.ts +8 -71
  101. package/src/schema/GGWireContextKey.node.ts +119 -0
  102. package/src/schema/GGWireContextKey.ts +46 -0
  103. package/src/schema/httpSchema.ts +3 -3
  104. package/src/server/GGHttp.ts +3 -34
  105. package/src/server/GGHttpSchema.startServer.ts +13 -38
  106. package/src/server/GGHttpServer.ts +33 -89
  107. package/src/server/applyRequestMiddleware.ts +40 -0
  108. package/src/server/applyResponseMiddleware.ts +5 -5
  109. package/src/server/checkers/checkPermissionsAtStart.ts +112 -0
  110. package/src/server/checkers/checkWireConflicts.ts +65 -0
  111. package/src/server/checkers/checkWiresImplemented.ts +42 -0
  112. package/src/tsconfig.json +2 -1
  113. package/dist/src/schema/cookieMiddleware.d.ts +0 -71
  114. package/dist/src/schema/cookieMiddleware.d.ts.map +0 -1
  115. package/dist/src/schema/cookieMiddleware.js +0 -126
  116. package/dist/src/schema/cookieMiddleware.js.map +0 -1
  117. package/dist/src/schema/headerBinding.d.ts +0 -35
  118. package/dist/src/schema/headerBinding.d.ts.map +0 -1
  119. package/dist/src/schema/headerBinding.js +0 -49
  120. package/dist/src/schema/headerBinding.js.map +0 -1
  121. package/dist/src/server/GG_PERMISSIONS.d.ts +0 -15
  122. package/dist/src/server/GG_PERMISSIONS.d.ts.map +0 -1
  123. package/dist/src/server/GG_PERMISSIONS.js +0 -15
  124. package/dist/src/server/GG_PERMISSIONS.js.map +0 -1
  125. package/src/schema/cookieMiddleware.ts +0 -164
  126. package/src/schema/headerBinding.ts +0 -65
  127. package/src/server/GG_PERMISSIONS.ts +0 -15
@@ -12,7 +12,7 @@
12
12
  const __KEY = Symbol.for("grest-ts:dedup:@grest-ts/http");
13
13
  const __slot = globalThis as Record<symbol, unknown>;
14
14
  const __existing = __slot[__KEY] as { path: string; version: string } | undefined;
15
- const __here = { path: import.meta.url, version: "0.0.48" };
15
+ const __here = { path: import.meta.url, version: "0.0.50" };
16
16
  // Same path + same version means the same physical package is being
17
17
  // re-evaluated by a different ESM runner (e.g. vitest projects share one
18
18
  // process but each project has its own module cache). That isn't a true
@@ -0,0 +1,106 @@
1
+ import {describe, it, expect, vi} from 'vitest'
2
+ import {GGContext} from '@grest-ts/context'
3
+ import {GGContextKey} from '@grest-ts/context'
4
+ import {GGContextKeySynchronizer} from './GGContextKeySynchronizer'
5
+ import {IsString} from '@grest-ts/schema'
6
+
7
+ const KEY_A = new GGContextKey<string | undefined>('sync-test:a', IsString.orUndefined)
8
+ const KEY_B = new GGContextKey<string | undefined>('sync-test:b', IsString.orUndefined)
9
+
10
+ function inContext<T>(fn: () => Promise<T>): Promise<T> {
11
+ return new GGContext('test').run(fn)
12
+ }
13
+
14
+ describe('GGContextKeySynchronizer', () => {
15
+
16
+ describe('waitFor — no controller', () => {
17
+ it('resolves immediately when no controller is registered', async () => {
18
+ await inContext(async () => {
19
+ await expect(GGContextKeySynchronizer.waitFor(KEY_A)).resolves.toBeUndefined()
20
+ })
21
+ })
22
+ })
23
+
24
+ describe('waitFor — not stale', () => {
25
+ it('does not call recover when isStale returns false', async () => {
26
+ await inContext(async () => {
27
+ const recover = vi.fn().mockResolvedValue(undefined)
28
+ GGContextKeySynchronizer.provide(KEY_A, {isStale: () => false, recover})
29
+ await GGContextKeySynchronizer.waitFor(KEY_A)
30
+ expect(recover).not.toHaveBeenCalled()
31
+ })
32
+ })
33
+ })
34
+
35
+ describe('waitFor — stale once', () => {
36
+ it('calls recover once and after it resolves a second waitFor does not call recover again', async () => {
37
+ await inContext(async () => {
38
+ let stale = true
39
+ const recover = vi.fn(async () => { stale = false })
40
+ GGContextKeySynchronizer.provide(KEY_A, {isStale: () => stale, recover})
41
+
42
+ await GGContextKeySynchronizer.waitFor(KEY_A)
43
+ expect(recover).toHaveBeenCalledTimes(1)
44
+
45
+ await GGContextKeySynchronizer.waitFor(KEY_A)
46
+ expect(recover).toHaveBeenCalledTimes(1)
47
+ })
48
+ })
49
+ })
50
+
51
+ describe('waitFor — coalescing', () => {
52
+ it('fires recover exactly once for 5 concurrent waitFor calls', async () => {
53
+ await inContext(async () => {
54
+ let resolveRecover!: () => void
55
+ const recoverDone = new Promise<void>(res => { resolveRecover = res })
56
+ const recover = vi.fn(() => recoverDone)
57
+
58
+ GGContextKeySynchronizer.provide(KEY_B, {isStale: () => true, recover})
59
+
60
+ const waits = Promise.all([
61
+ GGContextKeySynchronizer.waitFor(KEY_B),
62
+ GGContextKeySynchronizer.waitFor(KEY_B),
63
+ GGContextKeySynchronizer.waitFor(KEY_B),
64
+ GGContextKeySynchronizer.waitFor(KEY_B),
65
+ GGContextKeySynchronizer.waitFor(KEY_B),
66
+ ])
67
+
68
+ resolveRecover()
69
+ await waits
70
+ expect(recover).toHaveBeenCalledTimes(1)
71
+ })
72
+ })
73
+ })
74
+
75
+ describe('waitFor — recover rejects', () => {
76
+ it('rejects waitFor when recover rejects, then clears inflight so next call can retry', async () => {
77
+ await inContext(async () => {
78
+ let callCount = 0
79
+ const recover = vi.fn(async () => {
80
+ callCount++
81
+ if (callCount === 1) throw new Error('refresh failed')
82
+ })
83
+
84
+ GGContextKeySynchronizer.provide(KEY_A, {isStale: () => true, recover})
85
+
86
+ await expect(GGContextKeySynchronizer.waitFor(KEY_A)).rejects.toThrow('refresh failed')
87
+ expect(recover).toHaveBeenCalledTimes(1)
88
+
89
+ await expect(GGContextKeySynchronizer.waitFor(KEY_A)).resolves.toBeUndefined()
90
+ expect(recover).toHaveBeenCalledTimes(2)
91
+ })
92
+ })
93
+ })
94
+
95
+ describe('clear', () => {
96
+ it('removes the controller so subsequent waitFor is a no-op', async () => {
97
+ await inContext(async () => {
98
+ const recover = vi.fn().mockResolvedValue(undefined)
99
+ GGContextKeySynchronizer.provide(KEY_A, {isStale: () => true, recover})
100
+ GGContextKeySynchronizer.clear(KEY_A)
101
+ await GGContextKeySynchronizer.waitFor(KEY_A)
102
+ expect(recover).not.toHaveBeenCalled()
103
+ })
104
+ })
105
+ })
106
+ })
@@ -0,0 +1,42 @@
1
+ import {GGContextKey} from "@grest-ts/context"
2
+ import {IsAny, type GGSchema} from "@grest-ts/schema"
3
+
4
+ export interface GGKeyController {
5
+ isStale: () => boolean
6
+ recover: () => Promise<void>
7
+ }
8
+
9
+ interface ControllerEntry {
10
+ controller: GGKeyController
11
+ inflight: Promise<void> | undefined
12
+ }
13
+
14
+ const GG_SYNC_CONTROLLERS = new GGContextKey<Map<string, ControllerEntry>>(
15
+ "sync:controllers",
16
+ IsAny as unknown as GGSchema<Map<string, ControllerEntry>>
17
+ )
18
+
19
+ export class GGContextKeySynchronizer {
20
+
21
+ static provide(key: GGContextKey<any>, controller: GGKeyController): void {
22
+ let registry = GG_SYNC_CONTROLLERS.get()
23
+ if (registry === undefined) {
24
+ registry = new Map()
25
+ GG_SYNC_CONTROLLERS.set(registry)
26
+ }
27
+ registry.set(key.name, {controller, inflight: undefined})
28
+ }
29
+
30
+ static async waitFor(key: GGContextKey<any>): Promise<void> {
31
+ const entry = GG_SYNC_CONTROLLERS.get()?.get(key.name)
32
+ if (entry === undefined || !entry.controller.isStale()) return
33
+ entry.inflight ??= entry.controller.recover().finally(() => {
34
+ entry.inflight = undefined
35
+ })
36
+ await entry.inflight
37
+ }
38
+
39
+ static clear(key: GGContextKey<any>): void {
40
+ GG_SYNC_CONTROLLERS.get()?.delete(key.name)
41
+ }
42
+ }
@@ -2,13 +2,15 @@ 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
+ export * from "./schema/GGCookie";
6
+ export * from "./schema/GGWireContextKey";
7
+ export * from "./schema/GGHeader";
7
8
  export * from "./rpc/GGHttpRouteRPC";
8
9
  export * from "./rpc/RpcRequest/GGRpcRequestBuilder";
9
10
  export * from "./rpc/RpcResponse/GGRpcResponseParser";
10
11
 
11
12
  // Client
13
+ export * from "./client/GGContextKeySynchronizer";
12
14
  export * from "./client/GGHttpSchema.createClient";
13
15
 
14
16
  // Extensions
package/src/index-node.ts CHANGED
@@ -3,23 +3,29 @@ import "./_dedupCheck";
3
3
  import {_registerRpcServerCodecFactory} from "./rpc/GGHttpRouteRPC";
4
4
  import {GGRpcResponseBuilder} from "./rpc/RpcResponse/GGRpcResponseBuilder";
5
5
  import {GGRpcRequestParser} from "./rpc/RpcRequest/GGRpcRequestParser";
6
- _registerRpcServerCodecFactory((method, path, config) => ({
7
- parseRequest: new GGRpcRequestParser(method, path, config).parseRequest,
8
- sendResponse: new GGRpcResponseBuilder(config).sendResponse
9
- }));
6
+
7
+ _registerRpcServerCodecFactory((method, path, config) => {
8
+ const parser = new GGRpcRequestParser(method, path, config);
9
+ return {
10
+ readRequest: parser.readRequest,
11
+ validateInput: parser.validateInput,
12
+ sendResponse: new GGRpcResponseBuilder(config).sendResponse
13
+ };
14
+ });
10
15
 
11
16
  // Metrics
12
17
  export * from "./server/GGHttpMetrics";
13
18
 
14
19
  // Context
15
20
  export * from "./server/GG_HTTP_REQUEST";
16
- export * from "./server/GG_PERMISSIONS";
17
21
 
18
22
  // API Schema
19
23
  export * from "./schema/GGHttpSchema";
20
24
  export * from "./schema/httpSchema";
21
- export * from "./schema/cookieMiddleware";
22
- export * from "./schema/headerBinding";
25
+ export * from "./schema/GGCookie";
26
+ export * from "./schema/GGWireContextKey";
27
+ export * from "./schema/GGWireContextKey.node";
28
+ export * from "./schema/GGHeader";
23
29
  export * from "./rpc/GGHttpRouteRPC";
24
30
  export * from "./rpc/openApiSuccessResponse";
25
31
  export * from "./rpc/openApiHelpers";
@@ -34,8 +40,11 @@ export * from "./server/GGHttp";
34
40
  export * from "./server/GGHttpServer";
35
41
  export * from "./server/GG_HTTP_SERVER";
36
42
  export * from "./server/applyResponseMiddleware";
43
+ export * from "./server/applyRequestMiddleware";
44
+ export * from "./schema/GGHttpPermissionsChecker";
37
45
 
38
46
  // Client
47
+ export * from "./client/GGContextKeySynchronizer";
39
48
  export * from "./client/GGHttpSchema.createClient";
40
49
  export * from "./server/GGHttpSchema.startServer";
41
50
 
@@ -43,3 +52,4 @@ export * from "./server/GGHttpSchema.startServer";
43
52
  // Extensions
44
53
  import "./client/GGHttpSchema.createClient";
45
54
  import "./server/GGHttpSchema.startServer";
55
+ import "./schema/GGWireContextKey.node";
@@ -1,11 +1,13 @@
1
1
  import type {HttpMethod} from "@grest-ts/common";
2
2
  import {GGContractMethod} from "@grest-ts/schema";
3
- import {ClientHttpRouteToRpcTransformClientConfig, GGHttpFetchRequest, GGHttpTransportMiddleware} from "../../schema/GGHttpSchema";
3
+ import {ClientHttpRouteToRpcTransformClientConfig, GGHttpFetchRequest} from "../../schema/GGHttpSchema";
4
+ import {GGContextKey, type GGTransportMiddleware} from "@grest-ts/context";
5
+ import {GGContextKeySynchronizer} from "../../client/GGContextKeySynchronizer";
4
6
 
5
7
  export class GGRpcRequestBuilder {
6
8
 
7
9
  public readonly contract: GGContractMethod
8
- public readonly middlewares: readonly GGHttpTransportMiddleware[]
10
+ public readonly middlewares: readonly GGTransportMiddleware[]
9
11
  public readonly method: HttpMethod;
10
12
  public readonly pathTemplate: string;
11
13
  public readonly pathParams: string[];
@@ -26,7 +28,7 @@ export class GGRpcRequestBuilder {
26
28
  this.hasBody = method === "POST" || method === "PUT" || method === "PATCH"
27
29
  }
28
30
 
29
- public createRequest = (data: unknown): GGHttpFetchRequest => {
31
+ public createRequest = async (data: unknown): Promise<GGHttpFetchRequest> => {
30
32
  let result: GGHttpFetchRequest;
31
33
  if (this.hasBody) {
32
34
  result = {
@@ -43,7 +45,12 @@ export class GGRpcRequestBuilder {
43
45
  body: undefined
44
46
  }
45
47
  }
46
- this.middlewares?.forEach(mw => mw.updateRequest?.(result))
48
+ for (const mw of this.middlewares ?? []) {
49
+ if (mw instanceof GGContextKey) {
50
+ await GGContextKeySynchronizer.waitFor(mw)
51
+ }
52
+ }
53
+ this.middlewares?.forEach(mw => mw.update?.(result))
47
54
  return result
48
55
  }
49
56
 
@@ -1,8 +1,9 @@
1
1
  import type http from "http";
2
2
  import type {HttpMethod} from "@grest-ts/common";
3
3
  import {GGContractExecutor, GGContractMethod, PAYLOAD_TOO_LARGE} from "@grest-ts/schema";
4
- import {ClientHttpRouteToRpcTransformServerConfig, GGHttpRequest, GGHttpTransportMiddleware} from "../../schema/GGHttpSchema";
5
- import type {GGHttpServerMiddleware} from "../../server/GGHttpSchema.startServer";
4
+ import type {GGTransportMiddleware} from "@grest-ts/context";
5
+ import {ClientHttpRouteToRpcTransformServerConfig} from "../../schema/GGHttpSchema";
6
+ import {applyRequestMiddleware} from "../../server/applyRequestMiddleware";
6
7
 
7
8
  export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
8
9
 
@@ -12,8 +13,7 @@ export class GGRpcRequestParser {
12
13
  private readonly pathParams: string[];
13
14
  private readonly hasBody: boolean;
14
15
  protected readonly contract: GGContractMethod
15
- private readonly apiMiddlewares: readonly GGHttpTransportMiddleware[]
16
- private readonly serverMiddlewares: readonly GGHttpServerMiddleware[]
16
+ private readonly middlewares: readonly GGTransportMiddleware[]
17
17
 
18
18
  constructor(
19
19
  method: HttpMethod,
@@ -24,32 +24,31 @@ export class GGRpcRequestParser {
24
24
  this.pathParams = (pathTemplate.match(/:(\w+)/g) || []).map(m => m.slice(1))
25
25
  this.hasBody = method === "POST" || method === "PUT" || method === "PATCH"
26
26
  this.contract = config.contract
27
- this.apiMiddlewares = config.apiMiddlewares
28
- this.serverMiddlewares = config.serverMiddlewares
27
+ this.middlewares = config.middlewares
29
28
  }
30
29
 
31
- public parseRequest = async (req: http.IncomingMessage): Promise<unknown> => {
30
+ // Resolve identity (run the wire pipeline) and extract the raw, unvalidated input.
31
+ // Validation is deferred to validateInput so the permission gate can run in between.
32
+ public readRequest = async (req: http.IncomingMessage): Promise<unknown> => {
32
33
  const url = req.url || '/'
33
34
  const qIndex = url.indexOf('?')
34
35
  const queryArgs = this.parseQueryString(qIndex === -1 ? '' : url.substring(qIndex + 1))
35
- if (this.apiMiddlewares?.length > 0) {
36
- const mwQuery: GGHttpRequest = {headers: req.headers, queryArgs: queryArgs}
37
- this.apiMiddlewares?.forEach(mw => mw.parseRequest?.(mwQuery))
38
- }
39
- for (const mw of this.serverMiddlewares ?? []) await mw.process?.();
36
+ await applyRequestMiddleware(req, queryArgs, this.middlewares)
40
37
 
41
- let input: unknown;
42
38
  if (this.hasBody) {
43
- input = await this.parseBody(req);
39
+ return await this.parseBody(req);
44
40
  } else if (this.pathParams.length > 0) {
45
- input = {
41
+ return {
46
42
  ...this.extractPathParams(qIndex === -1 ? url : url.substring(0, qIndex)),
47
43
  ...queryArgs
48
44
  };
49
45
  } else {
50
- input = queryArgs;
46
+ return queryArgs;
51
47
  }
52
- return GGContractExecutor.parseInput(this.contract.input, input);
48
+ }
49
+
50
+ public validateInput = (rawInput: unknown): unknown => {
51
+ return GGContractExecutor.parseInput(this.contract.input, rawInput);
53
52
  }
54
53
 
55
54
  private parseQueryString(rawQuery: string): Record<string, string | string[]> {
@@ -1,17 +1,18 @@
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, GGHttpTransportMiddleware} from "../../schema/GGHttpSchema";
3
+ import type {GGTransportMiddleware} from "@grest-ts/context";
4
+ import {ClientHttpRouteToRpcTransformServerConfig} from "../../schema/GGHttpSchema";
4
5
  import {applyResponseMiddleware} from "../../server/applyResponseMiddleware";
5
6
 
6
7
 
7
8
  export class GGRpcResponseBuilder {
8
9
 
9
10
  protected readonly contract: GGContractMethod
10
- private readonly apiMiddlewares: readonly GGHttpTransportMiddleware[]
11
+ private readonly middlewares: readonly GGTransportMiddleware[]
11
12
 
12
13
  constructor(config: ClientHttpRouteToRpcTransformServerConfig) {
13
14
  this.contract = config.contract;
14
- this.apiMiddlewares = config.apiMiddlewares;
15
+ this.middlewares = config.middlewares;
15
16
  }
16
17
 
17
18
  public sendResponse = async (res: http.ServerResponse, rpcResult: ERROR<string, unknown> | OK<unknown>): Promise<void> => {
@@ -28,7 +29,7 @@ export class GGRpcResponseBuilder {
28
29
  json = this.makeError(GGContractExecutor.getResponseSchema(this.contract, rpcResult), rpcResult);
29
30
  statusCode = rpcResult.statusCode;
30
31
  }
31
- applyResponseMiddleware(res, this.apiMiddlewares);
32
+ applyResponseMiddleware(res, this.middlewares);
32
33
  res.writeHead(statusCode, {
33
34
  'Content-Type': 'application/json',
34
35
  'Content-Length': Buffer.byteLength(json)
@@ -0,0 +1,151 @@
1
+ import {GGContextKey, type GGInbound, type GGResponse} from "@grest-ts/context"
2
+ import {type GGSchema, IsAny, IsString, SERVER_ERROR} from "@grest-ts/schema"
3
+ import {GGWireContextKey} from "./GGWireContextKey"
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
+ * Per-request Set-Cookie writes scheduled via setCookie()/clearCookie(), keyed by the
14
+ * context key's name. Flushed onto the response by the matching cookie() binding.
15
+ */
16
+ const GG_COOKIE_PENDING = new GGContextKey<Map<string, PendingCookie>>("cookie:pending", IsAny as unknown as GGSchema<Map<string, PendingCookie>>)
17
+
18
+ export interface CookieOptions {
19
+ httpOnly?: boolean
20
+ secure?: boolean
21
+ sameSite?: "lax" | "strict" | "none"
22
+ path?: string
23
+ domain?: string
24
+ maxAgeSec?: number
25
+ }
26
+
27
+ const SAFE_DEFAULTS: CookieOptions = {
28
+ httpOnly: true,
29
+ secure: true,
30
+ sameSite: "lax",
31
+ path: "/"
32
+ } as const
33
+
34
+ interface PendingCookie {
35
+ value: string | undefined
36
+ options?: CookieOptions
37
+ }
38
+
39
+ /**
40
+ * A cookie that IS its own context key.
41
+ *
42
+ * // ambient — the named cookie's value lands in the wire and persists, no implementation needed.
43
+ * const SESSION_WIRE = new GGCookie("session_id")
44
+ *
45
+ * Reads ONLY the named cookie — never a header — from the inbound Cookie header into the wire.
46
+ * For HTTP that is the request Cookie; for WebSocket the real upgrade Cookie (the runtime fills
47
+ * inbound.cookie from there, never from the spoofable in-band message). The cookie name is the
48
+ * first constructor argument (case-sensitive — unlike a header it is not lowercased). To write
49
+ * the cookie, call GGCookie.setCookie(key, …) from a route that declared .updatesCookie(key);
50
+ * this wire flushes it as Set-Cookie (HTTP only — a WebSocket has no response to set a cookie on).
51
+ */
52
+ export class GGCookie extends GGWireContextKey {
53
+
54
+ public readonly cookieParams: Record<string, GGSchema<string | undefined>>
55
+
56
+ constructor(name: string) {
57
+ const WIRE_SCHEMA = IsString.orUndefined
58
+ super(name, WIRE_SCHEMA)
59
+ _assertCookieSafe("name", this.name)
60
+ this.cookieParams = {[this.name]: WIRE_SCHEMA}
61
+ }
62
+
63
+ public parse(inbound: GGInbound): void {
64
+ const value = _readCookie(inbound.cookie, this.name)
65
+ if (value !== undefined) this.set(value)
66
+ }
67
+
68
+ public respond(res: GGResponse): void {
69
+ const pending = GG_COOKIE_PENDING.get()?.get(this.name)
70
+ if (pending === undefined) return
71
+ const line = _serializeSetCookie(this.name, pending.value, pending.options)
72
+ const existing = res.headers["set-cookie"]
73
+ const arr = existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]
74
+ arr.push(line)
75
+ res.headers["set-cookie"] = arr
76
+ }
77
+
78
+ /**
79
+ * Schedule a Set-Cookie on the response (HTTP only). The value never touches key.get() —
80
+ * inbound reads stay set-once. The route must declare .updatesCookie(key); an undeclared
81
+ * call is invalid usage and throws SERVER_ERROR. The matching cookie() binding serializes
82
+ * the pending write with these options (HttpOnly/Secure/SameSite/Path/Domain/Max-Age).
83
+ */
84
+ public static setCookie(key: GGContextKey<string | undefined>, value: string | undefined, options?: CookieOptions): void {
85
+ if (options) {
86
+ if (options.maxAgeSec !== undefined && !Number.isFinite(options.maxAgeSec)) {
87
+ throw new Error(`setCookie("${key.name}"): maxAgeSec must be a finite number, got ${options.maxAgeSec}.`)
88
+ }
89
+ if (options.path !== undefined) _assertCookieSafe("path", options.path)
90
+ if (options.domain !== undefined) _assertCookieSafe("domain", options.domain)
91
+ }
92
+ if (!GG_COOKIE_WRITES.get()?.has(key.name)) {
93
+ 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.`})
94
+ }
95
+ let pending = GG_COOKIE_PENDING.get()
96
+ if (pending === undefined) {
97
+ pending = new Map()
98
+ GG_COOKIE_PENDING.set(pending)
99
+ }
100
+ pending.set(key.name, {value, options})
101
+ }
102
+
103
+ /**
104
+ * Clear the cookie — schedules a Set-Cookie with Max-Age=0. Pass path/domain to match a
105
+ * scoped cookie (deletion only works when path+domain match what was set). Equivalent to
106
+ * setCookie(key, undefined, options), but reads clearer at logout-style call sites.
107
+ */
108
+ public static clearCookie(key: GGContextKey<string | undefined>, options?: CookieOptions): void {
109
+ this.setCookie(key, undefined, options)
110
+ }
111
+
112
+ }
113
+
114
+
115
+ // Reject anything that could break out of the Set-Cookie line / inject attributes.
116
+ function _assertCookieSafe(label: string, value: string): void {
117
+ if (/[\x00-\x1f;]/.test(value)) {
118
+ throw new Error(`Cookie ${label} contains illegal characters (control chars or ';'): ${JSON.stringify(value)}`)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Read a single named cookie out of a raw `Cookie` header value. A malformed
124
+ * percent-encoding (e.g. "sid=%") must not crash — fall back to the raw value.
125
+ */
126
+ function _readCookie(rawCookieHeader: string | undefined, name: string): string | undefined {
127
+ if (typeof rawCookieHeader !== "string") return undefined
128
+ for (const part of rawCookieHeader.split(";")) {
129
+ const eq = part.indexOf("=")
130
+ if (eq === -1) continue
131
+ if (part.slice(0, eq).trim() === name) {
132
+ const rawValue = part.slice(eq + 1).trim()
133
+ try {
134
+ return decodeURIComponent(rawValue)
135
+ } catch {
136
+ return rawValue
137
+ }
138
+ }
139
+ }
140
+ return undefined
141
+ }
142
+
143
+ function _serializeSetCookie(name: string, value: string | undefined, options?: CookieOptions): string {
144
+ const o = {...SAFE_DEFAULTS, ...options}
145
+ const secure = o.sameSite === "none" ? true : o.secure
146
+ const sameSite = `${o.sameSite.charAt(0).toUpperCase()}${o.sameSite.slice(1)}`
147
+ const attrs = `; Path=${o.path}${o.domain ? `; Domain=${o.domain}` : ""}; SameSite=${sameSite}${secure ? "; Secure" : ""}${o.httpOnly ? "; HttpOnly" : ""}`
148
+ return value
149
+ ? `${name}=${encodeURIComponent(value)}${o.maxAgeSec !== undefined ? `; Max-Age=${Math.trunc(o.maxAgeSec)}` : ""}${attrs}`
150
+ : `${name}=; Max-Age=0${attrs}`
151
+ }
@@ -0,0 +1,48 @@
1
+ import {type GGInbound, type GGOutbound} from "@grest-ts/context"
2
+ import {type GGSchema, IsString} from "@grest-ts/schema"
3
+ import {GGWireContextKey} from "./GGWireContextKey"
4
+
5
+ const BEARER = "Bearer "
6
+
7
+
8
+ /**
9
+ * A request header that IS its own context key.
10
+ *
11
+ * // verified: requires .define() server-side; raw credential is ephemeral.
12
+ * const USER_TOKEN_WIRE = new GGHeader("authorization", {scheme: "bearer"})
13
+ *
14
+ * // ambient: parsed value lands in the wire and persists, no implementation needed.
15
+ * const CLIENT_VERSION_WIRE = new GGHeader("x-client-version")
16
+ */
17
+ export class GGHeader extends GGWireContextKey {
18
+
19
+ public readonly headers: Record<string, GGSchema<string | undefined>>
20
+ private readonly scheme?: "bearer"
21
+ private readonly wireName: string
22
+
23
+ constructor(name: string, options?: { scheme?: "bearer" }) {
24
+ const WIRE_SCHEMA = IsString.orUndefined
25
+ super(name, WIRE_SCHEMA)
26
+ this.scheme = options?.scheme
27
+ this.wireName = name.toLowerCase()
28
+ this.headers = {[this.wireName]: WIRE_SCHEMA}
29
+ }
30
+
31
+ public parse(inbound: GGInbound): void {
32
+ const raw = inbound.headers[this.wireName];
33
+ if (raw === undefined) {
34
+ return undefined
35
+ }
36
+ const value = this.scheme === "bearer" && raw.startsWith(BEARER) ? raw.slice(BEARER.length) : raw
37
+ if (value !== undefined) {
38
+ this.set(value)
39
+ }
40
+ }
41
+
42
+ public update(outbound: GGOutbound): void {
43
+ const value = this.get()
44
+ if (value !== undefined) {
45
+ outbound.headers[this.wireName] = (this.scheme === "bearer" ? `${BEARER}${value}` : value)
46
+ }
47
+ }
48
+ }