Skip to content

Instantly share code, notes, and snippets.

@bigmistqke
Created February 28, 2026 23:24
Show Gist options
  • Select an option

  • Save bigmistqke/6ad3482087047f102bb749c072234aa9 to your computer and use it in GitHub Desktop.

Select an option

Save bigmistqke/6ad3482087047f102bb749c072234aa9 to your computer and use it in GitHub Desktop.
import { createSignal, type Accessor } from 'solid-js'
/** Check if a function is a generator function */
function isGeneratorFunction<T extends (...args: any[]) => Generator>(
fn: Function,
): fn is T {
return fn.constructor.name === 'GeneratorFunction'
}
function isObject(value: unknown): value is {} {
return value !== null && typeof value === 'object'
}
function assertedNotNullish<T>(value: T, error?: string): NonNullable<T> {
if (assertNotNullish(value)) {
return value
} else {
console.error(value)
throw new Error(error)
}
}
/** Symbol to identify hold marker */
export const $HOLD = Symbol('hold')
export const $CLEANUP = Symbol('cleanup')
/** Hold marker type */
interface HoldMarker<T> {
[$HOLD]: true
getValue: () => T
}
export interface ActionContext {
signal: AbortSignal
/** Register a cleanup function to be called when action is cancelled/cleared/replaced */
onCleanup: (fn: () => void) => void
}
/** Generator that yields promises and returns R (or HoldMarker<R>) */
export type ActionGenerator<R> = Generator<Promise<unknown>, R | HoldMarker<R>, unknown>
/** Async function fetcher */
export type AsyncFetcher<T, R> = (args: T, context: ActionContext) => Promise<R | HoldMarker<R>>
/** Generator function fetcher - yields promises, runner awaits with abort checks */
export type GeneratorFetcher<T, R> = (args: T, context: ActionContext) => ActionGenerator<R>
/** Either async or generator fetcher */
export type ActionFetcher<T, R> = AsyncFetcher<T, R> | GeneratorFetcher<T, R>
export type ActionFn<T, R> = [T] extends [undefined] ? () => Promise<R> : (args: T) => Promise<R>
export type TryFn<T, R> = [T] extends [undefined]
? () => Promise<R | undefined>
: (args: T) => Promise<R | undefined>
export type Action<T, R> = ActionFn<T, R> & {
/** Whether the action is currently running */
pending: Accessor<boolean>
/** The result of the last successful invocation */
latest: Accessor<R | undefined>
/** Any error from the last invocation */
error: Accessor<unknown>
/** Cancel the current invocation */
cancel: () => void
/** Clear the result and error */
clear: () => void
/** Call the action without throwing - returns undefined on error */
try: TryFn<T, R>
/** Await the result - waits for completion if pending, returns current result otherwise */
promise: () => Promise<R | undefined>
}
export interface PromiseWithCleanup<T> extends Promise<T> {
[$CLEANUP]: (value: T) => void
}
/**********************************************************************************/
/* */
/* Internal Utils */
/* */
/**********************************************************************************/
/** Check if a value is a hold marker */
function isHoldMarker<T>(value: unknown): value is HoldMarker<T> {
return isObject(value) && $HOLD in value
}
function isPromiseWithCleanup<T>(value: unknown): value is PromiseWithCleanup<T> {
return isObject(value) && $CLEANUP in value
}
function waitForAbort(signal: AbortSignal) {
return new Promise<void>(resolve => {
signal.addEventListener('abort', () => resolve(), { once: true })
})
}
/** Run a generator with abort checks between yields */
async function runGenerator<R>(
gen: ActionGenerator<R>,
context: ActionContext,
): Promise<R | HoldMarker<R>> {
let result = gen.next()
while (!result.done) {
// Check abort before awaiting
if (context.signal.aborted) {
throw new CancelledError()
}
const promise = result.value
if (isPromiseWithCleanup(promise)) {
const cleanup = promise[$CLEANUP]
context.onCleanup(async () => cleanup(await promise))
}
// Normal promise - await it
const value = await result.value
// Check abort after awaiting
if (context.signal.aborted) {
throw new CancelledError()
}
result = gen.next(value)
}
return result.value
}
/**********************************************************************************/
/* */
/* Utils */
/* */
/**********************************************************************************/
export class CancelledError extends Error {
constructor() {
super('Action was cancelled')
this.name = 'CancelledError'
}
}
/**
* Wrap a promise for use with yield* in generator actions.
* Provides proper typing for the resolved value.
*
* @example
* const data = yield* defer(fetch('/api').then(r => r.json()))
* const stream = yield* defer(getUserMedia())
*/
export function* defer<T>(
promise: Promise<T>,
onCleanup?: (value: T) => void,
): Generator<Promise<T> | PromiseWithCleanup<T>, T, T> {
if (onCleanup) {
;(promise as PromiseWithCleanup<T>)[$CLEANUP] = onCleanup
}
return (yield promise) as T
}
/**
* Hold until the action is cancelled, then return the value.
* Use this for "run until cancelled" actions that should resolve (not throw) on cancel.
* Always the last statement - no code runs after cancellation.
*
* @example
* // With return value
* return hold(() => ({ trackIndex, duration }))
*
* // Void action (no return value)
* return hold()
*/
export function hold<T = void>(getValue?: () => T): HoldMarker<T> {
return { [$HOLD]: true, getValue: getValue ?? (() => undefined as T) }
}
/**********************************************************************************/
/* */
/* Utils */
/* */
/**********************************************************************************/
/**
* Creates an async action that can be called directly and awaited.
*
* Supports two styles:
* 1. Async function: `action(async (args, ctx) => { ... })`
* 2. Generator function: `action(function* (args, ctx) { yield* defer(promise); ... })`
*
* Generator style automatically checks for abort between yields.
*
* @example
* ```ts
* // Basic usage with defer() for typed async operations
* const fetchUser = action(function* () {
* const response = yield* defer(fetch('/api/user'))
* return yield* defer(response.json())
* })
*
* // With cleanup using onCleanup
* const record = action(function* (trackIndex: number, { onCleanup }) {
* const stream = yield* defer(navigator.mediaDevices.getUserMedia({ video: true }))
* onCleanup(() => stream.getTracks().forEach(t => t.stop()))
*
* // defer() not needed if you don't need type inference or cleanup
* yield startRecording(stream)
* })
*
* // With hold() - runs until cancelled, then returns value
* const record = action(function* (trackIndex: number, { onCleanup }) {
* const stream = yield* defer(navigator.mediaDevices.getUserMedia({ video: true }))
* onCleanup(() => stream.getTracks().forEach(t => t.stop()))
*
* const startTime = performance.now()
* // Hold until action.cancel() is called
* return hold(() => ({ trackIndex, duration: performance.now() - startTime }))
* })
*
* // Usage
* record(0) // Start recording
* record.pending() // true while running
* record.cancel() // Stop and trigger cleanup
* const result = await record.promise() // Get return value from hold()
* ```
*/
export function action<T = undefined, R = void>(fetcher: ActionFetcher<T, R>): Action<T, R> {
const [pending, setPending] = createSignal(false)
const [latest, setResult] = createSignal<R | undefined>(undefined)
const [error, setError] = createSignal<unknown>(undefined)
let abortController: AbortController | null = null
let cleanupFns: (() => void)[] = []
let currentPromise: Promise<R> | null = null
const { promise: initialPromise, resolve: resolveInitial } = Promise.withResolvers<void>()
function registerCleanup(fn: () => void) {
cleanupFns.push(fn)
}
function cleanup() {
for (const fn of cleanupFns) {
try {
fn()
} catch (e) {
console.error('Cleanup error:', e)
}
}
cleanupFns = []
if (abortController) {
abortController.abort()
abortController = null
}
}
function cancel() {
cleanup()
setPending(false)
}
function clear() {
cleanup()
setPending(false)
setResult(undefined)
setError(undefined)
}
function createContext(signal: AbortSignal): ActionContext {
return {
signal,
onCleanup: registerCleanup,
}
}
async function actionFn(args?: T): Promise<R> {
cleanup()
abortController = new AbortController()
const { signal } = abortController
const context = createContext(signal)
setPending(true)
setError(undefined)
const promise = (async () => {
try {
let value: R | HoldMarker<R>
if (isGeneratorFunction<GeneratorFetcher<T, R>>(fetcher)) {
value = await runGenerator(fetcher(args as T, context), context)
} else {
const promise = fetcher(args as T, context)
if (isPromiseWithCleanup(promise)) {
const cleanup = promise[$CLEANUP]
context.onCleanup(async () => cleanup(await promise))
}
value = await promise
}
// Check if return value is a hold marker
if (isHoldMarker(value)) {
await waitForAbort(signal)
value = value.getValue()
}
setResult(() => value)
resolveInitial()
return value
} catch (err) {
// If runGenerator threw CancelledError, re-throw it
if (err instanceof CancelledError) {
throw err
}
console.error(err)
setError(err)
throw err
} finally {
setPending(false)
}
})()
currentPromise = promise
return promise
}
async function tryAction(args?: T): Promise<R | undefined> {
try {
return await actionFn(args)
} catch {
return undefined
}
}
async function promise(): Promise<R | undefined> {
return initialPromise.then(() =>
assertedNotNullish(currentPromise!, 'Current Promise is undefined'),
)
}
return Object.assign(actionFn, {
pending,
latest,
error,
cancel,
clear,
try: tryAction,
promise,
}) as Action<T, R>
}
/**********************************************************************************/
/* */
/* Phased Actions */
/* */
/**********************************************************************************/
/** Context for phased action callbacks */
export interface PhaseContext extends ActionContext {
/** Set progress within current phase (0-1) */
setProgress: (value: number) => void
}
/** A phase callback can return sync, Promise, or Generator */
type PhaseResult<R> = R | Promise<R> | Generator<Promise<unknown>, R, unknown>
/** Phase function signature */
type PhaseFn<In, Out> = (input: In, ctx: PhaseContext) => PhaseResult<Out>
/** Stored phase definition */
interface PhaseDefinition<In = unknown, Out = unknown> {
name: string
fn: PhaseFn<In, Out>
}
/** Dual-purpose phase method: read current phase or add new phase */
export interface PhaseMethod<Phases extends string, CurrentResult> {
/** Get current phase (signal read) */
(): Phases | 'idle' | 'complete' | 'error'
/** Add a phase that receives previous result */
<Name extends string, NextResult>(
name: Name,
fn: PhaseFn<CurrentResult, NextResult>,
): PhasedAction<Phases | Name, NextResult>
}
/** Phased action with phase tracking */
export interface PhasedAction<Phases extends string, Result> {
/** Run the phased action */
(): Promise<Result | undefined>
/** Get current phase or add new phase */
phase: PhaseMethod<Phases, Result>
/** Whether action is running */
pending: Accessor<boolean>
/** Error if failed */
error: Accessor<unknown>
/** Progress within current phase (0-1) */
progress: Accessor<number>
/** Cancel execution */
cancel: () => void
}
/** Check if value is a generator (instance, not function) */
function isGenerator(value: unknown): value is Generator<unknown, unknown, unknown> {
return (
value !== null &&
typeof value === 'object' &&
'next' in value &&
typeof (value as Generator).next === 'function'
)
}
/** Run a generator with abort checks between yields */
async function runPhaseGenerator<R>(
gen: Generator<Promise<unknown>, R, unknown>,
signal: AbortSignal,
): Promise<R> {
let result = gen.next()
while (!result.done) {
if (signal.aborted) {
throw new CancelledError()
}
const value = await result.value
if (signal.aborted) {
throw new CancelledError()
}
result = gen.next(value)
}
return result.value
}
/**
* Create a phased action builder.
* Start with `action.phase(name, fn)` and chain more phases.
*
* @example
* ```ts
* const exportAction = action
* .phase('preparing', () => {
* return { duration: 10, clips: [...] }
* })
* .phase('audio', async ({ duration }) => {
* return await renderAudio(duration)
* })
* .phase('video', function* (audioBuffer, { setProgress }) {
* for (let i = 0; i < frames; i++) {
* yield renderFrame(i)
* setProgress(i / frames)
* }
* return blob
* })
*
* // Usage
* exportAction() // run
* exportAction.phase() // 'idle' | 'preparing' | 'audio' | 'video' | 'complete' | 'error'
* exportAction.pending() // true while running
* exportAction.progress() // 0-1 within current phase
* exportAction.cancel() // stop between phases
* ```
*/
function createPhaseBuilder<Phases extends string, CurrentResult>(
phases: PhaseDefinition[],
): PhasedAction<Phases, CurrentResult> {
const [phase, setPhase] = createSignal<Phases | 'idle' | 'complete' | 'error'>('idle')
const [pending, setPending] = createSignal(false)
const [error, setError] = createSignal<unknown>(undefined)
const [progress, setProgress] = createSignal(0)
let abortController: AbortController | null = null
let cleanupFns: (() => void)[] = []
function cleanup() {
for (const fn of cleanupFns) {
try {
fn()
} catch (err) {
console.error('Cleanup error:', err)
}
}
cleanupFns = []
if (abortController) {
abortController.abort()
abortController = null
}
}
function cancel() {
cleanup()
setPending(false)
}
async function run(): Promise<CurrentResult | undefined> {
cleanup()
abortController = new AbortController()
const { signal } = abortController
setPending(true)
setError(undefined)
setProgress(0)
setPhase('idle')
let result: unknown = undefined
try {
for (const phaseDef of phases) {
if (signal.aborted) {
throw new CancelledError()
}
setPhase(() => phaseDef.name as Phases)
setProgress(0)
const ctx: PhaseContext = {
signal,
setProgress,
onCleanup: fn => cleanupFns.push(fn),
}
const phaseResult = phaseDef.fn(result, ctx)
if (isGenerator(phaseResult)) {
result = await runPhaseGenerator(
phaseResult as Generator<Promise<unknown>, unknown, unknown>,
signal,
)
} else if (phaseResult instanceof Promise) {
result = await phaseResult
} else {
result = phaseResult
}
if (signal.aborted) {
throw new CancelledError()
}
}
setPhase('complete')
setProgress(1)
return result as CurrentResult
} catch (err) {
if (err instanceof CancelledError) {
return undefined
}
console.error('Phased action error:', err)
setError(err)
setPhase('error')
return undefined
} finally {
setPending(false)
cleanup()
}
}
// Create dual-purpose phase function:
// - phase() with no args -> returns current phase (signal read)
// - phase('name', fn) -> adds phase, returns new builder
function phaseMethod(): Phases | 'idle' | 'complete' | 'error'
function phaseMethod<Name extends string, NextResult>(
name: Name,
fn: PhaseFn<CurrentResult, NextResult>,
): PhasedAction<Phases | Name, NextResult>
function phaseMethod<Name extends string, NextResult>(
name?: Name,
fn?: PhaseFn<CurrentResult, NextResult>,
) {
if (name === undefined) {
// No args - return current phase value
return phase()
}
// With args - add phase and return new builder
return createPhaseBuilder<Phases | Name, NextResult>([
...phases,
{ name, fn: fn as PhaseFn<unknown, unknown> },
])
}
// Create the callable action with attached properties
const phasedAction = run as PhasedAction<Phases, CurrentResult>
phasedAction.phase = phaseMethod as typeof phasedAction.phase
phasedAction.pending = pending
phasedAction.error = error
phasedAction.progress = progress
phasedAction.cancel = cancel
return phasedAction
}
// Add static phase method to action
action.phase = function <Name extends string, Result>(
name: Name,
fn: PhaseFn<void, Result>,
): PhasedAction<Name, Result> {
return createPhaseBuilder<Name, Result>([{ name, fn: fn as PhaseFn<unknown, unknown> }])
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment