@frontera-sdk/core 1.43.9 → 1.44.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.
- package/README.md +17 -9
- package/package.json +5 -1
- package/src/app-session.ts +108 -0
- package/src/config.ts +4 -0
- package/src/create-frontera-app.tsx +124 -33
- package/src/react.ts +8 -0
- package/src/transport.ts +30 -1
package/README.md
CHANGED
|
@@ -8,23 +8,28 @@ bun add @frontera-sdk/core react react-dom @tanstack/react-query
|
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
```tsx
|
|
11
|
-
import {
|
|
11
|
+
import { FronteraAppProvider } from '@frontera-sdk/core/react'
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
14
|
+
return <FronteraAppProvider>{children}</FronteraAppProvider>
|
|
15
|
+
}
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
`FronteraAppProvider` selects one configured transport before connecting:
|
|
19
|
+
embedded postMessage bridge, same-origin standalone session, or loopback local
|
|
20
|
+
session broker. All three synthesize the same `BridgeInit`, so Blueprint and
|
|
21
|
+
other domain providers do not need transport-specific branches. A missing
|
|
22
|
+
transport renders a configuration diagnostic immediately.
|
|
23
|
+
|
|
24
|
+
`createFronteraApp()` remains the Vite compatibility wrapper. Embedded
|
|
25
|
+
`navigate()` synchronizes the platform-owned URL; standalone and local Apps
|
|
26
|
+
own routing themselves.
|
|
23
27
|
|
|
24
28
|
## Entry points
|
|
25
29
|
|
|
26
30
|
| Import | What it is |
|
|
27
31
|
|---|---|
|
|
32
|
+
| `@frontera-sdk/core/react` | `FronteraAppProvider`, `useFronteraApp`, and mode types |
|
|
28
33
|
| `@frontera-sdk/core/create-frontera-app` | app bootstrap and `useFronteraApp` |
|
|
29
34
|
| `@frontera-sdk/core/client` | the typed platform client |
|
|
30
35
|
| `@frontera-sdk/core/config` | credential and origin resolution |
|
|
@@ -34,6 +39,9 @@ the app.
|
|
|
34
39
|
| `@frontera-sdk/core/theme` | applying the host's design tokens |
|
|
35
40
|
| `@frontera-sdk/core/errors` | the error taxonomy, `code` mirroring the service |
|
|
36
41
|
|
|
42
|
+
Standalone and local sessions refresh their short-lived App token at 80% of
|
|
43
|
+
its lifetime. The browser never receives a long-lived workspace key.
|
|
44
|
+
|
|
37
45
|
Published as TypeScript source. Every consumer is a bundler or `tsc`, so a
|
|
38
46
|
build step would emit output the consumer immediately re-transpiles.
|
|
39
47
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"description": "Frontera app runtime: the platform bridge client, app bootstrap and typed platform client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -56,6 +56,10 @@
|
|
|
56
56
|
"./create-frontera-app": {
|
|
57
57
|
"types": "./src/create-frontera-app.tsx",
|
|
58
58
|
"import": "./src/create-frontera-app.tsx"
|
|
59
|
+
},
|
|
60
|
+
"./react": {
|
|
61
|
+
"types": "./src/react.ts",
|
|
62
|
+
"import": "./src/react.ts"
|
|
59
63
|
}
|
|
60
64
|
},
|
|
61
65
|
"scripts": {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { BridgeSession } from './bridge-client'
|
|
2
|
+
import { isHostMessage, type BridgeInit } from './bridge-protocol'
|
|
3
|
+
import type { FronteraRuntimeGlobal } from './config'
|
|
4
|
+
|
|
5
|
+
export type FronteraAppMode = 'embedded' | 'standalone' | 'local'
|
|
6
|
+
|
|
7
|
+
export function detectAppMode(
|
|
8
|
+
runtime: FronteraRuntimeGlobal,
|
|
9
|
+
framed: boolean,
|
|
10
|
+
): FronteraAppMode | null {
|
|
11
|
+
if (framed && runtime.platformOrigin) return 'embedded'
|
|
12
|
+
if (runtime.sessionEndpoint) return 'standalone'
|
|
13
|
+
if (runtime.devSessionEndpoint) return 'local'
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SessionResponse {
|
|
18
|
+
init: BridgeInit
|
|
19
|
+
expiresAt: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EndpointSessionOptions {
|
|
23
|
+
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
24
|
+
now?: () => number
|
|
25
|
+
setTimer?: (callback: () => void, delay: number) => unknown
|
|
26
|
+
clearTimer?: (timer: unknown) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseExpiry(value: unknown): number {
|
|
30
|
+
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN
|
|
31
|
+
if (!Number.isFinite(parsed)) throw new Error('Frontera session response has an invalid expiresAt')
|
|
32
|
+
return parsed
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseSessionResponse(value: unknown): SessionResponse {
|
|
36
|
+
const envelope = value as { data?: unknown } | null
|
|
37
|
+
const raw = (envelope && typeof envelope === 'object' && 'data' in envelope ? envelope.data : value) as
|
|
38
|
+
| Record<string, unknown>
|
|
39
|
+
| null
|
|
40
|
+
if (!raw || typeof raw !== 'object' || !isHostMessage(raw.init) || raw.init.type !== 'frontera:init') {
|
|
41
|
+
throw new Error('Frontera session response is malformed')
|
|
42
|
+
}
|
|
43
|
+
return { init: raw.init, expiresAt: parseExpiry(raw.expiresAt) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function fetchSession(
|
|
47
|
+
endpoint: string,
|
|
48
|
+
fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
|
49
|
+
): Promise<SessionResponse> {
|
|
50
|
+
const response = await fetchImpl(endpoint, {
|
|
51
|
+
credentials: 'include',
|
|
52
|
+
headers: { accept: 'application/json' },
|
|
53
|
+
})
|
|
54
|
+
if (!response.ok) throw new Error(`Frontera session request failed with ${response.status}`)
|
|
55
|
+
return parseSessionResponse(await response.json())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build a bridge-compatible session from standalone or local HTTP auth. */
|
|
59
|
+
export async function connectToSessionEndpoint(
|
|
60
|
+
endpoint: string,
|
|
61
|
+
options: EndpointSessionOptions = {},
|
|
62
|
+
): Promise<BridgeSession> {
|
|
63
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
64
|
+
const now = options.now ?? Date.now
|
|
65
|
+
const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay))
|
|
66
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
|
67
|
+
const first = await fetchSession(endpoint, fetchImpl)
|
|
68
|
+
const tokenHandlers = new Set<(token: string) => void>()
|
|
69
|
+
let disposed = false
|
|
70
|
+
let timer: unknown
|
|
71
|
+
|
|
72
|
+
const schedule = (expiresAt: number) => {
|
|
73
|
+
if (disposed) return
|
|
74
|
+
const delay = Math.max(0, Math.floor((expiresAt - now()) * 0.8))
|
|
75
|
+
timer = setTimer(() => {
|
|
76
|
+
void fetchSession(endpoint, fetchImpl)
|
|
77
|
+
.then((next) => {
|
|
78
|
+
if (disposed) return
|
|
79
|
+
for (const handler of tokenHandlers) handler(next.init.token)
|
|
80
|
+
schedule(next.expiresAt)
|
|
81
|
+
})
|
|
82
|
+
// A revoked session must not keep emitting credentials. Requests made
|
|
83
|
+
// with the previous short-lived token naturally stop at its expiry.
|
|
84
|
+
.catch(() => {})
|
|
85
|
+
}, delay)
|
|
86
|
+
}
|
|
87
|
+
schedule(first.expiresAt)
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
init: first.init,
|
|
91
|
+
send() {},
|
|
92
|
+
onState() {
|
|
93
|
+
return () => {}
|
|
94
|
+
},
|
|
95
|
+
onToken(handler) {
|
|
96
|
+
tokenHandlers.add(handler)
|
|
97
|
+
return () => tokenHandlers.delete(handler)
|
|
98
|
+
},
|
|
99
|
+
onTheme() {
|
|
100
|
+
return () => {}
|
|
101
|
+
},
|
|
102
|
+
dispose() {
|
|
103
|
+
disposed = true
|
|
104
|
+
if (timer !== undefined) clearTimer(timer)
|
|
105
|
+
tokenHandlers.clear()
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface FronteraRuntimeGlobal {
|
|
|
36
36
|
appId?: string
|
|
37
37
|
version?: string
|
|
38
38
|
platformOrigin?: string
|
|
39
|
+
/** Same-origin short-lived user session for direct App visits. */
|
|
40
|
+
sessionEndpoint?: string
|
|
41
|
+
/** Loopback-only CLI broker used during authenticated local development. */
|
|
42
|
+
devSessionEndpoint?: string
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export function readRuntimeGlobal(): FronteraRuntimeGlobal {
|
|
@@ -14,6 +14,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
|
14
14
|
import { connectToHost, type BridgeSession } from './bridge-client'
|
|
15
15
|
import type { BridgeInit } from './bridge-protocol'
|
|
16
16
|
import { FronteraClient } from './client'
|
|
17
|
+
import { readRuntimeGlobal } from './config'
|
|
18
|
+
import {
|
|
19
|
+
connectToSessionEndpoint,
|
|
20
|
+
detectAppMode,
|
|
21
|
+
type FronteraAppMode,
|
|
22
|
+
} from './app-session'
|
|
17
23
|
import { applyHostTheme } from './theme'
|
|
18
24
|
|
|
19
25
|
/**
|
|
@@ -32,6 +38,7 @@ import { applyHostTheme } from './theme'
|
|
|
32
38
|
*/
|
|
33
39
|
|
|
34
40
|
export interface FronteraAppValue {
|
|
41
|
+
mode: FronteraAppMode
|
|
35
42
|
/** Everything the host said at handshake, including `path`. */
|
|
36
43
|
init: BridgeInit
|
|
37
44
|
client: FronteraClient
|
|
@@ -73,6 +80,7 @@ export interface CreateFronteraAppOptions {
|
|
|
73
80
|
|
|
74
81
|
interface BoundaryProps {
|
|
75
82
|
onError: (error: Error, info: ErrorInfo) => void
|
|
83
|
+
fallback?: (error: Error) => ReactNode
|
|
76
84
|
children: ReactNode
|
|
77
85
|
}
|
|
78
86
|
|
|
@@ -96,7 +104,7 @@ class AppErrorBoundary extends Component<BoundaryProps, { error: Error | null }>
|
|
|
96
104
|
|
|
97
105
|
render() {
|
|
98
106
|
if (!this.state.error) return this.props.children
|
|
99
|
-
return diagnostic('This app stopped rendering', this.state.error.message)
|
|
107
|
+
return this.props.fallback?.(this.state.error) ?? diagnostic('This app stopped rendering', this.state.error.message)
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
110
|
|
|
@@ -126,12 +134,16 @@ function diagnostic(title: string, detail: string): ReactNode {
|
|
|
126
134
|
*/
|
|
127
135
|
function FronteraRoot({
|
|
128
136
|
session,
|
|
129
|
-
|
|
137
|
+
mode,
|
|
138
|
+
children,
|
|
130
139
|
providers,
|
|
140
|
+
errorFallback,
|
|
131
141
|
}: {
|
|
132
142
|
session: BridgeSession
|
|
133
|
-
|
|
143
|
+
mode: FronteraAppMode
|
|
144
|
+
children: ReactNode
|
|
134
145
|
providers: FronteraProvider[]
|
|
146
|
+
errorFallback?: (error: Error) => ReactNode
|
|
135
147
|
}) {
|
|
136
148
|
const { init } = session
|
|
137
149
|
const [client, setClient] = useState(
|
|
@@ -140,24 +152,27 @@ function FronteraRoot({
|
|
|
140
152
|
apiBaseUrl: init.apiBaseUrl,
|
|
141
153
|
orgId: init.orgId ?? undefined,
|
|
142
154
|
workspaceId: init.workspaceId ?? undefined,
|
|
143
|
-
credential: { kind: '
|
|
155
|
+
credential: { kind: 'token', token: init.token },
|
|
144
156
|
}),
|
|
145
157
|
)
|
|
146
158
|
|
|
147
159
|
useEffect(
|
|
148
|
-
() => session.onToken((token) => setClient((c) => c.withCredential({ kind: '
|
|
160
|
+
() => session.onToken((token) => setClient((c) => c.withCredential({ kind: 'token', token }))),
|
|
149
161
|
[session],
|
|
150
162
|
)
|
|
151
163
|
|
|
152
164
|
useEffect(() => session.onTheme((theme) => applyHostTheme(theme)), [session])
|
|
153
165
|
|
|
154
166
|
const value: FronteraAppValue = {
|
|
167
|
+
mode,
|
|
155
168
|
init,
|
|
156
169
|
client,
|
|
157
|
-
navigate: (path) =>
|
|
170
|
+
navigate: (path) => {
|
|
171
|
+
if (mode === 'embedded') session.send({ type: 'frontera:navigate', path })
|
|
172
|
+
},
|
|
158
173
|
}
|
|
159
174
|
|
|
160
|
-
let tree: ReactNode =
|
|
175
|
+
let tree: ReactNode = children
|
|
161
176
|
// Applied in reverse so the FIRST provider listed ends up outermost, which is
|
|
162
177
|
// the order a reader expects from the array.
|
|
163
178
|
for (const provider of [...providers].reverse()) tree = provider(value, tree)
|
|
@@ -165,6 +180,7 @@ function FronteraRoot({
|
|
|
165
180
|
return (
|
|
166
181
|
<FronteraAppReactContext.Provider value={value}>
|
|
167
182
|
<AppErrorBoundary
|
|
183
|
+
fallback={errorFallback}
|
|
168
184
|
onError={(error) =>
|
|
169
185
|
session.send({ type: 'frontera:error', message: error.message, stack: error.stack })
|
|
170
186
|
}
|
|
@@ -175,6 +191,100 @@ function FronteraRoot({
|
|
|
175
191
|
)
|
|
176
192
|
}
|
|
177
193
|
|
|
194
|
+
export interface FronteraAppProviderProps {
|
|
195
|
+
children: ReactNode
|
|
196
|
+
providers?: FronteraProvider[]
|
|
197
|
+
loading?: ReactNode
|
|
198
|
+
errorFallback?: (error: Error) => ReactNode
|
|
199
|
+
/** Compatibility hooks used by createFronteraApp. */
|
|
200
|
+
queryClient?: QueryClient
|
|
201
|
+
timeoutMs?: number
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Transport-neutral React entry point for embedded, standalone, and local Apps.
|
|
206
|
+
* Mode selection happens before connection, so an ordinary standalone page
|
|
207
|
+
* never waits for an iframe handshake that cannot succeed.
|
|
208
|
+
*/
|
|
209
|
+
export function FronteraAppProvider({
|
|
210
|
+
children,
|
|
211
|
+
providers = [],
|
|
212
|
+
loading = diagnostic('Connecting to Frontera', 'Establishing an authenticated App session.'),
|
|
213
|
+
errorFallback,
|
|
214
|
+
queryClient: providedQueryClient,
|
|
215
|
+
timeoutMs,
|
|
216
|
+
}: FronteraAppProviderProps): ReactNode {
|
|
217
|
+
const [connection, setConnection] = useState<{
|
|
218
|
+
mode: FronteraAppMode
|
|
219
|
+
session: BridgeSession
|
|
220
|
+
} | null>(null)
|
|
221
|
+
const [error, setError] = useState<Error | null>(null)
|
|
222
|
+
const [queryClient] = useState(
|
|
223
|
+
() =>
|
|
224
|
+
providedQueryClient ??
|
|
225
|
+
new QueryClient({
|
|
226
|
+
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const runtime = readRuntimeGlobal()
|
|
232
|
+
const framed = typeof window !== 'undefined' && window.parent !== window
|
|
233
|
+
const mode = detectAppMode(runtime, framed)
|
|
234
|
+
if (!mode) {
|
|
235
|
+
setError(
|
|
236
|
+
new Error(
|
|
237
|
+
'Frontera App is not configured: no embedded host, standalone session endpoint, or local development session endpoint was provided.',
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let active = true
|
|
244
|
+
let sessionToDispose: BridgeSession | null = null
|
|
245
|
+
const connected =
|
|
246
|
+
mode === 'embedded'
|
|
247
|
+
? connectToHost({ parentOrigin: runtime.platformOrigin, timeoutMs })
|
|
248
|
+
: connectToSessionEndpoint(
|
|
249
|
+
mode === 'standalone' ? runtime.sessionEndpoint! : runtime.devSessionEndpoint!,
|
|
250
|
+
)
|
|
251
|
+
void connected
|
|
252
|
+
.then((session) => {
|
|
253
|
+
sessionToDispose = session
|
|
254
|
+
if (!active) {
|
|
255
|
+
session.dispose()
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
applyHostTheme(session.init.theme)
|
|
259
|
+
setConnection({ mode, session })
|
|
260
|
+
})
|
|
261
|
+
.catch((reason) => {
|
|
262
|
+
if (active) setError(reason instanceof Error ? reason : new Error(String(reason)))
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
return () => {
|
|
266
|
+
active = false
|
|
267
|
+
sessionToDispose?.dispose()
|
|
268
|
+
}
|
|
269
|
+
}, [])
|
|
270
|
+
|
|
271
|
+
if (error) return errorFallback?.(error) ?? diagnostic('Frontera App could not start', error.message)
|
|
272
|
+
if (!connection) return loading
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<QueryClientProvider client={queryClient}>
|
|
276
|
+
<FronteraRoot
|
|
277
|
+
mode={connection.mode}
|
|
278
|
+
session={connection.session}
|
|
279
|
+
providers={providers}
|
|
280
|
+
errorFallback={errorFallback}
|
|
281
|
+
>
|
|
282
|
+
{children}
|
|
283
|
+
</FronteraRoot>
|
|
284
|
+
</QueryClientProvider>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
178
288
|
export async function createFronteraApp(
|
|
179
289
|
app: ReactNode,
|
|
180
290
|
options: CreateFronteraAppOptions = {},
|
|
@@ -187,34 +297,15 @@ export async function createFronteraApp(
|
|
|
187
297
|
|
|
188
298
|
const root = createRoot(element)
|
|
189
299
|
|
|
190
|
-
let session: BridgeSession
|
|
191
|
-
try {
|
|
192
|
-
session = await connectToHost({ timeoutMs: options.timeoutMs })
|
|
193
|
-
} catch (err) {
|
|
194
|
-
// NOT a thrown rejection. This is the everyday outcome of running `vite`
|
|
195
|
-
// directly, and an unhandled rejection in the console reads as a crash in
|
|
196
|
-
// the app's own code. A visible panel says what actually happened.
|
|
197
|
-
const detail = err instanceof Error ? err.message : String(err)
|
|
198
|
-
root.render(diagnostic('This app runs inside Frontera', detail))
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Before the first paint, so the app never renders in the wrong palette.
|
|
203
|
-
applyHostTheme(session.init.theme)
|
|
204
|
-
|
|
205
|
-
const queryClient =
|
|
206
|
-
options.queryClient ??
|
|
207
|
-
new QueryClient({
|
|
208
|
-
// Platform data is read-only here and a mounted app is usually one
|
|
209
|
-
// screen; refetching on every focus change is noise, not freshness.
|
|
210
|
-
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
|
|
211
|
-
})
|
|
212
|
-
|
|
213
300
|
root.render(
|
|
214
301
|
<StrictMode>
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
|
|
302
|
+
<FronteraAppProvider
|
|
303
|
+
providers={options.providers}
|
|
304
|
+
queryClient={options.queryClient}
|
|
305
|
+
timeoutMs={options.timeoutMs}
|
|
306
|
+
>
|
|
307
|
+
{app}
|
|
308
|
+
</FronteraAppProvider>
|
|
218
309
|
</StrictMode>,
|
|
219
310
|
)
|
|
220
311
|
}
|
package/src/react.ts
ADDED
package/src/transport.ts
CHANGED
|
@@ -10,6 +10,15 @@ export interface RequestOptions {
|
|
|
10
10
|
body?: unknown
|
|
11
11
|
query?: Record<string, string | number | boolean | undefined>
|
|
12
12
|
signal?: AbortSignal
|
|
13
|
+
/**
|
|
14
|
+
* Extra headers for ONE request. Added for `idempotency-key`, which the
|
|
15
|
+
* governed write plane reads from the header rather than the body — a key in
|
|
16
|
+
* the payload would be part of the invocation it is meant to deduplicate.
|
|
17
|
+
*
|
|
18
|
+
* Auth is not overridable: the credential headers are applied after these, so
|
|
19
|
+
* a caller cannot swap the principal by passing `authorization` here.
|
|
20
|
+
*/
|
|
21
|
+
headers?: Record<string, string>
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
/**
|
|
@@ -50,6 +59,13 @@ export function unwrap<T>(body: unknown): T {
|
|
|
50
59
|
return body as T
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
function lowerCasedKeys(headers: Record<string, string> | undefined): Record<string, string> {
|
|
63
|
+
if (!headers) return {}
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
function buildUrl(config: FronteraConfig, path: string, query?: RequestOptions['query']): string {
|
|
54
70
|
const url = new URL(`${config.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`)
|
|
55
71
|
if (query) {
|
|
@@ -71,7 +87,20 @@ export async function httpRequest<T>(
|
|
|
71
87
|
options: RequestOptions = {},
|
|
72
88
|
fetchImpl: typeof fetch = fetch,
|
|
73
89
|
): Promise<T> {
|
|
74
|
-
|
|
90
|
+
// Caller headers first, so the credential and content type below win. A
|
|
91
|
+
// request that could overwrite `authorization` would let any call site act as
|
|
92
|
+
// another principal, which is precisely what the transport exists to prevent.
|
|
93
|
+
//
|
|
94
|
+
// Lower-cased on the way in, because "later key wins" is a property of the
|
|
95
|
+
// OBJECT and header names are case-insensitive on the wire: `Authorization`
|
|
96
|
+
// and `authorization` are two distinct properties here and one name to
|
|
97
|
+
// `Headers`, which COMBINES them into `forged, real` rather than letting the
|
|
98
|
+
// credential replace the forgery.
|
|
99
|
+
const headers: Record<string, string> = {
|
|
100
|
+
...lowerCasedKeys(options.headers),
|
|
101
|
+
...authHeaders(config),
|
|
102
|
+
accept: 'application/json',
|
|
103
|
+
}
|
|
75
104
|
if (options.body !== undefined) headers['content-type'] = 'application/json'
|
|
76
105
|
|
|
77
106
|
const response = await fetchImpl(buildUrl(config, path, options.query), {
|