Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ const options = {
publicKey: '< URL Safe Base64 Encoded Public Key >',
privateKey: '< URL Safe Base64 Encoded Private Key >'
},
timeout: <Number>
timeout: <Number>,
signal: <AbortSignal>,
TTL: <Number>,
headers: {
'< header name >': '< header value >'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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: <AbortSignal>
}

try {
Expand Down Expand Up @@ -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

Expand All @@ -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.

<hr />

# Browser Support
Expand Down
18 changes: 16 additions & 2 deletions src/web-push-lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class WebPushLib {
let proxy;
let agent;
let timeout;
let signal;

if (options) {
const validOptionKeys = [
Expand All @@ -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) {
Expand Down Expand Up @@ -217,6 +219,8 @@ export class WebPushLib {
if (typeof options.timeout === 'number') {
timeout = options.timeout;
}

signal = options.signal;
}

if (typeof timeToLive === 'undefined') {
Expand Down Expand Up @@ -313,6 +317,10 @@ export class WebPushLib {
requestDetails.timeout = timeout;
}

if (signal !== undefined) {
requestDetails.signal = signal;
}

return requestDetails;
}

Expand Down Expand Up @@ -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) {
Expand Down
80 changes: 80 additions & 0 deletions test/test-abort-proxy.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
});
Loading