@frontera-sdk/core 1.43.10 → 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 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 { createFronteraApp } from '@frontera-sdk/core/create-frontera-app'
11
+ import { FronteraAppProvider } from '@frontera-sdk/core/react'
12
12
 
13
- import App from './App'
14
-
15
- createFronteraApp(<App />)
13
+ export function Providers({ children }: { children: React.ReactNode }) {
14
+ return <FronteraAppProvider>{children}</FronteraAppProvider>
15
+ }
16
16
  ```
17
17
 
18
- `createFronteraApp` completes the handshake, adopts the host's palette,
19
- installs the error boundary that reports crashes back to the platform, and only
20
- then mounts. An app has no data scope until the host says who it is — which is
21
- why opening the app's own origin directly renders a refusal panel rather than
22
- the app.
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.43.10",
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
- app,
137
+ mode,
138
+ children,
130
139
  providers,
140
+ errorFallback,
131
141
  }: {
132
142
  session: BridgeSession
133
- app: ReactNode
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: 'apiKey', key: init.token },
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: 'apiKey', key: token }))),
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) => session.send({ type: 'frontera:navigate', path }),
170
+ navigate: (path) => {
171
+ if (mode === 'embedded') session.send({ type: 'frontera:navigate', path })
172
+ },
158
173
  }
159
174
 
160
- let tree: ReactNode = app
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
- <QueryClientProvider client={queryClient}>
216
- <FronteraRoot session={session} app={app} providers={options.providers ?? []} />
217
- </QueryClientProvider>
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
@@ -0,0 +1,8 @@
1
+ export {
2
+ FronteraAppProvider,
3
+ useFronteraApp,
4
+ type FronteraAppProviderProps,
5
+ type FronteraAppValue,
6
+ type FronteraProvider,
7
+ } from './create-frontera-app'
8
+ export type { FronteraAppMode } from './app-session'
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
- const headers: Record<string, string> = { ...authHeaders(config), accept: 'application/json' }
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), {