From 3d927d93d5c7162e739bff3f4054f7a1bad49dbf Mon Sep 17 00:00:00 2001 From: George Bardis Date: Thu, 7 Sep 2023 12:38:09 +0300 Subject: [PATCH 1/3] feat: support for sendBeacon requests --- lib/interceptor.js | 79 +++++++++++++++++++++++++++++++++------ test/site/sendBeacon.html | 41 ++++++++++++++++++++ test/spec/plugin_test.js | 52 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 test/site/sendBeacon.html diff --git a/lib/interceptor.js b/lib/interceptor.js index 8e9b901d..4c64021c 100644 --- a/lib/interceptor.js +++ b/lib/interceptor.js @@ -40,6 +40,12 @@ var interceptor = { window.sessionStorage.removeItem(NAMESPACE); } + if (typeof window.navigator.sendBeacon == 'function') { + replaceSendBeacon(); + } else { + console.error(PKG_PREFIX + 'sendBeacon API preconditions not met!'); + } + if (typeof window.fetch == 'function') { replaceFetch(); if ( @@ -54,6 +60,48 @@ var interceptor = { done(window[NAMESPACE]); + function replaceSendBeacon() { + var _sendBeacon = window.navigator.sendBeacon; + var interceptSendBeacon = function (url, payload) { + var request = { + method: 'POST', + requestHeaders: {}, + requestBody: payload, + url: url, + }; + + addPendingRequest(request); + + var isRequestQueuedForTransfer = _sendBeacon.apply(window.navigator, [ + url, + payload, + ]); + + completeSendBeaconRequest(request, { + body: isRequestQueuedForTransfer, + }); + + // Forward the original response to the application on the current tick. + return isRequestQueuedForTransfer; + }; + + window.navigator.sendBeacon = function (target, data) { + var url = target; + + if (target instanceof URL) { + url = target.href; + } + + if (data instanceof Blob) { + return data.text().then(function (payload) { + return interceptSendBeacon(url, payload); + }); + } + + return interceptSendBeacon(url, data); + }; + } + function replaceFetch() { var _fetch = window.fetch; window.fetch = function () { @@ -98,16 +146,16 @@ var interceptor = { // After decoding the request's body (which may have come from Request#text()) // and the response body, we can store the completed request. - Promise.all([request.requestBody, responsePromise]).then(function ( - results - ) { - completeFetchRequest(request, { - requestBody: results[0], - body: results[1], - statusCode: clonedResponse.status, - headers: parseHeaders(clonedResponse.headers), - }); - }); + Promise.all([request.requestBody, responsePromise]).then( + function (results) { + completeFetchRequest(request, { + requestBody: results[0], + body: results[1], + statusCode: clonedResponse.status, + headers: parseHeaders(clonedResponse.headers), + }); + }, + ); // Forward the original response to the application on the current tick. return response; @@ -240,6 +288,13 @@ var interceptor = { pushToSessionStorage(startedRequest); } + function completeSendBeaconRequest(startedRequest, completedRequest) { + startedRequest.body = completedRequest.body; + startedRequest.statusCode = completedRequest.body === true ? 200 : 500; + startedRequest.__fulfilled = Date.now(); + replaceInSessionStorage(startedRequest); + } + function completeFetchRequest(startedRequest, completedRequest) { // Merge the completed data with the started request. startedRequest.requestBody = completedRequest.requestBody; @@ -266,7 +321,7 @@ var interceptor = { return JSON.parse(rawData); } catch (e) { throw new Error( - PKG_PREFIX + 'Could not parse sessionStorage data: ' + e.message + PKG_PREFIX + 'Could not parse sessionStorage data: ' + e.message, ); } } @@ -355,7 +410,7 @@ var interceptor = { parsed = JSON.parse(rawData); } catch (e) { throw new Error( - PKG_PREFIX + 'Could not parse sessionStorage data: ' + e.message + PKG_PREFIX + 'Could not parse sessionStorage data: ' + e.message, ); } } diff --git a/test/site/sendBeacon.html b/test/site/sendBeacon.html new file mode 100644 index 00000000..93a83052 --- /dev/null +++ b/test/site/sendBeacon.html @@ -0,0 +1,41 @@ + + + + + + sendBeacon request + + +

This file makes a sendBeacon request to /post.json

+ + +
+ + + diff --git a/test/spec/plugin_test.js b/test/spec/plugin_test.js index 20aa06fa..eba933f9 100644 --- a/test/spec/plugin_test.js +++ b/test/spec/plugin_test.js @@ -529,6 +529,58 @@ describe('webdriverajax', function testSuite() { }); }); + describe('sendBeacon API', async function () { + it('can intercept a simple POST request', async function () { + await browser.url('/sendBeacon.html'); + await browser.setupInterceptor(); + await browser.expectRequest('POST', '/post.json', 200); + await completedRequest('#buttonjson'); + await browser.assertRequests(); + await browser.assertExpectedRequestsOnly(); + }); + + it('can intercept when target is URL object', async function () { + await browser.url('/sendBeacon.html'); + await browser.setupInterceptor(); + await browser.expectRequest('POST', /\/post\.json/, 200); + await completedRequest('#buttonstring'); + await browser.assertRequests(); + await browser.assertExpectedRequestsOnly(); + }); + + it('can access a certain request', async function () { + await browser.url('/sendBeacon.html'); + await browser.setupInterceptor(); + await completedRequest('#buttonjson'); + const request = await browser.getRequest(0); + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/post.json'); + assert.equal(request.response.body, true); + assert.equal(request.response.statusCode, 200); + assert.deepEqual(request.response.headers, {}); + }); + + it('can assess the request body using string data', async function () { + await browser.url('/sendBeacon.html'); + await browser.setupInterceptor(); + await completedRequest('#buttonstring'); + const request = await browser.getRequest(0); + assert.equal(request.url, 'http://localhost:8080/post.json'); + assert.equal(request.response.body, true); + assert.deepEqual(request.body, 'bar'); + }); + + it('can assess the request body using JSON data as Blob', async function () { + await browser.url('/sendBeacon.html'); + await browser.setupInterceptor(); + await completedRequest('#buttonjson'); + const request = await browser.getRequest(0); + assert.equal(request.url, '/post.json'); + assert.equal(request.response.body, true); + assert.deepEqual(request.body, { foo: 'bar' }); + }); + }); + describe('fetch API', async function () { it('can intercept a simple GET request', async function () { await browser.url('/get.html'); From 2961b5cab65a575f5f8717b4f601467721376ad8 Mon Sep 17 00:00:00 2001 From: tehhowch Date: Mon, 4 Mar 2024 18:20:34 -0600 Subject: [PATCH 2/3] refactor: maintain synchronous API make sure that `if (!sent)` properly triggers for Blob payloads. --- lib/interceptor.js | 62 +++++++++++++++++++-------------------- test/site/sendBeacon.html | 31 +++++++++++--------- test/spec/plugin_test.js | 54 ++++++++++++++++------------------ test/utils/express.js | 6 +++- 4 files changed, 78 insertions(+), 75 deletions(-) diff --git a/lib/interceptor.js b/lib/interceptor.js index 5f274717..928807d3 100644 --- a/lib/interceptor.js +++ b/lib/interceptor.js @@ -62,44 +62,45 @@ var interceptor = { function replaceSendBeacon() { var _sendBeacon = window.navigator.sendBeacon; - var interceptSendBeacon = function (url, payload) { + window.navigator.sendBeacon = function (target, data) { + var url = target; + + if (target instanceof URL) { + url = target.href; + } + var request = { method: 'POST', requestHeaders: {}, - requestBody: payload, - url: url, + requestBody: data, + url: url }; + // While the SendBeacon API is synchronous--we do not _need_ to do the dance + // of registering a pending request and then immediately completing it--doing + // so allows us to preserve the method's synchronous API while still logging + // its possible payloads. addPendingRequest(request); - var isRequestQueuedForTransfer = _sendBeacon.apply(window.navigator, [ - url, - payload, - ]); - - completeSendBeaconRequest(request, { - body: isRequestQueuedForTransfer, - }); + // TODO: we might need to clone the data in order to log it, for some kinds of input (e.g. buffers). + var queued = _sendBeacon.call(window.navigator, url, data); + handleDoneRequest(request, queued); // Forward the original response to the application on the current tick. - return isRequestQueuedForTransfer; + return queued; }; - window.navigator.sendBeacon = function (target, data) { - var url = target; - - if (target instanceof URL) { - url = target.href; - } - - if (data instanceof Blob) { - return data.text().then(function (payload) { - return interceptSendBeacon(url, payload); + function handleDoneRequest(rq, isQueued) { + var input = rq.requestBody; + if (input instanceof Blob) { + input.text().then(function (payload) { + rq.requestBody = payload; + completeSendBeaconRequest(rq, isQueued); }); + } else { + completeSendBeaconRequest(rq, isQueued); } - - return interceptSendBeacon(url, data); - }; + } } function replaceFetch() { @@ -154,7 +155,7 @@ var interceptor = { statusCode: clonedResponse.status, headers: parseHeaders(clonedResponse.headers) }); - }, + } ); // Forward the original response to the application on the current tick. @@ -288,9 +289,8 @@ var interceptor = { pushToSessionStorage(startedRequest); } - function completeSendBeaconRequest(startedRequest, completedRequest) { - startedRequest.body = completedRequest.body; - startedRequest.statusCode = completedRequest.body === true ? 200 : 500; + function completeSendBeaconRequest(startedRequest, isQueued) { + startedRequest.statusCode = isQueued ? 200 : 500; startedRequest.__fulfilled = Date.now(); replaceInSessionStorage(startedRequest); } @@ -321,7 +321,7 @@ var interceptor = { return JSON.parse(rawData); } catch (e) { throw new Error( - PKG_PREFIX + 'Could not parse sessionStorage data: ' + e.message, + PKG_PREFIX + 'Could not parse sessionStorage data: ' + e.message ); } } @@ -410,7 +410,7 @@ var interceptor = { parsed = JSON.parse(rawData); } catch (e) { throw new Error( - PKG_PREFIX + 'Could not parse sessionStorage data: ' + e.message, + PKG_PREFIX + 'Could not parse sessionStorage data: ' + e.message ); } } diff --git a/test/site/sendBeacon.html b/test/site/sendBeacon.html index 93a83052..6460315a 100644 --- a/test/site/sendBeacon.html +++ b/test/site/sendBeacon.html @@ -7,6 +7,7 @@

This file makes a sendBeacon request to /post.json

+
@@ -14,25 +15,27 @@ 'use strict'; (function (window, document) { - var buttonstring = document.querySelector('#buttonstring'); - var buttonjson = document.querySelector('#buttonjson'); + const resp = document.querySelector('#response'); - buttonstring.addEventListener('click', function (evt) { - var data = 'bar'; - var isQueuedForDelivery = window.navigator.sendBeacon(new URL('/post.json', window.location.origin), data); + function log(queued, when) { + resp.textContent += `${queued === true ? "did" : "did not"} queue update ${when}\n`; + } - if(isQueuedForDelivery) { - document.querySelector('#response').textContent += "queued string for delivery\n"; - } + document.querySelector('#url').addEventListener('click', function () { + const queued = window.navigator.sendBeacon(new URL('/telemetry?data=querystring', window.location.origin)); + log(queued, 'when using `new URL()`'); }); - buttonjson.addEventListener('click', function (evt) { - var data = new Blob([JSON.stringify({ foo: 'bar' })], { type: 'text/plain' }); - var isQueuedForDelivery = window.navigator.sendBeacon('/post.json', data); + document.querySelector('#buttonstring').addEventListener('click', function () { + const data = 'bar'; + const isQueuedForDelivery = window.navigator.sendBeacon('/telemetry', data); + log(isQueuedForDelivery, "when payload is string"); + }); - if(isQueuedForDelivery) { - document.querySelector('#response').textContent += "queued json for delivery\n"; - } + document.querySelector('#buttonjson').addEventListener('click', function () { + const data = new Blob([JSON.stringify({ foo: 'bar' })], { type: 'text/plain' }); + const isQueuedForDelivery = window.navigator.sendBeacon('/telemetry', data); + log(isQueuedForDelivery, 'when payload is "text/plain" Blob') }); })(window, window.document); diff --git a/test/spec/plugin_test.js b/test/spec/plugin_test.js index a2af8d71..b1b0862f 100644 --- a/test/spec/plugin_test.js +++ b/test/spec/plugin_test.js @@ -543,54 +543,50 @@ describe('webdriverajax', function testSuite() { }); describe('sendBeacon API', async function () { - it('can intercept a simple POST request', async function () { + this.beforeEach(async function () { await browser.url('/sendBeacon.html'); await browser.setupInterceptor(); - await browser.expectRequest('POST', '/post.json', 200); - await completedRequest('#buttonjson'); - await browser.assertRequests(); - await browser.assertExpectedRequestsOnly(); }); - it('can intercept when target is URL object', async function () { - await browser.url('/sendBeacon.html'); - await browser.setupInterceptor(); - await browser.expectRequest('POST', /\/post\.json/, 200); + it('reports beacons as requests with POST method', async function () { await completedRequest('#buttonstring'); - await browser.assertRequests(); - await browser.assertExpectedRequestsOnly(); + const requests = await browser.getRequests(); + assert.strictEqual(requests.length, 1, 'should capture single request'); + assert.strictEqual( + requests[0].method, + 'POST', + 'should capture POST request', + ); }); - it('can access a certain request', async function () { - await browser.url('/sendBeacon.html'); - await browser.setupInterceptor(); - await completedRequest('#buttonjson'); - const request = await browser.getRequest(0); - assert.equal(request.method, 'POST'); - assert.equal(request.url, '/post.json'); - assert.equal(request.response.body, true); - assert.equal(request.response.statusCode, 200); - assert.deepEqual(request.response.headers, {}); + it('can intercept when target is URL object', async function () { + await browser.expectRequest('POST', /\/telemetry\?data=querystring/, 200); + await completedRequest('#url'); + await browser.assertRequests(); + await browser.assertExpectedRequestsOnly(); + const action = await browser.$('#response').getText(); + assert.strictEqual(action, 'did queue update when using `new URL()`'); }); it('can assess the request body using string data', async function () { - await browser.url('/sendBeacon.html'); - await browser.setupInterceptor(); await completedRequest('#buttonstring'); const request = await browser.getRequest(0); - assert.equal(request.url, 'http://localhost:8080/post.json'); - assert.equal(request.response.body, true); + assert.equal(request.url, '/telemetry'); assert.deepEqual(request.body, 'bar'); + const action = await browser.$('#response').getText(); + assert.strictEqual(action, 'did queue update when payload is string'); }); it('can assess the request body using JSON data as Blob', async function () { - await browser.url('/sendBeacon.html'); - await browser.setupInterceptor(); await completedRequest('#buttonjson'); const request = await browser.getRequest(0); - assert.equal(request.url, '/post.json'); - assert.equal(request.response.body, true); + assert.equal(request.url, '/telemetry'); assert.deepEqual(request.body, { foo: 'bar' }); + const action = await browser.$('#response').getText(); + assert.strictEqual( + action, + 'did queue update when payload is "text/plain" Blob', + ); }); }); diff --git a/test/utils/express.js b/test/utils/express.js index 069a625b..3e757f4e 100644 --- a/test/utils/express.js +++ b/test/utils/express.js @@ -22,7 +22,11 @@ async function initialize({ baseUrl }) { app.use((req, resp, nextFn) => { if (req.method === 'POST') { // resp.sendFile(`${resources}${req.path}`); - resp.json({ OK: true }); + if (req.url.startsWith('/telemetry')) { + resp.sendStatus(204); + } else { + resp.json({ OK: true }); + } } else { nextFn(); } From 23bc320589f054e3d18abf6276dd16dc2d8cf44e Mon Sep 17 00:00:00 2001 From: tehhowch Date: Tue, 5 Mar 2024 19:26:36 -0600 Subject: [PATCH 3/3] chore: cleanup --- test/site/{header-parsing.html => header_parsing.html} | 0 test/site/{sendBeacon.html => send_beacon.html} | 6 +++--- test/spec/plugin_test.js | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) rename test/site/{header-parsing.html => header_parsing.html} (100%) rename test/site/{sendBeacon.html => send_beacon.html} (85%) diff --git a/test/site/header-parsing.html b/test/site/header_parsing.html similarity index 100% rename from test/site/header-parsing.html rename to test/site/header_parsing.html diff --git a/test/site/sendBeacon.html b/test/site/send_beacon.html similarity index 85% rename from test/site/sendBeacon.html rename to test/site/send_beacon.html index 6460315a..00d0b316 100644 --- a/test/site/sendBeacon.html +++ b/test/site/send_beacon.html @@ -6,10 +6,10 @@ sendBeacon request -

This file makes a sendBeacon request to /post.json

+

This file makes various sendBeacon requests

- +