sync-later 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ID.md ADDED
@@ -0,0 +1,137 @@
1
+ # Rest Queue
2
+
3
+ **Engine Mutasi Offline-First untuk Standar REST API**
4
+
5
+
6
+ `sync-later` adalah perpustakaan (library) yang kuat dan ringan yang dirancang untuk memastikan mutasi data Anda (POST, PUT, DELETE, PATCH) tidak pernah gagal, bahkan dalam kondisi jaringan yang tidak stabil. Library ini menyimpan permintaan (request), menangani percobaan ulang (retry) dengan strategi exponential backoff, mengelola rantai dependensi yang kompleks antar permintaan, dan kini mendukung unggahan file serta pembaruan event yang reaktif.
7
+
8
+ Berbeda dengan solusi berat seperti TanStack Query atau Apollo Client yang berfokus pada *fetching* (pengambilan data), `sync-later` berfokus murni pada *mutasi yang reliable*.
9
+
10
+ ## Fitur 🚀
11
+
12
+ - **Tanpa Dependensi**: Murni TypeScript, ukuran bundle sangat kecil.
13
+ - **Offline-First**: Permintaan disimpan ke IndexedDB secara langsung.
14
+ - **Tangguh**: Percobaan ulang otomatis dengan strategi exponential backoff.
15
+ - **Dependency Chaining**: Buat rantai permintaan orang tua-anak (misalnya, buat Post -> buat Komentar) di mana anak bergantung pada ID orang tua bahkan sebelum orang tua berhasil dibuat.
16
+ - **Sistem Event Reaktif**: Berlangganan ke pembaruan antrean (`queue_update`, `process_success`, `process_fail`).
17
+ - **Dukungan File**: Dukungan kelas satu untuk unggahan `FormData`, `Blob`, dan `File`.
18
+ - **Dapat Dibatalkan**: Batalkan permintaan yang tertunda dengan mudah.
19
+ - **Kontrol Konkurensi**: Pemrosesan serial menjamin urutan.
20
+
21
+ ## Instalasi
22
+
23
+ ```bash
24
+ npm install sync-later
25
+ # atau
26
+ pnpm add sync-later
27
+ # atau
28
+ yarn add sync-later
29
+ ```
30
+
31
+ ## Mulai Cepat
32
+
33
+ ```typescript
34
+ import { RequestQueue } from 'sync-later';
35
+
36
+ // 1. Inisialisasi antrean
37
+ const queue = new RequestQueue({
38
+ retryPolicy: { maxRetries: 3, initialDelayMs: 1000 },
39
+ onQueueChange: (items) => console.log('Antrean diperbarui:', items.length)
40
+ });
41
+
42
+ // 2. Tambahkan permintaan (mengembalikan ID unik)
43
+ const id = await queue.add({
44
+ url: 'https://api.example.com/posts',
45
+ method: 'POST',
46
+ body: { title: 'Halo Dunia' }
47
+ });
48
+
49
+ // Selesai! Library ini menangani sisanya:
50
+ // - Menyimpan ke IndexedDB
51
+ // - Memeriksa status jaringan
52
+ // - Mengirim permintaan
53
+ // - Mencoba lagi jika gagal
54
+ // - Menghapus jika berhasil
55
+ ```
56
+
57
+ ## Konsep Inti
58
+
59
+ ### 1. Dependency Chaining 🔗
60
+ Eksekusi permintaan yang bergantung tanpa menunggu permintaan pertama selesai. Gunakan `tempId` yang akan diganti secara otomatis ketika permintaan parent berhasil.
61
+
62
+ ```typescript
63
+ const tempId = 'temp-123';
64
+
65
+ // 1. Buat parent (Post)
66
+ await queue.add({
67
+ tempId, // Tetapkan ID sementara
68
+ url: '/posts',
69
+ method: 'POST',
70
+ body: { title: 'Postingan Baru' }
71
+ });
72
+
73
+ // 2. Buat child (Komentar) - Menggunakan tempId
74
+ await queue.add({
75
+ url: '/comments',
76
+ method: 'POST',
77
+ body: {
78
+ postId: tempId, // Akan diganti dengan ID asli (misal: 101) setelah parent sukses
79
+ content: 'Postingan bagus!'
80
+ }
81
+ });
82
+ ```
83
+
84
+ ### 2. Unggah File 📁
85
+ Unggah file dengan mulus. Library mendeteksi `FormData` dan melewati serialisasi JSON.
86
+
87
+ ```typescript
88
+ const formData = new FormData();
89
+ formData.append('file', myFile);
90
+
91
+ await queue.add({
92
+ url: '/upload',
93
+ method: 'POST',
94
+ body: formData
95
+ });
96
+ ```
97
+
98
+ ### 3. Event & Reaktivitas ⚡
99
+ Perbarui UI Anda secara real-time.
100
+
101
+ ```typescript
102
+ queue.addListener('queue_update', (items) => {
103
+ // Perbarui UI Anda dengan status antrean terbaru
104
+ setQueueItems(items);
105
+ });
106
+
107
+ queue.addListener('process_success', ({ id, response }) => {
108
+ console.log(`Permintaan ${id} berhasil!`, response);
109
+ });
110
+ ```
111
+
112
+ ## Referensi API
113
+
114
+ ### `RequestQueue`
115
+ Kelas utama.
116
+
117
+ #### Constructor `new RequestQueue(config?)`
118
+ - `config.retryPolicy`: `{ maxRetries: number, initialDelayMs: number }`
119
+ - `config.userId`: `string` (Opsional, untuk dukungan isolasi multi-pengguna)
120
+ - `config.onBeforeSend`: `(item) => Promise<item>` (Hook untuk memodifikasi permintaan sebelum dikirim, misal: melampirkan token)
121
+ - `config.onQueueChange`: `(items) => void` (Jalan pintas untuk event queue_update)
122
+
123
+ #### Metode
124
+ - `add(request)`: Menambahkan permintaan. Mengembalikan `Promise<string>` (ID permintaan).
125
+ - `remove(id)`: Membatalkan permintaan yang tertunda.
126
+ - `getQueue()`: Mengembalikan semua item antrean saat ini.
127
+ - `addListener(event, callback)`: Berlangganan event.
128
+ - `removeListener(event, callback)`: Berhenti berlangganan.
129
+
130
+ ### Event
131
+ - `queue_update`: Dipicu setiap kali antrean menambahkan, menghapus, atau memperbarui item.
132
+ - `process_success`: Dipicu ketika permintaan berhasil.
133
+ - `process_fail`: Dipicu ketika permintaan gagal secara permanen (setelah percobaan ulang).
134
+
135
+ ## Lisensi
136
+
137
+ ISC © 2026 denisetiya
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # Rest Queue
2
+
3
+ **Offline Mutation Engine for Standard REST API**
4
+
5
+ `sync-later` is a robust, lightweight library designed to ensure your data mutations (POST, PUT, DELETE, PATCH) never fail, even in unstable network conditions. It persists requests, handles retries with exponential backoff, manages complex dependency chains between requests, and now supports file uploads and reactive event updates.
6
+
7
+ Unlike heavyweight solutions like TanStack Query or Apollo Client which focus on *fetching*, `sync-later` focuses purely on *reliable mutations*.
8
+
9
+ ## Features 🚀
10
+
11
+ - **Zero Dependencies**: Pure TypeScript, tiny bundle size.
12
+ - **Offline-First**: Requests are persisted to IndexedDB immediately.
13
+ - **Resilient**: Automatic retries with exponential backoff strategy.
14
+ - **Dependency Chaining**: Create parent-child request chains (e.g., create Post -> create Comment) where the child relies on the parent's ID before the parent even succeeds.
15
+ - **Reactive Event System**: Subscribe to queue updates (`queue_update`, `process_success`, `process_fail`).
16
+ - **File Support**: First-class support for `FormData`, `Blob`, and `File` uploads.
17
+ - **Cancellable**: Cancel pending requests easily.
18
+ - **Concurrency Control**: Serial processing guarantees order.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install sync-later
24
+ # or
25
+ pnpm add sync-later
26
+ # or
27
+ yarn add sync-later
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```typescript
33
+ import { RequestQueue } from 'sync-later';
34
+
35
+ // 1. Initialize the queue
36
+ const queue = new RequestQueue({
37
+ retryPolicy: { maxRetries: 3, initialDelayMs: 1000 },
38
+ onQueueChange: (items) => console.log('Queue updated:', items.length)
39
+ });
40
+
41
+ // 2. Add a request (returns a unique ID)
42
+ const id = await queue.add({
43
+ url: 'https://api.example.com/posts',
44
+ method: 'POST',
45
+ body: { title: 'Hello World' }
46
+ });
47
+
48
+ // That's it! The library handles the rest:
49
+ // - Saves to IndexedDB
50
+ // - Checks network status
51
+ // - Sends request
52
+ // - Retries on failure
53
+ // - Removes on success
54
+ ```
55
+
56
+ ## Core Concepts
57
+
58
+ ### 1. Dependency Chaining 🔗
59
+ Execute dependent requests without waiting for the first one to finish. Use a `tempId` that gets replaced automatically when the parent request succeeds.
60
+
61
+ ```typescript
62
+ const tempId = 'temp-123';
63
+
64
+ // 1. Create Parent (Post)
65
+ await queue.add({
66
+ tempId, // Assign a temporary ID
67
+ url: '/posts',
68
+ method: 'POST',
69
+ body: { title: 'New Post' }
70
+ });
71
+
72
+ // 2. Create Child (Comment) - Uses tempId
73
+ await queue.add({
74
+ url: '/comments',
75
+ method: 'POST',
76
+ body: {
77
+ postId: tempId, // Will be replaced by real ID (e.g., 101) after parent succeeds
78
+ content: 'Nice post!'
79
+ }
80
+ });
81
+ ```
82
+
83
+ ### 2. File Uploads 📁
84
+ Upload files seamlessly. The library detects `FormData` and skips JSON serialization.
85
+
86
+ ```typescript
87
+ const formData = new FormData();
88
+ formData.append('file', myFile);
89
+
90
+ await queue.add({
91
+ url: '/upload',
92
+ method: 'POST',
93
+ body: formData
94
+ });
95
+ ```
96
+
97
+ ### 3. Events & Reactivity ⚡
98
+ Update your UI in real-time.
99
+
100
+ ```typescript
101
+ queue.addListener('queue_update', (items) => {
102
+ // Update your UI with the latest queue state
103
+ setQueueItems(items);
104
+ });
105
+
106
+ queue.addListener('process_success', ({ id, response }) => {
107
+ console.log(`Request ${id} succeeded!`, response);
108
+ });
109
+ ```
110
+
111
+ ## API Reference
112
+
113
+ ### `RequestQueue`
114
+ The main class.
115
+
116
+ #### Constructor `new RequestQueue(config?)`
117
+ - `config.retryPolicy`: `{ maxRetries: number, initialDelayMs: number }`
118
+ - `config.userId`: `string` (Optional, for multi-user isolation support)
119
+ - `config.onBeforeSend`: `(item) => Promise<item>` (Hook to modify request before sending, e.g., attach tokens)
120
+ - `config.onQueueChange`: `(items) => void` (Shortcut for queue_update event)
121
+
122
+ #### Methods
123
+ - `add(request)`: Adds a request. Returns `Promise<string>` (the request ID).
124
+ - `remove(id)`: Cancels a pending request.
125
+ - `getQueue()`: Returns all current queue items.
126
+ - `addListener(event, callback)`: Subscribe to events.
127
+ - `removeListener(event, callback)`: Unsubscribe.
128
+
129
+ ### Events
130
+ - `queue_update`: Fired whenever the queue adds, removes, or updates an item.
131
+ - `process_success`: Fired when a request succeeds.
132
+ - `process_fail`: Fired when a request permanently fails (after retries).
133
+
134
+ ## License
135
+
136
+ ISC © 2026 denisetiya
@@ -0,0 +1,59 @@
1
+ type HttpMethod = 'POST' | 'PUT' | 'DELETE' | 'PATCH';
2
+ type QueueItemStatus = 'PENDING' | 'PROCESSING' | 'RETRYING' | 'COMPLETED' | 'FAILED';
3
+ interface QueueItem {
4
+ id: string;
5
+ tempId?: string;
6
+ url: string;
7
+ method: HttpMethod;
8
+ body?: unknown;
9
+ headers?: Record<string, string>;
10
+ createdAt: number;
11
+ status: QueueItemStatus;
12
+ retryCount: number;
13
+ userId?: string;
14
+ error?: string;
15
+ }
16
+ interface QueueConfig {
17
+ remoteUrl?: string;
18
+ retryPolicy?: {
19
+ maxRetries: number;
20
+ initialDelayMs: number;
21
+ };
22
+ userId?: string;
23
+ onBeforeSend?: (request: QueueItem) => Promise<QueueItem | void>;
24
+ onQueueChange?: (queue: QueueItem[]) => void;
25
+ }
26
+ type QueueEventType = 'queue_update' | 'process_success' | 'process_fail';
27
+ type QueueEventCallback = (data?: unknown) => void;
28
+
29
+ declare class RequestQueue {
30
+ private queue;
31
+ private isProcessing;
32
+ private config;
33
+ private storage;
34
+ private readyPromise;
35
+ private listeners;
36
+ constructor(config?: QueueConfig);
37
+ addListener(type: QueueEventType, callback: QueueEventCallback): void;
38
+ removeListener(type: QueueEventType, callback: QueueEventCallback): void;
39
+ private emit;
40
+ private setupNetworkListener;
41
+ private hydrate;
42
+ /**
43
+ * Add a request to the queue
44
+ */
45
+ add(request: Omit<QueueItem, 'id' | 'createdAt' | 'status' | 'retryCount'>): Promise<string>;
46
+ /**
47
+ * Remove a request (Cancel)
48
+ */
49
+ remove(id: string): Promise<void>;
50
+ /**
51
+ * Process the queue serially
52
+ */
53
+ private process;
54
+ private resolveDependencies;
55
+ private performRequest;
56
+ getQueue(): Promise<QueueItem[]>;
57
+ }
58
+
59
+ export { type HttpMethod, type QueueConfig, type QueueEventCallback, type QueueEventType, type QueueItem, type QueueItemStatus, RequestQueue };
@@ -0,0 +1,59 @@
1
+ type HttpMethod = 'POST' | 'PUT' | 'DELETE' | 'PATCH';
2
+ type QueueItemStatus = 'PENDING' | 'PROCESSING' | 'RETRYING' | 'COMPLETED' | 'FAILED';
3
+ interface QueueItem {
4
+ id: string;
5
+ tempId?: string;
6
+ url: string;
7
+ method: HttpMethod;
8
+ body?: unknown;
9
+ headers?: Record<string, string>;
10
+ createdAt: number;
11
+ status: QueueItemStatus;
12
+ retryCount: number;
13
+ userId?: string;
14
+ error?: string;
15
+ }
16
+ interface QueueConfig {
17
+ remoteUrl?: string;
18
+ retryPolicy?: {
19
+ maxRetries: number;
20
+ initialDelayMs: number;
21
+ };
22
+ userId?: string;
23
+ onBeforeSend?: (request: QueueItem) => Promise<QueueItem | void>;
24
+ onQueueChange?: (queue: QueueItem[]) => void;
25
+ }
26
+ type QueueEventType = 'queue_update' | 'process_success' | 'process_fail';
27
+ type QueueEventCallback = (data?: unknown) => void;
28
+
29
+ declare class RequestQueue {
30
+ private queue;
31
+ private isProcessing;
32
+ private config;
33
+ private storage;
34
+ private readyPromise;
35
+ private listeners;
36
+ constructor(config?: QueueConfig);
37
+ addListener(type: QueueEventType, callback: QueueEventCallback): void;
38
+ removeListener(type: QueueEventType, callback: QueueEventCallback): void;
39
+ private emit;
40
+ private setupNetworkListener;
41
+ private hydrate;
42
+ /**
43
+ * Add a request to the queue
44
+ */
45
+ add(request: Omit<QueueItem, 'id' | 'createdAt' | 'status' | 'retryCount'>): Promise<string>;
46
+ /**
47
+ * Remove a request (Cancel)
48
+ */
49
+ remove(id: string): Promise<void>;
50
+ /**
51
+ * Process the queue serially
52
+ */
53
+ private process;
54
+ private resolveDependencies;
55
+ private performRequest;
56
+ getQueue(): Promise<QueueItem[]>;
57
+ }
58
+
59
+ export { type HttpMethod, type QueueConfig, type QueueEventCallback, type QueueEventType, type QueueItem, type QueueItemStatus, RequestQueue };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";var d=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var f=Object.getOwnPropertyNames;var p=Object.prototype.hasOwnProperty;var y=(u,e)=>{for(var s in e)d(u,s,{get:e[s],enumerable:!0})},m=(u,e,s,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of f(e))!p.call(u,i)&&i!==s&&d(u,i,{get:()=>e[i],enumerable:!(t=h(e,i))||t.enumerable});return u};var g=u=>m(d({},"__esModule",{value:!0}),u);var b={};y(b,{RequestQueue:()=>l});module.exports=g(b);var w="sync-later-db";var a="queue",c=class{dbPromise;constructor(){this.dbPromise=this.openDB()}openDB(){return typeof window>"u"||!window.indexedDB?Promise.reject(new Error("IndexedDB not supported")):new Promise((e,s)=>{let t=window.indexedDB.open(w,1);t.onupgradeneeded=i=>{let r=i.target.result;r.objectStoreNames.contains(a)||r.createObjectStore(a,{keyPath:"id"})},t.onsuccess=i=>{e(i.target.result)},t.onerror=i=>{s(i.target.error)}})}async getAll(){let e=await this.dbPromise;return new Promise((s,t)=>{let o=e.transaction(a,"readonly").objectStore(a).getAll();o.onsuccess=()=>s(o.result),o.onerror=()=>t(o.error)})}async save(e){let s=await this.dbPromise;return new Promise((t,i)=>{let n=s.transaction(a,"readwrite").objectStore(a).put(e);n.onsuccess=()=>t(),n.onerror=()=>i(n.error)})}async delete(e){let s=await this.dbPromise;return new Promise((t,i)=>{let n=s.transaction(a,"readwrite").objectStore(a).delete(e);n.onsuccess=()=>t(),n.onerror=()=>i(n.error)})}async clear(){let e=await this.dbPromise;return new Promise((s,t)=>{let o=e.transaction(a,"readwrite").objectStore(a).clear();o.onsuccess=()=>s(),o.onerror=()=>t(o.error)})}};var l=class{queue=[];isProcessing=!1;config;storage;readyPromise;listeners=new Map;constructor(e={}){this.config=e,this.storage=new c,this.setupNetworkListener(),this.readyPromise=this.hydrate()}addListener(e,s){this.listeners.has(e)||this.listeners.set(e,new Set),this.listeners.get(e)?.add(s)}removeListener(e,s){this.listeners.get(e)?.delete(s)}emit(e,s){this.listeners.get(e)?.forEach(t=>t(s)),e==="queue_update"&&this.config.onQueueChange&&this.config.onQueueChange(this.queue)}setupNetworkListener(){typeof window<"u"&&window.addEventListener&&window.addEventListener("online",()=>{console.log("Network online, processing queue..."),this.process()})}async hydrate(){try{let e=await this.storage.getAll();e&&e.length>0&&(this.queue=e.sort((s,t)=>s.createdAt-t.createdAt))}catch(e){console.warn("Failed to hydrate queue from storage",e)}}async add(e){if(await this.readyPromise,!e.body||typeof e.body=="object"&&!(e.body instanceof Blob)&&!(e.body instanceof FormData)){let r=e.body?JSON.stringify(e.body):"",o=this.queue.find(n=>n.status==="PENDING"&&n.url===e.url&&n.method===e.method&&(n.body?JSON.stringify(n.body):"")===r);if(o)return console.warn("Duplicate request detected, ignoring.",e),o.id}let t=crypto.randomUUID(),i={...e,id:t,createdAt:Date.now(),status:"PENDING",retryCount:0};return this.queue.push(i),await this.storage.save(i),this.emit("queue_update",this.queue),typeof navigator<"u"&&navigator.onLine===!1||this.process(),t}async remove(e){await this.readyPromise;let s=this.queue.findIndex(i=>i.id===e);if(s===-1)return;if(this.queue[s].status==="PROCESSING"){console.warn("Cannot cancel a request that is currently processing:",e);return}this.queue.splice(s,1),await this.storage.delete(e),this.emit("queue_update",this.queue)}async process(){if(!this.isProcessing&&(await this.readyPromise,this.queue.length!==0&&!(typeof navigator<"u"&&navigator.onLine===!1))){this.isProcessing=!0;try{let e=this.queue[0];if(this.config.userId&&e.userId&&e.userId!==this.config.userId){this.isProcessing=!1;return}if(e.status==="RETRYING"){let i=(this.config.retryPolicy||{maxRetries:3,initialDelayMs:1e3}).initialDelayMs*Math.pow(2,e.retryCount-1);await new Promise(r=>setTimeout(r,i))}e.status="PROCESSING",await this.storage.save(e),this.emit("queue_update",this.queue);let s=e;if(this.config.onBeforeSend){let t=await this.config.onBeforeSend(e);t&&(s=t)}try{let t=await this.performRequest(s);if(e.status="COMPLETED",this.emit("process_success",{id:e.id,response:t}),e.tempId&&t&&typeof t=="object"&&"id"in t){let i=String(t.id);await this.resolveDependencies(e.tempId,i)}this.queue.shift(),await this.storage.delete(e.id),this.emit("queue_update",this.queue)}catch(t){console.error("Request failed",t);let i=this.config.retryPolicy?.maxRetries??3;e.retryCount<i?(e.status="RETRYING",e.retryCount++,e.error=String(t),await this.storage.save(e),this.emit("queue_update",this.queue)):(e.status="FAILED",e.error=String(t),this.queue.shift(),await this.storage.delete(e.id),this.emit("process_fail",{id:e.id,error:t}),this.emit("queue_update",this.queue))}}finally{this.isProcessing=!1,this.queue.length>0&&this.process()}}}async resolveDependencies(e,s){for(let t of this.queue){if(t.status!=="PENDING")continue;let i=!1;if(t.url.includes(e)&&(t.url=t.url.replace(e,s),i=!0),t.body){let r=JSON.stringify(t.body);if(r.includes(e)){let o=r.replaceAll(e,s);t.body=JSON.parse(o),i=!0}}i&&await this.storage.save(t)}}async performRequest(e){let s={...e.headers},t;e.body instanceof FormData||e.body instanceof Blob||e.body instanceof File?t=e.body:e.body&&(t=JSON.stringify(e.body),s["Content-Type"]||(s["Content-Type"]="application/json"));let i=await fetch(e.url,{method:e.method,body:t,headers:s});if(!i.ok)throw new Error(`HTTP Error ${i.status}`);let r=await i.text();try{return r?JSON.parse(r):{}}catch{return{}}}async getQueue(){return await this.readyPromise,[...this.queue]}};0&&(module.exports={RequestQueue});
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+ var l="sync-later-db";var a="queue",u=class{dbPromise;constructor(){this.dbPromise=this.openDB()}openDB(){return typeof window>"u"||!window.indexedDB?Promise.reject(new Error("IndexedDB not supported")):new Promise((e,s)=>{let t=window.indexedDB.open(l,1);t.onupgradeneeded=i=>{let r=i.target.result;r.objectStoreNames.contains(a)||r.createObjectStore(a,{keyPath:"id"})},t.onsuccess=i=>{e(i.target.result)},t.onerror=i=>{s(i.target.error)}})}async getAll(){let e=await this.dbPromise;return new Promise((s,t)=>{let o=e.transaction(a,"readonly").objectStore(a).getAll();o.onsuccess=()=>s(o.result),o.onerror=()=>t(o.error)})}async save(e){let s=await this.dbPromise;return new Promise((t,i)=>{let n=s.transaction(a,"readwrite").objectStore(a).put(e);n.onsuccess=()=>t(),n.onerror=()=>i(n.error)})}async delete(e){let s=await this.dbPromise;return new Promise((t,i)=>{let n=s.transaction(a,"readwrite").objectStore(a).delete(e);n.onsuccess=()=>t(),n.onerror=()=>i(n.error)})}async clear(){let e=await this.dbPromise;return new Promise((s,t)=>{let o=e.transaction(a,"readwrite").objectStore(a).clear();o.onsuccess=()=>s(),o.onerror=()=>t(o.error)})}};var c=class{queue=[];isProcessing=!1;config;storage;readyPromise;listeners=new Map;constructor(e={}){this.config=e,this.storage=new u,this.setupNetworkListener(),this.readyPromise=this.hydrate()}addListener(e,s){this.listeners.has(e)||this.listeners.set(e,new Set),this.listeners.get(e)?.add(s)}removeListener(e,s){this.listeners.get(e)?.delete(s)}emit(e,s){this.listeners.get(e)?.forEach(t=>t(s)),e==="queue_update"&&this.config.onQueueChange&&this.config.onQueueChange(this.queue)}setupNetworkListener(){typeof window<"u"&&window.addEventListener&&window.addEventListener("online",()=>{console.log("Network online, processing queue..."),this.process()})}async hydrate(){try{let e=await this.storage.getAll();e&&e.length>0&&(this.queue=e.sort((s,t)=>s.createdAt-t.createdAt))}catch(e){console.warn("Failed to hydrate queue from storage",e)}}async add(e){if(await this.readyPromise,!e.body||typeof e.body=="object"&&!(e.body instanceof Blob)&&!(e.body instanceof FormData)){let r=e.body?JSON.stringify(e.body):"",o=this.queue.find(n=>n.status==="PENDING"&&n.url===e.url&&n.method===e.method&&(n.body?JSON.stringify(n.body):"")===r);if(o)return console.warn("Duplicate request detected, ignoring.",e),o.id}let t=crypto.randomUUID(),i={...e,id:t,createdAt:Date.now(),status:"PENDING",retryCount:0};return this.queue.push(i),await this.storage.save(i),this.emit("queue_update",this.queue),typeof navigator<"u"&&navigator.onLine===!1||this.process(),t}async remove(e){await this.readyPromise;let s=this.queue.findIndex(i=>i.id===e);if(s===-1)return;if(this.queue[s].status==="PROCESSING"){console.warn("Cannot cancel a request that is currently processing:",e);return}this.queue.splice(s,1),await this.storage.delete(e),this.emit("queue_update",this.queue)}async process(){if(!this.isProcessing&&(await this.readyPromise,this.queue.length!==0&&!(typeof navigator<"u"&&navigator.onLine===!1))){this.isProcessing=!0;try{let e=this.queue[0];if(this.config.userId&&e.userId&&e.userId!==this.config.userId){this.isProcessing=!1;return}if(e.status==="RETRYING"){let i=(this.config.retryPolicy||{maxRetries:3,initialDelayMs:1e3}).initialDelayMs*Math.pow(2,e.retryCount-1);await new Promise(r=>setTimeout(r,i))}e.status="PROCESSING",await this.storage.save(e),this.emit("queue_update",this.queue);let s=e;if(this.config.onBeforeSend){let t=await this.config.onBeforeSend(e);t&&(s=t)}try{let t=await this.performRequest(s);if(e.status="COMPLETED",this.emit("process_success",{id:e.id,response:t}),e.tempId&&t&&typeof t=="object"&&"id"in t){let i=String(t.id);await this.resolveDependencies(e.tempId,i)}this.queue.shift(),await this.storage.delete(e.id),this.emit("queue_update",this.queue)}catch(t){console.error("Request failed",t);let i=this.config.retryPolicy?.maxRetries??3;e.retryCount<i?(e.status="RETRYING",e.retryCount++,e.error=String(t),await this.storage.save(e),this.emit("queue_update",this.queue)):(e.status="FAILED",e.error=String(t),this.queue.shift(),await this.storage.delete(e.id),this.emit("process_fail",{id:e.id,error:t}),this.emit("queue_update",this.queue))}}finally{this.isProcessing=!1,this.queue.length>0&&this.process()}}}async resolveDependencies(e,s){for(let t of this.queue){if(t.status!=="PENDING")continue;let i=!1;if(t.url.includes(e)&&(t.url=t.url.replace(e,s),i=!0),t.body){let r=JSON.stringify(t.body);if(r.includes(e)){let o=r.replaceAll(e,s);t.body=JSON.parse(o),i=!0}}i&&await this.storage.save(t)}}async performRequest(e){let s={...e.headers},t;e.body instanceof FormData||e.body instanceof Blob||e.body instanceof File?t=e.body:e.body&&(t=JSON.stringify(e.body),s["Content-Type"]||(s["Content-Type"]="application/json"));let i=await fetch(e.url,{method:e.method,body:t,headers:s});if(!i.ok)throw new Error(`HTTP Error ${i.status}`);let r=await i.text();try{return r?JSON.parse(r):{}}catch{return{}}}async getQueue(){return await this.readyPromise,[...this.queue]}};export{c as RequestQueue};
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "sync-later",
3
+ "version": "1.0.0",
4
+ "description": "Zero-dependency, offline-first mutation engine for REST APIs. Features reliable persistence, dependency chaining, automatic retries with backoff, and file upload support.",
5
+ "keywords": [
6
+ "rest-api",
7
+ "offline-first",
8
+ "queue",
9
+ "request-queue",
10
+ "mutation-queue",
11
+ "fetch",
12
+ "retry",
13
+ "backoff",
14
+ "persistence",
15
+ "indexeddb",
16
+ "network-resilience",
17
+ "typescript",
18
+ "zero-dependency",
19
+ "file-upload",
20
+ "resilience"
21
+ ],
22
+ "main": "./dist/index.js",
23
+ "module": "./dist/index.mjs",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.mjs",
29
+ "require": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsup src/index.ts --format cjs,esm --dts --minify --clean",
37
+ "test": "vitest --environment happy-dom",
38
+ "lint": "eslint ."
39
+ },
40
+ "author": "denisetiya",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/denisetiya/sync-later.git"
44
+ },
45
+ "homepage": "https://github.com/denisetiya/sync-later#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/denisetiya/sync-later/issues"
48
+ },
49
+ "license": "ISC",
50
+ "release": {
51
+ "branches": [
52
+ "main"
53
+ ],
54
+ "plugins": [
55
+ "@semantic-release/commit-analyzer",
56
+ "@semantic-release/release-notes-generator",
57
+ "@semantic-release/changelog",
58
+ "@semantic-release/npm",
59
+ [
60
+ "@semantic-release/git",
61
+ {
62
+ "assets": [
63
+ "package.json",
64
+ "CHANGELOG.md"
65
+ ],
66
+ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
67
+ }
68
+ ],
69
+ "@semantic-release/github"
70
+ ]
71
+ },
72
+ "packageManager": "pnpm@10.27.0",
73
+ "devDependencies": {
74
+ "@eslint/js": "^9.39.2",
75
+ "@semantic-release/changelog": "^6.0.3",
76
+ "@semantic-release/git": "^10.0.1",
77
+ "@semantic-release/github": "^12.0.2",
78
+ "@semantic-release/npm": "^13.1.3",
79
+ "@types/node": "^25.0.3",
80
+ "conventional-changelog-conventionalcommits": "^9.1.0",
81
+ "eslint": "^9.39.2",
82
+ "globals": "^17.0.0",
83
+ "happy-dom": "^20.0.11",
84
+ "semantic-release": "^25.0.2",
85
+ "tsup": "^8.5.1",
86
+ "typescript": "^5.9.3",
87
+ "typescript-eslint": "^8.51.0",
88
+ "vitest": "^4.0.16"
89
+ }
90
+ }