@player-ui/context-plugin 0.16.0--canary.891.38194

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/plugin.ts ADDED
@@ -0,0 +1,261 @@
1
+ import type { Player, PlayerPlugin, Flow } from "@player-ui/player";
2
+ import { SyncHook, SyncWaterfallHook } from "tapable-ts";
3
+ import { ContextStore } from "./store";
4
+ import { ContextHistory } from "./history";
5
+ import { ContextPluginSymbol } from "./symbols";
6
+ import { defineContextKey, nameOfContextKey } from "./key";
7
+ import type {
8
+ ContextEntryDescriptor,
9
+ ContextGlobalSubscriber,
10
+ ContextKey,
11
+ ContextSubscriber,
12
+ ContextTransform,
13
+ FrozenContextSnapshot,
14
+ SubscriptionToken,
15
+ } from "./types";
16
+
17
+ type TransformRegistration = {
18
+ key: ContextKey;
19
+ transform: ContextTransform<unknown>;
20
+ };
21
+
22
+ let subscriptionCounter = 0;
23
+
24
+ /**
25
+ * Maintains a per-flow store of context entries keyed by symbol, with
26
+ * registered transforms for derived values and a subscription API for
27
+ * external consumers. On flow end the active store is frozen into a snapshot
28
+ * and pushed onto a history stack; a fresh store is created for the next flow,
29
+ * with transforms re-applied. Subscribers persist across flow rotations.
30
+ */
31
+ export class ContextPlugin implements PlayerPlugin {
32
+ name = "context";
33
+
34
+ static Symbol: symbol = ContextPluginSymbol;
35
+ public readonly symbol: symbol = ContextPlugin.Symbol;
36
+
37
+ public readonly hooks = {
38
+ onSet: new SyncHook<[ContextKey, unknown]>(),
39
+ resolveValue: new SyncWaterfallHook<[unknown, ContextKey]>(),
40
+ onRegister: new SyncHook<[ContextKey]>(),
41
+ onFlowFrozen: new SyncHook<[FrozenContextSnapshot]>(),
42
+ };
43
+
44
+ protected store: ContextStore;
45
+ protected historyStack: ContextHistory;
46
+ private transforms = new Map<symbol, TransformRegistration>();
47
+ private perKeySubs = new Map<
48
+ symbol,
49
+ Map<SubscriptionToken, ContextSubscriber<unknown>>
50
+ >();
51
+ private globalSubs = new Map<SubscriptionToken, ContextGlobalSubscriber>();
52
+ private tokenIndex = new Map<SubscriptionToken, symbol | undefined>();
53
+ private currentFlowId: string | undefined;
54
+
55
+ constructor() {
56
+ this.store = new ContextStore();
57
+ this.historyStack = new ContextHistory();
58
+ }
59
+
60
+ apply(player: Player) {
61
+ const existing = player.findPlugin<ContextPlugin>(ContextPluginSymbol);
62
+ if (existing !== undefined && existing !== this) {
63
+ this.store = existing.store;
64
+ this.historyStack = existing.historyStack;
65
+ this.transforms = existing.transforms;
66
+ this.perKeySubs = existing.perKeySubs;
67
+ this.globalSubs = existing.globalSubs;
68
+ this.tokenIndex = existing.tokenIndex;
69
+ return;
70
+ }
71
+
72
+ player.hooks.onStart.tap(this.name, (flow: Flow) => {
73
+ this.currentFlowId = flow?.id;
74
+ });
75
+
76
+ player.hooks.onEnd.tap(this.name, () => {
77
+ const snapshot = this.store.freeze({
78
+ flowId: this.currentFlowId,
79
+ endedAt: Date.now(),
80
+ });
81
+ this.historyStack.push(snapshot);
82
+ this.hooks.onFlowFrozen.call(snapshot);
83
+ this.rotateStore();
84
+ this.currentFlowId = undefined;
85
+ });
86
+ }
87
+
88
+ register<Value>(key: ContextKey<Value>): void {
89
+ const added = this.store.register(key);
90
+ if (added) {
91
+ this.hooks.onRegister.call(key);
92
+ }
93
+ }
94
+
95
+ set<Value>(key: ContextKey<Value>, value: Value): void {
96
+ const isFirstSighting = !this.store.has(key);
97
+ this.store.set(key, value);
98
+ if (isFirstSighting) {
99
+ this.hooks.onRegister.call(key);
100
+ }
101
+ this.notify(key, value);
102
+
103
+ const dependents = this.store.dependentsOf(key.symbol);
104
+ for (const dep of dependents) {
105
+ const computed = this.store.get(dep);
106
+ this.notify(dep, computed);
107
+ }
108
+ }
109
+
110
+ get<Value>(key: ContextKey<Value>): Value | undefined {
111
+ const raw = this.store.get(key);
112
+ const resolved = this.hooks.resolveValue.call(raw, key);
113
+ return resolved as Value | undefined;
114
+ }
115
+
116
+ has(key: ContextKey): boolean {
117
+ return this.store.has(key);
118
+ }
119
+
120
+ registerTransform<Value>(
121
+ key: ContextKey<Value>,
122
+ transform: ContextTransform<Value>,
123
+ ): void {
124
+ const isFirstSighting = !this.store.has(key);
125
+ this.store.registerTransform(key, transform);
126
+ this.transforms.set(key.symbol, {
127
+ key,
128
+ transform: {
129
+ sources: transform.sources,
130
+ compute: transform.compute as ContextTransform<unknown>["compute"],
131
+ },
132
+ });
133
+ if (isFirstSighting) {
134
+ this.hooks.onRegister.call(key);
135
+ }
136
+ }
137
+
138
+ subscribe<Value>(
139
+ key: ContextKey<Value>,
140
+ handler: ContextSubscriber<Value>,
141
+ ): SubscriptionToken {
142
+ const token = this.nextToken();
143
+ let bucket = this.perKeySubs.get(key.symbol);
144
+ if (!bucket) {
145
+ bucket = new Map();
146
+ this.perKeySubs.set(key.symbol, bucket);
147
+ }
148
+ bucket.set(token, handler as ContextSubscriber<unknown>);
149
+ this.tokenIndex.set(token, key.symbol);
150
+ return token;
151
+ }
152
+
153
+ subscribeAll(handler: ContextGlobalSubscriber): SubscriptionToken {
154
+ const token = this.nextToken();
155
+ this.globalSubs.set(token, handler);
156
+ this.tokenIndex.set(token, undefined);
157
+ return token;
158
+ }
159
+
160
+ unsubscribe(token: SubscriptionToken): void {
161
+ const owner = this.tokenIndex.get(token);
162
+ if (owner === undefined) {
163
+ this.globalSubs.delete(token);
164
+ } else {
165
+ this.perKeySubs.get(owner)?.delete(token);
166
+ }
167
+ this.tokenIndex.delete(token);
168
+ }
169
+
170
+ list(): ReadonlyArray<ContextEntryDescriptor> {
171
+ return this.store.list();
172
+ }
173
+
174
+ history(): ReadonlyArray<FrozenContextSnapshot> {
175
+ return this.historyStack.entries();
176
+ }
177
+
178
+ snapshot(): FrozenContextSnapshot {
179
+ return this.store.freeze({
180
+ flowId: this.currentFlowId,
181
+ endedAt: Date.now(),
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Bridge-friendly: set a value by string name. Used by native wrappers that
187
+ * cannot construct a `ContextKey` object directly.
188
+ */
189
+ setByName(name: string, description: string, value: unknown): void {
190
+ this.set(this.ensureNamedKey(name, description), value);
191
+ }
192
+
193
+ /** Bridge-friendly: get a value by string name. */
194
+ getByName(name: string): unknown {
195
+ return this.get(this.ensureNamedKey(name));
196
+ }
197
+
198
+ /** Bridge-friendly: check presence by string name. */
199
+ hasByName(name: string): boolean {
200
+ return this.has(this.ensureNamedKey(name));
201
+ }
202
+
203
+ /** Bridge-friendly: subscribe by string name. */
204
+ subscribeByName(
205
+ name: string,
206
+ description: string,
207
+ handler: (value: unknown, name: string) => void,
208
+ ): SubscriptionToken {
209
+ return this.subscribe(this.ensureNamedKey(name, description), (value) =>
210
+ handler(value, name),
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Bridge-friendly: subscribe to all updates. The handler receives the
216
+ * key's resolved name (or undefined for non-namespaced keys).
217
+ */
218
+ subscribeAllByName(
219
+ handler: (
220
+ value: unknown,
221
+ name: string | undefined,
222
+ description: string,
223
+ ) => void,
224
+ ): SubscriptionToken {
225
+ return this.subscribeAll((value, key) =>
226
+ handler(value, nameOfContextKey(key), key.description),
227
+ );
228
+ }
229
+
230
+ private ensureNamedKey(
231
+ name: string,
232
+ description?: string,
233
+ ): ContextKey<unknown> {
234
+ return defineContextKey<unknown>(name, description ?? name);
235
+ }
236
+
237
+ private notify<Value>(key: ContextKey<Value>, value: Value | undefined) {
238
+ this.hooks.onSet.call(key, value);
239
+ const bucket = this.perKeySubs.get(key.symbol);
240
+ if (bucket) {
241
+ for (const handler of bucket.values()) {
242
+ (handler as ContextSubscriber<Value>)(value, key);
243
+ }
244
+ }
245
+ for (const handler of this.globalSubs.values()) {
246
+ handler(value, key);
247
+ }
248
+ }
249
+
250
+ private rotateStore() {
251
+ this.store = new ContextStore();
252
+ for (const { key, transform } of this.transforms.values()) {
253
+ this.store.registerTransform(key, transform);
254
+ }
255
+ }
256
+
257
+ private nextToken(): SubscriptionToken {
258
+ subscriptionCounter += 1;
259
+ return `ctx_${subscriptionCounter}`;
260
+ }
261
+ }
@@ -0,0 +1,286 @@
1
+ import type { Player, PlayerPlugin } from "@player-ui/player";
2
+ import { defineContextKey } from "./key";
3
+ import { getContextPlugin } from "./utils";
4
+ import type { ContextKey } from "./types";
5
+
6
+ /** Set a single binding in the Player data model. */
7
+ export type SetDataAction = (binding: string, value: unknown) => void;
8
+
9
+ /** Transition the running flow using the given transition value. */
10
+ export type TransitionAction = (transition: string) => void;
11
+
12
+ /** A single validation, projected to its serializable fields. */
13
+ export type ContextValidation = {
14
+ severity: "error" | "warning";
15
+ message: string;
16
+ displayTarget?: "page" | "section" | "field";
17
+ blocking?: boolean | "once";
18
+ };
19
+
20
+ /** Validation state for the running view, keyed by binding. */
21
+ export type ValidationContext = {
22
+ /** Whether the view has no blocking validations (derived, no side effects). */
23
+ canTransition: boolean;
24
+ /** Active validations per binding string. */
25
+ byBinding: Record<string, ReadonlyArray<ContextValidation>>;
26
+ };
27
+
28
+ /** Identifier of the currently-running flow. */
29
+ export const flowIdContextKey: ContextKey<string> = defineContextKey<string>(
30
+ "player.flow.id",
31
+ "Identifier of the running flow",
32
+ );
33
+
34
+ /** Name of the current FSM state within the running flow. */
35
+ export const flowStateContextKey: ContextKey<string> = defineContextKey<string>(
36
+ "player.flow.state",
37
+ "Name of the current FSM state in the running flow",
38
+ );
39
+
40
+ /** Identifier of the view currently resolved by the ViewController. */
41
+ export const viewIdContextKey: ContextKey<string> = defineContextKey<string>(
42
+ "player.view.id",
43
+ "Identifier of the currently-resolved view",
44
+ );
45
+
46
+ /** Full resolved view object for the current FSM state. */
47
+ export const viewContextKey: ContextKey<unknown> = defineContextKey<unknown>(
48
+ "player.view",
49
+ "Full resolved view object for the current FSM state",
50
+ );
51
+
52
+ /** Full data model tree for the running flow. */
53
+ export const dataContextKey: ContextKey<unknown> = defineContextKey<unknown>(
54
+ "player.data",
55
+ "Full data model tree for the running flow",
56
+ );
57
+
58
+ /** Player flow status: not-started, in-progress, completed, or error. */
59
+ export const playerStatusContextKey: ContextKey<string> =
60
+ defineContextKey<string>(
61
+ "player.status",
62
+ "Player flow status: not-started, in-progress, completed, or error",
63
+ );
64
+
65
+ /** Validation state for the running view, keyed by binding. */
66
+ export const validationContextKey: ContextKey<ValidationContext> =
67
+ defineContextKey<ValidationContext>(
68
+ "player.validation",
69
+ "Validation state for the running view, keyed by binding",
70
+ );
71
+
72
+ /**
73
+ * Action entry whose value is a callable that sets a binding in the data
74
+ * model. Read via `ctx.get(setDataActionKey)`; absent until a flow is running.
75
+ */
76
+ export const setDataActionKey: ContextKey<SetDataAction> =
77
+ defineContextKey<SetDataAction>(
78
+ "player.data.set",
79
+ "Set a value in the Player data model at the given binding",
80
+ );
81
+
82
+ /**
83
+ * Action entry whose value is a callable that transitions the running flow.
84
+ * Read via `ctx.get(transitionActionKey)`; absent until a flow is running.
85
+ */
86
+ export const transitionActionKey: ContextKey<TransitionAction> =
87
+ defineContextKey<TransitionAction>(
88
+ "player.flow.transition",
89
+ "Transition the current flow using the given transition value",
90
+ );
91
+
92
+ /**
93
+ * Aggregated snapshot composed from every other StateContextPlugin key.
94
+ *
95
+ * Actions are scoped to the construct they operate on — `transition` lives
96
+ * under `flow`, `set` under `data` — rather than in a flat actions bag. Each
97
+ * is bound to the live controller and is absent until a flow is in-progress.
98
+ */
99
+ export type PlayerStateContext = {
100
+ status?: string;
101
+ flow: {
102
+ id?: string;
103
+ state?: string;
104
+ /** Transition the running flow (e.g. 'Next'). */
105
+ transition?: TransitionAction;
106
+ };
107
+ view: {
108
+ id?: string;
109
+ resolved?: unknown;
110
+ };
111
+ data: {
112
+ /** Full data model tree for the running flow. */
113
+ model?: unknown;
114
+ /** Set a value in the data model at the given binding. */
115
+ set?: SetDataAction;
116
+ };
117
+ /** Validation state for the running view, keyed by binding. */
118
+ validation: ValidationContext;
119
+ };
120
+
121
+ /**
122
+ * Single roll-up key that aggregates every other [[StateContextPlugin]] entry
123
+ * into one object. Backed by a transform — reading recomputes from the latest
124
+ * source values, and subscribers fire whenever any source updates.
125
+ */
126
+ export const playerStateContextKey: ContextKey<PlayerStateContext> =
127
+ defineContextKey<PlayerStateContext>(
128
+ "player.state",
129
+ "Aggregated snapshot of every Player runtime context entry",
130
+ );
131
+
132
+ /**
133
+ * A consumer plugin that mirrors Player runtime state into the ContextPlugin
134
+ * store. Registers a small, opinionated set of context entries (flow id,
135
+ * current FSM state, view id, full view, data model, status) so external
136
+ * automation/devtools can observe the running Player without tapping every
137
+ * controller hook themselves.
138
+ *
139
+ * Auto-registers a [[ContextPlugin]] if one isn't already on the player.
140
+ */
141
+ export class StateContextPlugin implements PlayerPlugin {
142
+ name = "state-context";
143
+
144
+ apply(player: Player) {
145
+ const ctx = getContextPlugin(player);
146
+
147
+ // The validation controller for the running flow, captured so data/view
148
+ // updates can re-publish a passive (side-effect-free) validation snapshot.
149
+ type ValidationControllerLike = Parameters<
150
+ Parameters<typeof player.hooks.validationController.tap>[1]
151
+ >[0];
152
+ let validationController: ValidationControllerLike | undefined;
153
+
154
+ const publishValidation = () => {
155
+ if (!validationController) return;
156
+ const byBinding: Record<string, ReadonlyArray<ContextValidation>> = {};
157
+ let canTransition = true;
158
+ validationController.getBindings().forEach((binding) => {
159
+ const all = (validationController!
160
+ .getValidationForBinding(binding)
161
+ ?.getAll() ?? []) as ReadonlyArray<ContextValidation>;
162
+ if (all.length === 0) return;
163
+ byBinding[binding.asString()] = all.map((v) => ({
164
+ severity: v.severity,
165
+ message: v.message,
166
+ displayTarget: v.displayTarget,
167
+ blocking: v.blocking,
168
+ }));
169
+ if (all.some((v) => v.blocking)) {
170
+ canTransition = false;
171
+ }
172
+ });
173
+ ctx.set(validationContextKey, { canTransition, byBinding });
174
+ };
175
+
176
+ ctx.register(flowIdContextKey);
177
+ ctx.register(flowStateContextKey);
178
+ ctx.register(viewIdContextKey);
179
+ ctx.register(viewContextKey);
180
+ ctx.register(dataContextKey);
181
+ ctx.register(playerStatusContextKey);
182
+ ctx.register(validationContextKey);
183
+ ctx.register(setDataActionKey);
184
+ ctx.register(transitionActionKey);
185
+
186
+ ctx.registerTransform(playerStateContextKey, {
187
+ sources: [
188
+ flowIdContextKey,
189
+ flowStateContextKey,
190
+ viewIdContextKey,
191
+ viewContextKey,
192
+ dataContextKey,
193
+ playerStatusContextKey,
194
+ validationContextKey,
195
+ setDataActionKey,
196
+ transitionActionKey,
197
+ ],
198
+ compute: (read) => ({
199
+ status: read(playerStatusContextKey),
200
+ flow: {
201
+ id: read(flowIdContextKey),
202
+ state: read(flowStateContextKey),
203
+ transition: read(transitionActionKey),
204
+ },
205
+ view: {
206
+ id: read(viewIdContextKey),
207
+ resolved: read(viewContextKey),
208
+ },
209
+ data: {
210
+ model: read(dataContextKey),
211
+ set: read(setDataActionKey),
212
+ },
213
+ validation: read(validationContextKey) ?? {
214
+ canTransition: true,
215
+ byBinding: {},
216
+ },
217
+ }),
218
+ });
219
+
220
+ player.hooks.onStart.tap(this.name, (flow) => {
221
+ if (flow?.id) {
222
+ ctx.set(flowIdContextKey, flow.id);
223
+ }
224
+ });
225
+
226
+ player.hooks.state.tap(this.name, (state) => {
227
+ ctx.set(playerStatusContextKey, state.status);
228
+ });
229
+
230
+ player.hooks.flowController.tap(this.name, (flowController) => {
231
+ // Bind to the concrete controller instance for the running flow.
232
+ const transition: TransitionAction = (value) =>
233
+ flowController.transition(value);
234
+ ctx.set(transitionActionKey, transition);
235
+
236
+ flowController.hooks.flow.tap(this.name, (flowInstance) => {
237
+ const recordState = () => {
238
+ const name = flowInstance.currentState?.name;
239
+ if (name) {
240
+ ctx.set(flowStateContextKey, name);
241
+ }
242
+ };
243
+ recordState();
244
+ flowInstance.hooks.afterTransition.tap(this.name, recordState);
245
+ });
246
+ });
247
+
248
+ player.hooks.validationController.tap(this.name, (vc) => {
249
+ validationController = vc;
250
+ publishValidation();
251
+ });
252
+
253
+ player.hooks.viewController.tap(this.name, (viewController) => {
254
+ viewController.hooks.view.tap(this.name, (view) => {
255
+ const id = view.initialView?.id;
256
+ if (id) {
257
+ ctx.set(viewIdContextKey, id);
258
+ }
259
+ view.hooks.onUpdate.tap(this.name, (resolved) => {
260
+ ctx.set(viewContextKey, resolved);
261
+ if (resolved?.id) {
262
+ ctx.set(viewIdContextKey, resolved.id);
263
+ }
264
+ // Validation resets per view; re-publish for the resolved view.
265
+ publishValidation();
266
+ });
267
+ });
268
+ });
269
+
270
+ player.hooks.dataController.tap(this.name, (dataController) => {
271
+ // Bind the set-data action to this concrete controller instance.
272
+ const setData: SetDataAction = (binding, value) => {
273
+ dataController.set([[binding, value]]);
274
+ };
275
+ ctx.set(setDataActionKey, setData);
276
+
277
+ const publish = () => {
278
+ ctx.set(dataContextKey, dataController.serialize());
279
+ // Validation re-evaluates on data change.
280
+ publishValidation();
281
+ };
282
+ dataController.hooks.onUpdate.tap(this.name, publish);
283
+ publish();
284
+ });
285
+ }
286
+ }