@player-ui/external-state-plugin 0.15.4--canary.881.37421

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/src/index.ts ADDED
@@ -0,0 +1,238 @@
1
+ import type {
2
+ Player,
3
+ PlayerPlugin,
4
+ InProgressState,
5
+ PlayerFlowState,
6
+ NavigationFlowState,
7
+ NavigationFlowExternalState,
8
+ ErrorController,
9
+ FlowInstance,
10
+ } from "@player-ui/player";
11
+ import { Registry } from "@player-ui/partial-match-registry";
12
+ import { ExternalStatePluginSymbol } from "./symbols.js";
13
+ import { ExternalStateError } from "./ExternalStateError.js";
14
+
15
+ export { ExternalStateError } from "./ExternalStateError.js";
16
+ export type { ExternalStateErrorMetadata } from "./ExternalStateError.js";
17
+
18
+ export type ExternalStateHandlerMatch = Record<string, unknown>;
19
+
20
+ export type ExternalStateHandlerFunction = (
21
+ state: NavigationFlowExternalState,
22
+ options: InProgressState["controllers"],
23
+ ) => string | undefined | Promise<string | undefined>;
24
+
25
+ export type ExternalStateHandler = {
26
+ /** The name of the external state. This will appear as it's "ref" property in the DSL. */
27
+ ref: string;
28
+ /** Additional properties to match against the external state. */
29
+ match?: ExternalStateHandlerMatch;
30
+ /** The function to run when the external state is transitioned to. This should return the `ref` of the next state to transition to. */
31
+ handlerFunction: ExternalStateHandlerFunction;
32
+ };
33
+
34
+ function isExternal(
35
+ state: NavigationFlowState,
36
+ ): state is NavigationFlowExternalState {
37
+ return state.state_type === "EXTERNAL";
38
+ }
39
+
40
+ function isInProgress(state: PlayerFlowState): state is InProgressState {
41
+ return state.status === "in-progress";
42
+ }
43
+
44
+ /**
45
+ * A plugin to handle external states
46
+ *
47
+ * This plugin uses a registry-based approach to match external states to handler functions.
48
+ * Multiple plugins can be registered, and handlers are matched using partial object matching
49
+ * with specificity ordering (more specific matches take precedence).
50
+ */
51
+ export class ExternalStatePlugin implements PlayerPlugin {
52
+ name = "ExternalStatePlugin";
53
+
54
+ /** Symbol used to identify and find existing instances of this plugin */
55
+ static Symbol: symbol = ExternalStatePluginSymbol;
56
+ public readonly symbol: symbol = ExternalStatePlugin.Symbol;
57
+
58
+ /**
59
+ * The shared registry that maps external states to handlers.
60
+ * All plugin instances use the same registry.
61
+ */
62
+ private registry?: Registry<ExternalStateHandlerFunction>;
63
+
64
+ /**
65
+ * The handlers for this plugin instance.
66
+ */
67
+ private readonly handlers: ExternalStateHandler[];
68
+
69
+ /** The error controller to use for this plugin.
70
+ * Only the first instance of the plugin should tap the error controller hook.
71
+ */
72
+ private errorController?: ErrorController;
73
+
74
+ /** Creates a new ExternalStatePlugin */
75
+ constructor(handlers: ExternalStateHandler[]) {
76
+ this.handlers = handlers;
77
+ }
78
+
79
+ apply(player: Player): void {
80
+ const isFirstInstance = this.createRegistry(player);
81
+ this.registerHandlers(player);
82
+
83
+ // Only the first instance should tap the hooks to avoid redundant taps
84
+ if (!isFirstInstance) {
85
+ return;
86
+ }
87
+
88
+ player.hooks.errorController.tap(this.name, (errorController) => {
89
+ this.errorController = errorController;
90
+ });
91
+
92
+ player.hooks.flowController.tap(this.name, (flowController) => {
93
+ flowController.hooks.flow.tap(this.name, (flow) => {
94
+ flow.hooks.afterTransition.tap(this.name, (flowInstance) => {
95
+ this.handleAfterTransition(player, flowInstance);
96
+ });
97
+ });
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Resolve an EXTERNAL state transition.
103
+ */
104
+ private async handleAfterTransition(
105
+ player: Player,
106
+ flowInstance: FlowInstance,
107
+ ): Promise<void> {
108
+ const toState = flowInstance.currentState;
109
+ const currentState = player.getState();
110
+
111
+ if (
112
+ !toState ||
113
+ !toState.value ||
114
+ !isExternal(toState.value) ||
115
+ !isInProgress(currentState)
116
+ ) {
117
+ return;
118
+ }
119
+
120
+ try {
121
+ const handler = this.registry?.get(toState.value);
122
+
123
+ if (!handler) {
124
+ this.reportError(
125
+ player,
126
+ ExternalStateError.missingHandler(toState.value.ref),
127
+ );
128
+ return;
129
+ }
130
+
131
+ const transitionValue = await handler(
132
+ toState.value,
133
+ currentState.controllers,
134
+ );
135
+
136
+ if (!transitionValue) {
137
+ this.reportError(
138
+ player,
139
+ ExternalStateError.missingTransitionValue(toState.value.ref),
140
+ );
141
+ return;
142
+ }
143
+
144
+ const latestState = player.getState();
145
+
146
+ // Ensure the Player is still in the same state after waiting for transitionValue
147
+ if (
148
+ isInProgress(latestState) &&
149
+ latestState.controllers.flow.current?.currentState?.name ===
150
+ toState.name
151
+ ) {
152
+ latestState.controllers.flow.transition(transitionValue);
153
+ } else {
154
+ player.logger.warn(
155
+ `External state resolved with [${transitionValue}], but Player already navigated away from [${toState.name}]`,
156
+ );
157
+ }
158
+ } catch (error) {
159
+ // Thrown errors are treated as purposefully unrecoverable: fail the flow rather than
160
+ // routing through captureError.
161
+ if (error instanceof Error) {
162
+ currentState.fail(error);
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Report an ExternalStateError via the errorController.
169
+ */
170
+ private reportError(player: Player, error: ExternalStateError): void {
171
+ // The compiler believes errorController could be nil, but in practice it should always
172
+ // be set by the time this method runs. The logger fallback exists only as defense
173
+ // against an unexpected lifecycle regression — if it ever fires, that's a bug to
174
+ // investigate, not normal operation.
175
+ if (!this.errorController) {
176
+ player.logger.error(
177
+ `${error.message} (errorController was unexpectedly undefined; it should always be set by the time this code runs)`,
178
+ );
179
+ return;
180
+ }
181
+
182
+ this.errorController.captureError(error);
183
+ }
184
+
185
+ /**
186
+ * Create or share the registry for this plugin instance.
187
+ *
188
+ * Uses the Player's plugin registry to find if another instance of ExternalStatePlugin
189
+ * has already been registered. If found, this instance will share that plugin's registry.
190
+ * Otherwise, this instance creates a new registry.
191
+ */
192
+ private createRegistry(player: Player): boolean {
193
+ // Find the first instance of this plugin registered to the Player
194
+ const existing = player.findPlugin<ExternalStatePlugin>(
195
+ ExternalStatePluginSymbol,
196
+ );
197
+
198
+ // If we found a plugin and it's not ourselves, we are not the first plugin instance
199
+ if (existing && existing !== this) {
200
+ // Use the first plugin's registry
201
+ this.registry = existing.registry;
202
+ return false;
203
+ }
204
+
205
+ // We are the first plugin instance, create the registry
206
+ this.registry = new Registry<ExternalStateHandlerFunction>(
207
+ undefined,
208
+ player.logger,
209
+ );
210
+ return true;
211
+ }
212
+
213
+ /**
214
+ * Register this plugin's handlers to the shared registry.
215
+ *
216
+ * If a handler with the same specificity already exists, it will be replaced
217
+ * and a debug log will be emitted (accessible via player.logger.debug).
218
+ */
219
+ private registerHandlers(player: Player): void {
220
+ for (const handler of this.handlers) {
221
+ // Runtime check for 'ref' property is necessary despite TypeScript constraint because
222
+ // the Swift bridge allows improperly formatted objects to bypass TypeScript validation.
223
+ // We log this here and not in the constructor because the Logger is not yet available in the constructor.
224
+ if (handler.match?.ref) {
225
+ player.logger.warn(
226
+ `An ExternalStateHandler contains a superfluous 'match.ref' property. 'match.ref' will be ignored. 'ref' will be used instead. Handler: ${JSON.stringify({ ref: handler.ref, match: handler.match })}`,
227
+ );
228
+ delete handler.match?.["ref"];
229
+ continue;
230
+ }
231
+ // Registry will handle keeping only the last handlerFunction for each match
232
+ this.registry?.set(
233
+ { ref: handler.ref, ...handler.match },
234
+ handler.handlerFunction,
235
+ );
236
+ }
237
+ }
238
+ }
package/src/symbols.ts ADDED
@@ -0,0 +1,4 @@
1
+ // We prefix with "@player-ui" to avoid conflicts with symbols from other packages
2
+ export const ExternalStatePluginSymbol: symbol = Symbol.for(
3
+ "@player-ui/ExternalStatePlugin",
4
+ );
@@ -0,0 +1,20 @@
1
+ import type { ErrorMetadata, PlayerErrorMetadata } from "@player-ui/player";
2
+ import { ErrorSeverity } from "@player-ui/player";
3
+ export type ExternalStateErrorReason = "missing-handler" | "missing-transition-value";
4
+ export interface ExternalStateErrorMetadata extends ErrorMetadata {
5
+ /** The `ref` of the EXTERNAL state that produced the error */
6
+ ref: string;
7
+ /** Which failure mode this error represents */
8
+ reason: ExternalStateErrorReason;
9
+ }
10
+ export declare class ExternalStateError extends Error implements PlayerErrorMetadata<ExternalStateErrorMetadata> {
11
+ readonly type: string;
12
+ readonly severity: ErrorSeverity;
13
+ readonly metadata: ExternalStateErrorMetadata;
14
+ private constructor();
15
+ /** No handler was registered for the EXTERNAL state's ref. */
16
+ static missingHandler(ref: string): ExternalStateError;
17
+ /** A handler ran but returned no transition value. */
18
+ static missingTransitionValue(ref: string): ExternalStateError;
19
+ }
20
+ //# sourceMappingURL=ExternalStateError.d.ts.map
@@ -0,0 +1,66 @@
1
+ import type { Player, PlayerPlugin, InProgressState, NavigationFlowExternalState } from "@player-ui/player";
2
+ export { ExternalStateError } from "./ExternalStateError.js";
3
+ export type { ExternalStateErrorMetadata } from "./ExternalStateError.js";
4
+ export type ExternalStateHandlerMatch = Record<string, unknown>;
5
+ export type ExternalStateHandlerFunction = (state: NavigationFlowExternalState, options: InProgressState["controllers"]) => string | undefined | Promise<string | undefined>;
6
+ export type ExternalStateHandler = {
7
+ /** The name of the external state. This will appear as it's "ref" property in the DSL. */
8
+ ref: string;
9
+ /** Additional properties to match against the external state. */
10
+ match?: ExternalStateHandlerMatch;
11
+ /** The function to run when the external state is transitioned to. This should return the `ref` of the next state to transition to. */
12
+ handlerFunction: ExternalStateHandlerFunction;
13
+ };
14
+ /**
15
+ * A plugin to handle external states
16
+ *
17
+ * This plugin uses a registry-based approach to match external states to handler functions.
18
+ * Multiple plugins can be registered, and handlers are matched using partial object matching
19
+ * with specificity ordering (more specific matches take precedence).
20
+ */
21
+ export declare class ExternalStatePlugin implements PlayerPlugin {
22
+ name: string;
23
+ /** Symbol used to identify and find existing instances of this plugin */
24
+ static Symbol: symbol;
25
+ readonly symbol: symbol;
26
+ /**
27
+ * The shared registry that maps external states to handlers.
28
+ * All plugin instances use the same registry.
29
+ */
30
+ private registry?;
31
+ /**
32
+ * The handlers for this plugin instance.
33
+ */
34
+ private readonly handlers;
35
+ /** The error controller to use for this plugin.
36
+ * Only the first instance of the plugin should tap the error controller hook.
37
+ */
38
+ private errorController?;
39
+ /** Creates a new ExternalStatePlugin */
40
+ constructor(handlers: ExternalStateHandler[]);
41
+ apply(player: Player): void;
42
+ /**
43
+ * Resolve an EXTERNAL state transition.
44
+ */
45
+ private handleAfterTransition;
46
+ /**
47
+ * Report an ExternalStateError via the errorController.
48
+ */
49
+ private reportError;
50
+ /**
51
+ * Create or share the registry for this plugin instance.
52
+ *
53
+ * Uses the Player's plugin registry to find if another instance of ExternalStatePlugin
54
+ * has already been registered. If found, this instance will share that plugin's registry.
55
+ * Otherwise, this instance creates a new registry.
56
+ */
57
+ private createRegistry;
58
+ /**
59
+ * Register this plugin's handlers to the shared registry.
60
+ *
61
+ * If a handler with the same specificity already exists, it will be replaced
62
+ * and a debug log will be emitted (accessible via player.logger.debug).
63
+ */
64
+ private registerHandlers;
65
+ }
66
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export declare const ExternalStatePluginSymbol: symbol;
2
+ //# sourceMappingURL=symbols.d.ts.map