Skip to content

Commit 1e872b0

Browse files
committed
split restful-queue into its own module and add tests for the happy-path
1 parent ce61732 commit 1e872b0

File tree

5 files changed

+6380
-0
lines changed

5 files changed

+6380
-0
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.DS_Store
2+
node_modules
3+
coverage

__tests__/index.js

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {jest} from '@jest/globals'
2+
3+
// Code under test
4+
import { queueAndAttemptRequest, getOutstandingRequests, syncRequests } from '../index.js';
5+
6+
/**
7+
* FakeIndexDB doesn't support blobs
8+
* See https://github.com/dumbmatter/fakeIndexedDB/issues/56
9+
* Therefore, extend the Request class to fake the blob function to return something which fakeIndexDB can handle
10+
*/
11+
class BloblessRequest extends Request {
12+
clone() {
13+
const clone = super.clone();
14+
clone.blob = this.blob;
15+
return clone;
16+
}
17+
async blob() {
18+
return this.body;
19+
}
20+
}
21+
22+
describe('Queuing with reliable network', () => {
23+
test('Add requests to queue', async () => {
24+
const mockFetch = jest.spyOn(global, "fetch").mockResolvedValue(new Response({status: 204, statusText: "No Content"}));
25+
const request1 = new BloblessRequest("https://example.com/api/endpoint1", {method: 'PUT'});
26+
const request2 = new BloblessRequest("https://example.com/api/endpoint2", {method: 'PATCH'});
27+
28+
await queueAndAttemptRequest(request1);
29+
30+
let queue = await getOutstandingRequests();
31+
expect(queue).toHaveLength(1);
32+
expect(queue[0].method).toEqual(request1.method);
33+
expect(queue[0].url).toEqual(request1.url);
34+
35+
await new Promise(process.nextTick);
36+
queue = await getOutstandingRequests();
37+
expect(queue).toHaveLength(0);
38+
expect(mockFetch.mock.calls).toHaveLength(1);
39+
expect(mockFetch.mock.calls[0][0].method).toEqual(request1.method);
40+
expect(mockFetch.mock.calls[0][0].url).toEqual(request1.url);
41+
42+
await queueAndAttemptRequest(request2);
43+
queue = await getOutstandingRequests();
44+
expect(queue).toHaveLength(1);
45+
expect(queue[0].method).toEqual(request2.method);
46+
expect(queue[0].url).toEqual(request2.url);
47+
48+
await new Promise(process.nextTick);
49+
queue = await getOutstandingRequests();
50+
expect(queue).toHaveLength(0);
51+
expect(mockFetch.mock.calls).toHaveLength(2);
52+
expect(mockFetch.mock.calls[1][0].method).toEqual(request2.method);
53+
expect(mockFetch.mock.calls[1][0].url).toEqual(request2.url);
54+
});
55+
});

index.js

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { openDB } from 'idb';
2+
3+
const dbPromise = openDB('restful-queue', 1, {
4+
upgrade(db) {
5+
db.createObjectStore('requests', {
6+
keyPath: 'id',
7+
autoIncrement: true,
8+
});
9+
},
10+
});
11+
12+
/**
13+
* Stores a request in the queue and then attempts to send it to the server
14+
*
15+
* @param {Request} request A Request object from the Fetch API, which isn't unusable
16+
*
17+
* @returns {Promise}
18+
*/
19+
export async function queueAndAttemptRequest(request) {
20+
const requestid = await queueRequest(request);
21+
syncRequests();
22+
return new Response(new Blob(), {status: 202, statusText: "Added to Queue"});
23+
}
24+
25+
/**
26+
* Stores a request object in indexDB
27+
*
28+
* @param {Request} request A Request object from the Fetch API
29+
*
30+
* @returns {Promise.<number>} A promise which resolves with a unique requestid when succesfully stored (or rejects on failure)
31+
*/
32+
async function queueRequest(request) {
33+
34+
// Store a cloned version of the request, so the orginal can still be fetched later
35+
request = request.clone();
36+
const { url, method } = request;
37+
const headers = [...request.headers];
38+
const body = await request.blob();
39+
const rawData = { url, method, headers, body };
40+
41+
const db = await dbPromise;
42+
const requestid = await db.add('requests', rawData);
43+
return requestid;
44+
}
45+
46+
/**
47+
* Attempts to fetch a request from the queue. If successful, the request is removed from the queue.
48+
*
49+
* @param {number} requestid The unique ID for this request stored in indexDB
50+
* @param {Request} request A Request object from the Fetch API
51+
*
52+
* @returns {Promise.<Response>} A promise which resolves with the requests response following removal from the queue (or rejects on failure)
53+
*/
54+
async function attemptRequest(requestid, request) {
55+
const response = await fetch(request);
56+
await removeFromQueue(requestid);
57+
return response;
58+
}
59+
60+
/**
61+
* Removes a request from the queue
62+
*
63+
* @param {number} requestid The unique ID for the request to remove from indexDB+
64+
*
65+
* @returns {Promise} A promise which resolves when succesfully removed (or rejects on failure)
66+
*/
67+
async function removeFromQueue(requestid) {
68+
const db = await dbPromise;
69+
await db.delete('requests', requestid);
70+
}
71+
72+
/**
73+
* Fetches all the outstanding requests, along with their IDs from indexDB
74+
* NB: getOutstandRequests is a simplified public wrapper for this function, which doesn't expose the internal requestids
75+
*
76+
* @returns {Array.<{id: number, request: Request}>} An array containing requests and their associated requestids
77+
*/
78+
async function getOutstandingRequestsAndIds() {
79+
const db = await dbPromise;
80+
return (await db.getAll('requests')).map(raw => {
81+
const { url, method, headers, body, id } = raw;
82+
const request = new Request(url, {method, headers, body});
83+
return {id, request};
84+
});
85+
}
86+
87+
/**
88+
* Fetches all the outstanding requests from indexDB
89+
*
90+
* @returns {Array.<Request>} An array containing Fetch API Request objects
91+
*/
92+
export async function getOutstandingRequests() {
93+
return (await getOutstandingRequestsAndIds())
94+
.map(({request}) => request);
95+
}
96+
97+
let currentSync = null;
98+
99+
/**
100+
* Starts off an asynchronous function to sync up any outstanding requests with the server
101+
* Ensures there's only one running at a time to avoid race conditions
102+
*/
103+
export function syncRequests() {
104+
105+
// If there's no existing sync, then start one
106+
if (!currentSync) {
107+
currentSync = attemptOutstandingRequests();
108+
return;
109+
}
110+
111+
// Otherwise, queue another sync after the current one.
112+
currentSync = currentSync.then(attemptOutstandingRequests);
113+
114+
}
115+
116+
/**
117+
* Attempts to fetch an outstanding requests, and if successful remove them from the queue.
118+
* Stops after the first failure and doesn't attempt any subsequent requests in the queue.
119+
* NB: Calling this function whilst a previous invocation hasn't completed yet, may cause a race condition. Use the `syncRequests` function to avoid this.
120+
*
121+
* @returns {Promise} A promise which resolves when all requests have been succesfully removed from the queue, or rejects after encountering the first failure
122+
*/
123+
async function attemptOutstandingRequests() {
124+
const requests = await getOutstandingRequestsAndIds();
125+
for (const {id, request} of requests) {
126+
await attemptRequest(id, request);
127+
}
128+
}

0 commit comments

Comments
 (0)