@clicktap/ui 2.1.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ # `createAuthAwareGraphqlClient`: a server-side request-headers provider, so SSR calls can carry the shopper
2
+
3
+ **Date:** 2026-09-15
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `utils/createAuthAwareGraphqlClient` — new export `setServerRequestHeadersProvider()`; `executeGraphql` and `createAuthAwareFetch` consult it server-side
6
+ **Classification:** FEATURE
7
+ **Breaking:** No. Nothing changes until a provider is registered; explicit request headers always win over provided ones.
8
+ **Ticket:** CTAP-1719
9
+
10
+ ## Why
11
+
12
+ A request the Node server makes to the middleware on a shopper's behalf (SSR, route handlers)
13
+ carried nothing identifying the shopper — the middleware saw the Node container for every visitor.
14
+ Anything keyed on the client there (a rate limit, a lockout) would put the whole storefront in one
15
+ bucket.
16
+
17
+ The fix has to be in the shared client, because that is the one place every server-side request
18
+ passes through — `graphqlRequest`, the quote/checkout transports (`request` and `createFetch`), route
19
+ handlers. But this module is framework-agnostic and reachable from client components, so it cannot
20
+ import `next/headers` itself. Hence a **provider** the app registers:
21
+
22
+ ```ts
23
+ import { setServerRequestHeadersProvider } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
24
+
25
+ setServerRequestHeadersProvider(() => {
26
+ const forwardedFor = headers().get('x-forwarded-for'); // next/headers, in a server-only module
27
+ return forwardedFor ? { 'x-forwarded-for': forwardedFor } : undefined;
28
+ });
29
+ ```
30
+
31
+ ## Rules
32
+
33
+ - Consulted **only server-side** (`typeof window === 'undefined'`); the browser path is untouched.
34
+ - **Explicit headers win.** A header the caller passed is never overwritten by the provider.
35
+ - **A throwing provider contributes nothing.** `headers()` throws outside a request scope (a static
36
+ render, a warm-up); that is "nothing to forward", not a failed request.
37
+ - Sync or async providers are accepted.
38
+ - Applies to both `createAuthAwareGraphqlClient()` requests and `createAuthAwareFetch()` calls.
39
+
40
+ ## What a consumer does
41
+
42
+ Register a provider from a **server-only** module (`import 'server-only'` + `next/headers`), imported
43
+ by the root layouts and by any route handler that fetches — see the `frontend` guide of the same
44
+ date for the reference module. Forward the incoming `X-Forwarded-For` **verbatim**: the middleware's
45
+ trusted-proxy walk reads it from its own end, so the browser's forged entries are inert and
46
+ "cleaning" the chain here would only remove information.
47
+
48
+ ## Verification
49
+
50
+ ```bash
51
+ nx run ui:test -- createAuthAwareGraphqlClient
52
+ ```
@@ -9,6 +9,16 @@
9
9
  "breaking": "No API change. **Behavioral:** while its trigger is closed and has never opened, the overlay now renders `null` instead of a mounted `motion()` wrapper around an empty react-aria overlay. Nothing consumer-visible was in the DOM before either.",
10
10
  "affects": "`src/components/ModalOverlay/ModalOverlay.tsx` — every trigger-driven `<ModalOverlay>` (no `isOpen` prop, state from `DialogTrigger` / `OverlayTriggerStateContext`). Controlled overlays (`isOpen` given) are unchanged."
11
11
  },
12
+ {
13
+ "path": "2026/09/ui-2026-09-15-server-request-headers-provider.md",
14
+ "module": "libs/frontend-modules/ui",
15
+ "package": "@clicktap/ui",
16
+ "date": "2026-09-15",
17
+ "title": "`createAuthAwareGraphqlClient`: a server-side request-headers provider, so SSR calls can carry the shopper",
18
+ "classification": "FEATURE",
19
+ "breaking": "No. Nothing changes until a provider is registered; explicit request headers always win over provided ones.",
20
+ "affects": "`utils/createAuthAwareGraphqlClient` — new export `setServerRequestHeadersProvider()`; `executeGraphql` and `createAuthAwareFetch` consult it server-side"
21
+ },
12
22
  {
13
23
  "path": "2026/09/ui-2026-09-09-graphql-field-errors-reported-in-production.md",
14
24
  "module": "libs/frontend-modules/ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clicktap/ui",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "private": false,
5
5
  "author": "Clicktap",
6
6
  "description": "A library of React UI components and low-level hooks.",
@@ -111,6 +111,24 @@ declare class AuthAwareEventBus {
111
111
  * listen, regardless of which call triggered the event).
112
112
  */
113
113
  export declare const authAwareEvents: AuthAwareEventBus;
114
+ /**
115
+ * Headers a SERVER-SIDE caller wants attached to every outbound request, resolved per request.
116
+ *
117
+ * CTAP-1719: a request the Node server makes to the middleware on a shopper's behalf (SSR, route
118
+ * handlers) carried no trace of the shopper — the middleware saw the Node container's address for
119
+ * every visitor, so anything keyed on the client (a rate limit, a lockout) would put the whole
120
+ * storefront in one bucket. The app registers a provider that reads the incoming request's
121
+ * `X-Forwarded-For` (via `next/headers`, which only exists in a request scope) and returns it here,
122
+ * so the middleware's trusted-proxy walk lands on the browser rather than on Node.
123
+ *
124
+ * Registered rather than imported because this module must stay framework-agnostic and bundleable
125
+ * on the client: `next/headers` cannot be imported from anything a client component reaches. The
126
+ * provider is only ever consulted server-side (`typeof window === 'undefined'`); explicit request
127
+ * headers always win over what it returns; and a provider that throws (no request scope — a build-time
128
+ * render, a cron) contributes nothing rather than failing the request.
129
+ */
130
+ export type ServerRequestHeadersProvider = () => HeadersInit | undefined | Promise<HeadersInit | undefined>;
131
+ export declare function setServerRequestHeadersProvider(provider: ServerRequestHeadersProvider | undefined): void;
114
132
  export type AuthAwareGraphqlRequest = <TData, TVariables extends GraphqlVariables = GraphqlVariables>(url: string, document: GraphqlDocument<TData, TVariables>, variables?: TVariables, requestHeaders?: HeadersInit) => Promise<TData>;
115
133
  export type AuthAwareGraphqlRequestWithOptions = <TData, TVariables extends GraphqlVariables = GraphqlVariables>(options: {
116
134
  url: string;
@@ -1 +1 @@
1
- class l extends Error{constructor(t,s){const e=t.errors?.[0]?.message;super(`GraphQL request failed: ${e??`HTTP ${t.status}`}`),this.name="ClientError",this.response=t,this.request=s}}class f{constructor(){this.listeners=new Map}subscribe(t,s){return this.listeners.has(t)||this.listeners.set(t,new Set),this.listeners.get(t).add(s),()=>{this.listeners.get(t)?.delete(s)}}dispatch(t,s){this.listeners.get(t)?.forEach(e=>e(s))}}const u=new f;function p(r,t){r===401?u.dispatch("unauthenticated",t):r===403?u.dispatch("permissionDenied",t):r>=500&&u.dispatch("serverError",t)}function w(r,t,s){if(!s.length)return;const e=r.match(/^\s*(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1];for(const n of s){const c=n.path?.join(".")??"unknown",a=`[GraphQL] ${e??"operation"} — field error at ${c}: ${n.message}`;if(process.env.NODE_ENV==="development"){console.error(a,{error:n,request:{operationName:e,variables:t,query:r}});continue}console.error(a,{operationName:e,path:n.path,code:n.extensions?.code,debugMessage:n.extensions?.debugMessage})}}async function d(r){const{url:t,query:s,variables:e,requestHeaders:n,signal:c}=r,a=new Headers(n);a.set("Content-Type","application/json"),a.has("Accept")||a.set("Accept","application/json");const i=await fetch(t,{method:"POST",headers:a,body:JSON.stringify({query:s,variables:e}),signal:c});let o;try{o=await i.json()}catch{o=void 0}if(!i.ok){const h=new l({status:i.status,data:o?.data,errors:o?.errors},{query:s,variables:e});throw p(i.status,{status:i.status,error:h}),h}if(o?.data)return o.errors?.length&&w(s,e,o.errors),o.data;throw new l({status:i.status,errors:o?.errors},{query:s,variables:e})}function g(){const r=async(e,n,c,a)=>d({url:e,query:String(n),variables:c,requestHeaders:a}),t=async e=>d({url:e.url,query:String(e.document),variables:e.variables,requestHeaders:e.requestHeaders,signal:e.signal}),s=r;return s.withOptions=t,s}function y(){return async function(t,s){const e=await fetch(t,s);return(e.status===401||e.status===403||e.status>=500)&&p(e.status,{status:e.status,response:e.clone()}),e}}export{l as ClientError,u as authAwareEvents,y as createAuthAwareFetch,g as createAuthAwareGraphqlClient};
1
+ class l extends Error{constructor(t,s){const e=t.errors?.[0]?.message;super(`GraphQL request failed: ${e??`HTTP ${t.status}`}`),this.name="ClientError",this.response=t,this.request=s}}class v{constructor(){this.listeners=new Map}subscribe(t,s){return this.listeners.has(t)||this.listeners.set(t,new Set),this.listeners.get(t).add(s),()=>{this.listeners.get(t)?.delete(s)}}dispatch(t,s){this.listeners.get(t)?.forEach(e=>e(s))}}const u=new v;function p(r,t){r===401?u.dispatch("unauthenticated",t):r===403?u.dispatch("permissionDenied",t):r>=500&&u.dispatch("serverError",t)}function g(r,t,s){if(!s.length)return;const e=r.match(/^\s*(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/)?.[1];for(const n of s){const a=n.path?.join(".")??"unknown",o=`[GraphQL] ${e??"operation"} — field error at ${a}: ${n.message}`;if(process.env.NODE_ENV==="development"){console.error(o,{error:n,request:{operationName:e,variables:t,query:r}});continue}console.error(o,{operationName:e,path:n.path,code:n.extensions?.code,debugMessage:n.extensions?.debugMessage})}}let d;function y(r){d=r}async function w(){if(!(typeof window<"u"||!d))try{const r=await d();return r?new Headers(r):void 0}catch{return}}async function q(r){return(await w())?.forEach((s,e)=>{r.has(e)||r.set(e,s)}),r}async function f(r){const{url:t,query:s,variables:e,requestHeaders:n,signal:a}=r,o=await q(new Headers(n));o.set("Content-Type","application/json"),o.has("Accept")||o.set("Accept","application/json");const c=await fetch(t,{method:"POST",headers:o,body:JSON.stringify({query:s,variables:e}),signal:a});let i;try{i=await c.json()}catch{i=void 0}if(!c.ok){const h=new l({status:c.status,data:i?.data,errors:i?.errors},{query:s,variables:e});throw p(c.status,{status:c.status,error:h}),h}if(i?.data)return i.errors?.length&&g(s,e,i.errors),i.data;throw new l({status:c.status,errors:i?.errors},{query:s,variables:e})}function E(){const r=async(e,n,a,o)=>f({url:e,query:String(n),variables:a,requestHeaders:o}),t=async e=>f({url:e.url,query:String(e.document),variables:e.variables,requestHeaders:e.requestHeaders,signal:e.signal}),s=r;return s.withOptions=t,s}function A(){return async function(t,s){const e=await w();let n=s;if(e){const o=new Headers(s?.headers);e.forEach((c,i)=>{o.has(i)||o.set(i,c)}),n={...s,headers:o}}const a=await fetch(t,n);return(a.status===401||a.status===403||a.status>=500)&&p(a.status,{status:a.status,response:a.clone()}),a}}export{l as ClientError,u as authAwareEvents,A as createAuthAwareFetch,E as createAuthAwareGraphqlClient,y as setServerRequestHeadersProvider};