@frontera-sdk/core 1.50.25 → 1.50.27

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
@@ -27,6 +27,27 @@ own routing themselves.
27
27
 
28
28
  ## Entry points
29
29
 
30
+ ### Embedded application lifecycle
31
+
32
+ `useFronteraApp()` exposes `active` and `setUnsavedChanges(dirty)`. Use `active`
33
+ to pause optional visible-only work; an inactive app may remain mounted, with
34
+ its credentials refreshed. Inactivity does not cancel server-side operations.
35
+
36
+ Report local draft changes explicitly, and report `false` only after the draft
37
+ is saved or deliberately discarded. An app that never reports its state is
38
+ treated as unknown when the platform is asked to release its session. This API
39
+ does not implement saving for the app or provide recovery after a browser reload.
40
+
41
+ The injected app-host bridge reports native push, replacement, traversal and
42
+ fragment changes. A `frontera:host-navigate` request carries a `requestId` and
43
+ relative app path; the bridge replaces the child location and responds with
44
+ `frontera:navigation-ack` using the same ID. It does not add a second child history
45
+ step for a navigation the host already owns. Both directions validate the
46
+ expected origin and sender window. Older hosts may ignore the new lifecycle
47
+ messages; older app-host scripts do not support acknowledged navigation.
48
+
49
+ ### Modules
50
+
30
51
  | Import | What it is |
31
52
  |---|---|
32
53
  | `@frontera-sdk/core/react` | `FronteraAppProvider`, `useFronteraApp`, and mode types |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/core",
3
- "version": "1.50.25",
3
+ "version": "1.50.27",
4
4
  "description": "Frontera app runtime: the platform bridge client, app bootstrap and typed platform client.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -112,6 +112,9 @@ export async function connectToSessionEndpoint(
112
112
  onTheme() {
113
113
  return () => {}
114
114
  },
115
+ onActivation() {
116
+ return () => {}
117
+ },
115
118
  dispose() {
116
119
  disposed = true
117
120
  if (timer !== undefined) clearTimer(timer)
@@ -22,6 +22,8 @@ export interface BridgeSession {
22
22
  onToken(handler: (token: string) => void): () => void
23
23
  /** Subscribe to host palette / colour-scheme changes. */
24
24
  onTheme(handler: (theme: HostTheme) => void): () => void
25
+ /** Visibility changes do not revoke credentials or imply execution is paused. */
26
+ onActivation(handler: (active: boolean) => void): () => void
25
27
  dispose(): void
26
28
  }
27
29
 
@@ -84,6 +86,8 @@ export function connectToHost(options: ConnectOptions = {}): Promise<BridgeSessi
84
86
  const stateHandlers = new Set<(s: Record<string, unknown>) => void>()
85
87
  const tokenHandlers = new Set<(t: string) => void>()
86
88
  const themeHandlers = new Set<(t: HostTheme) => void>()
89
+ const activationHandlers = new Set<(active: boolean) => void>()
90
+ let active = true
87
91
 
88
92
  return new Promise<BridgeSession>((resolve, reject) => {
89
93
  let settled = false
@@ -91,7 +95,7 @@ export function connectToHost(options: ConnectOptions = {}): Promise<BridgeSessi
91
95
  const send = (message: AppMessage) => window.parent.postMessage(message, parentOrigin)
92
96
 
93
97
  const onMessage = (event: MessageEvent) => {
94
- if (event.origin !== parentOrigin) return
98
+ if (event.origin !== parentOrigin || event.source !== window.parent) return
95
99
  if (!isHostMessage(event.data)) return
96
100
  const message = event.data
97
101
 
@@ -100,6 +104,7 @@ export function connectToHost(options: ConnectOptions = {}): Promise<BridgeSessi
100
104
  if (settled) return
101
105
  settled = true
102
106
  clearTimeout(timer)
107
+ active = message.active ?? active
103
108
  resolve({
104
109
  init: message,
105
110
  send,
@@ -115,11 +120,17 @@ export function connectToHost(options: ConnectOptions = {}): Promise<BridgeSessi
115
120
  themeHandlers.add(handler)
116
121
  return () => themeHandlers.delete(handler)
117
122
  },
123
+ onActivation(handler) {
124
+ activationHandlers.add(handler)
125
+ handler(active)
126
+ return () => activationHandlers.delete(handler)
127
+ },
118
128
  dispose() {
119
129
  window.removeEventListener('message', onMessage)
120
130
  stateHandlers.clear()
121
131
  tokenHandlers.clear()
122
132
  themeHandlers.clear()
133
+ activationHandlers.clear()
123
134
  },
124
135
  })
125
136
  break
@@ -138,6 +149,10 @@ export function connectToHost(options: ConnectOptions = {}): Promise<BridgeSessi
138
149
  handler({ tokens: message.tokens, colorScheme: message.colorScheme })
139
150
  }
140
151
  break
152
+ case 'frontera:activation':
153
+ active = message.active
154
+ for (const handler of activationHandlers) handler(message.active)
155
+ break
141
156
  default:
142
157
  break
143
158
  }
@@ -35,10 +35,14 @@ export interface BridgeInit {
35
35
  * own default.
36
36
  */
37
37
  path?: string
38
+ active?: boolean
38
39
  }
39
40
 
40
41
  export type HostMessage =
41
42
  | BridgeInit
43
+ | { type: 'frontera:host-ready' }
44
+ | { type: 'frontera:host-navigate'; requestId: string; path: string }
45
+ | { type: 'frontera:activation'; active: boolean }
42
46
  | { type: 'frontera:state'; state: Record<string, unknown> }
43
47
  | {
44
48
  type: 'frontera:theme'
@@ -50,7 +54,10 @@ export type HostMessage =
50
54
 
51
55
  export type AppMessage =
52
56
  | { type: 'frontera:ready' }
53
- | { type: 'frontera:navigate'; path: string }
57
+ | { type: 'frontera:route-ready'; protocol: 1 }
58
+ | { type: 'frontera:navigate'; path: string; mode?: 'push' | 'replace' | 'traverse' | 'hash' | 'load' }
59
+ | { type: 'frontera:navigation-ack'; requestId: string; path: string }
60
+ | { type: 'frontera:unsaved'; dirty: boolean }
54
61
  | { type: 'frontera:resize'; height: number }
55
62
  | { type: 'frontera:state-write'; patch: Record<string, unknown> }
56
63
  | { type: 'frontera:error'; message: string; stack?: string }
@@ -60,6 +67,12 @@ export function isHostMessage(data: unknown): data is HostMessage {
60
67
  if (!data || typeof data !== 'object') return false
61
68
  const m = data as Record<string, unknown>
62
69
  switch (m.type) {
70
+ case 'frontera:host-ready':
71
+ return true
72
+ case 'frontera:host-navigate':
73
+ return typeof m.requestId === 'string' && isAppPath(m.path)
74
+ case 'frontera:activation':
75
+ return typeof m.active === 'boolean'
63
76
  case 'frontera:init':
64
77
  return typeof m.token === 'string' && typeof m.apiBaseUrl === 'string' && typeof m.appId === 'string'
65
78
  case 'frontera:state':
@@ -77,10 +90,16 @@ export function isAppMessage(data: unknown): data is AppMessage {
77
90
  if (!data || typeof data !== 'object') return false
78
91
  const m = data as Record<string, unknown>
79
92
  switch (m.type) {
93
+ case 'frontera:unsaved':
94
+ return typeof m.dirty === 'boolean'
95
+ case 'frontera:route-ready':
96
+ return m.protocol === 1
97
+ case 'frontera:navigation-ack':
98
+ return typeof m.requestId === 'string' && isAppPath(m.path)
80
99
  case 'frontera:ready':
81
100
  return true
82
101
  case 'frontera:navigate':
83
- return typeof m.path === 'string'
102
+ return isAppPath(m.path) && (m.mode === undefined || ['push', 'replace', 'traverse', 'hash', 'load'].includes(m.mode as string))
84
103
  case 'frontera:resize':
85
104
  return typeof m.height === 'number' && Number.isFinite(m.height)
86
105
  case 'frontera:state-write':
@@ -93,3 +112,8 @@ export function isAppMessage(data: unknown): data is AppMessage {
93
112
  return false
94
113
  }
95
114
  }
115
+
116
+ /** App locations are relative to the isolated app origin. */
117
+ function isAppPath(path: unknown): path is string {
118
+ return typeof path === 'string' && path.startsWith('/') && !path.startsWith('//') && !path.includes('\\')
119
+ }
@@ -2,6 +2,7 @@ import {
2
2
  Component,
3
3
  StrictMode,
4
4
  createContext,
5
+ useCallback,
5
6
  useContext,
6
7
  useEffect,
7
8
  useState,
@@ -42,6 +43,10 @@ export interface FronteraAppValue {
42
43
  /** Everything the host said at handshake, including `path`. */
43
44
  init: BridgeInit
44
45
  client: FronteraClient
46
+ /** Whether this app is the visible platform surface. */
47
+ active: boolean
48
+ /** Report local work explicitly; unreported state remains unknown to the host. */
49
+ setUnsavedChanges(dirty: boolean): void
45
50
  /** Ask the platform to change its URL — the counterpart to `init.path`. */
46
51
  navigate(path: string): void
47
52
  }
@@ -383,11 +388,18 @@ function FronteraRoot({
383
388
  )
384
389
 
385
390
  useEffect(() => session.onTheme((theme) => applyHostTheme(theme)), [session])
391
+ const [active, setActive] = useState(init.active ?? true)
392
+ useEffect(() => session.onActivation(setActive), [session])
393
+ const setUnsavedChanges = useCallback((dirty: boolean) => {
394
+ if (mode === 'embedded') session.send({ type: 'frontera:unsaved', dirty })
395
+ }, [mode, session])
386
396
 
387
397
  const value: FronteraAppValue = {
388
398
  mode,
389
399
  init,
390
400
  client,
401
+ active,
402
+ setUnsavedChanges,
391
403
  navigate: (path) => {
392
404
  if (mode === 'embedded') session.send({ type: 'frontera:navigate', path })
393
405
  },