@microsoft/rayfin-app-state-fabric 1.35.0-alpha.1412
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/LICENSE +21 -0
- package/README.md +140 -0
- package/assets/docs/index.md +211 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +54 -0
- package/dist/fabricAppState.d.ts +32 -0
- package/dist/fabricAppState.js +311 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +11 -0
- package/dist/launchState.d.ts +56 -0
- package/dist/launchState.js +116 -0
- package/dist/protocol.d.ts +22 -0
- package/dist/protocol.js +22 -0
- package/dist/types.d.ts +183 -0
- package/dist/types.js +5 -0
- package/dist/validation.d.ts +23 -0
- package/dist/validation.js +117 -0
- package/package.json +50 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fabricAppState — deep-linking client for Rayfin apps embedded in Fabric.
|
|
3
|
+
*
|
|
4
|
+
* An embedded app cannot touch the portal address bar, so it hands the host
|
|
5
|
+
* an opaque JSON object and the host owns the URL. That indirection is what
|
|
6
|
+
* lets the encoding change without a breaking SDK release.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const appState = createFabricAppStateClient();
|
|
11
|
+
*
|
|
12
|
+
* // Restore before first render so defaults never flash.
|
|
13
|
+
* restoreApplicationState(appState.getLaunchStateSync());
|
|
14
|
+
*
|
|
15
|
+
* // User-initiated: Back should undo it.
|
|
16
|
+
* await appState.setState({ view: 'sales-by-region', filter: 'AT' });
|
|
17
|
+
*
|
|
18
|
+
* // App-initiated: do not grow history.
|
|
19
|
+
* await appState.replaceState({ view: 'sales-by-region', filter: 'DE' });
|
|
20
|
+
*
|
|
21
|
+
* const unsubscribe = appState.onStateChange(restoreApplicationState);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import { sendBridgeRequest, subscribeBridgeEvents, } from '@microsoft/fabric-embedded-host';
|
|
25
|
+
import { assertBrowser } from '@microsoft/rayfin-lib';
|
|
26
|
+
import { toAppStateError } from './errors.js';
|
|
27
|
+
import { readLaunchStateFromUrl, scrubLaunchParamFromUrl } from './launchState.js';
|
|
28
|
+
import { FABRIC_APP_STATE_CHANNEL, KIND_CHANGED, KIND_GET_CAPABILITIES, KIND_GET_LAUNCH_STATE, KIND_PUSH, KIND_REPLACE, } from './protocol.js';
|
|
29
|
+
import { DEFAULT_MAX_DEPTH, DEFAULT_MAX_ENCODED_BYTES, validateAppState, } from './validation.js';
|
|
30
|
+
/** An unregistered channel may mean the host bridge has not mounted yet. */
|
|
31
|
+
const READINESS_RETRY_LIMIT = 2;
|
|
32
|
+
const READINESS_RETRY_DELAY_MS = 150;
|
|
33
|
+
function delay(ms) {
|
|
34
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a {@link FabricAppStateClient} bound to the Fabric host.
|
|
38
|
+
*
|
|
39
|
+
* Safe to call once per application; create a single instance and share
|
|
40
|
+
* it rather than constructing one per component.
|
|
41
|
+
*/
|
|
42
|
+
export function createFabricAppStateClient(options = {}) {
|
|
43
|
+
assertBrowser('createFabricAppStateClient');
|
|
44
|
+
const { target = window.parent, targetOrigin, timeoutMs, maxEncodedBytes = DEFAULT_MAX_ENCODED_BYTES, maxDepth = DEFAULT_MAX_DEPTH, launchSearch, scrubLaunchParam = true, } = options;
|
|
45
|
+
const launchState = readLaunchStateFromUrl(launchSearch);
|
|
46
|
+
// Only rewrite the address bar when the state actually came from it, and
|
|
47
|
+
// only when embedded — a standalone page has no host that seeded it, so
|
|
48
|
+
// scrubbing there would mutate a URL the client does not own.
|
|
49
|
+
if (launchSearch === undefined && scrubLaunchParam && target !== window) {
|
|
50
|
+
scrubLaunchParamFromUrl();
|
|
51
|
+
}
|
|
52
|
+
const listeners = new Set();
|
|
53
|
+
let unsubscribeBridge;
|
|
54
|
+
let capabilitiesPromise;
|
|
55
|
+
/** Whether the last capability probe failed for a retryable reason. */
|
|
56
|
+
let capabilitiesTransientMiss = false;
|
|
57
|
+
/** `postMessage` gives no ordering guarantee, so stale events are dropped. */
|
|
58
|
+
let lastRevision = -1;
|
|
59
|
+
/** Generation of {@link lastRevision}; a change clears the high-water mark. */
|
|
60
|
+
let lastEpoch = 0;
|
|
61
|
+
/** Writes are chained so rapid calls reach the host in call order. */
|
|
62
|
+
let writeQueue = Promise.resolve();
|
|
63
|
+
/**
|
|
64
|
+
* The replace waiting to be sent, and the flush that will send it.
|
|
65
|
+
*
|
|
66
|
+
* The value lives in a slot rather than a bare variable so the flush
|
|
67
|
+
* sends whatever was pending when it was queued. A push closes the slot,
|
|
68
|
+
* which keeps a later replace from joining a flush scheduled ahead of it.
|
|
69
|
+
*/
|
|
70
|
+
let replaceSlot;
|
|
71
|
+
let replaceFlush;
|
|
72
|
+
function request(kind, payload) {
|
|
73
|
+
return sendBridgeRequest({
|
|
74
|
+
target,
|
|
75
|
+
channel: FABRIC_APP_STATE_CHANNEL,
|
|
76
|
+
kind,
|
|
77
|
+
payload,
|
|
78
|
+
...(targetOrigin !== undefined ? { targetOrigin } : {}),
|
|
79
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Fold a response revision into the event watermark.
|
|
84
|
+
*
|
|
85
|
+
* Responses carry no epoch, so one that was already in flight when the host
|
|
86
|
+
* reset its counter would raise the watermark of the *new* epoch and
|
|
87
|
+
* silently discard every event after it. Skipping the merge when the epoch
|
|
88
|
+
* moved keeps the two generations from mixing, at the cost of one missed
|
|
89
|
+
* watermark update.
|
|
90
|
+
*/
|
|
91
|
+
function mergeResponseRevision(result, epochAtRequest) {
|
|
92
|
+
if (lastEpoch !== epochAtRequest)
|
|
93
|
+
return;
|
|
94
|
+
if (typeof result?.revision === 'number') {
|
|
95
|
+
lastRevision = Math.max(lastRevision, result.revision);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Serialise a write behind any in-flight writes. */
|
|
99
|
+
function enqueueWrite(kind, state) {
|
|
100
|
+
// A Promise-returning method must never throw synchronously.
|
|
101
|
+
try {
|
|
102
|
+
validateAppState(state, maxEncodedBytes, maxDepth);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return Promise.reject(toAppStateError(err));
|
|
106
|
+
}
|
|
107
|
+
// Close the open replace slot so a replace issued after this push
|
|
108
|
+
// queues behind it instead of collapsing into an earlier flush.
|
|
109
|
+
replaceSlot = undefined;
|
|
110
|
+
replaceFlush = undefined;
|
|
111
|
+
const epochAtRequest = { value: lastEpoch };
|
|
112
|
+
const send = () => {
|
|
113
|
+
// Captured at send rather than enqueue, so queuing behind a slow write
|
|
114
|
+
// does not needlessly widen the window.
|
|
115
|
+
epochAtRequest.value = lastEpoch;
|
|
116
|
+
return request(kind, { state });
|
|
117
|
+
};
|
|
118
|
+
// Chain on both settlement paths so one failure does not poison the queue.
|
|
119
|
+
const run = writeQueue.then(send, send);
|
|
120
|
+
writeQueue = run.catch(() => undefined);
|
|
121
|
+
return run.then((result) => {
|
|
122
|
+
mergeResponseRevision(result, epochAtRequest.value);
|
|
123
|
+
}, (err) => {
|
|
124
|
+
throw toAppStateError(err);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function ensureSubscribed() {
|
|
128
|
+
if (unsubscribeBridge)
|
|
129
|
+
return;
|
|
130
|
+
unsubscribeBridge = subscribeBridgeEvents({
|
|
131
|
+
source: target,
|
|
132
|
+
channel: FABRIC_APP_STATE_CHANNEL,
|
|
133
|
+
kind: KIND_CHANGED,
|
|
134
|
+
...(targetOrigin !== undefined ? { origin: targetOrigin } : {}),
|
|
135
|
+
onEvent: (payload, revision, epoch) => {
|
|
136
|
+
// A reset counter would otherwise sit below the high-water mark forever.
|
|
137
|
+
if (epoch !== lastEpoch) {
|
|
138
|
+
lastEpoch = epoch;
|
|
139
|
+
lastRevision = -1;
|
|
140
|
+
}
|
|
141
|
+
if (revision <= lastRevision)
|
|
142
|
+
return;
|
|
143
|
+
lastRevision = revision;
|
|
144
|
+
// Undefined means the URL carries no state; the app restores defaults.
|
|
145
|
+
const state = payload?.state;
|
|
146
|
+
// Copy: a listener may unsubscribe while the set is being walked.
|
|
147
|
+
for (const listener of [...listeners]) {
|
|
148
|
+
try {
|
|
149
|
+
listener(state);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error('[FabricAppState] onStateChange listener threw', err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** Fill in any limits the host did not report with the client defaults. */
|
|
159
|
+
function toCapabilities(raw) {
|
|
160
|
+
return {
|
|
161
|
+
version: typeof raw?.version === 'number' ? raw.version : 1,
|
|
162
|
+
maxEncodedBytes: typeof raw?.maxEncodedBytes === 'number'
|
|
163
|
+
? raw.maxEncodedBytes
|
|
164
|
+
: maxEncodedBytes,
|
|
165
|
+
maxDepth: typeof raw?.maxDepth === 'number' ? raw.maxDepth : maxDepth,
|
|
166
|
+
canPush: raw?.canPush !== false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Ask the host for its limits, tolerating a bridge that is still mounting. */
|
|
170
|
+
async function requestCapabilities() {
|
|
171
|
+
capabilitiesTransientMiss = false;
|
|
172
|
+
for (let attempt = 0;; attempt++) {
|
|
173
|
+
try {
|
|
174
|
+
const raw = await request(KIND_GET_CAPABILITIES, {});
|
|
175
|
+
return toCapabilities(typeof raw === 'object' && raw !== null ? raw : undefined);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
const { code } = toAppStateError(err);
|
|
179
|
+
// A host still mounting its listener either rejects the channel or
|
|
180
|
+
// does not answer at all, so both are retryable.
|
|
181
|
+
if (code === 'UNSUPPORTED_HOST_CAPABILITY' ||
|
|
182
|
+
code === 'BRIDGE_TIMEOUT') {
|
|
183
|
+
if (attempt < READINESS_RETRY_LIMIT) {
|
|
184
|
+
await delay(READINESS_RETRY_DELAY_MS);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
// Silence is not proof the feature is absent, so allow a later
|
|
188
|
+
// call to probe again rather than hiding the feature for good.
|
|
189
|
+
capabilitiesTransientMiss = code === 'BRIDGE_TIMEOUT';
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
// Not embedded at all: definitive, so re-probing cannot help.
|
|
193
|
+
if (code === 'NO_HOST_WINDOW') {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
// Answered but does not know this kind: an older host that still
|
|
197
|
+
// services push and replace, so report defaults rather than hiding a
|
|
198
|
+
// feature that works.
|
|
199
|
+
if (code === 'UNKNOWN_OPERATION') {
|
|
200
|
+
return toCapabilities();
|
|
201
|
+
}
|
|
202
|
+
// Anything else is an operational failure. Reporting capabilities
|
|
203
|
+
// here would advertise a feature that is currently broken.
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
getLaunchStateSync() {
|
|
210
|
+
return launchState;
|
|
211
|
+
},
|
|
212
|
+
async getLaunchState() {
|
|
213
|
+
// Seeded onto the iframe URL before load, so no round trip is needed.
|
|
214
|
+
if (launchState)
|
|
215
|
+
return launchState;
|
|
216
|
+
// Fallback for hosts that do not seed the URL.
|
|
217
|
+
try {
|
|
218
|
+
const epochAtRequest = lastEpoch;
|
|
219
|
+
const result = await request(KIND_GET_LAUNCH_STATE, {});
|
|
220
|
+
mergeResponseRevision(result, epochAtRequest);
|
|
221
|
+
return result?.state;
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
const error = toAppStateError(err);
|
|
225
|
+
// Starting up must survive a host that predates this feature, is not
|
|
226
|
+
// there at all, or is too slow to answer: all three mean "no launch
|
|
227
|
+
// state", so callers need no try/catch around startup.
|
|
228
|
+
if (error.code === 'UNSUPPORTED_HOST_CAPABILITY' ||
|
|
229
|
+
error.code === 'NO_HOST_WINDOW' ||
|
|
230
|
+
error.code === 'BRIDGE_TIMEOUT') {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
isSupported() {
|
|
237
|
+
capabilitiesPromise ??= requestCapabilities().then((capabilities) => {
|
|
238
|
+
// Definitive answers stay cached; a timeout is re-probed on the
|
|
239
|
+
// next call so a host that mounts late is not missed forever.
|
|
240
|
+
if (capabilities === undefined && capabilitiesTransientMiss) {
|
|
241
|
+
capabilitiesPromise = undefined;
|
|
242
|
+
}
|
|
243
|
+
return capabilities;
|
|
244
|
+
});
|
|
245
|
+
return capabilitiesPromise;
|
|
246
|
+
},
|
|
247
|
+
setState(state) {
|
|
248
|
+
return enqueueWrite(KIND_PUSH, state);
|
|
249
|
+
},
|
|
250
|
+
replaceState(state) {
|
|
251
|
+
// Replace is used for continuous input such as a slider drag.
|
|
252
|
+
// Sending every intermediate value would queue them behind one
|
|
253
|
+
// round trip each and leave the URL visibly lagging the UI, so
|
|
254
|
+
// only the newest pending value is written.
|
|
255
|
+
try {
|
|
256
|
+
validateAppState(state, maxEncodedBytes, maxDepth);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
return Promise.reject(toAppStateError(err));
|
|
260
|
+
}
|
|
261
|
+
// Coalesce into the slot that is still waiting to be sent. Once a
|
|
262
|
+
// push has closed it, open a new one so this value lands after it.
|
|
263
|
+
const open = replaceSlot;
|
|
264
|
+
if (open !== undefined && replaceFlush !== undefined) {
|
|
265
|
+
open.state = state;
|
|
266
|
+
return replaceFlush;
|
|
267
|
+
}
|
|
268
|
+
const slot = { state };
|
|
269
|
+
replaceSlot = slot;
|
|
270
|
+
const flush = writeQueue.then(runFlush, runFlush);
|
|
271
|
+
writeQueue = flush.catch(() => undefined);
|
|
272
|
+
replaceFlush = flush;
|
|
273
|
+
return flush;
|
|
274
|
+
async function runFlush() {
|
|
275
|
+
// Close the slot before sending so later replaces open a new one.
|
|
276
|
+
if (replaceSlot === slot) {
|
|
277
|
+
replaceSlot = undefined;
|
|
278
|
+
replaceFlush = undefined;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const epochAtRequest = lastEpoch;
|
|
282
|
+
const result = await request(KIND_REPLACE, { state: slot.state });
|
|
283
|
+
mergeResponseRevision(result, epochAtRequest);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
throw toAppStateError(err);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
onStateChange(listener) {
|
|
291
|
+
listeners.add(listener);
|
|
292
|
+
ensureSubscribed();
|
|
293
|
+
return () => {
|
|
294
|
+
listeners.delete(listener);
|
|
295
|
+
// Release the window listener once nobody is observing.
|
|
296
|
+
if (listeners.size === 0 && unsubscribeBridge) {
|
|
297
|
+
unsubscribeBridge();
|
|
298
|
+
unsubscribeBridge = undefined;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
dispose() {
|
|
303
|
+
listeners.clear();
|
|
304
|
+
if (unsubscribeBridge) {
|
|
305
|
+
unsubscribeBridge();
|
|
306
|
+
unsubscribeBridge = undefined;
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
//# sourceMappingURL=fabricAppState.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { FabricAppState, FabricAppStateValue, FabricAppStateCapabilities, FabricAppStateClient, FabricAppStateClientOptions, FabricAppStateListener, } from './types.js';
|
|
2
|
+
export { createFabricAppStateClient } from './fabricAppState.js';
|
|
3
|
+
export { FabricAppStateError } from './errors.js';
|
|
4
|
+
export { DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_DEPTH } from './validation.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// ── Deep-link application state ──────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The URL representation is owned by the host and is deliberately NOT
|
|
4
|
+
// exported: no parameter name, no codec, no channel constant. Exporting
|
|
5
|
+
// them would let a Builder depend on the encoding, which is the one
|
|
6
|
+
// thing this design exists to keep changeable. They remain importable
|
|
7
|
+
// from the individual modules for tests and for the host-side contract.
|
|
8
|
+
export { createFabricAppStateClient } from './fabricAppState.js';
|
|
9
|
+
export { FabricAppStateError } from './errors.js';
|
|
10
|
+
export { DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_DEPTH } from './validation.js';
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch-state seeding: decoding the state the host places on the app's
|
|
3
|
+
* own iframe URL.
|
|
4
|
+
*
|
|
5
|
+
* Everything in this module is the **URL contract between the host and
|
|
6
|
+
* this client**, not part of the Builder-facing surface. Apps read
|
|
7
|
+
* launch state through the client, never by parsing the URL themselves —
|
|
8
|
+
* that is what allows the host to change the encoding (query parameter,
|
|
9
|
+
* path segment, short link) without a breaking SDK release.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
import type { FabricAppState } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* Query parameter the host places on the app's iframe URL to seed
|
|
16
|
+
* launch state.
|
|
17
|
+
*
|
|
18
|
+
* Launch state must be available before first render, which a `postMessage`
|
|
19
|
+
* round trip cannot provide. Seeding lets the app read it synchronously from
|
|
20
|
+
* its own location.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export declare const LAUNCH_STATE_PARAM = "fabricAppState";
|
|
25
|
+
/**
|
|
26
|
+
* Encoding prefix, so the wire format can be versioned.
|
|
27
|
+
*
|
|
28
|
+
* Counts against the host's encoded-size budget, so the write-path
|
|
29
|
+
* validator subtracts it before converting that budget to a raw-JSON one.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export declare const LAUNCH_STATE_PREFIX = "v1.";
|
|
34
|
+
/**
|
|
35
|
+
* Read seeded launch state from the current document's URL.
|
|
36
|
+
*
|
|
37
|
+
* Synchronous and safe to call before first render.
|
|
38
|
+
*
|
|
39
|
+
* @param search - Query string to parse. Defaults to the live location,
|
|
40
|
+
* and is injectable for tests.
|
|
41
|
+
* @returns The decoded state, or `undefined` when absent or unusable.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
export declare function readLaunchStateFromUrl(search?: string): FabricAppState | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Remove the seeded parameter from the app's own address bar.
|
|
48
|
+
*
|
|
49
|
+
* Leaving it would resend the state to the app's server on reload and expose
|
|
50
|
+
* it in referrers to subresources. Only this frame's history entry is
|
|
51
|
+
* rewritten; the portal URL, which the host owns, is untouched.
|
|
52
|
+
*
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export declare function scrubLaunchParamFromUrl(): void;
|
|
56
|
+
//# sourceMappingURL=launchState.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch-state seeding: decoding the state the host places on the app's
|
|
3
|
+
* own iframe URL.
|
|
4
|
+
*
|
|
5
|
+
* Everything in this module is the **URL contract between the host and
|
|
6
|
+
* this client**, not part of the Builder-facing surface. Apps read
|
|
7
|
+
* launch state through the client, never by parsing the URL themselves —
|
|
8
|
+
* that is what allows the host to change the encoding (query parameter,
|
|
9
|
+
* path segment, short link) without a breaking SDK release.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Query parameter the host places on the app's iframe URL to seed
|
|
15
|
+
* launch state.
|
|
16
|
+
*
|
|
17
|
+
* Launch state must be available before first render, which a `postMessage`
|
|
18
|
+
* round trip cannot provide. Seeding lets the app read it synchronously from
|
|
19
|
+
* its own location.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export const LAUNCH_STATE_PARAM = 'fabricAppState';
|
|
24
|
+
/**
|
|
25
|
+
* Encoding prefix, so the wire format can be versioned.
|
|
26
|
+
*
|
|
27
|
+
* Counts against the host's encoded-size budget, so the write-path
|
|
28
|
+
* validator subtracts it before converting that budget to a raw-JSON one.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export const LAUNCH_STATE_PREFIX = 'v1.';
|
|
33
|
+
/**
|
|
34
|
+
* Decode one base64url-encoded launch-state value.
|
|
35
|
+
*
|
|
36
|
+
* Returns `undefined` for anything unusable rather than throwing.
|
|
37
|
+
* Tolerance is a requirement, not leniency: links shared while the
|
|
38
|
+
* feature was enabled keep the parameter after it is disabled, and such
|
|
39
|
+
* a link must still open the app in its default state.
|
|
40
|
+
*/
|
|
41
|
+
function decodeLaunchState(raw) {
|
|
42
|
+
if (!raw.startsWith(LAUNCH_STATE_PREFIX))
|
|
43
|
+
return undefined;
|
|
44
|
+
const encoded = raw.slice(LAUNCH_STATE_PREFIX.length);
|
|
45
|
+
if (!encoded)
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
// base64url -> base64, restoring the padding atob() requires.
|
|
49
|
+
const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
|
50
|
+
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
|
51
|
+
// Reached through globalThis so the reference resolves in any
|
|
52
|
+
// module system, and so lint does not treat it as an undeclared
|
|
53
|
+
// browser global. Guarded because a non-browser host may lack it.
|
|
54
|
+
const decodeBase64 = globalThis.atob;
|
|
55
|
+
if (typeof decodeBase64 !== 'function')
|
|
56
|
+
return undefined;
|
|
57
|
+
const binary = decodeBase64(padded);
|
|
58
|
+
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
59
|
+
const json = new TextDecoder().decode(bytes);
|
|
60
|
+
const parsed = JSON.parse(json);
|
|
61
|
+
if (typeof parsed !== 'object' ||
|
|
62
|
+
parsed === null ||
|
|
63
|
+
Array.isArray(parsed)) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read seeded launch state from the current document's URL.
|
|
74
|
+
*
|
|
75
|
+
* Synchronous and safe to call before first render.
|
|
76
|
+
*
|
|
77
|
+
* @param search - Query string to parse. Defaults to the live location,
|
|
78
|
+
* and is injectable for tests.
|
|
79
|
+
* @returns The decoded state, or `undefined` when absent or unusable.
|
|
80
|
+
*
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
export function readLaunchStateFromUrl(search) {
|
|
84
|
+
const query = search ??
|
|
85
|
+
(typeof window !== 'undefined' ? window.location.search : undefined);
|
|
86
|
+
if (!query)
|
|
87
|
+
return undefined;
|
|
88
|
+
const raw = new URLSearchParams(query).get(LAUNCH_STATE_PARAM);
|
|
89
|
+
if (!raw)
|
|
90
|
+
return undefined;
|
|
91
|
+
return decodeLaunchState(raw);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Remove the seeded parameter from the app's own address bar.
|
|
95
|
+
*
|
|
96
|
+
* Leaving it would resend the state to the app's server on reload and expose
|
|
97
|
+
* it in referrers to subresources. Only this frame's history entry is
|
|
98
|
+
* rewritten; the portal URL, which the host owns, is untouched.
|
|
99
|
+
*
|
|
100
|
+
* @internal
|
|
101
|
+
*/
|
|
102
|
+
export function scrubLaunchParamFromUrl() {
|
|
103
|
+
try {
|
|
104
|
+
if (typeof window === 'undefined')
|
|
105
|
+
return;
|
|
106
|
+
const url = new URL(window.location.href);
|
|
107
|
+
if (!url.searchParams.has(LAUNCH_STATE_PARAM))
|
|
108
|
+
return;
|
|
109
|
+
url.searchParams.delete(LAUNCH_STATE_PARAM);
|
|
110
|
+
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Hardening only; never let this break startup.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=launchState.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol constants for the `fabric-app-state` channel.
|
|
3
|
+
*
|
|
4
|
+
* These describe the contract between an embedded Rayfin app and the
|
|
5
|
+
* Fabric host that services it. They are implementation detail of the
|
|
6
|
+
* client and are not part of the Builder-facing surface.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
/** Channel routed by the host's `AppStatePlugin`. */
|
|
11
|
+
export declare const FABRIC_APP_STATE_CHANNEL = "fabric-app-state";
|
|
12
|
+
/** Request kind: read the host's limits and supported features. */
|
|
13
|
+
export declare const KIND_GET_CAPABILITIES = "appState.getCapabilities";
|
|
14
|
+
/** Request kind: read the state the app was launched with. */
|
|
15
|
+
export declare const KIND_GET_LAUNCH_STATE = "appState.getLaunchState";
|
|
16
|
+
/** Request kind: commit state as a new browser history entry. */
|
|
17
|
+
export declare const KIND_PUSH = "appState.push";
|
|
18
|
+
/** Request kind: commit state over the current history entry. */
|
|
19
|
+
export declare const KIND_REPLACE = "appState.replace";
|
|
20
|
+
/** Event kind: the host reports externally-changed state. */
|
|
21
|
+
export declare const KIND_CHANGED = "appState.changed";
|
|
22
|
+
//# sourceMappingURL=protocol.d.ts.map
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol constants for the `fabric-app-state` channel.
|
|
3
|
+
*
|
|
4
|
+
* These describe the contract between an embedded Rayfin app and the
|
|
5
|
+
* Fabric host that services it. They are implementation detail of the
|
|
6
|
+
* client and are not part of the Builder-facing surface.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
/** Channel routed by the host's `AppStatePlugin`. */
|
|
11
|
+
export const FABRIC_APP_STATE_CHANNEL = 'fabric-app-state';
|
|
12
|
+
/** Request kind: read the host's limits and supported features. */
|
|
13
|
+
export const KIND_GET_CAPABILITIES = 'appState.getCapabilities';
|
|
14
|
+
/** Request kind: read the state the app was launched with. */
|
|
15
|
+
export const KIND_GET_LAUNCH_STATE = 'appState.getLaunchState';
|
|
16
|
+
/** Request kind: commit state as a new browser history entry. */
|
|
17
|
+
export const KIND_PUSH = 'appState.push';
|
|
18
|
+
/** Request kind: commit state over the current history entry. */
|
|
19
|
+
export const KIND_REPLACE = 'appState.replace';
|
|
20
|
+
/** Event kind: the host reports externally-changed state. */
|
|
21
|
+
export const KIND_CHANGED = 'appState.changed';
|
|
22
|
+
//# sourceMappingURL=protocol.js.map
|