@internetarchive/fetch-handler 1.0.1

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.
Files changed (62) hide show
  1. package/.editorconfig +29 -0
  2. package/.github/workflows/ci.yml +27 -0
  3. package/.github/workflows/gh-pages-main.yml +40 -0
  4. package/.github/workflows/pr-preview.yml +63 -0
  5. package/.husky/pre-commit +4 -0
  6. package/.prettierignore +1 -0
  7. package/.vscode/extensions.json +12 -0
  8. package/.vscode/tasks.json +12 -0
  9. package/LICENSE +661 -0
  10. package/README.md +82 -0
  11. package/coverage/.gitignore +3 -0
  12. package/coverage/.placeholder +0 -0
  13. package/demo/app-root.ts +84 -0
  14. package/demo/index.html +27 -0
  15. package/dist/demo/app-root.d.ts +12 -0
  16. package/dist/demo/app-root.js +90 -0
  17. package/dist/demo/app-root.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +2 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/src/fetch-handler-interface.d.ts +33 -0
  22. package/dist/src/fetch-handler-interface.js +2 -0
  23. package/dist/src/fetch-handler-interface.js.map +1 -0
  24. package/dist/src/ia-fetch-handler.d.ts +36 -0
  25. package/dist/src/ia-fetch-handler.js +70 -0
  26. package/dist/src/ia-fetch-handler.js.map +1 -0
  27. package/dist/src/utils/fetch-retrier.d.ts +36 -0
  28. package/dist/src/utils/fetch-retrier.js +94 -0
  29. package/dist/src/utils/fetch-retrier.js.map +1 -0
  30. package/dist/src/utils/promised-sleep.d.ts +13 -0
  31. package/dist/src/utils/promised-sleep.js +16 -0
  32. package/dist/src/utils/promised-sleep.js.map +1 -0
  33. package/dist/test/fetch-retrier.test.d.ts +1 -0
  34. package/dist/test/fetch-retrier.test.js +139 -0
  35. package/dist/test/fetch-retrier.test.js.map +1 -0
  36. package/dist/test/ia-fetch-handler.test.d.ts +1 -0
  37. package/dist/test/ia-fetch-handler.test.js +50 -0
  38. package/dist/test/ia-fetch-handler.test.js.map +1 -0
  39. package/dist/test/mocks/mock-analytics-handler.d.ts +20 -0
  40. package/dist/test/mocks/mock-analytics-handler.js +28 -0
  41. package/dist/test/mocks/mock-analytics-handler.js.map +1 -0
  42. package/dist/vite.config.d.ts +2 -0
  43. package/dist/vite.config.js +25 -0
  44. package/dist/vite.config.js.map +1 -0
  45. package/eslint.config.mjs +53 -0
  46. package/index.ts +2 -0
  47. package/package.json +74 -0
  48. package/renovate.json +6 -0
  49. package/screenshot/gh-pages-settings.png +0 -0
  50. package/src/fetch-handler-interface.ts +39 -0
  51. package/src/ia-fetch-handler.ts +94 -0
  52. package/src/utils/fetch-retrier.ts +141 -0
  53. package/src/utils/promised-sleep.ts +15 -0
  54. package/ssl/server.crt +22 -0
  55. package/ssl/server.key +28 -0
  56. package/test/fetch-retrier.test.ts +173 -0
  57. package/test/ia-fetch-handler.test.ts +66 -0
  58. package/test/mocks/mock-analytics-handler.ts +43 -0
  59. package/tsconfig.json +31 -0
  60. package/vite.config.ts +25 -0
  61. package/web-dev-server.config.mjs +32 -0
  62. package/web-test-runner.config.mjs +41 -0
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Asynchronously pause execution for a given number of milliseconds.
3
+ *
4
+ * Used for waiting for some asynchronous updates.
5
+ *
6
+ * Usage:
7
+ * await promisedSleep(100)
8
+ *
9
+ * @export
10
+ * @param {number} ms
11
+ * @returns {Promise<void>}
12
+ */
13
+ export function promisedSleep(ms) {
14
+ return new Promise(resolve => setTimeout(resolve, ms));
15
+ }
16
+ //# sourceMappingURL=promised-sleep.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promised-sleep.js","sourceRoot":"","sources":["../../../src/utils/promised-sleep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,aAAa,CAAC,EAAU;IACtC,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;AACzD,CAAC","sourcesContent":["/**\n * Asynchronously pause execution for a given number of milliseconds.\n *\n * Used for waiting for some asynchronous updates.\n *\n * Usage:\n * await promisedSleep(100)\n *\n * @export\n * @param {number} ms\n * @returns {Promise<void>}\n */\nexport function promisedSleep(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,139 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import sinon from 'sinon';
3
+ import { FetchRetrier } from '../src/utils/fetch-retrier';
4
+ import { MockAnalyticsHandler } from './mocks/mock-analytics-handler';
5
+ describe('FetchRetrier', () => {
6
+ let fetchStub;
7
+ let sleepStub;
8
+ let analytics;
9
+ beforeEach(() => {
10
+ analytics = new MockAnalyticsHandler();
11
+ fetchStub = sinon.stub(globalThis, 'fetch');
12
+ sleepStub = sinon.stub().resolves(); // stubbed promisedSleep
13
+ });
14
+ afterEach(() => {
15
+ fetchStub.restore();
16
+ });
17
+ it('returns response on first success', async () => {
18
+ fetchStub.resolves(new Response('ok', { status: 200 }));
19
+ const retrier = new FetchRetrier({
20
+ analyticsHandler: analytics,
21
+ sleepFn: sleepStub,
22
+ });
23
+ const res = await retrier.fetchRetry('https://foo.org/data');
24
+ expect(res.status).to.equal(200);
25
+ expect(fetchStub.callCount).to.equal(1);
26
+ expect(sleepStub.callCount).to.equal(0);
27
+ expect(analytics.events.length).to.equal(0);
28
+ });
29
+ it('does not retry on 404 and logs event', async () => {
30
+ fetchStub.resolves(new Response('not found', { status: 404 }));
31
+ const retrier = new FetchRetrier({
32
+ analyticsHandler: analytics,
33
+ sleepFn: sleepStub,
34
+ });
35
+ const res = await retrier.fetchRetry('https://foo.org/404');
36
+ expect(res.status).to.equal(404);
37
+ expect(fetchStub.callCount).to.equal(1);
38
+ expect(sleepStub.callCount).to.equal(0);
39
+ expect(analytics.events[0].action).to.equal('status404NotRetrying');
40
+ });
41
+ it('retries on 500 and logs retry/failure events', async () => {
42
+ fetchStub.onCall(0).resolves(new Response('fail', { status: 500 }));
43
+ fetchStub.onCall(1).resolves(new Response('fail again', { status: 500 }));
44
+ fetchStub.onCall(2).resolves(new Response('still fail', { status: 500 }));
45
+ const retrier = new FetchRetrier({
46
+ analyticsHandler: analytics,
47
+ retryCount: 2,
48
+ retryDelay: 1,
49
+ sleepFn: sleepStub,
50
+ });
51
+ const res = await retrier.fetchRetry('https://foo.org/fail');
52
+ expect(res.status).to.equal(500);
53
+ expect(fetchStub.callCount).to.equal(3);
54
+ expect(sleepStub.callCount).to.equal(2);
55
+ expect(analytics.events.some(e => e.action === 'retryingFetch')).to.be.true;
56
+ expect(analytics.events.some(e => e.action === 'fetchFailed')).to.be.true;
57
+ });
58
+ it('retries on fetch error and eventually succeeds', async () => {
59
+ fetchStub.onCall(0).rejects(new Error('Network error'));
60
+ fetchStub.onCall(1).resolves(new Response('ok', { status: 200 }));
61
+ const retrier = new FetchRetrier({
62
+ analyticsHandler: analytics,
63
+ retryCount: 1,
64
+ retryDelay: 1,
65
+ sleepFn: sleepStub,
66
+ });
67
+ const res = await retrier.fetchRetry('https://foo.org/retry');
68
+ expect(res.status).to.equal(200);
69
+ expect(fetchStub.callCount).to.equal(2);
70
+ expect(sleepStub.calledOnce).to.be.true;
71
+ expect(analytics.events.some(e => e.action === 'retryingFetch')).to.be.true;
72
+ });
73
+ it('throws and logs when retries are exhausted due to network error', async () => {
74
+ fetchStub.rejects(new Error('Boom'));
75
+ const retrier = new FetchRetrier({
76
+ analyticsHandler: analytics,
77
+ retryCount: 1,
78
+ retryDelay: 1,
79
+ sleepFn: sleepStub,
80
+ });
81
+ try {
82
+ await retrier.fetchRetry('https://foo.org/networkfail');
83
+ throw new Error('Should have thrown');
84
+ }
85
+ catch (err) {
86
+ expect(err.message).to.equal('Boom');
87
+ }
88
+ expect(fetchStub.callCount).to.equal(2);
89
+ expect(sleepStub.callCount).to.equal(1);
90
+ expect(analytics.events.some(e => e.action === 'fetchFailed')).to.be.true;
91
+ });
92
+ it('detects content blocker error and does not retry', async () => {
93
+ const blockerError = new TypeError('Content Blocker denied request');
94
+ fetchStub.rejects(blockerError);
95
+ const retrier = new FetchRetrier({
96
+ analyticsHandler: analytics,
97
+ retryCount: 2,
98
+ sleepFn: sleepStub,
99
+ });
100
+ try {
101
+ await retrier.fetchRetry('https://foo.org/blocked');
102
+ throw new Error('Should have thrown');
103
+ }
104
+ catch (err) {
105
+ expect(err).to.equal(blockerError);
106
+ }
107
+ expect(fetchStub.callCount).to.equal(1);
108
+ expect(sleepStub.callCount).to.equal(0);
109
+ expect(analytics.events.some(e => e.action === 'contentBlockerDetectedNotRetrying')).to.be.true;
110
+ });
111
+ it('calls sleepFn on retry with correct delay', async () => {
112
+ fetchStub.onCall(0).resolves(new Response(null, { status: 500 }));
113
+ fetchStub.onCall(1).resolves(new Response('ok', { status: 200 }));
114
+ const retrier = new FetchRetrier({
115
+ analyticsHandler: analytics,
116
+ retryCount: 2,
117
+ retryDelay: 1234,
118
+ sleepFn: sleepStub,
119
+ });
120
+ const res = await retrier.fetchRetry('https://foo.org/retry-with-sleep');
121
+ expect(res.status).to.equal(200);
122
+ expect(sleepStub.calledOnce).to.be.true;
123
+ expect(sleepStub.calledWith(1234)).to.be.true;
124
+ });
125
+ it('sleeps for each retry attempt', async () => {
126
+ fetchStub.resolves(new Response(null, { status: 500 }));
127
+ const retrier = new FetchRetrier({
128
+ analyticsHandler: analytics,
129
+ retryCount: 2,
130
+ retryDelay: 300,
131
+ sleepFn: sleepStub,
132
+ });
133
+ const res = await retrier.fetchRetry('https://foo.org/retry-fail');
134
+ expect(res.status).to.equal(500);
135
+ expect(sleepStub.callCount).to.equal(2);
136
+ expect(sleepStub.alwaysCalledWith(300)).to.be.true;
137
+ });
138
+ });
139
+ //# sourceMappingURL=fetch-retrier.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch-retrier.test.js","sourceRoot":"","sources":["../../test/fetch-retrier.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { expect } from '@open-wc/testing';\nimport sinon from 'sinon';\nimport { FetchRetrier } from '../src/utils/fetch-retrier';\nimport { MockAnalyticsHandler } from './mocks/mock-analytics-handler';\n\ndescribe('FetchRetrier', () => {\n let fetchStub: sinon.SinonStub;\n let sleepStub: sinon.SinonStub;\n let analytics: MockAnalyticsHandler;\n\n beforeEach(() => {\n analytics = new MockAnalyticsHandler();\n fetchStub = sinon.stub(globalThis, 'fetch');\n sleepStub = sinon.stub().resolves(); // stubbed promisedSleep\n });\n\n afterEach(() => {\n fetchStub.restore();\n });\n\n it('returns response on first success', async () => {\n fetchStub.resolves(new Response('ok', { status: 200 }));\n const retrier = new FetchRetrier({\n analyticsHandler: analytics,\n sleepFn: sleepStub,\n });\n\n const res = await retrier.fetchRetry('https://foo.org/data');\n\n expect(res.status).to.equal(200);\n expect(fetchStub.callCount).to.equal(1);\n expect(sleepStub.callCount).to.equal(0);\n expect(analytics.events.length).to.equal(0);\n });\n\n it('does not retry on 404 and logs event', async () => {\n fetchStub.resolves(new Response('not found', { status: 404 }));\n const retrier = new FetchRetrier({\n analyticsHandler: analytics,\n sleepFn: sleepStub,\n });\n\n const res = await retrier.fetchRetry('https://foo.org/404');\n\n expect(res.status).to.equal(404);\n expect(fetchStub.callCount).to.equal(1);\n expect(sleepStub.callCount).to.equal(0);\n expect(analytics.events[0].action).to.equal('status404NotRetrying');\n });\n\n it('retries on 500 and logs retry/failure events', async () => {\n fetchStub.onCall(0).resolves(new Response('fail', { status: 500 }));\n fetchStub.onCall(1).resolves(new Response('fail again', { status: 500 }));\n fetchStub.onCall(2).resolves(new Response('still fail', { status: 500 }));\n\n const retrier = new FetchRetrier({\n analyticsHandler: analytics,\n retryCount: 2,\n retryDelay: 1,\n sleepFn: sleepStub,\n });\n\n const res = await retrier.fetchRetry('https://foo.org/fail');\n\n expect(res.status).to.equal(500);\n expect(fetchStub.callCount).to.equal(3);\n expect(sleepStub.callCount).to.equal(2);\n expect(analytics.events.some(e => e.action === 'retryingFetch')).to.be.true;\n expect(analytics.events.some(e => e.action === 'fetchFailed')).to.be.true;\n });\n\n it('retries on fetch error and eventually succeeds', async () => {\n fetchStub.onCall(0).rejects(new Error('Network error'));\n fetchStub.onCall(1).resolves(new Response('ok', { status: 200 }));\n\n const retrier = new FetchRetrier({\n analyticsHandler: analytics,\n retryCount: 1,\n retryDelay: 1,\n sleepFn: sleepStub,\n });\n\n const res = await retrier.fetchRetry('https://foo.org/retry');\n\n expect(res.status).to.equal(200);\n expect(fetchStub.callCount).to.equal(2);\n expect(sleepStub.calledOnce).to.be.true;\n expect(analytics.events.some(e => e.action === 'retryingFetch')).to.be.true;\n });\n\n it('throws and logs when retries are exhausted due to network error', async () => {\n fetchStub.rejects(new Error('Boom'));\n\n const retrier = new FetchRetrier({\n analyticsHandler: analytics,\n retryCount: 1,\n retryDelay: 1,\n sleepFn: sleepStub,\n });\n\n try {\n await retrier.fetchRetry('https://foo.org/networkfail');\n throw new Error('Should have thrown');\n } catch (err: any) {\n expect(err.message).to.equal('Boom');\n }\n\n expect(fetchStub.callCount).to.equal(2);\n expect(sleepStub.callCount).to.equal(1);\n expect(analytics.events.some(e => e.action === 'fetchFailed')).to.be.true;\n });\n\n it('detects content blocker error and does not retry', async () => {\n const blockerError = new TypeError('Content Blocker denied request');\n fetchStub.rejects(blockerError);\n\n const retrier = new FetchRetrier({\n analyticsHandler: analytics,\n retryCount: 2,\n sleepFn: sleepStub,\n });\n\n try {\n await retrier.fetchRetry('https://foo.org/blocked');\n throw new Error('Should have thrown');\n } catch (err: any) {\n expect(err).to.equal(blockerError);\n }\n\n expect(fetchStub.callCount).to.equal(1);\n expect(sleepStub.callCount).to.equal(0);\n expect(\n analytics.events.some(\n e => e.action === 'contentBlockerDetectedNotRetrying',\n ),\n ).to.be.true;\n });\n\n it('calls sleepFn on retry with correct delay', async () => {\n fetchStub.onCall(0).resolves(new Response(null, { status: 500 }));\n fetchStub.onCall(1).resolves(new Response('ok', { status: 200 }));\n\n const retrier = new FetchRetrier({\n analyticsHandler: analytics,\n retryCount: 2,\n retryDelay: 1234,\n sleepFn: sleepStub,\n });\n\n const res = await retrier.fetchRetry('https://foo.org/retry-with-sleep');\n\n expect(res.status).to.equal(200);\n expect(sleepStub.calledOnce).to.be.true;\n expect(sleepStub.calledWith(1234)).to.be.true;\n });\n\n it('sleeps for each retry attempt', async () => {\n fetchStub.resolves(new Response(null, { status: 500 }));\n\n const retrier = new FetchRetrier({\n analyticsHandler: analytics,\n retryCount: 2,\n retryDelay: 300,\n sleepFn: sleepStub,\n });\n\n const res = await retrier.fetchRetry('https://foo.org/retry-fail');\n\n expect(res.status).to.equal(500);\n expect(sleepStub.callCount).to.equal(2);\n expect(sleepStub.alwaysCalledWith(300)).to.be.true;\n });\n});\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { IaFetchHandler } from '../src/ia-fetch-handler';
3
+ class MockFetchRetrier {
4
+ async fetchRetry(requestInfo, init, retries) {
5
+ this.init = init;
6
+ this.requestInfo = requestInfo;
7
+ this.retries = retries;
8
+ return new Response(JSON.stringify({ boop: 'snoot' }), { status: 200 });
9
+ }
10
+ }
11
+ describe('Fetch Handler', () => {
12
+ describe('fetch', () => {
13
+ it('adds reCache=1 if it is in the current url', async () => {
14
+ const fetchRetrier = new MockFetchRetrier();
15
+ const fetchHandler = new IaFetchHandler({
16
+ fetchRetrier: fetchRetrier,
17
+ searchParams: '?reCache=1',
18
+ });
19
+ await fetchHandler.fetch('https://foo.org/api/v1/snoot');
20
+ expect(fetchRetrier.requestInfo).to.equal('https://foo.org/api/v1/snoot?reCache=1');
21
+ });
22
+ });
23
+ describe('fetchIAApiResponse', () => {
24
+ it('prepends the IA basehost to the url when making a request', async () => {
25
+ const endpoint = '/foo/service/endpoint.php';
26
+ const fetchRetrier = new MockFetchRetrier();
27
+ const fetchHandler = new IaFetchHandler({
28
+ iaApiBaseUrl: 'www.example.com',
29
+ fetchRetrier: fetchRetrier,
30
+ });
31
+ await fetchHandler.fetchIAApiResponse(endpoint);
32
+ expect(fetchRetrier.requestInfo).to.equal('www.example.com/foo/service/endpoint.php');
33
+ });
34
+ });
35
+ describe('fetchApiResponse', () => {
36
+ it('adds credentials: include if requested', async () => {
37
+ const endpoint = '/foo/service/endpoint.php';
38
+ const fetchRetrier = new MockFetchRetrier();
39
+ const fetchHandler = new IaFetchHandler({
40
+ iaApiBaseUrl: 'www.example.com',
41
+ fetchRetrier: fetchRetrier,
42
+ });
43
+ await fetchHandler.fetchApiResponse(endpoint, {
44
+ includeCredentials: true,
45
+ });
46
+ expect(fetchRetrier.init).to.deep.equal({ credentials: 'include' });
47
+ });
48
+ });
49
+ });
50
+ //# sourceMappingURL=ia-fetch-handler.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ia-fetch-handler.test.js","sourceRoot":"","sources":["../../test/ia-fetch-handler.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAGzD,MAAM,gBAAgB;IAKpB,KAAK,CAAC,UAAU,CACd,WAAwB,EACxB,IAAkB,EAClB,OAAgB;QAEhB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;QAC/B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IAC1E,CAAC;CACF;AAED,QAAQ,CAAC,eAAe,EAAE,GAAG,EAAE;IAC7B,QAAQ,CAAC,OAAO,EAAE,GAAG,EAAE;QACrB,EAAE,CAAC,4CAA4C,EAAE,KAAK,IAAI,EAAE;YAC1D,MAAM,YAAY,GAAG,IAAI,gBAAgB,EAAE,CAAC;YAC5C,MAAM,YAAY,GAAG,IAAI,cAAc,CAAC;gBACtC,YAAY,EAAE,YAAY;gBAC1B,YAAY,EAAE,YAAY;aAC3B,CAAC,CAAC;YACH,MAAM,YAAY,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YACzD,MAAM,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC,KAAK,CACvC,wCAAwC,CACzC,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;QAClC,EAAE,CAAC,2DAA2D,EAAE,KAAK,IAAI,EAAE;YACzE,MAAM,QAAQ,GAAG,2BAA2B,CAAC;YAC7C,MAAM,YAAY,GAAG,IAAI,gBAAgB,EAAE,CAAC;YAC5C,MAAM,YAAY,GAAG,IAAI,cAAc,CAAC;gBACtC,YAAY,EAAE,iBAAiB;gBAC/B,YAAY,EAAE,YAAY;aAC3B,CAAC,CAAC;YACH,MAAM,YAAY,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAC;YAChD,MAAM,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC,KAAK,CACvC,0CAA0C,CAC3C,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;QAChC,EAAE,CAAC,wCAAwC,EAAE,KAAK,IAAI,EAAE;YACtD,MAAM,QAAQ,GAAG,2BAA2B,CAAC;YAC7C,MAAM,YAAY,GAAG,IAAI,gBAAgB,EAAE,CAAC;YAC5C,MAAM,YAAY,GAAG,IAAI,cAAc,CAAC;gBACtC,YAAY,EAAE,iBAAiB;gBAC/B,YAAY,EAAE,YAAY;aAC3B,CAAC,CAAC;YACH,MAAM,YAAY,CAAC,gBAAgB,CAAC,QAAQ,EAAE;gBAC5C,kBAAkB,EAAE,IAAI;aACzB,CAAC,CAAC;YACH,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC,CAAC;QACtE,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import { expect } from '@open-wc/testing';\nimport { IaFetchHandler } from '../src/ia-fetch-handler';\nimport { FetchRetrierInterface } from '../src/utils/fetch-retrier';\n\nclass MockFetchRetrier implements FetchRetrierInterface {\n requestInfo?: RequestInfo;\n init?: RequestInit;\n retries?: number;\n\n async fetchRetry(\n requestInfo: RequestInfo,\n init?: RequestInit,\n retries?: number,\n ): Promise<Response> {\n this.init = init;\n this.requestInfo = requestInfo;\n this.retries = retries;\n return new Response(JSON.stringify({ boop: 'snoot' }), { status: 200 });\n }\n}\n\ndescribe('Fetch Handler', () => {\n describe('fetch', () => {\n it('adds reCache=1 if it is in the current url', async () => {\n const fetchRetrier = new MockFetchRetrier();\n const fetchHandler = new IaFetchHandler({\n fetchRetrier: fetchRetrier,\n searchParams: '?reCache=1',\n });\n await fetchHandler.fetch('https://foo.org/api/v1/snoot');\n expect(fetchRetrier.requestInfo).to.equal(\n 'https://foo.org/api/v1/snoot?reCache=1',\n );\n });\n });\n\n describe('fetchIAApiResponse', () => {\n it('prepends the IA basehost to the url when making a request', async () => {\n const endpoint = '/foo/service/endpoint.php';\n const fetchRetrier = new MockFetchRetrier();\n const fetchHandler = new IaFetchHandler({\n iaApiBaseUrl: 'www.example.com',\n fetchRetrier: fetchRetrier,\n });\n await fetchHandler.fetchIAApiResponse(endpoint);\n expect(fetchRetrier.requestInfo).to.equal(\n 'www.example.com/foo/service/endpoint.php',\n );\n });\n });\n\n describe('fetchApiResponse', () => {\n it('adds credentials: include if requested', async () => {\n const endpoint = '/foo/service/endpoint.php';\n const fetchRetrier = new MockFetchRetrier();\n const fetchHandler = new IaFetchHandler({\n iaApiBaseUrl: 'www.example.com',\n fetchRetrier: fetchRetrier,\n });\n await fetchHandler.fetchApiResponse(endpoint, {\n includeCredentials: true,\n });\n expect(fetchRetrier.init).to.deep.equal({ credentials: 'include' });\n });\n });\n});\n"]}
@@ -0,0 +1,20 @@
1
+ import type { AnalyticsEvent } from '@internetarchive/analytics-manager';
2
+ import type { AnalyticsHandlerInterface } from '@internetarchive/analytics-manager';
3
+ export type MockAnalyticsEvent = AnalyticsEvent & {
4
+ bucketType: '1%' | '100%';
5
+ additionalEventParams?: object;
6
+ };
7
+ export declare class MockAnalyticsHandler implements AnalyticsHandlerInterface {
8
+ events: MockAnalyticsEvent[];
9
+ sendPing(values: Record<string, any>): void;
10
+ sendEvent(event: MockAnalyticsEvent): void;
11
+ send_event(category: string, action: string, label?: string, additionalEventParams?: object): void;
12
+ sendEventNoSampling(event: AnalyticsEvent): void;
13
+ trackIaxParameter(location: string): void;
14
+ trackPageView(options?: {
15
+ mediaType?: string;
16
+ mediaLanguage?: string;
17
+ primaryCollection?: string;
18
+ page?: string;
19
+ }): void;
20
+ }
@@ -0,0 +1,28 @@
1
+ export class MockAnalyticsHandler {
2
+ constructor() {
3
+ this.events = [];
4
+ }
5
+ sendPing(values) { }
6
+ sendEvent(event) {
7
+ const thisEvent = Object.assign({}, event, { bucketType: '1%' });
8
+ this.events.push(thisEvent);
9
+ }
10
+ send_event(category, action, label, additionalEventParams) {
11
+ this.events.push({
12
+ category,
13
+ action,
14
+ label,
15
+ bucketType: '1%',
16
+ additionalEventParams: { ...additionalEventParams },
17
+ });
18
+ }
19
+ sendEventNoSampling(event) {
20
+ this.events.push({
21
+ ...event,
22
+ bucketType: '100%',
23
+ });
24
+ }
25
+ trackIaxParameter(location) { }
26
+ trackPageView(options) { }
27
+ }
28
+ //# sourceMappingURL=mock-analytics-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock-analytics-handler.js","sourceRoot":"","sources":["../../../test/mocks/mock-analytics-handler.ts"],"names":[],"mappings":"AAOA,MAAM,OAAO,oBAAoB;IAAjC;QACE,WAAM,GAAyB,EAAE,CAAC;IAkCpC,CAAC;IAhCC,QAAQ,CAAC,MAA2B,IAAS,CAAC;IAC9C,SAAS,CAAC,KAAyB;QACjC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;QACjE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAC9B,CAAC;IACD,UAAU,CACR,QAAgB,EAChB,MAAc,EACd,KAAc,EACd,qBAA8B;QAE9B,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC;YACf,QAAQ;YACR,MAAM;YACN,KAAK;YACL,UAAU,EAAE,IAAI;YAChB,qBAAqB,EAAE,EAAE,GAAG,qBAAqB,EAAE;SACpD,CAAC,CAAC;IACL,CAAC;IACD,mBAAmB,CAAC,KAAqB;QACvC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC;YACf,GAAG,KAAK;YACR,UAAU,EAAE,MAAM;SACc,CAAC,CAAC;IACtC,CAAC;IACD,iBAAiB,CAAC,QAAgB,IAAS,CAAC;IAC5C,aAAa,CAAC,OAKb,IAAS,CAAC;CACZ","sourcesContent":["import type { AnalyticsEvent } from '@internetarchive/analytics-manager';\nimport type { AnalyticsHandlerInterface } from '@internetarchive/analytics-manager';\n\nexport type MockAnalyticsEvent = AnalyticsEvent & {\n bucketType: '1%' | '100%';\n additionalEventParams?: object;\n};\nexport class MockAnalyticsHandler implements AnalyticsHandlerInterface {\n events: MockAnalyticsEvent[] = [];\n\n sendPing(values: Record<string, any>): void {}\n sendEvent(event: MockAnalyticsEvent): void {\n const thisEvent = Object.assign({}, event, { bucketType: '1%' });\n this.events.push(thisEvent);\n }\n send_event(\n category: string,\n action: string,\n label?: string,\n additionalEventParams?: object,\n ): void {\n this.events.push({\n category,\n action,\n label,\n bucketType: '1%',\n additionalEventParams: { ...additionalEventParams },\n });\n }\n sendEventNoSampling(event: AnalyticsEvent): void {\n this.events.push({\n ...event,\n bucketType: '100%',\n } as unknown as MockAnalyticsEvent);\n }\n trackIaxParameter(location: string): void {}\n trackPageView(options?: {\n mediaType?: string;\n mediaLanguage?: string;\n primaryCollection?: string;\n page?: string;\n }): void {}\n}\n"]}
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,25 @@
1
+ import { defineConfig } from 'vite';
2
+ import { resolve } from 'path';
3
+ // https://vitejs.dev/config/
4
+ export default defineConfig({
5
+ base: './',
6
+ root: resolve(__dirname, './demo'),
7
+ build: {
8
+ /**
9
+ * This is the directory where the built files will be placed
10
+ * that we upload to GitHub Pages.
11
+ */
12
+ outDir: '../ghpages/demo',
13
+ emptyOutDir: true,
14
+ manifest: true,
15
+ rollupOptions: {
16
+ input: {
17
+ main: resolve(__dirname, 'demo/index.html'),
18
+ },
19
+ output: {
20
+ entryFileNames: 'app-root.js',
21
+ },
22
+ },
23
+ },
24
+ });
25
+ //# sourceMappingURL=vite.config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.config.js","sourceRoot":"","sources":["../vite.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAE/B,6BAA6B;AAC7B,eAAe,YAAY,CAAC;IAC1B,IAAI,EAAE,IAAI;IACV,IAAI,EAAE,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC;IAClC,KAAK,EAAE;QACL;;;WAGG;QACH,MAAM,EAAE,iBAAiB;QACzB,WAAW,EAAE,IAAI;QACjB,QAAQ,EAAE,IAAI;QACd,aAAa,EAAE;YACb,KAAK,EAAE;gBACL,IAAI,EAAE,OAAO,CAAC,SAAS,EAAE,iBAAiB,CAAC;aAC5C;YACD,MAAM,EAAE;gBACN,cAAc,EAAE,aAAa;aAC9B;SACF;KACF;CACF,CAAC,CAAC","sourcesContent":["import { defineConfig } from 'vite';\nimport { resolve } from 'path';\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n base: './',\n root: resolve(__dirname, './demo'),\n build: {\n /**\n * This is the directory where the built files will be placed\n * that we upload to GitHub Pages.\n */\n outDir: '../ghpages/demo',\n emptyOutDir: true,\n manifest: true,\n rollupOptions: {\n input: {\n main: resolve(__dirname, 'demo/index.html'),\n },\n output: {\n entryFileNames: 'app-root.js',\n },\n },\n },\n});\n"]}
@@ -0,0 +1,53 @@
1
+ import typescriptEslint from '@typescript-eslint/eslint-plugin';
2
+ import html from 'eslint-plugin-html';
3
+ import tsParser from '@typescript-eslint/parser';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import js from '@eslint/js';
7
+ import { FlatCompat } from '@eslint/eslintrc';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const compat = new FlatCompat({
12
+ baseDirectory: __dirname,
13
+ recommendedConfig: js.configs.recommended,
14
+ allConfig: js.configs.all,
15
+ });
16
+
17
+ export default [
18
+ ...compat.extends('plugin:@typescript-eslint/recommended'),
19
+ {
20
+ plugins: {
21
+ '@typescript-eslint': typescriptEslint,
22
+ html,
23
+ },
24
+
25
+ languageOptions: {
26
+ parser: tsParser,
27
+ },
28
+
29
+ settings: {
30
+ 'import/resolver': {
31
+ node: {
32
+ extensions: ['.ts', '.tsx'],
33
+ moduleDirectory: ['node_modules', 'src', 'demo'],
34
+ },
35
+ },
36
+ },
37
+
38
+ rules: {
39
+ '@typescript-eslint/no-unsafe-function-type': 'warn',
40
+ '@typescript-eslint/no-unused-vars': 'warn',
41
+ '@typescript-eslint/no-explicit-any': 'warn',
42
+ },
43
+ },
44
+ {
45
+ ignores: ['**/*.js', '**/*.mjs', '**/*.d.ts'],
46
+ },
47
+ {
48
+ files: ['**/*.test.ts'],
49
+ rules: {
50
+ '@typescript-eslint/no-unused-expressions': 'off',
51
+ },
52
+ },
53
+ ];
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { IaFetchHandler } from './src/ia-fetch-handler';
2
+ export { FetchHandlerInterface } from './src/fetch-handler-interface';
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@internetarchive/fetch-handler",
3
+ "description": "A custom service for handling API requests",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/internetarchive/fetch-handler.git"
7
+ },
8
+ "license": "AGPL-3.0-only",
9
+ "author": "Internet Archive",
10
+ "version": "1.0.1",
11
+ "main": "dist/index.js",
12
+ "module": "dist/index.js",
13
+ "scripts": {
14
+ "start": "tsc && concurrently -k -r \"tsc --watch --preserveWatchOutput\" \"wds\"",
15
+ "prepare": "rimraf dist && tsc && husky install",
16
+ "build": "tsc",
17
+ "lint": "eslint . && prettier \"**/*.ts\" --check",
18
+ "format": "eslint . --fix && prettier \"**/*.ts\" --write",
19
+ "circular": "madge --circular --extensions ts .",
20
+ "test": "tsc && npm run lint && npm run circular && wtr --coverage",
21
+ "test:watch": "tsc && concurrently -k -r \"tsc --watch --preserveWatchOutput\" \"wtr --watch\"",
22
+ "ghpages:build": "rimraf ghpages && npm run prepare && vite build",
23
+ "ghpages:publish": "npm run ghpages:prepare -e $(git branch --show-current)",
24
+ "ghpages:prepare": "npm run ghpages:build && touch ghpages/.nojekyll && npm run ghpages:generate",
25
+ "ghpages:generate": "gh-pages -t -d ghpages -m \"Build for $(git log --pretty=format:\"%h %an %ai %s\" -n1) [skip ci]\""
26
+ },
27
+ "types": "dist/index.d.ts",
28
+ "dependencies": {
29
+ "@internetarchive/analytics-manager": "^0.1.5",
30
+ "lit": "^2.8.0"
31
+ },
32
+ "devDependencies": {
33
+ "@open-wc/eslint-config": "^12.0.3",
34
+ "@open-wc/testing": "^4.0.0",
35
+ "@types/mocha": "^10.0.10",
36
+ "@typescript-eslint/eslint-plugin": "^8.19.1",
37
+ "@typescript-eslint/parser": "^8.19.1",
38
+ "@web/dev-server": "^0.4.6",
39
+ "@web/test-runner": "^0.20.0",
40
+ "concurrently": "^9.1.2",
41
+ "eslint": "^9.17.0",
42
+ "eslint-config-prettier": "^10.1.1",
43
+ "eslint-plugin-html": "^8.1.2",
44
+ "eslint-plugin-import": "^2.31.0",
45
+ "eslint-plugin-lit": "^1.15.0",
46
+ "eslint-plugin-lit-a11y": "^4.1.4",
47
+ "eslint-plugin-no-only-tests": "^3.3.0",
48
+ "eslint-plugin-wc": "^2.2.0",
49
+ "gh-pages": "^6.3.0",
50
+ "husky": "^9.1.7",
51
+ "madge": "^8.0.0",
52
+ "prettier": "^3.4.2",
53
+ "rimraf": "^6.0.1",
54
+ "sinon": "^19.0.2",
55
+ "ts-lit-plugin": "^2.0.2",
56
+ "tslib": "^2.8.1",
57
+ "typescript": "^5.7.2",
58
+ "vite": "^6.0.7"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ },
63
+ "prettier": {
64
+ "singleQuote": true,
65
+ "arrowParens": "avoid"
66
+ },
67
+ "lint-staged": {
68
+ "*.ts": [
69
+ "eslint --fix",
70
+ "prettier --write",
71
+ "git add"
72
+ ]
73
+ }
74
+ }
package/renovate.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": [
3
+ "config:base",
4
+ ":preserveSemverRanges"
5
+ ]
6
+ }
Binary file
@@ -0,0 +1,39 @@
1
+ export interface FetchHandlerInterface {
2
+ /**
3
+ * Generic fetch function that handles retries and common IA parameters like `reCache=1`
4
+ *
5
+ * @param input RequestInfo
6
+ * @param init RequestInit
7
+ */
8
+ fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
9
+
10
+ /**
11
+ * A helper function to fetch a response from an API and get a JSON object
12
+ *
13
+ * @param path string
14
+ * @param options?: { includeCredentials?: boolean }
15
+ */
16
+ fetchApiResponse<T>(
17
+ url: string,
18
+ options?: {
19
+ includeCredentials?: boolean;
20
+ method?: string;
21
+ body?: BodyInit;
22
+ headers?: HeadersInit;
23
+ },
24
+ ): Promise<T>;
25
+
26
+ /**
27
+ * A helper function to fetch a response from the IA API and get a JSON object
28
+ *
29
+ * This allows you to just pass the path to the API and get the response instead
30
+ * of the full URL. If you need a full URL, use `fetchApiResponse` instead.
31
+ *
32
+ * @param path string
33
+ * @param options?: { includeCredentials?: boolean }
34
+ */
35
+ fetchIAApiResponse<T>(
36
+ path: string,
37
+ options?: { includeCredentials?: boolean },
38
+ ): Promise<T>;
39
+ }
@@ -0,0 +1,94 @@
1
+ import { FetchRetrier, FetchRetrierInterface } from './utils/fetch-retrier';
2
+ import type { FetchHandlerInterface } from './fetch-handler-interface';
3
+
4
+ /**
5
+ * The FetchHandler adds some common helpers:
6
+ * - retry the request if it fails
7
+ * - add `reCache=1` to the request if it's in the current url so the backend sees it
8
+ * - add convenience method for fetching/decoding an API response by just the path
9
+ */
10
+ export class IaFetchHandler implements FetchHandlerInterface {
11
+ private iaApiBaseUrl?: string;
12
+
13
+ private fetchRetrier: FetchRetrierInterface = new FetchRetrier();
14
+
15
+ private searchParams?: string;
16
+
17
+ constructor(options?: {
18
+ iaApiBaseUrl?: string;
19
+ fetchRetrier?: FetchRetrierInterface;
20
+ searchParams?: string;
21
+ }) {
22
+ if (options?.iaApiBaseUrl) this.iaApiBaseUrl = options.iaApiBaseUrl;
23
+ if (options?.fetchRetrier) this.fetchRetrier = options.fetchRetrier;
24
+ if (options?.searchParams) {
25
+ this.searchParams = options.searchParams;
26
+ } else {
27
+ this.searchParams = window.location.search;
28
+ }
29
+ }
30
+
31
+ /** @inheritdoc */
32
+ async fetchIAApiResponse<T>(
33
+ path: string,
34
+ options?: {
35
+ includeCredentials?: boolean;
36
+ },
37
+ ): Promise<T> {
38
+ const url = `${this.iaApiBaseUrl}${path}`;
39
+ return this.fetchApiResponse(url, options);
40
+ }
41
+
42
+ /** @inheritdoc */
43
+ async fetchApiResponse<T>(
44
+ url: string,
45
+ options?: {
46
+ includeCredentials?: boolean;
47
+ method?: string;
48
+ body?: BodyInit;
49
+ headers?: HeadersInit;
50
+ },
51
+ ): Promise<T> {
52
+ const requestInit: RequestInit = {};
53
+ if (options?.includeCredentials) requestInit.credentials = 'include';
54
+ if (options?.method) requestInit.method = options.method;
55
+ if (options?.body) requestInit.body = options.body;
56
+ if (options?.headers) requestInit.headers = options.headers;
57
+ const response = await this.fetch(url, requestInit);
58
+ const json = await response.json();
59
+ return json as T;
60
+ }
61
+
62
+ /** @inheritdoc */
63
+ async fetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
64
+ let finalInput = input;
65
+ const urlParams = new URLSearchParams(this.searchParams);
66
+ if (urlParams.get('reCache') === '1') {
67
+ finalInput = this.addSearchParams(input, { reCache: '1' });
68
+ }
69
+ return this.fetchRetrier.fetchRetry(finalInput, init);
70
+ }
71
+
72
+ /**
73
+ * Since RequestInfo can be either a `Request` or `string`, we need to change
74
+ * the way we add search params to it depending on the input.
75
+ */
76
+ private addSearchParams(
77
+ input: RequestInfo,
78
+ params: Record<string, string>,
79
+ ): RequestInfo {
80
+ const urlString = typeof input === 'string' ? input : input.url;
81
+ const url = new URL(urlString, window.location.href);
82
+
83
+ for (const [key, value] of Object.entries(params)) {
84
+ url.searchParams.set(key, value);
85
+ }
86
+
87
+ if (typeof input === 'string') {
88
+ return url.href;
89
+ } else {
90
+ const newRequest = new Request(url.href, input);
91
+ return newRequest;
92
+ }
93
+ }
94
+ }