@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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Public types for the deep-link application state client.
3
+ */
4
+ /**
5
+ * A single JSON-compatible value permitted inside {@link FabricAppState}.
6
+ *
7
+ * `undefined`, functions, symbols, `NaN`, and `Infinity` are excluded
8
+ * because they cannot survive a JSON round trip through the URL.
9
+ */
10
+ export type FabricAppStateValue = string | number | boolean | null | FabricAppStateValue[] | {
11
+ [key: string]: FabricAppStateValue;
12
+ };
13
+ /**
14
+ * Application state persisted into the shareable Fabric portal URL.
15
+ *
16
+ * **Never place secrets, access tokens, or personal data here.** The
17
+ * value is visible in browser history, screenshots, copied links,
18
+ * corporate proxy logs, and anywhere the link is pasted. Use an opaque
19
+ * identifier that maps to server-side data when the state is sensitive
20
+ * or large.
21
+ */
22
+ export interface FabricAppState {
23
+ [key: string]: FabricAppStateValue;
24
+ }
25
+ /** Options accepted by `createFabricAppStateClient`. */
26
+ export interface FabricAppStateClientOptions {
27
+ /** Host window. Defaults to `window.parent`. */
28
+ target?: Window;
29
+ /**
30
+ * Expected origin of the Fabric host.
31
+ *
32
+ * Strongly recommended. When omitted, messages are posted with `"*"`
33
+ * and inbound events are not origin-checked.
34
+ */
35
+ targetOrigin?: string;
36
+ /** Per-request timeout in milliseconds. Defaults to the bridge default. */
37
+ timeoutMs?: number;
38
+ /** Override for the default encoded-size ceiling. */
39
+ maxEncodedBytes?: number;
40
+ /** Override for the default nesting-depth ceiling. */
41
+ maxDepth?: number;
42
+ /**
43
+ * Query string to read seeded launch state from.
44
+ *
45
+ * Defaults to the live `window.location.search`. Injectable for
46
+ * tests and for hosts that supply the value by another route.
47
+ */
48
+ launchSearch?: string;
49
+ /**
50
+ * Whether to remove the seeded parameter from the app's own URL once
51
+ * it has been read. Defaults to `true`.
52
+ *
53
+ * Leaving it in place would resend the state to the app's own server
54
+ * on every reload and expose it in referrers to subresources.
55
+ */
56
+ scrubLaunchParam?: boolean;
57
+ }
58
+ /**
59
+ * Limits and features reported by the Fabric host.
60
+ *
61
+ * The host is authoritative. The client's own limits exist only to fail
62
+ * fast before a round trip, so treat these values as the real contract.
63
+ */
64
+ export interface FabricAppStateCapabilities {
65
+ /** App-state protocol version implemented by the host. */
66
+ version: number;
67
+ /** Largest encoded state the host will accept, in bytes. */
68
+ maxEncodedBytes: number;
69
+ /** Deepest nesting the host will accept. */
70
+ maxDepth: number;
71
+ /**
72
+ * Whether the host can create browser history entries.
73
+ *
74
+ * When `false`, {@link FabricAppStateClient.setState} still updates the
75
+ * URL — so links stay shareable — but no history entry is created and
76
+ * in-app Back and Forward will not work. Hide affordances that depend
77
+ * on history when this is `false`.
78
+ */
79
+ canPush: boolean;
80
+ }
81
+ /**
82
+ * Listener invoked when the host reports externally-changed state.
83
+ *
84
+ * Receives `undefined` when navigation reaches a URL that carries no
85
+ * state, which means the app should restore its own defaults. Treat the
86
+ * value as untrusted: anyone can edit a link before sharing it.
87
+ */
88
+ export type FabricAppStateListener = (state: FabricAppState | undefined) => void;
89
+ /**
90
+ * Typed client for reading and writing deep-link state.
91
+ */
92
+ export interface FabricAppStateClient {
93
+ /**
94
+ * Read the state the app was launched with.
95
+ *
96
+ * Returns `undefined` when the URL carries no state, which is the
97
+ * normal case for a fresh navigation.
98
+ *
99
+ * Resolves **synchronously** from the seeded iframe URL when the host
100
+ * supports it, so awaiting this does not delay first render. Falls
101
+ * back to a bridge round trip only on hosts that do not seed the URL.
102
+ *
103
+ * Use {@link FabricAppStateClient.getLaunchStateSync} when the state
104
+ * is needed in a code path that cannot be asynchronous at all.
105
+ */
106
+ getLaunchState(): Promise<FabricAppState | undefined>;
107
+ /**
108
+ * Read seeded launch state without awaiting.
109
+ *
110
+ * Returns `undefined` when the host did not seed the URL, in which
111
+ * case {@link FabricAppStateClient.getLaunchState} must be awaited.
112
+ * Intended for the first render path, where launch state must be
113
+ * available before paint.
114
+ */
115
+ getLaunchStateSync(): FabricAppState | undefined;
116
+ /**
117
+ * Resolve the host's capabilities, or `undefined` when the host does
118
+ * not support deep-link state.
119
+ *
120
+ * Deep linking rolls out per tenant, so check this before showing a
121
+ * share button rather than letting the user click one that cannot
122
+ * work. The result is cached.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * const capabilities = await appState.isSupported();
127
+ * if (capabilities?.canPush) enableBackForwardHints();
128
+ * ```
129
+ */
130
+ isSupported(): Promise<FabricAppStateCapabilities | undefined>;
131
+ /**
132
+ * Commit state as a new browser history entry.
133
+ *
134
+ * Choose between this and {@link FabricAppStateClient.replaceState} by
135
+ * **who caused the change**:
136
+ *
137
+ * - The *user* caused it — a click, a filter change, opening a record.
138
+ * Use `setState`, so Back returns them to where they were.
139
+ * - The *app* caused it — restoring, reconciling, or normalising state
140
+ * the user did not ask for. Use `replaceState`, so Back is not
141
+ * cluttered with entries the user never navigated to.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * // The user picked a region: Back should undo it.
146
+ * await appState.setState({ view: 'sales', region: 'AT' });
147
+ * ```
148
+ */
149
+ setState(state: FabricAppState): Promise<void>;
150
+ /**
151
+ * Commit state by replacing the current history entry.
152
+ *
153
+ * Use for app-initiated changes and for high-frequency updates such as
154
+ * a slider drag, where one history entry per update would make Back
155
+ * unusable. See {@link FabricAppStateClient.setState} for the rule.
156
+ *
157
+ * **Platform caveat:** the Fabric host downgrades a replace to a push
158
+ * when the previous history entry belongs to a different extension, to
159
+ * stop one extension overwriting another's history. The first
160
+ * `replaceState()` after the user arrives from elsewhere in Fabric may
161
+ * therefore create an entry. This is platform behaviour and cannot be
162
+ * overridden.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * // Continuous updates while dragging: do not grow history.
167
+ * await appState.replaceState({ view: 'sales', threshold: value });
168
+ * ```
169
+ */
170
+ replaceState(state: FabricAppState): Promise<void>;
171
+ /**
172
+ * Observe state changes the app did not initiate: browser Back or
173
+ * Forward, or a deep link opened in the current tab.
174
+ *
175
+ * Echoes of the app's own writes are suppressed by the host.
176
+ *
177
+ * @returns An unsubscribe function.
178
+ */
179
+ onStateChange(listener: FabricAppStateListener): () => void;
180
+ /** Remove all listeners and release the underlying bridge subscription. */
181
+ dispose(): void;
182
+ }
183
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Public types for the deep-link application state client.
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * State validation: what may be stored, how large, and how deeply
3
+ * nested.
4
+ */
5
+ import type { FabricAppState } from './types.js';
6
+ /**
7
+ * Default ceiling on the *encoded* state, in bytes.
8
+ *
9
+ * Shared links must survive corporate proxies, mail gateways, Teams, and
10
+ * SharePoint, which commonly truncate beyond roughly 8 000 characters.
11
+ * The canonical item path plus existing portal parameters already
12
+ * consume several hundred, so 4 KiB of encoded state leaves headroom.
13
+ */
14
+ export declare const DEFAULT_MAX_ENCODED_BYTES = 4096;
15
+ /** Default ceiling on nesting depth; the root object counts as depth 1. */
16
+ export declare const DEFAULT_MAX_DEPTH = 20;
17
+ /**
18
+ * Validate a state object and return its serialised form.
19
+ *
20
+ * @internal Exported for unit tests.
21
+ */
22
+ export declare function validateAppState(state: FabricAppState, maxEncodedBytes: number, maxDepth: number): string;
23
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1,117 @@
1
+ /**
2
+ * State validation: what may be stored, how large, and how deeply
3
+ * nested.
4
+ */
5
+ import { FabricAppStateError } from './errors.js';
6
+ import { LAUNCH_STATE_PREFIX } from './launchState.js';
7
+ /**
8
+ * Default ceiling on the *encoded* state, in bytes.
9
+ *
10
+ * Shared links must survive corporate proxies, mail gateways, Teams, and
11
+ * SharePoint, which commonly truncate beyond roughly 8 000 characters.
12
+ * The canonical item path plus existing portal parameters already
13
+ * consume several hundred, so 4 KiB of encoded state leaves headroom.
14
+ */
15
+ export const DEFAULT_MAX_ENCODED_BYTES = 4096;
16
+ /** Default ceiling on nesting depth; the root object counts as depth 1. */
17
+ export const DEFAULT_MAX_DEPTH = 20;
18
+ /**
19
+ * base64url expands binary by 4/3, so the raw JSON budget is three
20
+ * quarters of the encoded budget. Validating against the raw size lets
21
+ * the SDK fail fast without duplicating the host's encoder.
22
+ */
23
+ const BASE64_EXPANSION_NUMERATOR = 3;
24
+ const BASE64_EXPANSION_DENOMINATOR = 4;
25
+ /**
26
+ * Reject values that cannot survive a JSON round trip, and enforce the
27
+ * depth limit.
28
+ *
29
+ * `JSON.stringify` is not sufficient on its own: it *silently drops*
30
+ * `undefined` and function-valued properties rather than failing, which
31
+ * would let an app believe it saved state that never reached the URL.
32
+ * The explicit walk also produces an actionable error path.
33
+ *
34
+ * @param seen - Ancestors on the current branch, used for cycle
35
+ * detection. A `Set` of the current path is correct here; a global
36
+ * `WeakSet` would wrongly reject the same object appearing twice as
37
+ * siblings, which serialises fine.
38
+ */
39
+ function assertSerializable(value, path, depth, maxDepth, seen) {
40
+ if (value === null)
41
+ return;
42
+ const type = typeof value;
43
+ if (type === 'string' || type === 'boolean')
44
+ return;
45
+ if (type === 'number') {
46
+ if (!Number.isFinite(value)) {
47
+ throw new FabricAppStateError(`State value at "${path}" is NaN or Infinity, which cannot be represented in JSON.`, 'INVALID_STATE');
48
+ }
49
+ return;
50
+ }
51
+ if (type === 'undefined') {
52
+ throw new FabricAppStateError(`State value at "${path}" is undefined. Omit the property or use null.`, 'INVALID_STATE');
53
+ }
54
+ if (type === 'function' || type === 'symbol' || type === 'bigint') {
55
+ throw new FabricAppStateError(`State value at "${path}" is of unsupported type "${type}".`, 'INVALID_STATE');
56
+ }
57
+ // Objects and arrays from here on.
58
+ if (depth > maxDepth) {
59
+ throw new FabricAppStateError(`State nesting exceeds the maximum depth of ${maxDepth} at "${path}".`, 'STATE_TOO_DEEP');
60
+ }
61
+ const obj = value;
62
+ if (seen.has(obj)) {
63
+ throw new FabricAppStateError(`State contains a circular reference at "${path}".`, 'INVALID_STATE');
64
+ }
65
+ // Dates, Maps, Sets, and class instances all survive structuredClone
66
+ // but lose their identity through JSON, so reject them explicitly
67
+ // rather than silently degrading them to {} or an ISO string.
68
+ if (!Array.isArray(obj) && Object.getPrototypeOf(obj) !== Object.prototype) {
69
+ throw new FabricAppStateError(`State value at "${path}" must be a plain object or array.`, 'INVALID_STATE');
70
+ }
71
+ seen.add(obj);
72
+ if (Array.isArray(obj)) {
73
+ // Indexed rather than `forEach`, which skips holes. `JSON.stringify`
74
+ // turns a hole into `null`, so skipping one would silently change the
75
+ // state the app believes it saved.
76
+ for (let index = 0; index < obj.length; index++) {
77
+ if (!(index in obj)) {
78
+ throw new FabricAppStateError(`State value at "${path}[${index}]" is an empty slot in a sparse array, which JSON turns into null. Use null explicitly.`, 'INVALID_STATE');
79
+ }
80
+ assertSerializable(obj[index], `${path}[${index}]`, depth + 1, maxDepth, seen);
81
+ }
82
+ }
83
+ else {
84
+ for (const [key, item] of Object.entries(obj)) {
85
+ assertSerializable(item, `${path}.${key}`, depth + 1, maxDepth, seen);
86
+ }
87
+ }
88
+ seen.delete(obj);
89
+ }
90
+ /**
91
+ * Validate a state object and return its serialised form.
92
+ *
93
+ * @internal Exported for unit tests.
94
+ */
95
+ export function validateAppState(state, maxEncodedBytes, maxDepth) {
96
+ if (typeof state !== 'object' ||
97
+ state === null ||
98
+ Array.isArray(state) ||
99
+ Object.getPrototypeOf(state) !== Object.prototype) {
100
+ throw new FabricAppStateError('State must be a plain object.', 'INVALID_STATE');
101
+ }
102
+ assertSerializable(state, 'state', 1, maxDepth, new Set());
103
+ const json = JSON.stringify(state);
104
+ // The host measures the prefix as part of its budget, so leaving it out
105
+ // here would pass states the host then rejects on the round trip.
106
+ const rawBudget = Math.floor(((maxEncodedBytes - LAUNCH_STATE_PREFIX.length) *
107
+ BASE64_EXPANSION_NUMERATOR) /
108
+ BASE64_EXPANSION_DENOMINATOR);
109
+ const byteLength = new TextEncoder().encode(json).length;
110
+ if (byteLength > rawBudget) {
111
+ throw new FabricAppStateError(`State is ${byteLength} bytes, which exceeds the ${rawBudget}-byte limit ` +
112
+ `(${maxEncodedBytes} bytes once encoded). Store large state server-side ` +
113
+ `and put an identifier in the URL instead.`, 'STATE_TOO_LARGE');
114
+ }
115
+ return json;
116
+ }
117
+ //# sourceMappingURL=validation.js.map
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@microsoft/rayfin-app-state-fabric",
3
+ "version": "1.35.0-alpha.1412",
4
+ "description": "Deep-link application state for Rayfin apps embedded in the Fabric portal",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist/**/*.js",
9
+ "dist/**/*.d.ts",
10
+ "assets/docs/**/*.md",
11
+ "!dist/**/__tests__/**",
12
+ "LICENSE"
13
+ ],
14
+ "type": "module",
15
+ "dependencies": {
16
+ "@microsoft/fabric-embedded-host": "1.35.0-alpha.1412",
17
+ "@microsoft/rayfin-lib": "1.35.0-alpha.1412"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.8.3",
21
+ "vitest": "^3.2.3",
22
+ "@vitest/coverage-v8": "~3.2.4",
23
+ "rimraf": "~6.0.1"
24
+ },
25
+ "publishConfig": {
26
+ "registry": "https://npm.pkg.github.com",
27
+ "access": "restricted"
28
+ },
29
+ "rayfinDocs": {
30
+ "version": 1,
31
+ "dir": "assets/docs",
32
+ "module": "rayfin-app-state-fabric",
33
+ "kind": "api-reference"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/microsoft/rayfin.git",
38
+ "directory": "packages/typescript-sdk/app-state-fabric"
39
+ },
40
+ "keywords": [],
41
+ "author": "",
42
+ "license": "MIT",
43
+ "scripts": {
44
+ "build": "tsc && node ../scripts/fix-esm-extensions.mjs ./dist",
45
+ "build:watch": "tsc --watch",
46
+ "clean": "rimraf dist && rimraf .tsbuildinfo",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest"
49
+ }
50
+ }