├── .eslintrc.json
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── CONTRIBUTE.md
├── LICENSE
├── README.md
├── __tests__
├── Metrics.test.js
├── loggers.test.js
├── md5.test.js
└── sw.test.js
├── babel.config.js
├── helpers
├── initializeIndexDb.js
├── loggers.js
├── md5.js
└── metrics.js
├── index.js
├── jest.config.js
├── loQL.js
├── package-lock.json
└── package.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "ecmaVersion": 12,
4 | "sourceType": "module"
5 | },
6 | "rules": {
7 | "no-const-assign": 2,
8 | "no-dupe-else-if": 2,
9 | "no-dupe-keys": 2,
10 | "no-irregular-whitespace": 2,
11 | "no-mixed-spaces-and-tabs": 2,
12 | "no-unused-labels": 2,
13 | "no-useless-escape": 2,
14 | "no-delete-var": 2,
15 | "indent": ["warn", 2],
16 | "no-unused-vars": ["off", { "vars": "local" }],
17 | "prefer-const": "warn",
18 | "quotes": ["warn", "single"],
19 | "semi": ["warn", "always"],
20 | "space-infix-ops": "warn"
21 | },
22 | "env": {
23 | "es2021": true,
24 | "node": true,
25 | "serviceworker": true,
26 | "browser": true
27 | }
28 | }
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | # Runs test test suite
2 | name: Node.js CI
3 | on:
4 | pull_request:
5 | branches: ['main', 'dev']
6 | push:
7 | branches: 'main'
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 | container: node:10.18-jessie # Run in NodeJS container
12 | steps:
13 | - uses: actions/checkout@v2
14 | - name: Testing
15 | uses: actions/setup-node@v2
16 | with:
17 | node-version:
18 | cache: 'npm'
19 | - run: npm ci
20 | - run: npm run test:ci
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/CONTRIBUTE.md:
--------------------------------------------------------------------------------
1 | # Contribute to loQL
2 |
3 | Thank you for your interest in improving loQL.
4 |
5 | ## Pull Requests
6 |
7 | 1. Fork the repo and create your branch from `main`.
8 | 2. If you've added code that should be tested, add tests to the __tests__ folder.
9 | 3. If you've changed APIs, update the documentation.
10 | 4. Ensure the test suite passes.
11 | 5. Make sure your code lints.
12 | 6. Submit your pull request!
13 |
14 | ## Issues
15 |
16 | We use GitHub issues to track public bugs. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue.
17 |
18 | ## Coding Style
19 |
20 | Linting enforced by configuration in eslintrc.json file.
21 |
22 | ## License
23 |
24 | By contributing, you agree that your contributions will be licensed under loQL's MIT License.
25 |
26 | ### References
27 |
28 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/master/CONTRIBUTING.md)
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 loQL
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # loQL
2 |
3 | A light, modular npm package for performant client-side GraphQL caching with Service Workers and IndexedDB. More detailed information about installing and configuring loQL can be found here.
4 |
5 | ## Installation
6 |
7 | Install via [npm](https://www.npmjs.com/package/loql-cache)
8 |
9 | ```bash
10 | npm install loql-cache
11 | ```
12 | Or with Yarn
13 |
14 | ```bash
15 | yarn add loql-cache
16 | ```
17 |
18 | The service worker must also be included in your build folder. With webpack:
19 |
20 | ```javascript
21 | const path = require('path');
22 |
23 | module.exports = {
24 | entry: {
25 | bundle: './client/index.js',
26 | loQL: './node_modules/loql-cache/loQL.js', // Add this line!
27 | },
28 | output: {
29 | path: path.resolve(__dirname, 'public'),
30 | filename: '[name].js',
31 | clean: true,
32 | },
33 | devServer: {
34 | static: './client',
35 | },
36 | };
37 | ```
38 |
39 | ## Register the service worker
40 |
41 | ```javascript
42 | import { register } from "loql-cache";
43 | register({ gqlEndpoints: ["https://foo.com"] });
44 | ```
45 |
46 | ## Settings
47 |
48 |
49 | `gqlEndpoints: string[] Required`
50 |
51 | Enable caching for specific GraphQL endpoint URLs. Network calls from the browser to any URL not listed here will be ignored by the service worker and the response data will not be cached.
52 |
53 | `useMetrics: boolean Optional`
54 |
55 | Enable metrics collection.
56 |
57 | `cacheMethod: string Optional`
58 |
59 | Desired caching strategy. The loql-cache package supports both "cache-first" and "cache-network" policies.
60 |
61 | `cacheExpirationLimit: Integer Optional`
62 |
63 | The interval, in milliseconds, after which cached data is considered stale.
64 |
65 | `doNotCacheGlobal: string[] Optional`
66 |
67 | Fields on a GraphQL query that will prevent the query from being cached, no matter the endpoint.
68 |
69 | `doNotCacheCustom:{ [url]: string[] } Optional`
70 |
71 | This setting is like doNotCacheGlobal, but can be used on a per-endpoint basis.
72 |
73 | ### Example Configuration
74 |
75 | ```javascript
76 | const loQLConfiguration = {
77 | gqlEndpoints: ['http://localhost:<###>/api/graphql', 'https://.com/graphql'],
78 | useMetrics: false,
79 | cacheExpirationLimit: 20000,
80 | cacheMethod: 'cache-network',
81 | doNotCacheGlobal: [],
82 | doNotCacheCustom: {
83 | 'http://localhost:<###>/api/graphql': ['password'],
84 | 'https://.com/graphql': ['account', 'real_time_data'];
85 | }
86 | };
87 |
88 | register(loqlConfiguration);
89 | ```
90 |
91 | ## Features
92 | - Enables offline use: IndexedDB storage provides high-capacity and persistent storage, while keeping reads/writes asynchronous
93 | - Minimum-dependency: No server-side component, avoid the use of large libraries
94 | - Cache validation: Keep data fresh with shorter expiration limits, cache-network strategy, or both!
95 | - Easy-to-use: Install package, register and configure service worker, start caching
96 | - Flexible: Works with GQL queries made as both fetch POST and GET requests
97 | - Easily exempt types of queries from being cached at the global or endpoint-specific level
98 |
99 | ## Usage Notes
100 | - Caching is currently only supported for query-type operations. Mutations, subscriptions, etc will still run,
101 | but will not be cached.
102 | - Cached data normalization feature is disabled.
103 |
104 | ## Contributing
105 | Contributions are welcome. Please read CONTRIBUTE.md prior to making a Pull Request.
106 |
107 | ## License
108 | [MIT](https://choosealicense.com/licenses/mit/)
109 |
--------------------------------------------------------------------------------
/__tests__/Metrics.test.js:
--------------------------------------------------------------------------------
1 | import { Metrics } from '../helpers/metrics';
2 |
3 | describe('The Metrics class.', () => {
4 | test('Metrics class should create a new instance with the proper fields and methods.', () => {
5 | const metrics = new Metrics();
6 | expect(metrics.isCached).toBe(false);
7 | expect(typeof metrics.start).toBe('number');
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/__tests__/loggers.test.js:
--------------------------------------------------------------------------------
1 | import { sw_log, sw_error_log } from '../helpers/loggers';
2 |
3 | xdescribe('The loggers.', () => {
4 | test('The sw_log function should log to the console an arbitrary number of messages.', () => {
5 | return false;
6 | });
7 |
8 | test('The sw_log_error function should log to the console an arbitrary number of errors.', () => {
9 | return false;
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/__tests__/md5.test.js:
--------------------------------------------------------------------------------
1 | import { ourMD5 } from '../helpers/md5';
2 |
3 | describe('The hashing function.', () => {
4 | test('Function should hash string to the same result each time.', () => {
5 | const result = ourMD5('This is a long string that should be hashed!');
6 | const result2 = ourMD5('This is a long string that should be hashed!');
7 | expect(result).toEqual(result2);
8 | });
9 |
10 | test('Two strings should yield different results.', () => {
11 | const result = ourMD5('This is a short string');
12 | const result2 = ourMD5('This is a long string that should be hashed!');
13 | expect(result).not.toEqual(result2);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/__tests__/sw.test.js:
--------------------------------------------------------------------------------
1 | import { doNotCacheCheck } from '../loQL';
2 |
3 | describe('doNotCacheCheck', () => {
4 | const settings = {
5 | gqlEndpoints: ['https://myapi.com/graphql', 'https://spiderman.com/graphql'],
6 | useMetrics: true,
7 | cacheMethod: 'cache-first',
8 | cacheExpirationLimit: null,
9 | doNotCacheGlobal: ['password'],
10 | doNotCacheCustom: { 'https://spiderman.com/graphql': ['villans'] },
11 | };
12 |
13 | test('Function should return true if sensitive data is found in global check.', () => {
14 | const urlObject = new URL('https://myapi.com/graphql');
15 | const queryCST = { operationType: 'query', fields: ['password', 'name', 'email'] };
16 | const result = doNotCacheCheck(queryCST, urlObject, settings);
17 | expect(result).toBe(true);
18 | });
19 |
20 | test('Function should return false if sensitive data is not found in global check.', () => {
21 | const urlObject = new URL('https://myapi.com/graphql');
22 | const queryCST = { operationType: 'query', fields: ['name', 'email'] };
23 | const result = doNotCacheCheck(queryCST, urlObject, settings);
24 | expect(result).toBe(false);
25 | });
26 |
27 | test('Function should return true if sensitive data is found in specific url.', () => {
28 | const urlObject = new URL('https://spiderman.com/graphql');
29 | const queryCST = { operationType: 'query', fields: ['name', 'villans'] };
30 | const result = doNotCacheCheck(queryCST, urlObject, settings);
31 | expect(result).toBe(true);
32 | });
33 |
34 | test('Function should return false if sensitive data is not found in specific url.', () => {
35 | const urlObject = new URL('https://spiderman.com/graphql');
36 | const queryCST = { operationType: 'query', fields: ['name', 'hometown'] };
37 | const result = doNotCacheCheck(queryCST, urlObject, settings);
38 | expect(result).toBe(false);
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | test: {
4 | plugins: ['@babel/plugin-transform-modules-commonjs'],
5 | },
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/helpers/initializeIndexDb.js:
--------------------------------------------------------------------------------
1 | import { openDB } from 'idb';
2 |
3 | /* Creates three new IDB object stores:
4 | * Queries (stores actual data)
5 | * Metrics (stores metadata about queries)
6 | * Settings (stores user settings for service worker)
7 | */
8 | const dbPromise = openDB('gql-store', 1, {
9 | upgrade(db) {
10 | db.createObjectStore('metrics');
11 | db.createObjectStore('queries');
12 | db.createObjectStore('settings');
13 | },
14 | });
15 |
16 | /* Functions for interacting with metrics ObjectStore.
17 | * These functions allow the service worker to get and set data.
18 | */
19 | export async function get(name, key) {
20 | return (await dbPromise).get(name, key);
21 | }
22 |
23 | export async function del(name, key) {
24 | return (await dbPromise).delete(name, key);
25 | }
26 |
27 | export async function set(name, key, val) {
28 | return (await dbPromise).put(name, val, key);
29 | }
30 |
31 | export async function clear(name) {
32 | return (await dbPromise).clear(name);
33 | }
34 |
35 | export async function setMany(objectStore, keyValuePairs) {
36 | const db = await openDB('gql-store');
37 | const transaction = db.transaction([objectStore], 'readwrite');
38 | await Promise.all(
39 | keyValuePairs.map(([val, key]) => transaction.store.put(key, val)),
40 | transaction.done
41 | );
42 | }
43 |
44 | export async function getAll(name) {
45 | return (await dbPromise).getAll(name);
46 | }
47 |
48 | export async function keys(name) {
49 | return (await dbPromise).getAllKeys(name);
50 | }
51 |
--------------------------------------------------------------------------------
/helpers/loggers.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Makes logs from the service worker green and bold!
3 | */
4 | export const sw_log = (...strings) =>
5 | strings.forEach((msg) => {
6 | console.log(`%c ${msg}`, 'color: green; font-weight: bold');
7 | });
8 |
9 | /*
10 | * Makes errors from the service worker red and bold!
11 | */
12 | export const sw_error_log = (...strings) =>
13 | strings.forEach((msg) => {
14 | console.log(`%c ${msg}`, 'color: red; font-weight: bold');
15 | });
16 |
--------------------------------------------------------------------------------
/helpers/md5.js:
--------------------------------------------------------------------------------
1 | /* Rather than importing the entire MD5 package,
2 | * we're simply importing this function directly.
3 | * This makes our package significantly lighter.
4 | */
5 | export var ourMD5 = function (string) {
6 | function RotateLeft(lValue, iShiftBits) {
7 | return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
8 | }
9 |
10 | function AddUnsigned(lX, lY) {
11 | var lX4, lY4, lX8, lY8, lResult;
12 | lX8 = lX & 0x80000000;
13 | lY8 = lY & 0x80000000;
14 | lX4 = lX & 0x40000000;
15 | lY4 = lY & 0x40000000;
16 | lResult = (lX & 0x3fffffff) + (lY & 0x3fffffff);
17 | if (lX4 & lY4) {
18 | return lResult ^ 0x80000000 ^ lX8 ^ lY8;
19 | }
20 | if (lX4 | lY4) {
21 | if (lResult & 0x40000000) {
22 | return lResult ^ 0xc0000000 ^ lX8 ^ lY8;
23 | } else {
24 | return lResult ^ 0x40000000 ^ lX8 ^ lY8;
25 | }
26 | } else {
27 | return lResult ^ lX8 ^ lY8;
28 | }
29 | }
30 |
31 | function F(x, y, z) {
32 | return (x & y) | (~x & z);
33 | }
34 | function G(x, y, z) {
35 | return (x & z) | (y & ~z);
36 | }
37 | function H(x, y, z) {
38 | return x ^ y ^ z;
39 | }
40 | function I(x, y, z) {
41 | return y ^ (x | ~z);
42 | }
43 |
44 | function FF(a, b, c, d, x, s, ac) {
45 | a = AddUnsigned(a, AddUnsigned(AddUnsigned(F(b, c, d), x), ac));
46 | return AddUnsigned(RotateLeft(a, s), b);
47 | }
48 |
49 | function GG(a, b, c, d, x, s, ac) {
50 | a = AddUnsigned(a, AddUnsigned(AddUnsigned(G(b, c, d), x), ac));
51 | return AddUnsigned(RotateLeft(a, s), b);
52 | }
53 |
54 | function HH(a, b, c, d, x, s, ac) {
55 | a = AddUnsigned(a, AddUnsigned(AddUnsigned(H(b, c, d), x), ac));
56 | return AddUnsigned(RotateLeft(a, s), b);
57 | }
58 |
59 | function II(a, b, c, d, x, s, ac) {
60 | a = AddUnsigned(a, AddUnsigned(AddUnsigned(I(b, c, d), x), ac));
61 | return AddUnsigned(RotateLeft(a, s), b);
62 | }
63 |
64 | function ConvertToWordArray(string) {
65 | var lWordCount;
66 | var lMessageLength = string.length;
67 | var lNumberOfWords_temp1 = lMessageLength + 8;
68 | var lNumberOfWords_temp2 =
69 | (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64;
70 | var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16;
71 | var lWordArray = Array(lNumberOfWords - 1);
72 | var lBytePosition = 0;
73 | var lByteCount = 0;
74 | while (lByteCount < lMessageLength) {
75 | lWordCount = (lByteCount - (lByteCount % 4)) / 4;
76 | lBytePosition = (lByteCount % 4) * 8;
77 | lWordArray[lWordCount] =
78 | lWordArray[lWordCount] |
79 | (string.charCodeAt(lByteCount) << lBytePosition);
80 | lByteCount++;
81 | }
82 | lWordCount = (lByteCount - (lByteCount % 4)) / 4;
83 | lBytePosition = (lByteCount % 4) * 8;
84 | lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
85 | lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
86 | lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
87 | return lWordArray;
88 | }
89 |
90 | function WordToHex(lValue) {
91 | var WordToHexValue = '',
92 | WordToHexValue_temp = '',
93 | lByte,
94 | lCount;
95 | for (lCount = 0; lCount <= 3; lCount++) {
96 | lByte = (lValue >>> (lCount * 8)) & 255;
97 | WordToHexValue_temp = '0' + lByte.toString(16);
98 | WordToHexValue =
99 | WordToHexValue +
100 | WordToHexValue_temp.substr(WordToHexValue_temp.length - 2, 2);
101 | }
102 | return WordToHexValue;
103 | }
104 |
105 | function Utf8Encode(string) {
106 | string = string.replace(/\r\n/g, '\n');
107 | var utftext = '';
108 |
109 | for (var n = 0; n < string.length; n++) {
110 | var c = string.charCodeAt(n);
111 |
112 | if (c < 128) {
113 | utftext += String.fromCharCode(c);
114 | } else if (c > 127 && c < 2048) {
115 | utftext += String.fromCharCode((c >> 6) | 192);
116 | utftext += String.fromCharCode((c & 63) | 128);
117 | } else {
118 | utftext += String.fromCharCode((c >> 12) | 224);
119 | utftext += String.fromCharCode(((c >> 6) & 63) | 128);
120 | utftext += String.fromCharCode((c & 63) | 128);
121 | }
122 | }
123 |
124 | return utftext;
125 | }
126 |
127 | var x = Array();
128 | var k, AA, BB, CC, DD, a, b, c, d;
129 | var S11 = 7,
130 | S12 = 12,
131 | S13 = 17,
132 | S14 = 22;
133 | var S21 = 5,
134 | S22 = 9,
135 | S23 = 14,
136 | S24 = 20;
137 | var S31 = 4,
138 | S32 = 11,
139 | S33 = 16,
140 | S34 = 23;
141 | var S41 = 6,
142 | S42 = 10,
143 | S43 = 15,
144 | S44 = 21;
145 |
146 | string = Utf8Encode(string);
147 |
148 | x = ConvertToWordArray(string);
149 |
150 | a = 0x67452301;
151 | b = 0xefcdab89;
152 | c = 0x98badcfe;
153 | d = 0x10325476;
154 |
155 | for (k = 0; k < x.length; k += 16) {
156 | AA = a;
157 | BB = b;
158 | CC = c;
159 | DD = d;
160 | a = FF(a, b, c, d, x[k + 0], S11, 0xd76aa478);
161 | d = FF(d, a, b, c, x[k + 1], S12, 0xe8c7b756);
162 | c = FF(c, d, a, b, x[k + 2], S13, 0x242070db);
163 | b = FF(b, c, d, a, x[k + 3], S14, 0xc1bdceee);
164 | a = FF(a, b, c, d, x[k + 4], S11, 0xf57c0faf);
165 | d = FF(d, a, b, c, x[k + 5], S12, 0x4787c62a);
166 | c = FF(c, d, a, b, x[k + 6], S13, 0xa8304613);
167 | b = FF(b, c, d, a, x[k + 7], S14, 0xfd469501);
168 | a = FF(a, b, c, d, x[k + 8], S11, 0x698098d8);
169 | d = FF(d, a, b, c, x[k + 9], S12, 0x8b44f7af);
170 | c = FF(c, d, a, b, x[k + 10], S13, 0xffff5bb1);
171 | b = FF(b, c, d, a, x[k + 11], S14, 0x895cd7be);
172 | a = FF(a, b, c, d, x[k + 12], S11, 0x6b901122);
173 | d = FF(d, a, b, c, x[k + 13], S12, 0xfd987193);
174 | c = FF(c, d, a, b, x[k + 14], S13, 0xa679438e);
175 | b = FF(b, c, d, a, x[k + 15], S14, 0x49b40821);
176 | a = GG(a, b, c, d, x[k + 1], S21, 0xf61e2562);
177 | d = GG(d, a, b, c, x[k + 6], S22, 0xc040b340);
178 | c = GG(c, d, a, b, x[k + 11], S23, 0x265e5a51);
179 | b = GG(b, c, d, a, x[k + 0], S24, 0xe9b6c7aa);
180 | a = GG(a, b, c, d, x[k + 5], S21, 0xd62f105d);
181 | d = GG(d, a, b, c, x[k + 10], S22, 0x2441453);
182 | c = GG(c, d, a, b, x[k + 15], S23, 0xd8a1e681);
183 | b = GG(b, c, d, a, x[k + 4], S24, 0xe7d3fbc8);
184 | a = GG(a, b, c, d, x[k + 9], S21, 0x21e1cde6);
185 | d = GG(d, a, b, c, x[k + 14], S22, 0xc33707d6);
186 | c = GG(c, d, a, b, x[k + 3], S23, 0xf4d50d87);
187 | b = GG(b, c, d, a, x[k + 8], S24, 0x455a14ed);
188 | a = GG(a, b, c, d, x[k + 13], S21, 0xa9e3e905);
189 | d = GG(d, a, b, c, x[k + 2], S22, 0xfcefa3f8);
190 | c = GG(c, d, a, b, x[k + 7], S23, 0x676f02d9);
191 | b = GG(b, c, d, a, x[k + 12], S24, 0x8d2a4c8a);
192 | a = HH(a, b, c, d, x[k + 5], S31, 0xfffa3942);
193 | d = HH(d, a, b, c, x[k + 8], S32, 0x8771f681);
194 | c = HH(c, d, a, b, x[k + 11], S33, 0x6d9d6122);
195 | b = HH(b, c, d, a, x[k + 14], S34, 0xfde5380c);
196 | a = HH(a, b, c, d, x[k + 1], S31, 0xa4beea44);
197 | d = HH(d, a, b, c, x[k + 4], S32, 0x4bdecfa9);
198 | c = HH(c, d, a, b, x[k + 7], S33, 0xf6bb4b60);
199 | b = HH(b, c, d, a, x[k + 10], S34, 0xbebfbc70);
200 | a = HH(a, b, c, d, x[k + 13], S31, 0x289b7ec6);
201 | d = HH(d, a, b, c, x[k + 0], S32, 0xeaa127fa);
202 | c = HH(c, d, a, b, x[k + 3], S33, 0xd4ef3085);
203 | b = HH(b, c, d, a, x[k + 6], S34, 0x4881d05);
204 | a = HH(a, b, c, d, x[k + 9], S31, 0xd9d4d039);
205 | d = HH(d, a, b, c, x[k + 12], S32, 0xe6db99e5);
206 | c = HH(c, d, a, b, x[k + 15], S33, 0x1fa27cf8);
207 | b = HH(b, c, d, a, x[k + 2], S34, 0xc4ac5665);
208 | a = II(a, b, c, d, x[k + 0], S41, 0xf4292244);
209 | d = II(d, a, b, c, x[k + 7], S42, 0x432aff97);
210 | c = II(c, d, a, b, x[k + 14], S43, 0xab9423a7);
211 | b = II(b, c, d, a, x[k + 5], S44, 0xfc93a039);
212 | a = II(a, b, c, d, x[k + 12], S41, 0x655b59c3);
213 | d = II(d, a, b, c, x[k + 3], S42, 0x8f0ccc92);
214 | c = II(c, d, a, b, x[k + 10], S43, 0xffeff47d);
215 | b = II(b, c, d, a, x[k + 1], S44, 0x85845dd1);
216 | a = II(a, b, c, d, x[k + 8], S41, 0x6fa87e4f);
217 | d = II(d, a, b, c, x[k + 15], S42, 0xfe2ce6e0);
218 | c = II(c, d, a, b, x[k + 6], S43, 0xa3014314);
219 | b = II(b, c, d, a, x[k + 13], S44, 0x4e0811a1);
220 | a = II(a, b, c, d, x[k + 4], S41, 0xf7537e82);
221 | d = II(d, a, b, c, x[k + 11], S42, 0xbd3af235);
222 | c = II(c, d, a, b, x[k + 2], S43, 0x2ad7d2bb);
223 | b = II(b, c, d, a, x[k + 9], S44, 0xeb86d391);
224 | a = AddUnsigned(a, AA);
225 | b = AddUnsigned(b, BB);
226 | c = AddUnsigned(c, CC);
227 | d = AddUnsigned(d, DD);
228 | }
229 |
230 | var temp = WordToHex(a) + WordToHex(b) + WordToHex(c) + WordToHex(d);
231 |
232 | return temp.toLowerCase();
233 | };
234 |
--------------------------------------------------------------------------------
/helpers/metrics.js:
--------------------------------------------------------------------------------
1 | import { sw_log, sw_error_log } from './loggers';
2 | import { get, set, keys, getAll } from './initializeIndexDb';
3 |
4 | export class Metrics {
5 | constructor() {
6 | this.isCached = false;
7 | this.start = Date.now();
8 | }
9 |
10 | /*
11 | Save to IDB, the last time the query was run to the API,
12 | and the speed of the query. Keep cached queries and uncached queries separate.
13 | */
14 | async save(hash) {
15 | const timeElapsed = Date.now() - this.start;
16 | const metrics = await get('metrics', hash).catch(sw_error_log);
17 | if (metrics === undefined) {
18 | await set('metrics', hash, {
19 | hash,
20 | uncachedSpeeds: [timeElapsed],
21 | cachedSpeeds: [],
22 | }).catch(sw_error_log);
23 | } else {
24 | if (this.isCached) {
25 | await set('metrics', hash, {
26 | ...metrics,
27 | cachedSpeeds: metrics.cachedSpeeds.concat(timeElapsed),
28 | }).catch(sw_error_log);
29 | } else {
30 | await set('metrics', hash, {
31 | ...metrics,
32 | uncachedSpeeds: metrics.uncachedSpeeds.concat(timeElapsed),
33 | }).catch(sw_error_log);
34 | }
35 | }
36 | }
37 | }
38 |
39 | export async function cachedAvg(hash) {
40 | const data = await get('metrics', hash).catch(sw_error_log);
41 | let totalMilliseconds = 0;
42 | for (const time of data.cachedSpeeds) {
43 | totalMilliseconds += time;
44 | }
45 | return totalMilliseconds / data.cachedSpeeds.length;
46 | }
47 |
48 | export async function uncachedAvg(hash) {
49 | const data = await get('metrics', hash);
50 | let totalMilliseconds = 0;
51 | for (const time of data.uncachedSpeeds) {
52 | totalMilliseconds += time;
53 | }
54 | return totalMilliseconds / data.uncachedSpeeds.length;
55 | }
56 |
57 | export async function avgDiff(hash) {
58 | const cached = await cachedAvg(hash);
59 | const uncached = await uncachedAvg(hash);
60 | console.table({
61 | 'Average Time Saved': Number((uncached - cached).toFixed(2)),
62 | 'Average Cached Speed': Number(cached.toFixed(2)),
63 | 'Average Uncached Speed': Number(uncached.toFixed(2)),
64 | });
65 | }
66 |
67 | export async function summary(log) {
68 | const store = 'metrics';
69 | const metricValues = await getAll(store); // An array of objects from the Metrics store
70 | const individualCachedSpeeds = [];
71 | const individualUncachedSpeeds = [];
72 | let uncachedTotalTime = 0; // Total time of all uncached queries
73 | let cachedTotalTime = 0; // Total time of all cached queries
74 | let uncachedTotalQueries = 0;
75 | let cachedTotalQueries = 0;
76 | let totalUncachedTimeSquared = 0;
77 | let lastCachedQuery;
78 | let lastUncachedQuery;
79 |
80 | for (const metricObject of metricValues) {
81 | let queryUncachedTime = 0;
82 | let queryCachedTime = 0;
83 | for (const time of metricObject['uncachedSpeeds']) {
84 | lastUncachedQuery = time;
85 | individualUncachedSpeeds.push(time);
86 | uncachedTotalQueries += 1; // Increments by 1 to elements within uncachedSpeeds
87 | uncachedTotalTime += time;
88 | queryUncachedTime += time; // Total amount of time to return data to client, for the individual (uncached) query
89 | }
90 |
91 | for (const time of metricObject['cachedSpeeds']) {
92 | individualCachedSpeeds.push(time);
93 | cachedTotalQueries += 1; //increments by 1 to elements within cachedSpeeds
94 | cachedTotalTime += time;
95 | queryCachedTime += time; //total amount of time to return data to client, for the individual (cached) query
96 | }
97 |
98 | totalUncachedTimeSquared +=
99 | (queryUncachedTime / metricObject['uncachedSpeeds'].length) *
100 | metricObject['cachedSpeeds'].length;
101 | }
102 |
103 | const totalUncachedAvg = Number((uncachedTotalTime / uncachedTotalQueries).toFixed(2));
104 | const totalCachedAvg = Number((cachedTotalTime / cachedTotalQueries).toFixed(2));
105 | const percentFaster = ((totalUncachedAvg - totalCachedAvg) / totalCachedAvg) * 100;
106 |
107 | const total = {
108 | 'Uncached Average Time': totalUncachedAvg + 'ms',
109 | 'Cached Average Time': totalCachedAvg + 'ms',
110 | 'Percent Speed Increase From Caching': percentFaster.toFixed(2) + '%',
111 | 'Total Time Saved': totalUncachedTimeSquared - cachedTotalTime + 'ms',
112 | 'Total Query Calls': uncachedTotalQueries + cachedTotalQueries,
113 | 'Created At': new Date(),
114 | };
115 |
116 | const totalDetail = {
117 | uncachedAverageTime: totalUncachedAvg,
118 | cachedAverageTime: totalCachedAvg,
119 | percent: Number(percentFaster.toFixed(2)),
120 | totalTimeSaved: totalUncachedTimeSquared - cachedTotalTime,
121 | totalQueryCalls: uncachedTotalQueries + cachedTotalQueries,
122 | individualCachedSpeeds,
123 | individualUncachedSpeeds,
124 | recentQuery: { averageCachedTime: 1 },
125 | };
126 |
127 | // Don't log in frontend if we pass false.
128 | if (log !== false) {
129 | console.table(total);
130 | }
131 | return totalDetail;
132 | }
133 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import { setMany } from './helpers/initializeIndexDb';
2 | import { sw_log, sw_error_log } from './helpers/loggers';
3 | import { avgDiff, cachedAvg, uncachedAvg, summary } from './helpers/metrics';
4 |
5 | /* gqlEndpoints: An array of endpoint URLs, as strings, to explicitly allow added endpoints to be queried from the client API.
6 | * useMetrics: Enable or disable saving caching metrics to IndexedDB.
7 | * cacheMethod: Desired strategy for serving/updating cached data to the client.
8 | * cacheExpirationLimit: Amount of time (in milliseconds) before cached data is refetched from GraphQL endpoint.
9 | * doNotCacheGlobal: An array of strings (types, as per the endpoint-specific GQL schema),
10 | * and whose inclusion will exempt a query response from being cached regardless of the GraphQL request endpoint.
11 | * doNotCacheCustom: An object where each key is an endpoint, and the corresponding value is an array of strings that references specific types,
12 | * (as defined in the GQL schema) whose inclusion in a query will exempt it from caching.
13 | */
14 |
15 | export const validSettings = [
16 | 'gqlEndpoints',
17 | 'useMetrics',
18 | 'cacheMethod',
19 | 'cacheExpirationLimit',
20 | 'doNotCacheGlobal',
21 | 'doNotCacheCustom',
22 | ];
23 |
24 | export const defaultSettings = {
25 | gqlEndpoints: [],
26 | useMetrics: true,
27 | cacheMethod: 'cache-first',
28 | cacheExpirationLimit: null,
29 | doNotCacheGlobal: [],
30 | doNotCacheCustom: {},
31 | };
32 |
33 | /* Registers service worker pulled in during build steps of webpack/parcel/etc.
34 | * Also creates settings in IDB for service worker passed during registration step.
35 | * Only creates settings that are contained in the validSettings array.
36 | */
37 | export const register = async (userSettings) => {
38 | let settings;
39 | try {
40 | settings = userSettings ? { ...defaultSettings, ...userSettings } : defaultSettings;
41 | } catch (err) {
42 | throw new Error('Please pass an object to configure the cache.');
43 | }
44 |
45 | Object.keys(settings).forEach((key) => {
46 | if (!validSettings.includes(key)) {
47 | throw new Error(`${key} is not a valid configuration setting`);
48 | }
49 | });
50 |
51 | if (navigator.serviceWorker) {
52 | await setMany('settings', Object.entries(settings));
53 | setupMetrics();
54 | navigator.serviceWorker
55 | .register('./loQL.js')
56 | .then((_) => {
57 | sw_log('Service worker registered.');
58 | })
59 | .catch((err) => {
60 | sw_error_log('Service worker not registered.');
61 | console.error(err);
62 | });
63 | } else {
64 | sw_log('Service workers are not possible on this browser.');
65 | }
66 | };
67 |
68 | export function setupMetrics() {
69 | window.avgDiff = avgDiff; // The total time saved for a particular query
70 | window.cachedAvg = cachedAvg; // The average speed for a particular query in the cache
71 | window.uncachedAvg = uncachedAvg; // The average speed for a particular query from the API
72 | window.summary = summary; // Prints the number of cached queries, and information about each of them
73 | }
74 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transform: {
3 | '^.+\\.[t|j]sx?$': 'babel-jest',
4 | },
5 | setupFiles: ['fake-indexeddb/auto'],
6 | testEnvironment: 'jsdom',
7 | };
8 |
--------------------------------------------------------------------------------
/loQL.js:
--------------------------------------------------------------------------------
1 | import { sw_log, sw_error_log } from './helpers/loggers';
2 | import { get, set, setMany } from './helpers/initializeIndexDb';
3 | import { Metrics } from './helpers/metrics';
4 | import { validSettings } from './index';
5 | import { ourMD5 } from './helpers/md5';
6 | import { parse, visit } from 'graphql/language';
7 |
8 | /* Ensure newer versions of service worker take over tab upon reload */
9 | self.addEventListener('install', function (event) {
10 | event.waitUntil(self.skipWaiting());
11 | });
12 |
13 | /*
14 | * Grab settings from IDB set during activation.
15 | * Do this before registering our event listeners.
16 | */
17 | const settings = {};
18 | self.addEventListener('activate', async (event) => {
19 | event.waitUntil(self.clients.claim()); // Take control on first load.
20 | try {
21 | await Promise.all(
22 | validSettings.map(async (setting) => {
23 | const result = await get('settings', setting);
24 | settings[setting] = result;
25 | })
26 | );
27 | sw_log('Service worker settings initialized.');
28 | } catch (err) {
29 | sw_error_log('Could not initialize service worker settings.');
30 | }
31 | });
32 |
33 | /*
34 | Listen for fetch events, and for those to the /graphql endpoint,
35 | run our caching logic, passing in information about the request.
36 | */
37 | self.addEventListener('fetch', async (fetchEvent) => {
38 | const metrics = new Metrics();
39 | const clone = fetchEvent.request.clone();
40 | const { url, method, headers } = clone;
41 | const urlObject = new URL(url);
42 | const { gqlEndpoints } = settings;
43 | const endpoint = urlObject.origin + urlObject.pathname;
44 |
45 | /* Check if the fetch request URL matches a graphQL endpoint as defined in settings. */
46 | if (gqlEndpoints && gqlEndpoints.indexOf(endpoint) !== -1) {
47 | fetchEvent.respondWith(
48 | fetchAndGetResponse({ urlObject, method, headers, metrics, request: fetchEvent.request })
49 | );
50 | }
51 | });
52 |
53 | /* Executes request and delivers response. */
54 | async function fetchAndGetResponse({ urlObject, method, headers, metrics, request }) {
55 | try {
56 | const { data, hashedQuery } = await runCachingLogic({
57 | urlObject,
58 | method,
59 | headers,
60 | metrics,
61 | request,
62 | });
63 | metrics.save(hashedQuery);
64 | return new Response(JSON.stringify(data), { status: 200 });
65 | } catch (err) {
66 | /* Global error catch. Catches errors and logs more detailed information. */
67 | sw_error_log('There was an error in the caching logic!', err);
68 | return await fetch(clone);
69 | }
70 | }
71 |
72 | /*
73 | The main wrapper function for our caching solution.
74 | Generates response data, either through API call or from cache,
75 | and sends it back. Updates the cache asynchronously after response.
76 | */
77 | async function runCachingLogic({ urlObject, method, headers, metrics, request }) {
78 | let query, variables;
79 | try {
80 | ({ query, variables } =
81 | method === 'GET' ? getQueryFromUrl(urlObject) : await getQueryFromBody(request));
82 | } catch (err) {
83 | sw_error_log('There was an error getting the query/variables from the request!');
84 | throw err;
85 | }
86 |
87 | /* Extract metadata from the query in order to parse through
88 | * our normalized cache. Skip caching logic if query contains fields that are part of
89 | * the doNotCache configuration object.
90 | */
91 | const metadata = metaParseAST(query);
92 | if (settings.doNotCacheGlobal && doNotCacheCheck(metadata, urlObject, settings) === true) {
93 | let responseData;
94 | try {
95 | responseData = await executeQuery({
96 | urlObject,
97 | method,
98 | headers,
99 | body,
100 | });
101 | } catch (err) {
102 | sw_error_log('There was an error getting the response data!');
103 | throw err;
104 | }
105 |
106 | return responseData;
107 | }
108 | let cachedData;
109 | let hashedQuery;
110 | let body;
111 | try {
112 | hashedQuery = ourMD5(query.concat(variables)); // NOTE: Variables could be null, that's okay!
113 | body = JSON.stringify({ query, variables });
114 | cachedData = await checkQueryExists(hashedQuery);
115 | } catch (err) {
116 | sw_error_log('There was an error getting the cached data!');
117 | throw err;
118 | }
119 |
120 | /* If the data is in the cache and the cache is fresh, then
121 | * return the data from the cache. If data is stale or not in cache,
122 | * then execute the query to the API and update the cache.
123 | */
124 |
125 | if (cachedData && checkCachedQueryIsFresh(cachedData.lastApiCall)) {
126 | metrics.isCached = true;
127 | sw_log('Fetched from cache');
128 | if (settings.cacheMethod === 'cache-network') {
129 | executeAndUpdate({ hashedQuery, urlObject, method, headers, body });
130 | }
131 | return { data: cachedData, hashedQuery };
132 | } else {
133 | const data = await executeAndUpdate({
134 | hashedQuery,
135 | urlObject,
136 | method,
137 | headers,
138 | body,
139 | });
140 | return { data, hashedQuery };
141 | }
142 | }
143 |
144 | /*
145 | * Gets the query and variables from a GET request url and returns them.
146 | * EG: 'http://localhost:4000/graphql?query=query\{human(input:\{id:"1"\})\{name\}\}'
147 | */
148 | export function getQueryFromUrl(urlObject) {
149 | const query = urlObject.searchParams.get('query');
150 | const variables = urlObject.searchParams.get('variables');
151 | if (!query) throw new Error(`This HTTP GET request is not a valid GQL request: ${urlObject}`);
152 | return { query, variables };
153 | }
154 |
155 | /*
156 | * Gets the query and variables from a POST request returns them.
157 | */
158 | export async function getQueryFromBody(request) {
159 | let query, variables;
160 | try {
161 | ({ query, variables } = await request.json());
162 | } catch (err) {
163 | sw_error_log('We couldn\'t get the query from the request body!');
164 | throw err;
165 | }
166 | return { query, variables };
167 | }
168 |
169 | // Checks for existence of hashed query in IDB.
170 | export async function checkQueryExists(hashedQuery) {
171 | try {
172 | return await get('queries', hashedQuery);
173 | } catch (err) {
174 | sw_error_log('Error getting query from IDB', err.message);
175 | }
176 | }
177 |
178 | /* Returns false if the cacheExpirationLimit has been set,
179 | * and the lastApiCall occured more than cacheExpirationLimit milliseconds ago.
180 | */
181 | export function checkCachedQueryIsFresh(lastApiCall) {
182 | try {
183 | const { cacheExpirationLimit } = settings;
184 | if (!cacheExpirationLimit) return true;
185 | return Date.now() - lastApiCall < cacheExpirationLimit;
186 | } catch (err) {
187 | sw_error_log('Could not check if cached query is fresh inside settings.');
188 | throw err;
189 | }
190 | }
191 |
192 | /* If the query doesn't exist in the cache, then execute
193 | * the query and return the result.
194 | */
195 | export async function executeQuery({ urlObject, method, headers, body }) {
196 | try {
197 | const options = { method, headers };
198 | if (method === 'POST') {
199 | options.body = body;
200 | }
201 | const response = await fetch(urlObject.href, options);
202 | const data = await response.json();
203 | return data;
204 | } catch (err) {
205 | sw_error_log('Error executing query', err.message);
206 | }
207 | }
208 |
209 | /* Write the result of the query to the cache,
210 | * and add the time it was called to the API for expiration purposes.
211 | */
212 | export async function writeToCache({ hashedQuery, data }) {
213 | if (!data) return;
214 | try {
215 | await set('queries', hashedQuery, { data, lastApiCall: Date.now() });
216 | sw_log('Wrote response to cache.');
217 | } catch (err) {
218 | sw_error_log('Could not write response to cache!', err.message);
219 | throw err;
220 | }
221 | }
222 |
223 | /* Logic to write normalized cache data to indexedDB */
224 | export async function writeToNormalizedCache({ normalizedData }) {
225 | const arrayKeyVals = normalizedData.denestedObjects.map((e) => Object.entries(e)[0]);
226 | const saveData = await setMany('queries', arrayKeyVals);
227 | const rootQuery = await get('queries', 'ROOT_QUERY');
228 | if (!rootQuery) {
229 | await set('queries', 'ROOT_QUERY', normalizedData.rootQueryObject);
230 | } else {
231 | const expandedRoot = {
232 | ...rootQuery,
233 | ...normalizedData.rootQueryObject,
234 | };
235 | await set('queries', 'ROOT_QUERY', expandedRoot);
236 | }
237 | }
238 |
239 | /*
240 | * Cache-update functionality (part of configuration object)
241 | * When a request comes in from the client, deliver the content from the cache (if possible) as usual.
242 | * In addition to the normal logic, even if the response is already in the cache, follow through with
243 | * sending the request to the server, updating the cache upon receipt of response.
244 | */
245 | export async function executeAndUpdate({ hashedQuery, urlObject, method, headers, body }) {
246 | const data = await executeQuery({ urlObject, method, headers, body });
247 | writeToCache({ hashedQuery, data });
248 |
249 | /* This feature is still under development. */
250 | // const normalizedData = normalizeResult(data.data);
251 | // writeToNormalizedCache({ normalizedData });
252 | return data;
253 | }
254 |
255 | /*
256 | * Generate an AST from GQL query string, and extract: operation type (query/mutation/subscription/etc), and fields.
257 | * Returns this metadata as an object (queryCST).
258 | */
259 | export function metaParseAST(query) {
260 | const queryCST = { operationType: '', fields: [] };
261 | const queryAST = parse(query);
262 | visit(queryAST, {
263 | OperationDefinition: {
264 | enter(node) {
265 | queryCST.operationType = node.operation;
266 | },
267 | },
268 | SelectionSet: {
269 | enter(node, kind, parent, path, ancestors) {
270 | const selections = node.selections;
271 | selections.forEach((selection) => queryCST.fields.push(selection.name.value));
272 | },
273 | },
274 | });
275 | return queryCST;
276 | }
277 |
278 | /*
279 | * Check metadata object for inclusion of field names that are included in "doNotCache" Configuration Objects.
280 | * If match is found, execute query and return response to client, bypassing the cache for the entire query.
281 | */
282 | export function doNotCacheCheck(queryCST, urlObject, settings) {
283 | const endpoint = urlObject.origin + urlObject.pathname;
284 | let doNotCache = [];
285 | const fieldsArray = queryCST.fields;
286 | if (endpoint in settings.doNotCacheCustom) {
287 | doNotCache = settings.doNotCacheCustom[endpoint].concat(...settings.doNotCacheGlobal);
288 | } else {
289 | doNotCache = settings.doNotCacheGlobal;
290 | }
291 |
292 | for (const field of fieldsArray) {
293 | if (doNotCache.includes(field)) return true;
294 | }
295 |
296 | return false;
297 | }
298 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "loql-cache",
3 | "version": "1.0.4",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "jest --watch",
8 | "test:ci": "jest"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/oslabs-beta/loQL.git"
13 | },
14 | "publishConfig": {
15 | "access": "public"
16 | },
17 | "keywords": [],
18 | "author": "Harrison Cramer (https://github.com/harrisoncramer), Andrew Lee (https://github.com/lolfill), Konstantin Hamilton (https://github.com/uitie), Jae Ryu (https://github.com/jae-ryu), Andrew Kessinger (https://github.com/andrewkess)",
19 | "license": "MIT",
20 | "bugs": {
21 | "url": "https://github.com/oslabs-beta/loQL/issues"
22 | },
23 | "homepage": "https://github.com/oslabs-beta/loQL#readme",
24 | "dependencies": {
25 | "graphql": "^15.5.1",
26 | "idb": "^6.1.2"
27 | },
28 | "devDependencies": {
29 | "@babel/core": "^7.15.5",
30 | "@babel/plugin-transform-modules-commonjs": "^7.15.4",
31 | "babel-jest": "^27.1.0",
32 | "eslint": "^7.32.0",
33 | "fake-indexeddb": "^3.1.3",
34 | "jest": "^27.1.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------