mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(store): initial release (#279)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { idle, raf } from './schedulers';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}),
|
||||
]);
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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]>>>;
|
||||
Reference in New Issue
Block a user