@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
|
@@ -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
|
|
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};
|