@player-ui/player 0.0.1-next.1

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/player.ts ADDED
@@ -0,0 +1,497 @@
1
+ import { SyncHook, SyncWaterfallHook } from 'tapable';
2
+ import type { FlowInstance } from '@player-ui/flow';
3
+ import { FlowController } from '@player-ui/flow';
4
+ import type { Logger } from '@player-ui/logger';
5
+ import { TapableLogger } from '@player-ui/logger';
6
+ import type { ExpressionHandler } from '@player-ui/expressions';
7
+ import { ExpressionEvaluator } from '@player-ui/expressions';
8
+ import { SchemaController } from '@player-ui/schema';
9
+ import { BindingParser } from '@player-ui/binding';
10
+ import type { ViewInstance } from '@player-ui/view';
11
+ import { setIn } from 'timm';
12
+ import deferred from 'p-defer';
13
+ import type { Flow as FlowType, FlowResult } from '@player-ui/types';
14
+ import { resolveDataRefs } from '@player-ui/string-resolver';
15
+ import { ConstantsController } from '@player-ui/constants';
16
+ import queueMicrotask from 'queue-microtask';
17
+ import { ViewController } from './view';
18
+ import { DataController } from './data';
19
+ import { ValidationController } from './validation';
20
+ import { FlowExpPlugin } from './plugins/flow-exp-plugin';
21
+ import type {
22
+ PlayerFlowState,
23
+ InProgressState,
24
+ CompletedState,
25
+ ErrorState,
26
+ } from './types';
27
+ import { NOT_STARTED_STATE } from './types';
28
+
29
+ // Variables injected at build time
30
+ const PLAYER_VERSION = '!!STABLE_VERSION!!';
31
+ const COMMIT = '!!STABLE_GIT_COMMIT!!';
32
+
33
+ export interface PlayerPlugin {
34
+ /**
35
+ * Unique identifier of the plugin.
36
+ * Enables the plugin to be retrievable from Player.
37
+ */
38
+ symbol?: symbol;
39
+
40
+ /** The name of the plugin */
41
+ name: string;
42
+
43
+ /**
44
+ * Use this to tap into Player hooks
45
+ */
46
+ apply: (player: Player) => void;
47
+ }
48
+
49
+ export interface PlayerConfigOptions {
50
+ /** A set of plugins to load */
51
+ plugins?: PlayerPlugin[];
52
+
53
+ /** A logger to use */
54
+ logger?: Logger;
55
+ }
56
+
57
+ export interface PlayerInfo {
58
+ /** Version of the running player */
59
+ version: string;
60
+
61
+ /** Hash of the HEAD commit used to build the current version */
62
+ commit: string;
63
+ }
64
+
65
+ /**
66
+ * This is it.
67
+ */
68
+ export class Player {
69
+ public static readonly info: PlayerInfo = {
70
+ version: PLAYER_VERSION,
71
+ commit: COMMIT,
72
+ };
73
+
74
+ public readonly logger = new TapableLogger();
75
+ public readonly constantsController = new ConstantsController();
76
+ private config: PlayerConfigOptions;
77
+ private state: PlayerFlowState = NOT_STARTED_STATE;
78
+
79
+ public readonly hooks = {
80
+ /** The hook that fires every time we create a new flowController (a new Content blob is passed in) */
81
+ flowController: new SyncHook<FlowController>(['flowController']),
82
+
83
+ /** The hook that updates/handles views */
84
+ viewController: new SyncHook<ViewController>(['viewController']),
85
+
86
+ /** A hook called every-time there's a new view. This is equivalent to the view hook on the view-controller */
87
+ view: new SyncHook<ViewInstance>(['view']),
88
+
89
+ /** Called when an expression evaluator was created */
90
+ expressionEvaluator: new SyncHook<ExpressionEvaluator>([
91
+ 'expressionEvaluator',
92
+ ]),
93
+
94
+ /** The hook that creates and manages data */
95
+ dataController: new SyncHook<DataController>(['dataController']),
96
+
97
+ /** Called after the schema is created for a flow */
98
+ schema: new SyncHook<SchemaController>(['schema']),
99
+
100
+ /** Manages validations (schema and x-field ) */
101
+ validationController: new SyncHook<ValidationController>([
102
+ 'validationController',
103
+ ]),
104
+
105
+ /** Manages parsing binding */
106
+ bindingParser: new SyncHook<BindingParser>(['bindingParser']),
107
+
108
+ /** A that's called for state changes in the flow execution */
109
+ state: new SyncHook<PlayerFlowState>(['state']),
110
+
111
+ /** A hook to access the current flow */
112
+ onStart: new SyncHook<FlowType>(['flow']),
113
+
114
+ /** A hook for when the flow ends either in success or failure */
115
+ onEnd: new SyncHook(),
116
+ /** Mutate the Content flow before starting */
117
+ resolveFlowContent: new SyncWaterfallHook<FlowType>(['content']),
118
+ };
119
+
120
+ constructor(config?: PlayerConfigOptions) {
121
+ const initialPlugins: PlayerPlugin[] = [];
122
+ const flowExpPlugin = new FlowExpPlugin();
123
+
124
+ initialPlugins.push(flowExpPlugin);
125
+
126
+ if (config?.logger) {
127
+ this.logger.addHandler(config.logger);
128
+ }
129
+
130
+ this.config = config || {};
131
+ this.config.plugins = [...(this.config.plugins || []), ...initialPlugins];
132
+ this.config.plugins?.forEach((plugin) => {
133
+ plugin.apply(this);
134
+ });
135
+ }
136
+
137
+ /** Find instance of [Plugin] that has been registered to Player */
138
+ public findPlugin<Plugin extends PlayerPlugin>(
139
+ symbol: symbol
140
+ ): Plugin | undefined {
141
+ return this.config.plugins?.find((el) => el.symbol === symbol) as Plugin;
142
+ }
143
+
144
+ /** Retrieve an instance of [Plugin] and conditionally invoke [apply] if it exists */
145
+ public applyTo<Plugin extends PlayerPlugin>(
146
+ symbol: symbol,
147
+ apply: (plugin: Plugin) => void
148
+ ): void {
149
+ const plugin = this.findPlugin<Plugin>(symbol);
150
+
151
+ if (plugin) {
152
+ apply(plugin);
153
+ }
154
+ }
155
+
156
+ /** Register and apply [Plugin] if one with the same symbol is not already registered. */
157
+ public registerPlugin(plugin: PlayerPlugin) {
158
+ plugin.apply(this);
159
+ this.config.plugins?.push(plugin);
160
+ }
161
+
162
+ /** Returns the current version of the running player */
163
+ public getVersion(): string {
164
+ return Player.info.version;
165
+ }
166
+
167
+ /** Returns the git commit used to build Player version */
168
+ public getCommit(): string {
169
+ return Player.info.commit;
170
+ }
171
+
172
+ /**
173
+ * Fetch the current state of Player.
174
+ * It will return either `not-started`, `in-progress`, `completed`
175
+ * with some extra data in each
176
+ */
177
+ public getState(): PlayerFlowState {
178
+ return this.state;
179
+ }
180
+
181
+ /**
182
+ * A private means of setting the state of Player
183
+ * Calls the hooks for subscribers to listen for this event
184
+ */
185
+ private setState(state: PlayerFlowState) {
186
+ this.state = state;
187
+ this.hooks.state.call(state);
188
+ }
189
+
190
+ /** Start Player with the given flow */
191
+ private setupFlow(userContent: FlowType): {
192
+ /** a callback to _actually_ start the flow */
193
+ start: () => void;
194
+
195
+ /** the state object to kick if off */
196
+ state: Omit<InProgressState, 'ref'>;
197
+ } {
198
+ const userFlow = this.hooks.resolveFlowContent.call(userContent);
199
+
200
+ const flowController = new FlowController(userFlow.navigation, {
201
+ logger: this.logger,
202
+ });
203
+
204
+ this.hooks.onStart.call(userFlow);
205
+
206
+ this.hooks.flowController.call(flowController);
207
+
208
+ // eslint-disable-next-line prefer-const
209
+ let expressionEvaluator: ExpressionEvaluator;
210
+ // eslint-disable-next-line prefer-const
211
+ let dataController: DataController;
212
+
213
+ const pathResolver = new BindingParser({
214
+ get: (binding) => {
215
+ return dataController.get(binding);
216
+ },
217
+ set: (transaction) => {
218
+ return dataController.set(transaction);
219
+ },
220
+ evaluate: (expression) => {
221
+ return expressionEvaluator.evaluate(expression);
222
+ },
223
+ });
224
+
225
+ this.hooks.bindingParser.call(pathResolver);
226
+ const parseBinding = pathResolver.parse;
227
+ const flowResultDeferred = deferred<FlowResult>();
228
+
229
+ const schema = new SchemaController(userFlow.schema);
230
+ this.hooks.schema.call(schema);
231
+
232
+ const validationController = new ValidationController(schema);
233
+
234
+ this.hooks.validationController.call(validationController);
235
+
236
+ dataController = new DataController(userFlow.data, {
237
+ pathResolver,
238
+ middleware: validationController.getDataMiddleware(),
239
+ logger: this.logger,
240
+ });
241
+
242
+ dataController.hooks.format.tap('player', (value, binding) => {
243
+ const formatter = schema.getFormatter(binding);
244
+
245
+ return formatter ? formatter.format(value) : value;
246
+ });
247
+
248
+ dataController.hooks.deformat.tap('player', (value, binding) => {
249
+ const formatter = schema.getFormatter(binding);
250
+
251
+ return formatter ? formatter.deformat(value) : value;
252
+ });
253
+
254
+ dataController.hooks.resolveDefaultValue.tap(
255
+ 'player',
256
+ (binding) => schema.getApparentType(binding)?.default
257
+ );
258
+
259
+ // eslint-disable-next-line prefer-const
260
+ let viewController: ViewController;
261
+
262
+ expressionEvaluator = new ExpressionEvaluator({
263
+ model: dataController,
264
+ logger: this.logger,
265
+ });
266
+
267
+ this.hooks.expressionEvaluator.call(expressionEvaluator);
268
+
269
+ expressionEvaluator.hooks.onError.tap('player', (e) => {
270
+ flowResultDeferred.reject(e);
271
+
272
+ return true;
273
+ });
274
+
275
+ /** Resolve any data references in a string */
276
+ function resolveStrings<T>(val: T) {
277
+ return resolveDataRefs(val, {
278
+ model: dataController,
279
+ evaluate: expressionEvaluator.evaluate,
280
+ });
281
+ }
282
+
283
+ flowController.hooks.flow.tap('player', (flow: FlowInstance) => {
284
+ flow.hooks.beforeTransition.tap('player', (state, transitionVal) => {
285
+ if (!('transitions' in state) || !state.transitions[transitionVal]) {
286
+ return state;
287
+ }
288
+
289
+ return setIn(
290
+ state,
291
+ ['transitions', transitionVal],
292
+ resolveStrings(state.transitions[transitionVal])
293
+ ) as any;
294
+ });
295
+
296
+ flow.hooks.skipTransition.tap('validation', (currentState) => {
297
+ if (currentState?.value.state_type === 'VIEW') {
298
+ const { canTransition, validations } =
299
+ validationController.validateView('navigation');
300
+
301
+ if (!canTransition && validations) {
302
+ const bindings = new Set(validations.keys());
303
+ viewController?.currentView?.update(bindings);
304
+
305
+ return true;
306
+ }
307
+ }
308
+
309
+ return undefined;
310
+ });
311
+
312
+ flow.hooks.resolveTransitionNode.tap('player', (state) => {
313
+ let newState = state;
314
+
315
+ if ('ref' in state) {
316
+ newState = setIn(state, ['ref'], resolveStrings(state.ref)) as any;
317
+ }
318
+
319
+ if ('param' in state) {
320
+ newState = setIn(
321
+ state,
322
+ ['param'],
323
+ resolveStrings(state.param)
324
+ ) as any;
325
+ }
326
+
327
+ return newState;
328
+ });
329
+
330
+ flow.hooks.transition.tap('player', (_oldState, newState) => {
331
+ if (newState.value.state_type === 'ACTION') {
332
+ const { exp } = newState.value;
333
+
334
+ // The nested transition call would trigger another round of the flow transition hooks to be called.
335
+ // This created a weird timing where this nested transition would happen before the view had a chance to respond to the first one
336
+ // Use a queueMicrotask to make sure the expression transition is outside the scope of the flow hook
337
+ queueMicrotask(() => {
338
+ flowController?.transition(
339
+ String(expressionEvaluator?.evaluate(exp))
340
+ );
341
+ });
342
+ }
343
+
344
+ expressionEvaluator.reset();
345
+ });
346
+ });
347
+
348
+ this.hooks.dataController.call(dataController);
349
+
350
+ validationController.setOptions({
351
+ parseBinding,
352
+ model: dataController,
353
+ logger: this.logger,
354
+ evaluate: expressionEvaluator.evaluate,
355
+ constants: this.constantsController,
356
+ });
357
+
358
+ viewController = new ViewController(userFlow.views || [], {
359
+ evaluator: expressionEvaluator,
360
+ parseBinding,
361
+ transition: flowController.transition,
362
+ model: dataController,
363
+ logger: this.logger,
364
+ flowController,
365
+ schema,
366
+ format: (binding, value) => {
367
+ const formatter = schema.getFormatter(binding);
368
+
369
+ return formatter?.format ? formatter.format(value) : value;
370
+ },
371
+ formatValue: (ref, value) => {
372
+ const formatter = schema.getFormatterForType(ref);
373
+
374
+ return formatter?.format ? formatter.format(value) : value;
375
+ },
376
+ validation: {
377
+ ...validationController.forView(parseBinding),
378
+ type: (b) => schema.getType(parseBinding(b)),
379
+ },
380
+ });
381
+ viewController.hooks.view.tap('player', (view) => {
382
+ validationController.onView(view);
383
+ this.hooks.view.call(view);
384
+ });
385
+ this.hooks.viewController.call(viewController);
386
+
387
+ /** Gets formatter for given formatName and formats value if found, returns value otherwise */
388
+ const formatFunction: ExpressionHandler<[unknown, string], any> = (
389
+ ctx,
390
+ value,
391
+ formatName
392
+ ) => {
393
+ return (
394
+ schema.getFormatterForType({ type: formatName })?.format(value) ?? value
395
+ );
396
+ };
397
+
398
+ expressionEvaluator.addExpressionFunction('format', formatFunction);
399
+
400
+ return {
401
+ start: () => {
402
+ flowController
403
+ .start()
404
+ .then((endState) => {
405
+ const flowResult: FlowResult = {
406
+ endState: resolveStrings(endState),
407
+ data: dataController.serialize(),
408
+ };
409
+
410
+ return flowResult;
411
+ })
412
+ .then(flowResultDeferred.resolve)
413
+ .catch((e) => {
414
+ this.logger.error(`Something went wrong: ${e.message}`);
415
+ throw e;
416
+ })
417
+ .catch(flowResultDeferred.reject)
418
+ .finally(() => this.hooks.onEnd.call());
419
+ },
420
+ state: {
421
+ status: 'in-progress',
422
+ flowResult: flowResultDeferred.promise,
423
+ controllers: {
424
+ data: dataController,
425
+ view: viewController,
426
+ flow: flowController,
427
+ schema,
428
+ expression: expressionEvaluator,
429
+ binding: pathResolver,
430
+ validation: validationController,
431
+ },
432
+ fail: flowResultDeferred.reject,
433
+ flow: userFlow,
434
+ logger: this.logger,
435
+ },
436
+ };
437
+ }
438
+
439
+ public async start(payload: FlowType): Promise<CompletedState> {
440
+ const ref = Symbol(payload?.id ?? 'payload');
441
+
442
+ /** A check to avoid updating the state for a flow that's not the current one */
443
+ const maybeUpdateState = <T extends PlayerFlowState>(newState: T) => {
444
+ if (this.state.ref !== ref) {
445
+ this.logger.warn(
446
+ `Received update for a flow that's not the current one`
447
+ );
448
+
449
+ return newState;
450
+ }
451
+
452
+ this.setState(newState);
453
+
454
+ return newState;
455
+ };
456
+
457
+ this.setState({
458
+ status: 'not-started',
459
+ ref,
460
+ });
461
+
462
+ try {
463
+ const { state, start } = this.setupFlow(payload);
464
+ this.setState({
465
+ ref,
466
+ ...state,
467
+ });
468
+
469
+ start();
470
+
471
+ // common data for the end state
472
+ // make sure to use the same ref as the starting one
473
+ const endProps = {
474
+ ref,
475
+ status: 'completed',
476
+ flow: state.flow,
477
+ dataModel: state.controllers.data.getModel(),
478
+ } as const;
479
+
480
+ return maybeUpdateState({
481
+ ...(await state.flowResult),
482
+ ...endProps,
483
+ });
484
+ } catch (error: any) {
485
+ const errorState: ErrorState = {
486
+ status: 'error',
487
+ ref,
488
+ flow: payload,
489
+ error,
490
+ };
491
+
492
+ maybeUpdateState(errorState);
493
+
494
+ throw error;
495
+ }
496
+ }
497
+ }
@@ -0,0 +1,65 @@
1
+ import type { Expression, ExpressionObject } from '@player-ui/types';
2
+ import type { ExpressionEvaluator } from '@player-ui/expressions';
3
+ import type { FlowInstance } from '@player-ui/flow';
4
+ import type { Player, PlayerPlugin } from '../player';
5
+ import type { InProgressState } from '../types';
6
+
7
+ /**
8
+ * A plugin that taps into the flow controller to evaluate available expressions
9
+ * Expressions can be exposed via lifecycle "hooks" in flow/state nodes
10
+ * e.g: onStart, onEnd
11
+ */
12
+ export class FlowExpPlugin implements PlayerPlugin {
13
+ name = 'flow-exp-plugin';
14
+
15
+ apply(player: Player) {
16
+ let expressionEvaluator: ExpressionEvaluator | undefined;
17
+
18
+ /**
19
+ * Eval Helper
20
+ *
21
+ * @param exp - an expression to be evaluated
22
+ */
23
+ const handleEval = (exp: Expression | ExpressionObject) => {
24
+ if (exp) {
25
+ if (typeof exp === 'object' && 'exp' in exp) {
26
+ expressionEvaluator?.evaluate(exp.exp);
27
+ } else {
28
+ expressionEvaluator?.evaluate(exp);
29
+ }
30
+ }
31
+ };
32
+
33
+ player.hooks.expressionEvaluator.tap(this.name, (evaluator) => {
34
+ expressionEvaluator = evaluator;
35
+ });
36
+
37
+ player.hooks.flowController.tap(this.name, (fc) => {
38
+ fc.hooks.flow.tap(this.name, (flow: FlowInstance) => {
39
+ // Eval flow nodes
40
+ flow.hooks.onStart.tap(this.name, (exp) => handleEval(exp));
41
+
42
+ flow.hooks.onEnd.tap(this.name, (exp) => handleEval(exp));
43
+ // Eval state nodes
44
+ flow.hooks.resolveTransitionNode.intercept({
45
+ call: (nextState) => {
46
+ /** Get the current state of Player */
47
+ const currentState = () => player.getState() as InProgressState;
48
+
49
+ /** Get the current flow state */
50
+ const currentFlowState =
51
+ currentState().controllers.flow.current?.currentState;
52
+
53
+ if (currentFlowState?.value.onEnd) {
54
+ handleEval(currentFlowState.value.onEnd);
55
+ }
56
+
57
+ if (nextState?.onStart) {
58
+ handleEval(nextState.onStart);
59
+ }
60
+ },
61
+ });
62
+ });
63
+ });
64
+ }
65
+ }
package/src/types.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { Flow, FlowResult } from '@player-ui/types';
2
+ import { Expression } from '@player-ui/types';
3
+ import type { DataModelWithParser } from '@player-ui/data';
4
+ import { DataModelOptions } from '@player-ui/data';
5
+ import type { FlowController } from '@player-ui/flow';
6
+ import { NamedState, TransitionFunction } from '@player-ui/flow';
7
+ import { ViewInstance } from '@player-ui/view';
8
+ import type { BindingParser, BindingLike } from '@player-ui/binding';
9
+ import type { SchemaController } from '@player-ui/schema';
10
+ import type { ExpressionEvaluator } from '@player-ui/expressions';
11
+ import type { Logger } from '@player-ui/logger';
12
+ import type { ViewController } from './view';
13
+ import type { DataController } from './data';
14
+ import type { ValidationController } from './validation';
15
+
16
+ /** The status for a flow's execution state */
17
+ export type PlayerFlowStatus =
18
+ | 'not-started'
19
+ | 'in-progress'
20
+ | 'completed'
21
+ | 'error';
22
+
23
+ /** Common interface for the state of Player's flow execution */
24
+ export interface BaseFlowState<T extends PlayerFlowStatus> {
25
+ /** A unique reference for the life-cycle of a flow */
26
+ ref: symbol;
27
+
28
+ /** The status of the given flow */
29
+ status: T;
30
+ }
31
+
32
+ /** The beginning state of Player, before it's seen a flow */
33
+ export type NotStartedState = BaseFlowState<'not-started'>;
34
+
35
+ export const NOT_STARTED_STATE: NotStartedState = {
36
+ ref: Symbol('not-started'),
37
+ status: 'not-started',
38
+ };
39
+
40
+ /** Shared properties for a flow in any state of execution (in-progress, completed successfully, or errored out) */
41
+ export interface PlayerFlowExecutionData {
42
+ /** The currently executing flow */
43
+ flow: Flow;
44
+ }
45
+
46
+ export interface ControllerState {
47
+ /** The manager for data for a flow */
48
+ data: DataController;
49
+
50
+ /** The view manager for a flow */
51
+ view: ViewController;
52
+
53
+ /** The schema manager for a flow */
54
+ schema: SchemaController;
55
+
56
+ /** The validation manager for a flow */
57
+ validation: ValidationController;
58
+
59
+ /** The expression evaluator for a flow */
60
+ expression: ExpressionEvaluator;
61
+
62
+ /** The manager for parsing and resolving bindings */
63
+ binding: BindingParser;
64
+
65
+ /** the manager for the flow state machine */
66
+ flow: FlowController;
67
+ }
68
+
69
+ /** A flow is currently executing */
70
+ export type InProgressState = BaseFlowState<'in-progress'> &
71
+ PlayerFlowExecutionData & {
72
+ /** A promise that resolves when the flow is completed */
73
+ flowResult: Promise<FlowResult>;
74
+
75
+ /** The underlying state controllers for the current flow */
76
+ controllers: ControllerState;
77
+
78
+ /** Allow other platforms to abort the current flow with an error */
79
+ fail: (error: Error) => void;
80
+
81
+ /**
82
+ * The Logger for the current player instance
83
+ */
84
+ logger: Logger;
85
+ };
86
+
87
+ /** The flow completed properly */
88
+ export type CompletedState = BaseFlowState<'completed'> &
89
+ PlayerFlowExecutionData &
90
+ FlowResult & {
91
+ /** The top-level data-model for the flow */
92
+ dataModel: DataModelWithParser;
93
+ };
94
+
95
+ /** The flow finished but not successfully */
96
+ export type ErrorState = BaseFlowState<'error'> & {
97
+ /** The currently executing flow */
98
+ flow: Flow;
99
+
100
+ /** The error associated with the failed flow */
101
+ error: Error;
102
+ };
103
+
104
+ /** Any Player state */
105
+ export type PlayerFlowState =
106
+ | NotStartedState
107
+ | InProgressState
108
+ | CompletedState
109
+ | ErrorState;
110
+
111
+ // Model
112
+
113
+ export type RawSetType = [BindingLike, any];
114
+ export type RawSetTransaction = Record<string, any> | RawSetType[];
@@ -0,0 +1,2 @@
1
+ // Declaration for libraries that don't have types
2
+ declare module 'babel-plugin-preval/macro';