diff --git a/README.md b/README.md index cb2d136f..388263cd 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,8 @@ const options = { publicKey: '< URL Safe Base64 Encoded Public Key >', privateKey: '< URL Safe Base64 Encoded Private Key >' }, - timeout: + timeout: , + signal: , TTL: , headers: { '< header name >': '< header value >' @@ -182,6 +183,13 @@ request only. This overrides any API key set via `setGCMAPIKey()`. - **vapidDetails** should be an object with *subject*, *publicKey* and *privateKey* values defined. These values should follow the [VAPID Spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid). - **timeout** is a value in milliseconds that specifies the request's socket timeout. On timeout, the request will be destroyed and the promise will be rejected with a meaningful error. It's a common misconception that a socket timeout is the timeout to receive the full response. So if you have a socket timeout of 1 second, and a response comprised of 3 TCP packets, where each response packet takes 0.9 seconds to arrive, for a total response time of 2.7 seconds, then there will be no timeout. Once a socket 'timeout' triggers the request will be aborted by the library (by default undefined). +- **signal** is an optional `AbortSignal` for cancelling the HTTP request, +including while waiting for its response body. An already-aborted signal rejects +without sending the request. Cancellation rejects with Node.js's `AbortError` +(`code: 'ABORT_ERR'`). It also cancels a pending connection to a configured proxy. +Cancellation during connection setup with a custom `agent` depends on that +agent's support. This option does not change the socket `timeout` and cannot +undo a notification already accepted by the push service. - **TTL** is a value in seconds that describes how long a push message is retained by the push service (by default, four weeks). - **headers** is an object with all the extra headers you want to add to the request. @@ -393,7 +401,8 @@ const options = { contentEncoding: '< Encoding type, e.g.: aesgcm or aes128gcm >', urgency:'< Default is normal "Defult" >', topic:'< Use a maximum of 32 characters from the URL or filename-safe Base64 characters sets. >', - proxy: '< proxy server options >' + proxy: '< proxy server options >', + signal: } try { @@ -452,6 +461,8 @@ retained by the push service (by default, four weeks). - **proxy** is the [HttpsProxyAgent's constructor argument](https://github.com/TooTallNate/node-https-proxy-agent#new-httpsproxyagentobject-options) that may either be a string URI of the proxy server (eg. http://< hostname >:< port >) or an "options" object with more specific properties. +- **signal** is an optional `AbortSignal` copied to the request details for a +custom transport to use. ### Returns @@ -463,6 +474,10 @@ object will contain: - *headers*, the headers to add to the request; - *body*, the body of the request (As a Node Buffer). +When supplied, *signal* is the same `AbortSignal` passed in the options. +`generateRequestDetails()` does not perform I/O or cancel payload generation; +a custom transport must forward this signal to its own request. +
# Browser Support diff --git a/src/web-push-lib.js b/src/web-push-lib.js index 5e1343bf..9e834a9f 100644 --- a/src/web-push-lib.js +++ b/src/web-push-lib.js @@ -109,6 +109,7 @@ export class WebPushLib { let proxy; let agent; let timeout; + let signal; if (options) { const validOptionKeys = [ @@ -121,7 +122,8 @@ export class WebPushLib { 'topic', 'proxy', 'agent', - 'timeout' + 'timeout', + 'signal' ]; const optionKeys = Object.keys(options); for (let i = 0; i < optionKeys.length; i += 1) { @@ -217,6 +219,8 @@ export class WebPushLib { if (typeof options.timeout === 'number') { timeout = options.timeout; } + + signal = options.signal; } if (typeof timeToLive === 'undefined') { @@ -313,6 +317,10 @@ export class WebPushLib { requestDetails.timeout = timeout; } + if (signal !== undefined) { + requestDetails.signal = signal; + } + return requestDetails; } @@ -352,12 +360,18 @@ export class WebPushLib { httpsOptions.timeout = requestDetails.timeout; } + if (requestDetails.signal !== undefined) { + httpsOptions.signal = requestDetails.signal; + } + if (requestDetails.agent) { httpsOptions.agent = requestDetails.agent; } if (requestDetails.proxy) { - httpsOptions.agent = new HttpsProxyAgent(requestDetails.proxy); + httpsOptions.agent = new HttpsProxyAgent(requestDetails.proxy, { + signal: requestDetails.signal + }); } const pushRequest = https.request(httpsOptions, function(pushResponse) { diff --git a/test/test-abort-proxy.js b/test/test-abort-proxy.js new file mode 100644 index 00000000..1248933b --- /dev/null +++ b/test/test-abort-proxy.js @@ -0,0 +1,80 @@ +import assert from 'node:assert'; +import { getEventListeners } from 'node:events'; +import http from 'node:http'; +import { suite, test } from 'mocha'; +import { sendNotification } from '../src/index.js'; + +suite('sendNotification cancellation through a proxy', function() { + test('aborts a pending proxy CONNECT and closes its socket', async function() { + const proxy = http.createServer(); + const sockets = new Set(); + const controller = new AbortController(); + const reason = new Error('Notification cancelled'); + let connected; + let closed; + let timer; + const proxyConnected = new Promise(function(resolve) { + connected = resolve; + }); + const proxyClosed = new Promise(function(resolve) { + closed = resolve; + }); + const deadline = new Promise(function(resolve, reject) { + timer = setTimeout(function() { + reject(new Error('Proxy cancellation did not complete')); + }, 1000); + }); + + proxy.on('connection', function(socket) { + sockets.add(socket); + socket.on('close', function() { + sockets.delete(socket); + }); + }); + proxy.on('connect', function(request, socket) { + // CONNECT sockets allow half-open connections; close the server's half + // after observing the client's FIN, without responding to CONNECT. + socket.on('end', function() { + socket.end(); + }); + socket.on('close', closed); + socket.resume(); + connected(); + }); + + try { + await new Promise(function(resolve) { + proxy.listen(0, '127.0.0.1', resolve); + }); + const rejected = assert.rejects(sendNotification({ + endpoint: 'https://example.test/push' + }, null, { + proxy: 'http://127.0.0.1:' + proxy.address().port, + signal: controller.signal, + vapidDetails: null + }), function(error) { + assert.equal(error.name, 'AbortError'); + assert.equal(error.code, 'ABORT_ERR'); + if ('reason' in controller.signal) { + assert.equal(error.cause, reason); + } + return true; + }); + + await Promise.race([proxyConnected, rejected, deadline]); + controller.abort(reason); + await Promise.race([rejected, deadline]); + await Promise.race([proxyClosed, deadline]); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + } finally { + clearTimeout(timer); + controller.abort(reason); + sockets.forEach(function(socket) { + socket.destroy(); + }); + await new Promise(function(resolve) { + proxy.close(resolve); + }); + } + }); +}); diff --git a/test/test-abort-signal.js b/test/test-abort-signal.js new file mode 100644 index 00000000..446e369a --- /dev/null +++ b/test/test-abort-signal.js @@ -0,0 +1,248 @@ +import assert from 'node:assert'; +import { getEventListeners, once } from 'node:events'; +import fs from 'node:fs'; +import https from 'node:https'; +import { afterEach, beforeEach, suite, test } from 'mocha'; +import { sendNotification, setGCMAPIKey, setVapidDetails, WebPushError } from '../src/index.js'; + +function deferred() { + let resolve; + const promise = new Promise(function(done) { + resolve = done; + }); + return { promise, resolve }; +} + +function expectAbort(notification) { + const rejected = assert.rejects(notification, { + name: 'AbortError', + code: 'ABORT_ERR' + }); + // Observe failures immediately, even while a test awaits a network event. + rejected.catch(function() {}); + return rejected; +} + +function beforeAbort(event, rejected) { + return Promise.race([ + event, + rejected.then(function() { + throw new Error('The request settled before the test aborted it'); + }) + ]); +} + +function socketClosed(socket) { + return new Promise(function(resolve) { + socket.once('close', resolve); + }); +} + +suite('AbortSignal', function() { + const pem = fs.readFileSync(new URL('./data/certs/cert.pem', import.meta.url)); + const originalHTTPSRequest = https.request; + let server; + let agent; + let subscription; + let sockets; + let requestReceived; + let handleRequest; + let requestCount; + + beforeEach(async function() { + setGCMAPIKey(null); + setVapidDetails(null); + sockets = new Set(); + requestCount = 0; + requestReceived = deferred(); + handleRequest = function() {}; + agent = new https.Agent({ rejectUnauthorized: false, keepAlive: true }); + server = https.createServer({ key: pem, cert: pem }, function(req, res) { + requestCount += 1; + requestReceived.resolve({ req, res }); + req.resume(); + handleRequest(req, res); + }); + server.on('connection', function(socket) { + sockets.add(socket); + socket.once('close', function() { + sockets.delete(socket); + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + subscription = { endpoint: 'https://127.0.0.1:' + server.address().port + '/push' }; + }); + + afterEach(async function() { + https.request = originalHTTPSRequest; + const closed = once(server, 'close'); + server.close(); + agent.destroy(); + for (const socket of sockets) { + socket.destroy(); + } + await closed; + }); + + test('rejects an already aborted signal without sending an HTTP request', async function() { + const controller = new AbortController(); + controller.abort(); + + await expectAbort(sendNotification(subscription, null, { + agent, + signal: controller.signal + })); + + assert.equal(requestCount, 0); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + }); + + test('aborts while waiting for response headers and closes the server socket', async function() { + const controller = new AbortController(); + const rejected = expectAbort(sendNotification(subscription, null, { + agent, + signal: controller.signal + })); + const { req } = await beforeAbort(requestReceived.promise, rejected); + const closed = socketClosed(req.socket); + + controller.abort(); + controller.abort(); + + await rejected; + await closed; + assert.equal(requestCount, 1); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + }); + + test('aborts after the client receives headers and a partial response body', async function() { + const controller = new AbortController(); + const chunkReceived = deferred(); + https.request = function(options, listener) { + return originalHTTPSRequest.call(https, options, function(response) { + response.once('data', function(chunk) { + chunkReceived.resolve({ response, chunk }); + }); + listener(response); + }); + }; + handleRequest = function(req, res) { + res.writeHead(201); + res.write('partial response'); + }; + const rejected = expectAbort(sendNotification(subscription, null, { + agent, + signal: controller.signal + })); + const { req } = await beforeAbort(requestReceived.promise, rejected); + const closed = socketClosed(req.socket); + const { response, chunk } = await beforeAbort(chunkReceived.promise, rejected); + assert.equal(response.statusCode, 201); + assert.equal(chunk.toString(), 'partial response'); + assert.equal(response.complete, false); + + controller.abort(); + + await rejected; + await closed; + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + }); + + test('preserves a custom abort reason as the native AbortError cause when supported', async function() { + const controller = new AbortController(); + const reason = new Error('Notification no longer needed'); + const notification = sendNotification(subscription, null, { + agent, + signal: controller.signal + }); + const rejected = assert.rejects(notification, function(error) { + assert.equal(error.name, 'AbortError'); + assert.equal(error.code, 'ABORT_ERR'); + if ('reason' in controller.signal) { + assert.strictEqual(error.cause, reason); + } + return true; + }); + rejected.catch(function() {}); + await beforeAbort(requestReceived.promise, rejected); + + controller.abort(reason); + + await rejected; + }); + + test('cleans up the signal after success and ignores aborts after settlement', async function() { + const controller = new AbortController(); + handleRequest = function(req, res) { + res.writeHead(201, { 'X-Push-Test': 'success' }); + res.end('accepted'); + }; + const free = once(agent, 'free'); + const notification = sendNotification(subscription, null, { + agent, + signal: controller.signal + }); + const response = await notification; + await free; + assert.equal(response.statusCode, 201); + assert.equal(response.body, 'accepted'); + assert.equal(response.headers['x-push-test'], 'success'); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + + controller.abort(); + controller.abort(); + + assert.strictEqual(await notification, response); + assert.equal(requestCount, 1); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + }); + + test('preserves WebPushError response details with an active signal', async function() { + const controller = new AbortController(); + handleRequest = function(req, res) { + res.writeHead(404, { 'X-Push-Test': 'missing' }); + res.end('subscription missing'); + }; + + await assert.rejects(sendNotification(subscription, null, { + agent, + signal: controller.signal + }), function(error) { + assert(error instanceof WebPushError); + assert.equal(error.statusCode, 404); + assert.equal(error.headers['x-push-test'], 'missing'); + assert.equal(error.body, 'subscription missing'); + assert.equal(error.endpoint, subscription.endpoint); + return true; + }); + assert.equal(controller.signal.aborted, false); + }); + + test('preserves the socket timeout error with an active signal', async function() { + const controller = new AbortController(); + + await assert.rejects(sendNotification(subscription, null, { + agent, + timeout: 50, + signal: controller.signal + }), function(error) { + assert.equal(error.message, 'Socket timeout'); + assert.notEqual(error.name, 'AbortError'); + return true; + }); + assert.equal(requestCount, 1); + assert.equal(controller.signal.aborted, false); + }); + + test('rejects an invalid signal with the native TypeError', async function() { + await assert.rejects(sendNotification(subscription, null, { + agent, + signal: 'invalid signal' + }), { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.equal(requestCount, 0); + }); +}); diff --git a/test/test-generate-request-details.js b/test/test-generate-request-details.js index 8918c188..a86fc62f 100644 --- a/test/test-generate-request-details.js +++ b/test/test-generate-request-details.js @@ -366,4 +366,21 @@ suite('Test Generate Request Details', function() { ); assert.equal(details.agent, extraOptions.agent); }); + + test('Signal option preserves the AbortSignal reference', function() { + const controller = new AbortController(); + const details = generateRequestDetails( + { endpoint: 'https://127.0.0.1:8080' }, + null, + { signal: controller.signal } + ); + + assert.strictEqual(details.signal, controller.signal); + }); + + test('Omitted signal does not add a request detail', function() { + const details = generateRequestDetails({ endpoint: 'https://127.0.0.1:8080' }); + + assert.equal(Object.prototype.hasOwnProperty.call(details, 'signal'), false); + }); });