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 +137 -0
- package/README.md +136 -0
- package/dist/index.d.mts +59 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +1 -0
- package/package.json +90 -0
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
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|