@jayethian/axiom 0.1.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/.editorconfig +8 -0
- package/.gitattributes +4 -0
- package/README.md +1 -0
- package/dist/index.d.mts +102 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +269 -0
- package/dist/index.mjs +238 -0
- package/package.json +33 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/memory.ts +22 -0
- package/src/engine/fetcher.ts +131 -0
- package/src/engine/sync.ts +96 -0
- package/src/index.ts +6 -0
- package/src/react/AxiomProvider.tsx +57 -0
- package/src/react/useAxiomQueue.ts +12 -0
- package/src/types.ts +20 -0
- package/tsconfig.json +16 -0
package/.editorconfig
ADDED
package/.gitattributes
ADDED
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# axiom
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type RequestPriority = 'urgent' | 'background';
|
|
4
|
+
interface QueuedRequest {
|
|
5
|
+
id: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
url: string;
|
|
8
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body?: string;
|
|
11
|
+
priority: RequestPriority;
|
|
12
|
+
retryCount: number;
|
|
13
|
+
}
|
|
14
|
+
interface AxiomConfig {
|
|
15
|
+
baseURL?: string;
|
|
16
|
+
defaultHeaders?: Record<string, string>;
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
onBeforeSync?: (request: QueuedRequest) => Promise<QueuedRequest>;
|
|
19
|
+
onDeadLetter?: (request: QueuedRequest, error: Error) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AxiomStorageAdapter {
|
|
23
|
+
/** Saves a request to the persistent queue */
|
|
24
|
+
save(request: QueuedRequest): Promise<void>;
|
|
25
|
+
/** Retrieves all pending requests, usually ordered by timestamp */
|
|
26
|
+
getAll(): Promise<QueuedRequest[]>;
|
|
27
|
+
/** Removes a specific request after it successfully syncs */
|
|
28
|
+
remove(id: string): Promise<void>;
|
|
29
|
+
/** Wipes the queue entirely (useful for user logout) */
|
|
30
|
+
clearAll(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare class MemoryStorageAdapter implements AxiomStorageAdapter {
|
|
34
|
+
private queue;
|
|
35
|
+
save(request: QueuedRequest): Promise<void>;
|
|
36
|
+
getAll(): Promise<QueuedRequest[]>;
|
|
37
|
+
remove(id: string): Promise<void>;
|
|
38
|
+
clearAll(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare class AxiomEngine {
|
|
42
|
+
private config;
|
|
43
|
+
private storage;
|
|
44
|
+
private syncManager;
|
|
45
|
+
/**
|
|
46
|
+
* Initializes the Axiom engine with your specific rules and storage.
|
|
47
|
+
*/
|
|
48
|
+
create(config: AxiomConfig, storageAdapter?: AxiomStorageAdapter): void;
|
|
49
|
+
forceSync(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Generates a unique ID for queued requests.
|
|
52
|
+
*/
|
|
53
|
+
private generateId;
|
|
54
|
+
/**
|
|
55
|
+
* The core POST method.
|
|
56
|
+
*/
|
|
57
|
+
post<T>(url: string, data?: any, options?: {
|
|
58
|
+
priority?: RequestPriority;
|
|
59
|
+
}): Promise<{
|
|
60
|
+
data?: T;
|
|
61
|
+
status: number;
|
|
62
|
+
isQueued: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
/** The core GET method.
|
|
65
|
+
* Note: GET requests typically don't have a body, but we still want to queue them if offline.
|
|
66
|
+
*/
|
|
67
|
+
get<T>(url: string, options?: {
|
|
68
|
+
priority?: RequestPriority;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
data?: T;
|
|
71
|
+
status: number;
|
|
72
|
+
isQueued: boolean;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* Internal logic to fire the request or catch the network drop.
|
|
76
|
+
*/
|
|
77
|
+
private attemptFetch;
|
|
78
|
+
/**
|
|
79
|
+
* Saves the request to whatever storage adapter was provided on startup.
|
|
80
|
+
*/
|
|
81
|
+
private enqueueRequest;
|
|
82
|
+
}
|
|
83
|
+
declare const axiom: AxiomEngine;
|
|
84
|
+
|
|
85
|
+
declare const AxiomProvider: React.FC<{
|
|
86
|
+
config: AxiomConfig;
|
|
87
|
+
storageAdapter?: AxiomStorageAdapter;
|
|
88
|
+
/** * A function that takes a callback, calls it whenever network state changes,
|
|
89
|
+
* and returns an unsubscribe function.
|
|
90
|
+
*/
|
|
91
|
+
networkListener: (callback: (isOnline: boolean) => void) => any;
|
|
92
|
+
children: React.ReactNode;
|
|
93
|
+
}>;
|
|
94
|
+
|
|
95
|
+
declare function useAxiomQueue(): {
|
|
96
|
+
/** Boolean indicating if the device currently has an active connection */
|
|
97
|
+
isOnline: boolean;
|
|
98
|
+
/** Manually trigger the background sync manager */
|
|
99
|
+
forceSync: () => Promise<void>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export { type AxiomConfig, AxiomEngine, AxiomProvider, type AxiomStorageAdapter, MemoryStorageAdapter, type QueuedRequest, type RequestPriority, axiom, useAxiomQueue };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type RequestPriority = 'urgent' | 'background';
|
|
4
|
+
interface QueuedRequest {
|
|
5
|
+
id: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
url: string;
|
|
8
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body?: string;
|
|
11
|
+
priority: RequestPriority;
|
|
12
|
+
retryCount: number;
|
|
13
|
+
}
|
|
14
|
+
interface AxiomConfig {
|
|
15
|
+
baseURL?: string;
|
|
16
|
+
defaultHeaders?: Record<string, string>;
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
onBeforeSync?: (request: QueuedRequest) => Promise<QueuedRequest>;
|
|
19
|
+
onDeadLetter?: (request: QueuedRequest, error: Error) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AxiomStorageAdapter {
|
|
23
|
+
/** Saves a request to the persistent queue */
|
|
24
|
+
save(request: QueuedRequest): Promise<void>;
|
|
25
|
+
/** Retrieves all pending requests, usually ordered by timestamp */
|
|
26
|
+
getAll(): Promise<QueuedRequest[]>;
|
|
27
|
+
/** Removes a specific request after it successfully syncs */
|
|
28
|
+
remove(id: string): Promise<void>;
|
|
29
|
+
/** Wipes the queue entirely (useful for user logout) */
|
|
30
|
+
clearAll(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare class MemoryStorageAdapter implements AxiomStorageAdapter {
|
|
34
|
+
private queue;
|
|
35
|
+
save(request: QueuedRequest): Promise<void>;
|
|
36
|
+
getAll(): Promise<QueuedRequest[]>;
|
|
37
|
+
remove(id: string): Promise<void>;
|
|
38
|
+
clearAll(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare class AxiomEngine {
|
|
42
|
+
private config;
|
|
43
|
+
private storage;
|
|
44
|
+
private syncManager;
|
|
45
|
+
/**
|
|
46
|
+
* Initializes the Axiom engine with your specific rules and storage.
|
|
47
|
+
*/
|
|
48
|
+
create(config: AxiomConfig, storageAdapter?: AxiomStorageAdapter): void;
|
|
49
|
+
forceSync(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Generates a unique ID for queued requests.
|
|
52
|
+
*/
|
|
53
|
+
private generateId;
|
|
54
|
+
/**
|
|
55
|
+
* The core POST method.
|
|
56
|
+
*/
|
|
57
|
+
post<T>(url: string, data?: any, options?: {
|
|
58
|
+
priority?: RequestPriority;
|
|
59
|
+
}): Promise<{
|
|
60
|
+
data?: T;
|
|
61
|
+
status: number;
|
|
62
|
+
isQueued: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
/** The core GET method.
|
|
65
|
+
* Note: GET requests typically don't have a body, but we still want to queue them if offline.
|
|
66
|
+
*/
|
|
67
|
+
get<T>(url: string, options?: {
|
|
68
|
+
priority?: RequestPriority;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
data?: T;
|
|
71
|
+
status: number;
|
|
72
|
+
isQueued: boolean;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* Internal logic to fire the request or catch the network drop.
|
|
76
|
+
*/
|
|
77
|
+
private attemptFetch;
|
|
78
|
+
/**
|
|
79
|
+
* Saves the request to whatever storage adapter was provided on startup.
|
|
80
|
+
*/
|
|
81
|
+
private enqueueRequest;
|
|
82
|
+
}
|
|
83
|
+
declare const axiom: AxiomEngine;
|
|
84
|
+
|
|
85
|
+
declare const AxiomProvider: React.FC<{
|
|
86
|
+
config: AxiomConfig;
|
|
87
|
+
storageAdapter?: AxiomStorageAdapter;
|
|
88
|
+
/** * A function that takes a callback, calls it whenever network state changes,
|
|
89
|
+
* and returns an unsubscribe function.
|
|
90
|
+
*/
|
|
91
|
+
networkListener: (callback: (isOnline: boolean) => void) => any;
|
|
92
|
+
children: React.ReactNode;
|
|
93
|
+
}>;
|
|
94
|
+
|
|
95
|
+
declare function useAxiomQueue(): {
|
|
96
|
+
/** Boolean indicating if the device currently has an active connection */
|
|
97
|
+
isOnline: boolean;
|
|
98
|
+
/** Manually trigger the background sync manager */
|
|
99
|
+
forceSync: () => Promise<void>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export { type AxiomConfig, AxiomEngine, AxiomProvider, type AxiomStorageAdapter, MemoryStorageAdapter, type QueuedRequest, type RequestPriority, axiom, useAxiomQueue };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
AxiomEngine: () => AxiomEngine,
|
|
24
|
+
AxiomProvider: () => AxiomProvider,
|
|
25
|
+
MemoryStorageAdapter: () => MemoryStorageAdapter,
|
|
26
|
+
axiom: () => axiom,
|
|
27
|
+
useAxiomQueue: () => useAxiomQueue
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/adapters/memory.ts
|
|
32
|
+
var MemoryStorageAdapter = class {
|
|
33
|
+
constructor() {
|
|
34
|
+
this.queue = /* @__PURE__ */ new Map();
|
|
35
|
+
}
|
|
36
|
+
async save(request) {
|
|
37
|
+
this.queue.set(request.id, request);
|
|
38
|
+
}
|
|
39
|
+
async getAll() {
|
|
40
|
+
return Array.from(this.queue.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
41
|
+
}
|
|
42
|
+
async remove(id) {
|
|
43
|
+
this.queue.delete(id);
|
|
44
|
+
}
|
|
45
|
+
async clearAll() {
|
|
46
|
+
this.queue.clear();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/engine/sync.ts
|
|
51
|
+
var SyncManager = class {
|
|
52
|
+
constructor(storage, config) {
|
|
53
|
+
this.storage = storage;
|
|
54
|
+
this.config = config;
|
|
55
|
+
this.isSyncing = false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The master trigger. Call this when the OS reports network is back online.
|
|
59
|
+
*/
|
|
60
|
+
async flushQueue() {
|
|
61
|
+
if (this.isSyncing) return;
|
|
62
|
+
this.isSyncing = true;
|
|
63
|
+
try {
|
|
64
|
+
const pending = await this.storage.getAll();
|
|
65
|
+
if (pending.length === 0) {
|
|
66
|
+
this.isSyncing = false;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(`[Axiom] Network restored. Syncing ${pending.length} queued requests...`);
|
|
70
|
+
for (const request of pending) {
|
|
71
|
+
await this.processRequest(request);
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
this.isSyncing = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Attempts to execute a single saved request.
|
|
79
|
+
*/
|
|
80
|
+
async processRequest(request) {
|
|
81
|
+
let reqToSync = request;
|
|
82
|
+
if (this.config.onBeforeSync) {
|
|
83
|
+
try {
|
|
84
|
+
reqToSync = await this.config.onBeforeSync(request);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error(`[Axiom] onBeforeSync failed for ${request.id}. Skipping.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(reqToSync.url, {
|
|
92
|
+
method: reqToSync.method,
|
|
93
|
+
headers: reqToSync.headers,
|
|
94
|
+
body: reqToSync.body
|
|
95
|
+
});
|
|
96
|
+
if (response.ok || response.status >= 400 && response.status < 500) {
|
|
97
|
+
await this.storage.remove(reqToSync.id);
|
|
98
|
+
console.log(`[Axiom] Request ${reqToSync.id} synced successfully.`);
|
|
99
|
+
} else {
|
|
100
|
+
await this.handleFailure(reqToSync);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
await this.handleFailure(reqToSync);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* MITIGATION 2: The Dead Letter Queue logic
|
|
108
|
+
*/
|
|
109
|
+
async handleFailure(request) {
|
|
110
|
+
request.retryCount += 1;
|
|
111
|
+
const maxRetries = this.config.maxRetries || 3;
|
|
112
|
+
if (request.retryCount >= maxRetries) {
|
|
113
|
+
console.warn(`[Axiom] Request ${request.id} failed ${maxRetries} times. Moving to Dead Letter.`);
|
|
114
|
+
await this.storage.remove(request.id);
|
|
115
|
+
if (this.config.onDeadLetter) {
|
|
116
|
+
this.config.onDeadLetter(request, new Error("Max retries exceeded"));
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
await this.storage.save(request);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// src/engine/fetcher.ts
|
|
125
|
+
var AxiomEngine = class {
|
|
126
|
+
constructor() {
|
|
127
|
+
this.config = {};
|
|
128
|
+
this.storage = new MemoryStorageAdapter();
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Initializes the Axiom engine with your specific rules and storage.
|
|
132
|
+
*/
|
|
133
|
+
create(config, storageAdapter) {
|
|
134
|
+
this.config = config;
|
|
135
|
+
if (storageAdapter) {
|
|
136
|
+
this.storage = storageAdapter;
|
|
137
|
+
}
|
|
138
|
+
this.syncManager = new SyncManager(this.storage, this.config);
|
|
139
|
+
}
|
|
140
|
+
async forceSync() {
|
|
141
|
+
if (!this.syncManager) {
|
|
142
|
+
console.error("[Axiom] Engine not initialized. Call axiom.create() first.");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await this.syncManager.flushQueue();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Generates a unique ID for queued requests.
|
|
149
|
+
*/
|
|
150
|
+
generateId() {
|
|
151
|
+
return Math.random().toString(36).substring(2, 15) + Date.now().toString(36);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The core POST method.
|
|
155
|
+
*/
|
|
156
|
+
async post(url, data, options) {
|
|
157
|
+
const fullUrl = this.config.baseURL ? `${this.config.baseURL}${url}` : url;
|
|
158
|
+
const request = {
|
|
159
|
+
id: this.generateId(),
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
url: fullUrl,
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
...this.config.defaultHeaders || {}
|
|
166
|
+
},
|
|
167
|
+
body: data ? JSON.stringify(data) : void 0,
|
|
168
|
+
priority: options?.priority || "urgent",
|
|
169
|
+
retryCount: 0
|
|
170
|
+
};
|
|
171
|
+
return this.attemptFetch(request);
|
|
172
|
+
}
|
|
173
|
+
/** The core GET method.
|
|
174
|
+
* Note: GET requests typically don't have a body, but we still want to queue them if offline.
|
|
175
|
+
*/
|
|
176
|
+
async get(url, options) {
|
|
177
|
+
const fullUrl = this.config.baseURL ? `${this.config.baseURL}${url}` : url;
|
|
178
|
+
const request = {
|
|
179
|
+
id: this.generateId(),
|
|
180
|
+
timestamp: Date.now(),
|
|
181
|
+
url: fullUrl,
|
|
182
|
+
method: "GET",
|
|
183
|
+
headers: {
|
|
184
|
+
...this.config.defaultHeaders || {}
|
|
185
|
+
},
|
|
186
|
+
priority: options?.priority || "urgent",
|
|
187
|
+
retryCount: 0
|
|
188
|
+
};
|
|
189
|
+
return this.attemptFetch(request);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Internal logic to fire the request or catch the network drop.
|
|
193
|
+
*/
|
|
194
|
+
async attemptFetch(request) {
|
|
195
|
+
try {
|
|
196
|
+
const response = await fetch(request.url, {
|
|
197
|
+
method: request.method,
|
|
198
|
+
headers: request.headers,
|
|
199
|
+
body: request.body
|
|
200
|
+
});
|
|
201
|
+
if (response.ok) {
|
|
202
|
+
const responseData = await response.json().catch(() => null);
|
|
203
|
+
return { data: responseData, status: response.status, isQueued: false };
|
|
204
|
+
}
|
|
205
|
+
if (response.status >= 500) {
|
|
206
|
+
throw new Error("Server Error");
|
|
207
|
+
}
|
|
208
|
+
return { status: response.status, isQueued: false };
|
|
209
|
+
} catch (error) {
|
|
210
|
+
await this.enqueueRequest(request);
|
|
211
|
+
return { status: 202, isQueued: true };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Saves the request to whatever storage adapter was provided on startup.
|
|
216
|
+
*/
|
|
217
|
+
async enqueueRequest(request) {
|
|
218
|
+
console.warn(`[Axiom] Network unreachable. Queuing request ${request.id}`);
|
|
219
|
+
await this.storage.save(request);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
var axiom = new AxiomEngine();
|
|
223
|
+
|
|
224
|
+
// src/react/AxiomProvider.tsx
|
|
225
|
+
var import_react = require("react");
|
|
226
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
227
|
+
var AxiomContext = (0, import_react.createContext)({
|
|
228
|
+
isOnline: true,
|
|
229
|
+
forceSync: async () => {
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
var AxiomProvider = ({ config, storageAdapter, networkListener, children }) => {
|
|
233
|
+
const [isOnline, setIsOnline] = (0, import_react.useState)(true);
|
|
234
|
+
(0, import_react.useEffect)(() => {
|
|
235
|
+
axiom.create(config, storageAdapter);
|
|
236
|
+
const unsubscribe = networkListener((onlineStatus) => {
|
|
237
|
+
setIsOnline(onlineStatus);
|
|
238
|
+
if (onlineStatus) {
|
|
239
|
+
axiom.forceSync();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
return () => {
|
|
243
|
+
if (typeof unsubscribe === "function") {
|
|
244
|
+
unsubscribe();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}, [config, storageAdapter, networkListener]);
|
|
248
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AxiomContext.Provider, { value: { isOnline, forceSync: () => axiom.forceSync() }, children });
|
|
249
|
+
};
|
|
250
|
+
var useAxiomContext = () => (0, import_react.useContext)(AxiomContext);
|
|
251
|
+
|
|
252
|
+
// src/react/useAxiomQueue.ts
|
|
253
|
+
function useAxiomQueue() {
|
|
254
|
+
const { isOnline, forceSync } = useAxiomContext();
|
|
255
|
+
return {
|
|
256
|
+
/** Boolean indicating if the device currently has an active connection */
|
|
257
|
+
isOnline,
|
|
258
|
+
/** Manually trigger the background sync manager */
|
|
259
|
+
forceSync
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
263
|
+
0 && (module.exports = {
|
|
264
|
+
AxiomEngine,
|
|
265
|
+
AxiomProvider,
|
|
266
|
+
MemoryStorageAdapter,
|
|
267
|
+
axiom,
|
|
268
|
+
useAxiomQueue
|
|
269
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// src/adapters/memory.ts
|
|
2
|
+
var MemoryStorageAdapter = class {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.queue = /* @__PURE__ */ new Map();
|
|
5
|
+
}
|
|
6
|
+
async save(request) {
|
|
7
|
+
this.queue.set(request.id, request);
|
|
8
|
+
}
|
|
9
|
+
async getAll() {
|
|
10
|
+
return Array.from(this.queue.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
11
|
+
}
|
|
12
|
+
async remove(id) {
|
|
13
|
+
this.queue.delete(id);
|
|
14
|
+
}
|
|
15
|
+
async clearAll() {
|
|
16
|
+
this.queue.clear();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// src/engine/sync.ts
|
|
21
|
+
var SyncManager = class {
|
|
22
|
+
constructor(storage, config) {
|
|
23
|
+
this.storage = storage;
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.isSyncing = false;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The master trigger. Call this when the OS reports network is back online.
|
|
29
|
+
*/
|
|
30
|
+
async flushQueue() {
|
|
31
|
+
if (this.isSyncing) return;
|
|
32
|
+
this.isSyncing = true;
|
|
33
|
+
try {
|
|
34
|
+
const pending = await this.storage.getAll();
|
|
35
|
+
if (pending.length === 0) {
|
|
36
|
+
this.isSyncing = false;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(`[Axiom] Network restored. Syncing ${pending.length} queued requests...`);
|
|
40
|
+
for (const request of pending) {
|
|
41
|
+
await this.processRequest(request);
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
this.isSyncing = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Attempts to execute a single saved request.
|
|
49
|
+
*/
|
|
50
|
+
async processRequest(request) {
|
|
51
|
+
let reqToSync = request;
|
|
52
|
+
if (this.config.onBeforeSync) {
|
|
53
|
+
try {
|
|
54
|
+
reqToSync = await this.config.onBeforeSync(request);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(`[Axiom] onBeforeSync failed for ${request.id}. Skipping.`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(reqToSync.url, {
|
|
62
|
+
method: reqToSync.method,
|
|
63
|
+
headers: reqToSync.headers,
|
|
64
|
+
body: reqToSync.body
|
|
65
|
+
});
|
|
66
|
+
if (response.ok || response.status >= 400 && response.status < 500) {
|
|
67
|
+
await this.storage.remove(reqToSync.id);
|
|
68
|
+
console.log(`[Axiom] Request ${reqToSync.id} synced successfully.`);
|
|
69
|
+
} else {
|
|
70
|
+
await this.handleFailure(reqToSync);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await this.handleFailure(reqToSync);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* MITIGATION 2: The Dead Letter Queue logic
|
|
78
|
+
*/
|
|
79
|
+
async handleFailure(request) {
|
|
80
|
+
request.retryCount += 1;
|
|
81
|
+
const maxRetries = this.config.maxRetries || 3;
|
|
82
|
+
if (request.retryCount >= maxRetries) {
|
|
83
|
+
console.warn(`[Axiom] Request ${request.id} failed ${maxRetries} times. Moving to Dead Letter.`);
|
|
84
|
+
await this.storage.remove(request.id);
|
|
85
|
+
if (this.config.onDeadLetter) {
|
|
86
|
+
this.config.onDeadLetter(request, new Error("Max retries exceeded"));
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
await this.storage.save(request);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// src/engine/fetcher.ts
|
|
95
|
+
var AxiomEngine = class {
|
|
96
|
+
constructor() {
|
|
97
|
+
this.config = {};
|
|
98
|
+
this.storage = new MemoryStorageAdapter();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Initializes the Axiom engine with your specific rules and storage.
|
|
102
|
+
*/
|
|
103
|
+
create(config, storageAdapter) {
|
|
104
|
+
this.config = config;
|
|
105
|
+
if (storageAdapter) {
|
|
106
|
+
this.storage = storageAdapter;
|
|
107
|
+
}
|
|
108
|
+
this.syncManager = new SyncManager(this.storage, this.config);
|
|
109
|
+
}
|
|
110
|
+
async forceSync() {
|
|
111
|
+
if (!this.syncManager) {
|
|
112
|
+
console.error("[Axiom] Engine not initialized. Call axiom.create() first.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await this.syncManager.flushQueue();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Generates a unique ID for queued requests.
|
|
119
|
+
*/
|
|
120
|
+
generateId() {
|
|
121
|
+
return Math.random().toString(36).substring(2, 15) + Date.now().toString(36);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The core POST method.
|
|
125
|
+
*/
|
|
126
|
+
async post(url, data, options) {
|
|
127
|
+
const fullUrl = this.config.baseURL ? `${this.config.baseURL}${url}` : url;
|
|
128
|
+
const request = {
|
|
129
|
+
id: this.generateId(),
|
|
130
|
+
timestamp: Date.now(),
|
|
131
|
+
url: fullUrl,
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
...this.config.defaultHeaders || {}
|
|
136
|
+
},
|
|
137
|
+
body: data ? JSON.stringify(data) : void 0,
|
|
138
|
+
priority: options?.priority || "urgent",
|
|
139
|
+
retryCount: 0
|
|
140
|
+
};
|
|
141
|
+
return this.attemptFetch(request);
|
|
142
|
+
}
|
|
143
|
+
/** The core GET method.
|
|
144
|
+
* Note: GET requests typically don't have a body, but we still want to queue them if offline.
|
|
145
|
+
*/
|
|
146
|
+
async get(url, options) {
|
|
147
|
+
const fullUrl = this.config.baseURL ? `${this.config.baseURL}${url}` : url;
|
|
148
|
+
const request = {
|
|
149
|
+
id: this.generateId(),
|
|
150
|
+
timestamp: Date.now(),
|
|
151
|
+
url: fullUrl,
|
|
152
|
+
method: "GET",
|
|
153
|
+
headers: {
|
|
154
|
+
...this.config.defaultHeaders || {}
|
|
155
|
+
},
|
|
156
|
+
priority: options?.priority || "urgent",
|
|
157
|
+
retryCount: 0
|
|
158
|
+
};
|
|
159
|
+
return this.attemptFetch(request);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Internal logic to fire the request or catch the network drop.
|
|
163
|
+
*/
|
|
164
|
+
async attemptFetch(request) {
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetch(request.url, {
|
|
167
|
+
method: request.method,
|
|
168
|
+
headers: request.headers,
|
|
169
|
+
body: request.body
|
|
170
|
+
});
|
|
171
|
+
if (response.ok) {
|
|
172
|
+
const responseData = await response.json().catch(() => null);
|
|
173
|
+
return { data: responseData, status: response.status, isQueued: false };
|
|
174
|
+
}
|
|
175
|
+
if (response.status >= 500) {
|
|
176
|
+
throw new Error("Server Error");
|
|
177
|
+
}
|
|
178
|
+
return { status: response.status, isQueued: false };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
await this.enqueueRequest(request);
|
|
181
|
+
return { status: 202, isQueued: true };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Saves the request to whatever storage adapter was provided on startup.
|
|
186
|
+
*/
|
|
187
|
+
async enqueueRequest(request) {
|
|
188
|
+
console.warn(`[Axiom] Network unreachable. Queuing request ${request.id}`);
|
|
189
|
+
await this.storage.save(request);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
var axiom = new AxiomEngine();
|
|
193
|
+
|
|
194
|
+
// src/react/AxiomProvider.tsx
|
|
195
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
196
|
+
import { jsx } from "react/jsx-runtime";
|
|
197
|
+
var AxiomContext = createContext({
|
|
198
|
+
isOnline: true,
|
|
199
|
+
forceSync: async () => {
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
var AxiomProvider = ({ config, storageAdapter, networkListener, children }) => {
|
|
203
|
+
const [isOnline, setIsOnline] = useState(true);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
axiom.create(config, storageAdapter);
|
|
206
|
+
const unsubscribe = networkListener((onlineStatus) => {
|
|
207
|
+
setIsOnline(onlineStatus);
|
|
208
|
+
if (onlineStatus) {
|
|
209
|
+
axiom.forceSync();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
return () => {
|
|
213
|
+
if (typeof unsubscribe === "function") {
|
|
214
|
+
unsubscribe();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}, [config, storageAdapter, networkListener]);
|
|
218
|
+
return /* @__PURE__ */ jsx(AxiomContext.Provider, { value: { isOnline, forceSync: () => axiom.forceSync() }, children });
|
|
219
|
+
};
|
|
220
|
+
var useAxiomContext = () => useContext(AxiomContext);
|
|
221
|
+
|
|
222
|
+
// src/react/useAxiomQueue.ts
|
|
223
|
+
function useAxiomQueue() {
|
|
224
|
+
const { isOnline, forceSync } = useAxiomContext();
|
|
225
|
+
return {
|
|
226
|
+
/** Boolean indicating if the device currently has an active connection */
|
|
227
|
+
isOnline,
|
|
228
|
+
/** Manually trigger the background sync manager */
|
|
229
|
+
forceSync
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
export {
|
|
233
|
+
AxiomEngine,
|
|
234
|
+
AxiomProvider,
|
|
235
|
+
MemoryStorageAdapter,
|
|
236
|
+
axiom,
|
|
237
|
+
useAxiomQueue
|
|
238
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jayethian/axiom",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"maintainers": [
|
|
5
|
+
"Jayetheus"
|
|
6
|
+
],
|
|
7
|
+
"description": "A resilient, offline-first fetch wrapper",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"module": "./dist/index.mjs",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
16
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"typecheck": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"author": "Jayetheus",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/react": "^19.2.14",
|
|
24
|
+
"react": "^19.2.6",
|
|
25
|
+
"tsup": "^8.5.1",
|
|
26
|
+
"typescript": "^6.0.3",
|
|
27
|
+
"vitest": "^4.1.6"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=17.0.0"
|
|
31
|
+
},
|
|
32
|
+
"packageManager": "yarn@4.12.0"
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { QueuedRequest } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface AxiomStorageAdapter {
|
|
4
|
+
/** Saves a request to the persistent queue */
|
|
5
|
+
save(request: QueuedRequest): Promise<void>;
|
|
6
|
+
|
|
7
|
+
/** Retrieves all pending requests, usually ordered by timestamp */
|
|
8
|
+
getAll(): Promise<QueuedRequest[]>;
|
|
9
|
+
|
|
10
|
+
/** Removes a specific request after it successfully syncs */
|
|
11
|
+
remove(id: string): Promise<void>;
|
|
12
|
+
|
|
13
|
+
/** Wipes the queue entirely (useful for user logout) */
|
|
14
|
+
clearAll(): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AxiomStorageAdapter } from './index';
|
|
2
|
+
import { QueuedRequest } from '../types';
|
|
3
|
+
|
|
4
|
+
export class MemoryStorageAdapter implements AxiomStorageAdapter {
|
|
5
|
+
private queue: Map<string, QueuedRequest> = new Map();
|
|
6
|
+
|
|
7
|
+
async save(request: QueuedRequest): Promise<void> {
|
|
8
|
+
this.queue.set(request.id, request);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async getAll(): Promise<QueuedRequest[]> {
|
|
12
|
+
return Array.from(this.queue.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async remove(id: string): Promise<void> {
|
|
16
|
+
this.queue.delete(id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async clearAll(): Promise<void> {
|
|
20
|
+
this.queue.clear();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { AxiomConfig, QueuedRequest, RequestPriority } from '../types';
|
|
2
|
+
import { AxiomStorageAdapter } from '../adapters';
|
|
3
|
+
import { MemoryStorageAdapter } from '../adapters/memory';
|
|
4
|
+
import { SyncManager } from './sync';
|
|
5
|
+
|
|
6
|
+
export class AxiomEngine {
|
|
7
|
+
private config: AxiomConfig = {};
|
|
8
|
+
private storage: AxiomStorageAdapter = new MemoryStorageAdapter();
|
|
9
|
+
private syncManager!: SyncManager;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initializes the Axiom engine with your specific rules and storage.
|
|
13
|
+
*/
|
|
14
|
+
public create(config: AxiomConfig, storageAdapter?: AxiomStorageAdapter): void {
|
|
15
|
+
this.config = config;
|
|
16
|
+
if (storageAdapter) {
|
|
17
|
+
this.storage = storageAdapter;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
this.syncManager = new SyncManager(this.storage, this.config);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public async forceSync(): Promise<void> {
|
|
24
|
+
if (!this.syncManager) {
|
|
25
|
+
console.error("[Axiom] Engine not initialized. Call axiom.create() first.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await this.syncManager.flushQueue();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generates a unique ID for queued requests.
|
|
33
|
+
*/
|
|
34
|
+
private generateId(): string {
|
|
35
|
+
return Math.random().toString(36).substring(2, 15) + Date.now().toString(36);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The core POST method.
|
|
40
|
+
*/
|
|
41
|
+
public async post<T>(
|
|
42
|
+
url: string,
|
|
43
|
+
data?: any,
|
|
44
|
+
options?: { priority?: RequestPriority }
|
|
45
|
+
): Promise<{ data?: T; status: number; isQueued: boolean }> {
|
|
46
|
+
|
|
47
|
+
const fullUrl = this.config.baseURL ? `${this.config.baseURL}${url}` : url;
|
|
48
|
+
|
|
49
|
+
const request: QueuedRequest = {
|
|
50
|
+
id: this.generateId(),
|
|
51
|
+
timestamp: Date.now(),
|
|
52
|
+
url: fullUrl,
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
...(this.config.defaultHeaders || {})
|
|
57
|
+
},
|
|
58
|
+
body: data ? JSON.stringify(data) : undefined,
|
|
59
|
+
priority: options?.priority || 'urgent',
|
|
60
|
+
retryCount: 0
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return this.attemptFetch<T>(request);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The core GET method.
|
|
67
|
+
* Note: GET requests typically don't have a body, but we still want to queue them if offline.
|
|
68
|
+
*/
|
|
69
|
+
public async get<T>(
|
|
70
|
+
url: string,
|
|
71
|
+
options?: { priority?: RequestPriority }
|
|
72
|
+
): Promise<{ data?: T; status: number; isQueued: boolean }> {
|
|
73
|
+
|
|
74
|
+
const fullUrl = this.config.baseURL ? `${this.config.baseURL}${url}` : url;
|
|
75
|
+
|
|
76
|
+
const request: QueuedRequest = {
|
|
77
|
+
id: this.generateId(),
|
|
78
|
+
timestamp: Date.now(),
|
|
79
|
+
url: fullUrl,
|
|
80
|
+
method: 'GET',
|
|
81
|
+
headers: {
|
|
82
|
+
...(this.config.defaultHeaders || {})
|
|
83
|
+
},
|
|
84
|
+
priority: options?.priority || 'urgent',
|
|
85
|
+
retryCount: 0
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return this.attemptFetch<T>(request);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Internal logic to fire the request or catch the network drop.
|
|
93
|
+
*/
|
|
94
|
+
private async attemptFetch<T>(request: QueuedRequest): Promise<{ data?: T; status: number; isQueued: boolean }> {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(request.url, {
|
|
97
|
+
method: request.method,
|
|
98
|
+
headers: request.headers,
|
|
99
|
+
body: request.body
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (response.ok) {
|
|
103
|
+
const responseData = await response.json().catch(() => null);
|
|
104
|
+
return { data: responseData, status: response.status, isQueued: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if (response.status >= 500) {
|
|
109
|
+
throw new Error('Server Error');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
return { status: response.status, isQueued: false };
|
|
114
|
+
|
|
115
|
+
} catch (error) {
|
|
116
|
+
await this.enqueueRequest(request);
|
|
117
|
+
|
|
118
|
+
return { status: 202, isQueued: true };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Saves the request to whatever storage adapter was provided on startup.
|
|
124
|
+
*/
|
|
125
|
+
private async enqueueRequest(request: QueuedRequest): Promise<void> {
|
|
126
|
+
console.warn(`[Axiom] Network unreachable. Queuing request ${request.id}`);
|
|
127
|
+
await this.storage.save(request);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const axiom = new AxiomEngine();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AxiomStorageAdapter } from '../adapters';
|
|
2
|
+
import { AxiomConfig, QueuedRequest } from '../types';
|
|
3
|
+
|
|
4
|
+
export class SyncManager {
|
|
5
|
+
private isSyncing = false;
|
|
6
|
+
|
|
7
|
+
constructor(
|
|
8
|
+
private storage: AxiomStorageAdapter,
|
|
9
|
+
private config: AxiomConfig
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The master trigger. Call this when the OS reports network is back online.
|
|
14
|
+
*/
|
|
15
|
+
public async flushQueue(): Promise<void> {
|
|
16
|
+
// Prevent overlapping syncs if the network toggles rapidly
|
|
17
|
+
if (this.isSyncing) return;
|
|
18
|
+
this.isSyncing = true;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const pending = await this.storage.getAll();
|
|
22
|
+
|
|
23
|
+
if (pending.length === 0) {
|
|
24
|
+
this.isSyncing = false;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log(`[Axiom] Network restored. Syncing ${pending.length} queued requests...`);
|
|
29
|
+
|
|
30
|
+
// We process sequentially to maintain the order of user actions
|
|
31
|
+
for (const request of pending) {
|
|
32
|
+
await this.processRequest(request);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
} finally {
|
|
36
|
+
this.isSyncing = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Attempts to execute a single saved request.
|
|
42
|
+
*/
|
|
43
|
+
private async processRequest(request: QueuedRequest): Promise<void> {
|
|
44
|
+
let reqToSync = request;
|
|
45
|
+
|
|
46
|
+
// MITIGATION 1: Just-in-Time Headers (Refresh Auth Tokens)
|
|
47
|
+
if (this.config.onBeforeSync) {
|
|
48
|
+
try {
|
|
49
|
+
reqToSync = await this.config.onBeforeSync(request);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`[Axiom] onBeforeSync failed for ${request.id}. Skipping.`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(reqToSync.url, {
|
|
58
|
+
method: reqToSync.method,
|
|
59
|
+
headers: reqToSync.headers,
|
|
60
|
+
body: reqToSync.body
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// If it's a success OR a permanent 400 error (like bad data), remove it.
|
|
64
|
+
if (response.ok || (response.status >= 400 && response.status < 500)) {
|
|
65
|
+
await this.storage.remove(reqToSync.id);
|
|
66
|
+
console.log(`[Axiom] Request ${reqToSync.id} synced successfully.`);
|
|
67
|
+
} else {
|
|
68
|
+
// It's a 500 Server Error. Treat as a failure and retry later.
|
|
69
|
+
await this.handleFailure(reqToSync);
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Network dropped again mid-sync.
|
|
73
|
+
await this.handleFailure(reqToSync);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* MITIGATION 2: The Dead Letter Queue logic
|
|
79
|
+
*/
|
|
80
|
+
private async handleFailure(request: QueuedRequest): Promise<void> {
|
|
81
|
+
request.retryCount += 1;
|
|
82
|
+
const maxRetries = this.config.maxRetries || 3;
|
|
83
|
+
|
|
84
|
+
if (request.retryCount >= maxRetries) {
|
|
85
|
+
console.warn(`[Axiom] Request ${request.id} failed ${maxRetries} times. Moving to Dead Letter.`);
|
|
86
|
+
await this.storage.remove(request.id);
|
|
87
|
+
|
|
88
|
+
if (this.config.onDeadLetter) {
|
|
89
|
+
this.config.onDeadLetter(request, new Error('Max retries exceeded'));
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
// Save the updated retry count back to storage
|
|
93
|
+
await this.storage.save(request);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { axiom } from '../engine/fetcher';
|
|
3
|
+
import { AxiomConfig } from '../types';
|
|
4
|
+
import { AxiomStorageAdapter } from '../adapters';
|
|
5
|
+
|
|
6
|
+
interface AxiomContextType {
|
|
7
|
+
isOnline: boolean;
|
|
8
|
+
forceSync: () => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Create a safe default context
|
|
12
|
+
const AxiomContext = createContext<AxiomContextType>({
|
|
13
|
+
isOnline: true,
|
|
14
|
+
forceSync: async () => {},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const AxiomProvider: React.FC<{
|
|
18
|
+
config: AxiomConfig;
|
|
19
|
+
storageAdapter?: AxiomStorageAdapter;
|
|
20
|
+
/** * A function that takes a callback, calls it whenever network state changes,
|
|
21
|
+
* and returns an unsubscribe function.
|
|
22
|
+
*/
|
|
23
|
+
networkListener: (callback: (isOnline: boolean) => void) => any;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}> = ({ config, storageAdapter, networkListener, children }) => {
|
|
26
|
+
const [isOnline, setIsOnline] = useState(true);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
// 1. Boot up the Axiom engine
|
|
30
|
+
axiom.create(config, storageAdapter);
|
|
31
|
+
|
|
32
|
+
// 2. Attach the OS-level network listener
|
|
33
|
+
const unsubscribe = networkListener((onlineStatus) => {
|
|
34
|
+
setIsOnline(onlineStatus);
|
|
35
|
+
|
|
36
|
+
// 3. The Magic: If the internet comes back, automatically flush the queue
|
|
37
|
+
if (onlineStatus) {
|
|
38
|
+
axiom.forceSync();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Cleanup listener on unmount
|
|
43
|
+
return () => {
|
|
44
|
+
if (typeof unsubscribe === 'function') {
|
|
45
|
+
unsubscribe();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}, [config, storageAdapter, networkListener]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<AxiomContext.Provider value={{ isOnline, forceSync: () => axiom.forceSync() }}>
|
|
52
|
+
{children}
|
|
53
|
+
</AxiomContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const useAxiomContext = () => useContext(AxiomContext);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useAxiomContext } from './AxiomProvider';
|
|
2
|
+
|
|
3
|
+
export function useAxiomQueue() {
|
|
4
|
+
const { isOnline, forceSync } = useAxiomContext();
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
/** Boolean indicating if the device currently has an active connection */
|
|
8
|
+
isOnline,
|
|
9
|
+
/** Manually trigger the background sync manager */
|
|
10
|
+
forceSync,
|
|
11
|
+
};
|
|
12
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type RequestPriority = 'urgent' | 'background';
|
|
2
|
+
|
|
3
|
+
export interface QueuedRequest {
|
|
4
|
+
id: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
url: string;
|
|
7
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
body?: string;
|
|
10
|
+
priority: RequestPriority;
|
|
11
|
+
retryCount: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AxiomConfig {
|
|
15
|
+
baseURL?: string;
|
|
16
|
+
defaultHeaders?: Record<string, string>;
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
onBeforeSync?: (request: QueuedRequest) => Promise<QueuedRequest>;
|
|
19
|
+
onDeadLetter?: (request: QueuedRequest, error: Error) => void;
|
|
20
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"ignoreDeprecations": "6.0"
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
16
|
+
}
|