@geepers/jobs
v1.0.0
Published
Framework-agnostic async job tracking with polling and localStorage persistence
Maintainers
Readme
@geepers/jobs
Framework-agnostic async job tracking with built-in polling, EventEmitter callbacks, and optional localStorage persistence. Works in any environment — browser, Node, Deno.
Extracted from the geepers-chat job system. Designed for long-running tasks like image generation, video rendering, TTS synthesis, or any async operation you need to poll for.
Install
npm install @geepers/jobsQuick start
import { JobManager, generateJobId } from '@geepers/jobs';
const manager = new JobManager({
storageKey: 'my-app-jobs', // persist active jobs to localStorage (browser only)
});
// Listen for completions
manager.onJobComplete(job => {
if (job.status === 'done') {
console.log('Done!', job);
} else {
console.error('Failed:', job.error);
}
});
// Add a job
const job = manager.addJob({
id: generateJobId(),
type: 'image',
status: 'pending',
prompt: 'A sunset over mountains',
startedAt: Date.now(),
});
// Start polling
manager.startPolling(job.id, {
activeInterval: 3000, // poll every 3s when visible
hiddenInterval: 15000, // poll every 15s when tab is hidden
pollFn: async (j) => {
const res = await fetch(`/api/jobs/${j.id}/status`);
const data = await res.json();
return {
status: data.status, // 'pending' | 'processing' | 'done' | 'failed'
result: { url: data.imageUrl }, // merged into job on completion
};
},
});API
new JobManager(options?)
| Option | Type | Description |
|--------|------|-------------|
| storageKey | string | localStorage key for persisting active jobs. Omit to disable. |
| persistFilter | (job) => boolean | Which jobs to persist. Default: pending or processing jobs. |
| defaultPollConfig | object | Default activeInterval, hiddenInterval, errorInterval. |
| onNotify | (msg, level) => void | Optional hook for completion notifications. |
Core methods
manager.addJob(job: Job): Job
manager.updateJob(id: string, updates: Partial<Job>): Job | undefined
manager.removeJob(id: string): void
manager.getJob(id: string): Job | undefined
manager.getJobs(type?: JobType): Job[]
manager.getActiveCount(type?: JobType): number
manager.clearJobs(type?: JobType): voidPolling
manager.startPolling(jobId: string, config: PollConfig): void
manager.stopPolling(pollKey: string): void
manager.stopAllPolling(): voidPollConfig:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| pollFn | (job) => Promise<PollResult> | required | Fetches current job status |
| activeInterval | number | 5000 | Interval (ms) when tab is visible |
| hiddenInterval | number | 15000 | Interval (ms) when tab is hidden |
| errorInterval | number | 10000 | Interval (ms) after a poll error |
PollResult:
interface PollResult {
status: string; // 'pending' | 'processing' | 'done' | 'failed' | 'error' | 'expired'
result?: Partial<Job>; // merged into the job on terminal status
error?: string;
}Events
manager.onJobComplete(callback: (job: Job) => void): () => void
manager.onJobUpdate(callback: (job: Job) => void): () => void
// Raw EventEmitter events:
// 'job:added' (job)
// 'job:updated' (job)
// 'job:complete' (job)
// 'job:removed' (id)
// 'jobs:cleared' (type?)Both onJobComplete and onJobUpdate return an unsubscribe function.
Helpers
import { createJobManager, generateJobId } from '@geepers/jobs';
const manager = createJobManager({ storageKey: 'my-jobs' });
const id = generateJobId(); // uses crypto.randomUUID() or timestamp fallbackCleanup
manager.destroy(); // stop all polling, remove all listeners, clear jobsBuilt-in job types
The package ships typed interfaces for common use cases:
type JobType = 'image' | 'video' | 'tts' | 'research' | (string & {});
interface ImageJob extends BaseJob { type: 'image'; url?: string; revised_prompt?: string; }
interface VideoJob extends BaseJob { type: 'video'; requestId: string; videoUrl?: string; }
interface TTSJob extends BaseJob { type: 'tts'; audioUrl?: string; voice?: string; }
interface ResearchJob extends BaseJob { type: 'research'; reportUrl?: string; agentCount?: number; }You can use any custom string as a type — the types are open-ended.
React usage
This package has no React dependency, but wrapping it in a context is straightforward:
import { useEffect, useRef } from 'react';
import { JobManager } from '@geepers/jobs';
const managerRef = useRef(new JobManager({ storageKey: 'my-app-jobs' }));
useEffect(() => {
return () => managerRef.current.destroy();
}, []);License
MIT — Luke Steuber
