feat(store): initial release (#279)

This commit is contained in:
rahim
2026-01-02 14:41:58 +11:00
committed by GitHub
parent c9a216dd2d
commit d74e4e6701
54 changed files with 6047 additions and 66 deletions
+1
View File
@@ -0,0 +1 @@
export { idle, raf } from './schedulers';
+75
View File
@@ -0,0 +1,75 @@
import type { TaskScheduler } from '../queue';
import { animationFrame, idleCallback } from '@videojs/utils/dom';
/**
* Create a scheduler that delays task execution until the next animation frame.
*
* Uses `requestAnimationFrame` under the hood. Ideal for UI updates that should
* sync with the browser's repaint cycle.
*
* @returns A TaskScheduler for use with the queue
*
* @example
* ```ts
* import { createQueue } from '@videojs/store';
* import { raf } from '@videojs/store/dom';
*
* const queue = createQueue();
*
* queue.enqueue({
* name: 'update-ui',
* key: 'ui',
* schedule: raf(),
* handler: async () => {
* // This runs on the next animation frame
* },
* });
* ```
*/
export function raf(): TaskScheduler {
return flush => animationFrame(flush);
}
/**
* Create a scheduler that delays task execution until the browser is idle.
*
* Uses `requestIdleCallback` under the hood (with `setTimeout` fallback for Safari).
* Ideal for non-critical background work.
*
* @param options - Optional idle callback options (e.g., timeout)
* @returns A TaskScheduler for use with the queue
*
* @example
* ```ts
* import { createQueue } from '@videojs/store';
* import { idle } from '@videojs/store/dom';
*
* const queue = createQueue();
*
* queue.enqueue({
* name: 'analytics',
* key: 'analytics',
* schedule: idle(),
* handler: async () => {
* // This runs when the browser is idle
* },
* });
* ```
*
* @example
* ```ts
* // With timeout to ensure execution within 2 seconds
* queue.enqueue({
* name: 'critical-background',
* key: 'bg',
* schedule: idle({ timeout: 2000 }),
* handler: async () => {
* // Runs when idle, or after 2 seconds
* },
* });
* ```
*/
export function idle(options?: IdleRequestOptions): TaskScheduler {
return flush => idleCallback(flush, options);
}
+17
View File
@@ -0,0 +1,17 @@
interface ErrorOptions {
cause?: unknown;
}
export class StoreError extends Error {
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
this.name = 'StoreError';
this.cause = options?.cause;
}
}
export function isStoreError(error: unknown): error is StoreError {
return error instanceof StoreError;
}
+87
View File
@@ -0,0 +1,87 @@
import { isBoolean } from '@videojs/utils';
import { StoreError } from './errors';
/**
* A guard gates request execution.
*
* - Truthy → proceed
* - Falsy → cancel
* - Promise resolves truthy → proceed
* - Promise resolves falsy → cancel
* - Promise rejects → cancel
*/
export type Guard<Target> = (ctx: {
target: Target;
signal: AbortSignal;
}) => boolean | Promise<unknown>;
/**
* Combine guards: All must pass (truthy).
*/
export function all<Target>(
...guards: Guard<Target>[]
): Guard<Target> {
return async (ctx) => {
for (const guard of guards) {
const result = await guard(ctx);
if (!result) return false;
}
return true;
};
}
/**
* Combine guards: Any must pass (first truthy wins).
*/
export function any<Target>(
...guards: Guard<Target>[]
): Guard<Target> {
return (ctx) => {
const results = guards.map(g => g(ctx));
// Check sync results first
if (results.includes(true)) return true;
// Filter to promises only
const promises = results.filter((r): r is Promise<unknown> => !isBoolean(r));
if (promises.length === 0) return false;
// Race: first truthy wins, all falsy = false
return new Promise((resolve, reject) => {
let pending = promises.length;
for (const p of promises) {
p.then((value) => {
if (value) resolve(value);
else if (--pending === 0) resolve(false);
}, reject);
}
});
};
}
/**
* Add timeout to a guard.
*/
export function timeout<Target>(
guard: Guard<Target>,
ms: number,
name = 'guard',
): Guard<Target> {
return async (ctx) => {
const result = guard(ctx);
if (isBoolean(result)) {
return result;
}
return Promise.race([
result,
new Promise<never>((_, reject) => {
const timer = setTimeout(() => reject(new StoreError(`Timeout: ${name}`)), ms);
ctx.signal.addEventListener('abort', () => clearTimeout(timer));
}),
]);
};
}
+7
View File
@@ -0,0 +1,7 @@
export * from './errors';
export * from './guard';
export * from './queue';
export * from './request';
export * from './slice';
export * from './state';
export * from './store';
+1
View File
@@ -0,0 +1 @@
export type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["./**/*.ts"]
}
+428
View File
@@ -0,0 +1,428 @@
import type { Request, RequestMeta } from './request';
import { isFunction, isUndefined } from '@videojs/utils';
import { StoreError } from './errors';
// ----------------------------------------
// Types
// ----------------------------------------
export type TaskKey<T = string | symbol> = T & (string | symbol);
/**
* A task scheduler controls when a task flushes.
*
* Returns an optional cancel function.
*/
export type TaskScheduler = (flush: () => void) => (() => void) | void;
/**
* Map of task key -> input/output types.
*/
export type TaskRecord = {
[K in TaskKey]: Request<any, any>
};
/**
* Default loose task types.
*/
export type DefaultTaskRecord = Record<TaskKey, Request<unknown, unknown>>;
/**
* Ensure T is a TaskRecord.
*/
export type EnsureTaskRecord<T> = T extends TaskRecord ? T : never;
/**
* Pending task info.
*/
export interface PendingTask<
Key extends TaskKey = TaskKey,
Input = unknown,
> {
id: symbol;
name: string;
key: Key;
input: Input;
startedAt: number;
abort: AbortController;
meta: RequestMeta | null;
}
/**
* Context passed to task handler.
*/
export interface TaskContext<
Input = unknown,
> {
input: Input;
signal: AbortSignal;
}
/**
* Queued task waiting to execute.
*/
interface QueuedTask<
Key extends TaskKey = TaskKey,
Input = unknown,
Output = unknown,
> {
id: symbol;
name: string;
key: Key;
input: Input;
meta: RequestMeta | null;
schedule: TaskScheduler | undefined;
handler: (ctx: TaskContext<Input>) => Promise<Output>;
resolve: (value: Output) => void;
reject: (error: unknown) => void;
/* Cancel scheduled execution. */
invalidate?: () => void;
}
/**
* Queue configuration.
*/
export interface QueueConfig<Tasks extends TaskRecord = DefaultTaskRecord> {
/** Default scheduler when task has no schedule */
scheduler?: TaskScheduler;
onDispatch?: <K extends keyof Tasks>(
task: PendingTask<TaskKey<K>, Tasks[K]['input']>,
) => void;
onSettled?: <K extends keyof Tasks>(
task: PendingTask<TaskKey<K>, Tasks[K]['input']>,
result: { status: 'success'; duration: number; result: Tasks[K]['output'] }
| { status: 'cancelled'; error: unknown; duration: number }
| { status: 'error'; error: unknown; duration: number },
) => void;
}
/**
* Task to enqueue.
*/
export interface QueueTask<
Key extends TaskKey = TaskKey,
Input = unknown,
Output = unknown,
> {
name: string;
key: Key;
input?: Input;
meta?: RequestMeta | null;
schedule?: TaskScheduler | undefined;
handler: (ctx: TaskContext<Input>) => Promise<Output>;
}
/**
* Queued task info (public).
*/
export interface QueuedTaskInfo<Key extends TaskKey = TaskKey> {
name: string;
key: Key;
}
// ----------------------------------------
// Schedulers
// ----------------------------------------
/**
* Default scheduler, delay to next microtask.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide}
*/
export const microtask: TaskScheduler = (flush) => {
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) flush();
});
return () => {
cancelled = true;
};
};
/**
* Delay execution by ms. Resets on each new task.
*
* @param ms - Milliseconds to delay
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout}
*/
export function delay(ms: number): TaskScheduler {
return (flush) => {
const id = setTimeout(flush, ms);
return () => clearTimeout(id);
};
}
// ----------------------------------------
// Implementation
// ----------------------------------------
export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
readonly #scheduler: TaskScheduler;
readonly #onDispatch: QueueConfig<Tasks>['onDispatch'];
readonly #onSettled: QueueConfig<Tasks>['onSettled'];
readonly #queued = new Map<TaskKey, QueuedTask>();
readonly #pending = new Map<TaskKey, PendingTask>();
#destroyed = false;
constructor(config: QueueConfig<Tasks> = {}) {
this.#scheduler = config.scheduler ?? microtask;
// Wrap callbacks to catch errors and prevent breaking queue/scheduler
const safeCallback = <Args extends [PendingTask, ...unknown[]]>(callback: ((...args: Args) => unknown) | undefined) => {
if (!callback) return undefined;
return (...args: Args) => {
try {
callback(...args);
} catch (e) {
console.error('[vjs-queue]', e);
}
};
};
this.#onDispatch = safeCallback(config.onDispatch);
this.#onSettled = safeCallback(config.onSettled);
}
get queued(): ReadonlyMap<TaskKey<keyof Tasks>, QueuedTaskInfo<TaskKey<keyof Tasks>>> {
const result = new Map<TaskKey, QueuedTaskInfo>();
for (const [k, v] of this.#queued) {
result.set(k, { name: v.name, key: v.key });
}
return result;
}
get pending(): ReadonlyMap<TaskKey<keyof Tasks>, PendingTask<TaskKey<keyof Tasks>>> {
return new Map(this.#pending);
}
get destroyed(): boolean {
return this.#destroyed;
}
enqueue<K extends TaskKey<keyof Tasks>>(
task: QueueTask<K, Tasks[K]['input'], Tasks[K]['output']>,
): Promise<Tasks[K]['output']> {
const { name, key, input, schedule, meta = null, handler } = task;
if (this.#destroyed) {
return Promise.reject(new StoreError('Queue destroyed'));
}
// Cancel any queued task with same key
const queued = this.#queued.get(key);
queued?.invalidate?.();
queued?.reject(new StoreError('Superseded'));
this.#queued.delete(key);
// Abort any pending task with same key
this.#pending.get(key)?.abort.abort(new StoreError('Superseded'));
return new Promise<Tasks[K]['output']>((resolve, reject) => {
const task: QueuedTask = {
id: Symbol('@videojs/task'),
name,
key,
input,
meta,
schedule,
handler,
resolve,
reject,
};
this.#queued.set(key, task);
let flushed = false;
try {
const scheduleFlush = schedule ?? this.#scheduler;
// Guard against multiple flushes
const safeFlush = () => {
if (flushed) return;
flushed = true;
this.#flushKey(key);
};
const cancel = scheduleFlush(safeFlush);
// Only set invalidate if we haven't already flushed
if (!flushed && isFunction(cancel)) {
task.invalidate = cancel;
}
} catch (err) {
if (!flushed) {
this.#queued.delete(key);
}
reject(err);
}
});
}
dequeue<K extends TaskKey<keyof Tasks>>(key: K): boolean {
const queued = this.#queued.get(key);
if (!queued) return false;
queued.invalidate?.();
queued.reject(new StoreError('Dequeued'));
this.#queued.delete(key);
return true;
}
clear(): void {
for (const queued of this.#queued.values()) {
queued.invalidate?.();
queued.reject(new StoreError('Cleared'));
}
this.#queued.clear();
}
flush(): Promise<void>;
flush<K extends TaskKey<keyof Tasks>>(key: K): Promise<void>;
async flush(key?: TaskKey): Promise<void> {
if (!isUndefined(key)) {
await this.#flushKey(key);
return;
}
// Flush all
const keys = [...this.#queued.keys()];
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
}
abort<K extends TaskKey<keyof Tasks>>(key: K, reason = 'Aborted'): void {
// Reject queued
const queued = this.#queued.get(key);
queued?.invalidate?.();
queued?.reject(new StoreError(reason));
this.#queued.delete(key);
// Abort pending with reason
this.#pending.get(key)?.abort.abort(new StoreError(reason));
}
abortAll(reason = 'All requests aborted'): void {
const error = new StoreError(reason);
// Reject all queued
for (const queued of this.#queued.values()) {
queued.invalidate?.();
queued.reject(error);
}
this.#queued.clear();
// Abort all pending with reason
for (const pending of this.#pending.values()) {
pending.abort.abort(error);
}
}
destroy(): void {
if (this.#destroyed) return;
this.#destroyed = true;
this.abortAll('Queue destroyed');
}
async #flushKey(key: TaskKey): Promise<void> {
if (this.#destroyed) return;
const task = this.#queued.get(key);
if (!task) return;
this.#queued.delete(key);
await this.#executeNow(task);
}
async #executeNow(task: QueuedTask): Promise<void> {
const { id, name, key, input, meta, handler, resolve, reject } = task;
const abort = new AbortController();
const startedAt = Date.now();
const pending: PendingTask = {
id,
name,
key,
input,
startedAt,
abort,
meta,
};
this.#pending.set(key, pending);
this.#onDispatch?.(pending);
try {
if (abort.signal.aborted) {
throw abort.signal.reason || new StoreError('Aborted');
}
const result = await handler({ input, signal: abort.signal });
if (abort.signal.aborted) {
throw abort.signal.reason || new StoreError('Aborted');
}
resolve(result);
this.#onSettled?.(pending, {
status: 'success',
duration: Date.now() - startedAt,
result,
});
} catch (error) {
reject(error);
const cancelled = abort.signal.aborted;
this.#onSettled?.(pending, {
status: cancelled ? 'cancelled' : 'error',
duration: Date.now() - startedAt,
error,
});
} finally {
// Only remove if we're still the pending task for this key
if (this.#pending.get(key) === pending) {
this.#pending.delete(key);
}
}
}
}
// ----------------------------------------
// Factory
// ----------------------------------------
/**
* Create a queue for managing task execution.
*
* - Same key = supersede previous (cancel queued, abort pending)
* - Tasks scheduled via schedule function (default: microtask)
*
* @example
* // Loose typing (default)
* const queue = createQueue();
*
* @example
* // Strongly typed keys
* const queue = createQueue<{
* 'playback': Request;
* 'volume': Request<number>;
* }>();
*/
export function createQueue<Tasks extends TaskRecord = DefaultTaskRecord>(
config: QueueConfig<Tasks> = {},
): Queue<Tasks> {
return new Queue<Tasks>(config);
}
+230
View File
@@ -0,0 +1,230 @@
import type { EventLike } from '@videojs/utils';
import type { Guard } from './guard';
import type { TaskKey, TaskScheduler } from './queue';
import { isFunction, isObject } from '@videojs/utils';
// ----------------------------------------
// Symbols
// ----------------------------------------
export const REQUEST_META: unique symbol = Symbol.for('@videojs/request');
// ----------------------------------------
// Types
// ----------------------------------------
export interface Request<Input = void, Output = void> {
input: Input;
output: Output;
}
export type RequestRecord = {
[K in string]: Request<any, any>;
};
/**
* Default loose request types.
*/
export type DefaultRequestRecord = Record<string, Request>;
/**
* Context passed to request handlers.
*/
export interface RequestContext<Target> {
target: Target;
signal: AbortSignal;
meta: RequestMeta | null;
}
/**
* Request key - static or derived from input.
*/
export type RequestKey<Input = unknown> = TaskKey | ((input: Input) => TaskKey);
/**
* Request cancel config.
*/
export type RequestCancel<Input = unknown>
= | TaskKey
| TaskKey[]
| ((input: Input) => TaskKey | TaskKey[]);
/**
* Request handler function.
*/
export type RequestHandler<Target, Input = unknown, Output = unknown>
= (input: Input, ctx: RequestContext<Target>) => Output | Promise<Output>;
/**
* Full request config.
*/
export interface RequestConfig<Target, Input = unknown, Output = unknown> {
key?: RequestKey<Input>;
schedule?: TaskScheduler;
guard?: Guard<Target> | Guard<Target>[];
cancel?: RequestCancel<Input>;
handler: RequestHandler<Target, Input, Output>;
}
/**
* Resolved request config (after normalization).
*/
export interface ResolvedRequestConfig<Target, Input = unknown, Output = unknown> {
key: RequestKey<Input>;
schedule?: TaskScheduler | undefined;
guard: Guard<Target>[];
cancel?: RequestCancel<Input> | undefined;
handler: RequestHandler<Target, Input, Output>;
}
export type RequestHandlerRecord = {
[K in string]: RequestHandler<any, any, any>;
};
/**
* Map of request names to handlers or configs. This is the config passed to `createSlice`.
*/
export type RequestConfigMap<Target, Requests extends RequestRecord> = {
[K in keyof Requests]: Requests[K] extends Request<infer I, infer O>
? RequestHandler<Target, I, O> | RequestConfig<Target, I, O>
: never;
};
/**
* Map of request config objects to resolved configs. This is the config stored internally in
* the store.
*/
export type ResolvedRequestConfigMap<Target, Requests extends RequestRecord> = {
[K in keyof Requests]: Requests[K] extends Request<infer I, infer O>
? ResolvedRequestConfig<Target, I, O>
: never;
};
// ----------------------------------------
// Type Inference
// ----------------------------------------
/**
* Infer the input type of a RequestHandler.
*/
export type InferRequestHandlerInput<Handler> = Handler extends (() => any)
? void
: Handler extends (input: infer I, ctx?: any) => any
? I
: Handler extends { handler: () => any }
? void
: Handler extends { handler: (input: infer I, ctx?: any) => any }
? I
: void;
/**
* Infer the output type of a RequestHandler.
*/
export type InferRequestHandlerOutput<Handler> = Handler extends ((...args: any[]) => infer O)
? Awaited<O>
: Handler extends { handler: (...args: any[]) => infer O }
? Awaited<O>
: void;
/**
* Resolve a RequestHandlerRecord to a RequestRecord.
*/
export type ResolveRequestMap<Requests> = {
[K in keyof Requests]: Request<InferRequestHandlerInput<Requests[K]>, InferRequestHandlerOutput<Requests[K]>>;
};
/**
* Resolve a Request (input/output) to its function signature.
*/
export type ResolveRequestHandler<R> = R extends Request<infer I, infer O>
? [I] extends [void]
? (input?: null, meta?: RequestMetaInit) => Promise<O>
: (input: I, meta?: RequestMetaInit) => Promise<O>
: never;
// ----------------------------------------
// Utilities
// ----------------------------------------
export function resolveRequests<Target, Requests extends RequestRecord>(
requests: RequestConfigMap<Target, Requests>,
): ResolvedRequestConfigMap<Target, Requests> {
const resolved: Record<string, ResolvedRequestConfig<Target>> = {};
for (const [name, config] of Object.entries(requests)) {
if (isFunction(config)) {
resolved[name] = {
key: name,
guard: [],
handler: config,
};
} else {
resolved[name] = {
...config,
guard: config.guard ? (Array.isArray(config.guard) ? config.guard : [config.guard]) : [],
};
}
}
return resolved as ResolvedRequestConfigMap<Target, Requests>;
}
export function resolveRequestKey(keyConfig: RequestKey<any>, input: unknown): TaskKey {
return isFunction(keyConfig) ? keyConfig(input) : keyConfig;
}
export function resolveRequestCancelKeys(
cancel: RequestCancel<any> | undefined,
input: unknown,
): TaskKey[] {
if (!cancel) return [];
const result = isFunction(cancel) ? cancel(input) : cancel;
return Array.isArray(result) ? result : [result];
}
// ----------------------------------------
// Request Meta
// ----------------------------------------
export type RequestMetaInit<Context = unknown> = Omit<RequestMeta<Context>, typeof REQUEST_META>;
export interface RequestMeta<Context = unknown> {
[REQUEST_META]: true;
source?: string;
timestamp?: number;
reason?: string;
context?: Context | undefined;
}
export function createRequestMeta<Context = unknown>(
init: RequestMetaInit<Context>,
): RequestMeta<Context> {
return {
[REQUEST_META]: true,
...init,
timestamp: init.timestamp ?? Date.now(),
};
}
/**
* Check if a value is a RequestMeta object.
*/
export function isRequestMeta(value: unknown): value is RequestMeta {
return isObject(value) && REQUEST_META in value;
}
/**
* Convert an event-like object to RequestMeta.
*/
export function createRequestMetaFromEvent<Context = unknown>(
event: EventLike,
context?: Context,
): RequestMeta<Context> {
return {
[REQUEST_META]: true,
source: event.isTrusted ? 'user' : 'system',
timestamp: event.timeStamp,
reason: event.type,
context,
};
}
+142
View File
@@ -0,0 +1,142 @@
import type { Request, RequestConfig, RequestConfigMap, RequestHandler, RequestRecord, ResolvedRequestConfigMap, ResolveRequestHandler, ResolveRequestMap } from './request';
import { resolveRequests } from './request';
// ----------------------------------------
// Types
// ----------------------------------------
export interface Slice<Target, State extends object, Requests extends RequestRecord> {
readonly id: symbol;
readonly initialState: State;
readonly getSnapshot: SliceGetSnapshot<Target, State>;
readonly subscribe: SliceSubscribe<Target, State>;
readonly request: ResolvedRequestConfigMap<Target, Requests>;
}
export type SliceGetSnapshot<Target, State> = (ctx: SliceGetSnapshotContext<Target, State>) => State;
export interface SliceGetSnapshotContext<Target, State> {
target: Target;
initialState: State;
}
export type SliceSubscribe<Target, State extends object> = (ctx: SliceSubscribeContext<Target, State>) => void;
export interface SliceSubscribeContext<Target, State extends object> {
target: Target;
update: SliceUpdate<State>;
signal: AbortSignal;
}
export interface SliceUpdate<State extends object> {
(): void;
(state: Partial<State>): void;
}
export interface SliceConfig<Target, State extends object, Requests extends RequestRecord> {
initialState: State;
getSnapshot: SliceGetSnapshot<Target, State>;
subscribe: SliceSubscribe<Target, State>;
request: RequestConfigMap<Target, Requests>;
}
// ----------------------------------------
// Type Inference
// ----------------------------------------
export type InferSliceTarget<S> = S extends Slice<infer T, any, any> ? T : never;
export type InferSliceState<S> = S extends Slice<any, infer State, any> ? State : never;
export type InferSliceRequests<S> = S extends Slice<any, any, infer R>
? { [K in keyof R]: R[K] }
: never;
export type ResolveSliceRequestHandlers<S> = S extends Slice<any, any, infer R>
? { [K in keyof R]: ResolveRequestHandler<R[K]> }
: never;
// ----------------------------------------
// createSlice
// ----------------------------------------
export interface SliceFactory<Target> {
<
State extends object,
const Requests extends Record<string, RequestHandler<Target, any, any> | RequestConfig<Target, any, any>>,
>(config: {
initialState: State;
getSnapshot: (ctx: SliceGetSnapshotContext<Target, State>) => State;
subscribe: (ctx: SliceSubscribeContext<Target, State>) => void;
request: Requests;
}): Slice<Target, State, ResolveRequestMap<Requests>>;
}
export type SliceFactoryResult<Target, Config> = Config extends {
initialState: infer S extends object;
request: infer R;
}
? Slice<Target, S, ResolveRequestMap<R>>
: never;
/**
* Create a slice for a target type.
*
* @example
* // Curried form - infers state and requests from config
* const audioSlice = createSlice<HTMLVideoElement>()({
* ...
* });
*
* @example
* // Explicit types
* interface AudioState {
* volume: number;
* muted: boolean
* }
*
* interface AudioRequests {
* setVolume: Request<number>;
* setMuted: Request<boolean>;
* }
* const audioSlice = createSlice<HTMLVideoElement, AudioState, AudioRequests>({...});
*/
export function createSlice<Target>(): SliceFactory<Target>;
export function createSlice<
Target,
State extends object,
Requests extends { [K in keyof Requests]: Request<any, any> },
>(config: SliceConfig<Target, State, Requests>): Slice<Target, State, Requests>;
export function createSlice<
Target,
State extends object = any,
Requests extends { [K in keyof Requests]: Request<any, any> } = any,
>(
config?: SliceConfig<Target, State, Requests>,
): Slice<Target, State, Requests> | SliceFactory<Target> {
if (arguments.length === 0) {
return (<
S extends object,
const R extends Record<string, RequestHandler<Target, any, any> | RequestConfig<Target, any, any>>,
>(config: {
initialState: S;
getSnapshot: (ctx: SliceGetSnapshotContext<Target, S>) => S;
subscribe: (ctx: SliceSubscribeContext<Target, S>) => void;
request: R;
}) => _createSlice(config)) as SliceFactory<Target>;
}
return _createSlice(config!);
}
function _createSlice<Target, State extends object, Requests extends RequestRecord>(
config: SliceConfig<Target, State, Requests>,
): Slice<Target, State, Requests> {
return {
id: Symbol('@videojs/slice'),
...config,
request: resolveRequests(config.request),
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Default state container.
*
* Extend or implement the same shape for custom state handling.
*/
export class State<T> {
#state: T;
readonly #listeners = new Set<(state: T) => void>();
readonly #keyListeners = new Map<keyof T, Set<(state: T) => void>>();
constructor(initial: T) {
this.#state = { ...initial };
}
get value(): T {
return this.#state;
}
set<K extends keyof T>(key: K, value: T[K]): void {
if (this.#state[key] === value) return;
this.#state = { ...this.#state, [key]: value };
this.#notify([key]);
}
patch(partial: Partial<T>): void {
const changedKeys: (keyof T)[] = [];
for (const [key, value] of Object.entries(partial)) {
if (this.#state[key as keyof T] !== value) {
changedKeys.push(key as keyof T);
}
}
if (changedKeys.length > 0) {
this.#state = { ...this.#state, ...partial };
this.#notify(changedKeys);
}
}
subscribe(listener: (state: T) => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
subscribeKeys<K extends keyof T>(
keys: K[],
listener: (state: Pick<T, K>) => void,
): () => void {
for (const key of keys) {
let set = this.#keyListeners.get(key);
if (!set) {
set = new Set();
this.#keyListeners.set(key, set);
}
set.add(listener);
}
return () => {
for (const key of keys) {
this.#keyListeners.get(key)?.delete(listener);
}
};
}
#notify(changedKeys: (keyof T)[]): void {
for (const listener of this.#listeners) {
listener(this.#state);
}
const notified = new Set<(state: T, changedKeys: (keyof T)[]) => void>();
for (const key of changedKeys) {
const set = this.#keyListeners.get(key);
if (!set) continue;
for (const listener of set) {
if (!notified.has(listener)) {
notified.add(listener);
listener(this.#state);
}
}
}
}
}
export type StateFactory<T> = (initial: T) => State<T>;
+381
View File
@@ -0,0 +1,381 @@
import type { EnsureTaskRecord, PendingTask, TaskContext, TaskRecord } from './queue';
import type { RequestMeta, RequestMetaInit, ResolvedRequestConfig } from './request';
import type { InferSliceRequests, InferSliceState, InferSliceTarget, ResolveSliceRequestHandlers, Slice } from './slice';
import type { StateFactory } from './state';
import { isNull } from '@videojs/utils';
import { StoreError } from './errors';
import { Queue } from './queue';
import { createRequestMeta, resolveRequestCancelKeys, resolveRequestKey } from './request';
import { State } from './state';
export class Store<
Target,
Slices extends Slice<Target, any, any>[] = Slice<Target, any, any>[],
Tasks extends TaskRecord = InferStoreTasks<Slices>,
> {
readonly #config: StoreConfig<Target, Slices, Tasks>;
readonly #slices: Slices;
readonly #queue: Queue<Tasks>;
readonly #state: State<InferStoreState<Slices>>;
readonly #request: InferStoreRequest<Slices>;
readonly #requestConfigs: Map<string, ResolvedRequestConfig<Target>>;
readonly #setupAbort = new AbortController();
#target: Target | null = null;
#attachAbort: AbortController | null = null;
#destroyed = false;
constructor(config: StoreConfig<Target, Slices, Tasks>) {
this.#config = config;
this.#slices = config.slices;
this.#queue = config.queue ?? new Queue<Tasks>();
// Use provided factory or default
const factory = config.state ?? (initial => new State(initial));
this.#state = factory(this.#createInitialState());
this.#requestConfigs = this.#buildRequestConfigs();
this.#request = this.#buildRequestProxy();
try {
config.onSetup?.({
store: this,
signal: this.#setupAbort.signal,
});
} catch (error) {
this.#handleError({ error });
}
}
// ----------------------------------------
// Public Getters
// ----------------------------------------
get target(): Target | null {
return this.#target;
}
get state(): InferStoreState<Slices> {
return this.#state.value;
}
get request(): InferStoreRequest<Slices> {
return this.#request;
}
get queue(): Queue<Tasks> {
return this.#queue;
}
get slices(): Slices {
return this.#slices;
}
get destroyed(): boolean {
return this.#destroyed;
}
// ----------------------------------------
// Attach / Detach
// ----------------------------------------
attach(newTarget: Target): () => void {
if (this.#destroyed) {
throw new StoreError('Store destroyed');
}
this.#attachAbort?.abort();
this.#target = newTarget;
this.#attachAbort = new AbortController();
const signal = this.#attachAbort.signal;
this.#resetState();
for (const slice of this.#slices) {
try {
const update = this.#createUpdate(slice);
slice.subscribe({ target: newTarget, update, signal });
} catch (error) {
this.#handleError({ error });
}
}
this.#syncAllSlices();
try {
this.#config.onAttach?.({
store: this,
target: newTarget,
signal,
});
} catch (error) {
this.#handleError({ error });
}
return () => this.#detach();
}
#createUpdate<State extends object>(slice: Slice<Target, State, any>) {
return (partial?: Partial<State>) => {
const target = this.#target;
if (!target) return;
try {
if (partial === undefined) {
this.#syncSlice(slice, target);
} else {
this.#state.patch(partial as Partial<InferStoreState<Slices>>);
}
} catch (error) {
this.#handleError({ error });
}
};
}
#detach(): void {
if (isNull(this.#target)) return;
this.#attachAbort?.abort();
this.#attachAbort = null;
this.#target = null;
this.#queue.abortAll('Target detached');
this.#resetState();
}
// ----------------------------------------
// Subscribe
// ----------------------------------------
subscribe(listener: (state: InferStoreState<Slices>) => void): () => void;
subscribe<K extends keyof InferStoreState<Slices>>(
keys: K[],
listener: (state: Pick<InferStoreState<Slices>, K>) => void,
): () => void;
subscribe<K extends keyof InferStoreState<Slices>>(
keysOrListener: ((state: InferStoreState<Slices>) => void) | K[],
maybeListener?: (state: Pick<InferStoreState<Slices>, K>) => void,
): () => void {
if (Array.isArray(keysOrListener)) {
return this.#state.subscribeKeys(keysOrListener, maybeListener!);
}
return this.#state.subscribe(keysOrListener);
}
// ----------------------------------------
// Destroy
// ----------------------------------------
destroy(): void {
if (this.#destroyed) return;
this.#destroyed = true;
this.#detach();
this.#setupAbort.abort();
this.#queue.destroy();
}
// ----------------------------------------
// State
// ----------------------------------------
#syncAllSlices(): void {
const target = this.#target;
if (!target) return;
for (const slice of this.#slices) {
this.#syncSlice(slice, target);
}
}
#syncSlice(slice: Slice<Target, any, any>, target: Target): void {
try {
const snapshot = slice.getSnapshot({
target,
initialState: slice.initialState,
});
this.#state.patch(snapshot);
} catch (error) {
this.#handleError({ error });
}
}
#createInitialState(): InferStoreState<Slices> {
const initialState: Record<string, unknown> = {};
for (const slice of this.#slices) {
Object.assign(initialState, slice.initialState);
}
return initialState as InferStoreState<Slices>;
}
#resetState(): void {
this.#state.patch(this.#createInitialState());
}
// ----------------------------------------
// Requests
// ----------------------------------------
#buildRequestConfigs(): Map<string, ResolvedRequestConfig<Target>> {
const configs = new Map<string, ResolvedRequestConfig<Target>>();
for (const slice of this.#slices) {
for (const [name, config] of Object.entries(slice.request)) {
configs.set(name, config as ResolvedRequestConfig<Target>);
}
}
return configs;
}
#buildRequestProxy(): InferStoreRequest<Slices> {
const proxy: Record<string, (...args: any[]) => Promise<unknown>> = {};
for (const [name, config] of this.#requestConfigs) {
proxy[name] = (input?: unknown, meta?: RequestMetaInit) => {
if (this.#destroyed) {
return Promise.reject(new StoreError('Store destroyed'));
}
return this.#execute(
name,
config,
input,
meta ? createRequestMeta(meta) : null,
);
};
}
return proxy as InferStoreRequest<Slices>;
}
async #execute(
name: string,
config: ResolvedRequestConfig<Target>,
input: unknown,
meta: RequestMeta | null,
): Promise<unknown> {
const key = resolveRequestKey(config.key, input);
for (const cancelKey of resolveRequestCancelKeys(config.cancel, input)) {
this.#queue.abort(cancelKey, `Cancelled by ${name}`);
}
const handler = async ({ input, signal }: TaskContext) => {
const target = this.#target;
if (!target) {
throw new StoreError('No target attached');
}
for (const guard of config.guard) {
if (signal.aborted) {
throw new StoreError('Aborted');
}
const result = await guard({ target, signal });
if (!result) {
throw new StoreError('Rejected');
}
}
return config.handler(input, { target, signal, meta });
};
try {
return await this.#queue.enqueue({
name,
key,
input,
meta,
schedule: config.schedule,
handler,
});
} catch (error) {
this.#handleError({
request: this.#queue.pending.get(key),
error,
});
throw error;
}
}
// ----------------------------------------
// Errors
// ----------------------------------------
#handleError(context: Omit<StoreErrorContext<Target, Slices, Tasks>, 'store'>): void {
if (this.#config.onError) {
this.#config.onError({ ...context, store: this });
} else {
console.error('[vjs-store]', context.error);
}
}
}
// ----------------------------------------
// Factory
// ----------------------------------------
export function createStore<Slices extends Slice<any, any, any>[]>(
config: StoreConfig<InferSliceTarget<Slices[number]>, Slices, InferStoreTasks<Slices>>,
): Store<InferSliceTarget<Slices[number]>, Slices, InferStoreTasks<Slices>> {
return new Store(config);
}
// ----------------------------------------
// Types
// ----------------------------------------
export interface StoreConfig<
Target,
Slices extends Slice<Target, any, any>[],
Tasks extends TaskRecord = InferStoreTasks<Slices>,
> {
slices: Slices;
queue?: Queue<Tasks>;
state?: StateFactory<InferStoreState<Slices>>;
onSetup?: (ctx: StoreSetupContext<Target, Slices, Tasks>) => void;
onAttach?: (ctx: StoreAttachContext<Target, Slices, Tasks>) => void;
onError?: (ctx: StoreErrorContext<Target, Slices, Tasks>) => void;
}
export interface StoreSetupContext<Target, Slices extends Slice<Target, any, any>[], Tasks extends TaskRecord> {
store: Store<Target, Slices, Tasks>;
signal: AbortSignal;
}
export interface StoreAttachContext<Target, Slices extends Slice<Target, any, any>[], Tasks extends TaskRecord> {
store: Store<Target, Slices, Tasks>;
target: Target;
signal: AbortSignal;
}
export interface StoreErrorContext<Target, Slices extends Slice<Target, any, any>[], Tasks extends TaskRecord> {
request?: PendingTask | undefined;
error: unknown;
store: Store<Target, Slices, Tasks>;
}
// ----------------------------------------
// Type Inference
// ----------------------------------------
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void
? I
: never;
export type InferStoreState<Slices extends Slice<any, any, any>[]> = UnionToIntersection<
InferSliceState<Slices[number]>
>;
export type InferStoreRequest<Slices extends Slice<any, any, any>[]>
= UnionToIntersection<ResolveSliceRequestHandlers<Slices[number]>>;
export type InferStoreTasks<Slices extends Slice<any, any, any>[]>
= EnsureTaskRecord<UnionToIntersection<InferSliceRequests<Slices[number]>>>;