pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
553 lines (552 loc) • 30.2 kB
JavaScript
;
/**
* Node.js Transport provider module.
*
* @internal
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.NodeTransport = void 0;
const undici_1 = require("undici");
const buffer_1 = require("buffer");
const zlib = __importStar(require("zlib"));
const abort_signal_1 = require("../core/components/abort_signal");
const categories_1 = __importDefault(require("../core/constants/categories"));
const pubnub_api_error_1 = require("../errors/pubnub-api-error");
const utils_1 = require("../core/utils");
/**
* Class representing a `fetch`-based Node.js transport provider.
*
* Requests are issued through the global {@link fetch} (so that HTTP mocking libraries which patch
* the global stay effective in tests), while connection management and HTTP/2 negotiation are driven
* by an `undici` dispatcher passed via the `dispatcher` request option.
*
* @internal
*/
class NodeTransport {
/**
* Creates a new `fetch`-based transport instance.
*
* @param logger - Registered loggers' manager.
* @param keepAlive - Indicates whether keep-alive should be enabled.
* @param [keepAliveSettings] - Optional settings for keep-alive.
*
* @returns Transport for performing network requests.
*
* @internal
*/
constructor(logger, keepAlive = false, keepAliveSettings = { timeout: 30000 }) {
this.logger = logger;
this.keepAlive = keepAlive;
this.keepAliveSettings = keepAliveSettings;
logger.debug('NodeTransport', () => ({
messageType: 'object',
message: { keepAlive, keepAliveSettings },
details: 'Create with configuration:',
}));
}
/**
* Update request proxy configuration.
*
* @param configuration - New proxy configuration.
*
* @internal
*/
setProxy(configuration) {
if (configuration)
this.logger.debug('NodeTransport', 'Proxy configuration has been set.');
else
this.logger.debug('NodeTransport', 'Proxy configuration has been removed.');
this.proxyConfiguration = configuration;
// Invalidate the cached proxy dispatcher so the new configuration takes effect on the next
// request. Sockets held by the previous dispatcher are released asynchronously.
if (this.proxyAgent) {
void this.proxyAgent.close().catch(() => { });
this.proxyAgent = undefined;
}
}
makeSendable(req) {
let controller = undefined;
let abortController;
// `undici`'s `fetch` has no per-request `timeout` option (unlike `node-fetch`), so the timeout is
// expressed as an abort signal. The signal is created at fetch time (inside the `.then` below),
// NOT here: `node-fetch`'s `{timeout}` started counting at the `fetch()` call, so charging the
// async body-building work in `requestFromTransportRequest` (e.g. reading a large upload into
// memory via `file.toArrayBuffer()`) against the request timeout would be a behaviour change.
// Declared here so the `.catch` classifier can read its `.aborted` state.
let timeoutSignal;
if (req.cancellable) {
abortController = new AbortController();
controller = {
// Storing a controller inside to prolong object lifetime.
abortController,
abort: (reason) => {
if (!abortController || abortController.signal.aborted)
return;
this.logger.trace('NodeTransport', `On-demand request aborting: ${reason}`);
abortController === null || abortController === void 0 ? void 0 : abortController.abort(reason);
},
};
}
return [
this.requestFromTransportRequest(req)
.then((request) => {
this.logger.debug('NodeTransport', () => ({ messageType: 'network-request', message: req }));
// Start the timeout clock now, at fetch time (see note above). `AbortSignal.timeout` schedules
// an unref-ed timer, so it does not keep the event loop alive on its own.
timeoutSignal = AbortSignal.timeout(req.timeout * 1000);
// The request must abort on whichever happens first: user cancellation or timeout.
const signal = abortController
? AbortSignal.any([abortController.signal, timeoutSignal])
: timeoutSignal;
return fetch(request, {
signal,
// The `undici` dispatcher carries connection-pool / proxy settings and the `allowH2` flag.
// It is set on the `fetch` init (not on the `Request`) and is not part of the standard
// `RequestInit`, hence the cast.
dispatcher: this.dispatcherForTransportRequest(),
})
.then((response) => response.arrayBuffer().then((arrayBuffer) => [response, arrayBuffer]))
.then((response) => {
const responseBody = response[1].byteLength > 0 ? response[1] : undefined;
const { status, headers: requestHeaders } = response[0];
const headers = {};
// Copy Headers object content into plain Record.
requestHeaders.forEach((value, key) => (headers[key] = value.toLowerCase()));
const transportResponse = {
status,
url: request.url,
headers,
body: responseBody,
};
this.logger.debug('NodeTransport', () => {
var _a;
return ({
messageType: 'network-response',
message: transportResponse,
// Most-recently negotiated application-layer protocol (e.g. `h2` / `http/1.1`). See
// `lastNegotiatedProtocol`. `unknown` when the connection went through a proxy
// dispatcher (whose sockets the keep-alive connector does not observe) or over plain
// HTTP (no TLS, hence no ALPN).
details: `Negotiated protocol: ${(_a = this.lastNegotiatedProtocol) !== null && _a !== void 0 ? _a : 'unknown'}`,
});
});
if (status >= 400)
throw pubnub_api_error_1.PubNubAPIError.create(transportResponse);
return transportResponse;
});
})
.catch((error) => {
// The `.catch` is attached to the OUTER promise (not the inner `fetch` chain) so it also
// classifies rejections from `requestFromTransportRequest` — e.g. `file.toArrayBuffer()`
// throwing on an unreadable upload, or `new Request(...)` rejecting a malformed URL/header —
// instead of letting them escape unclassified.
//
// Classification relies on signal state rather than parsing the rejection value, because
// an aborted `fetch` may reject with the abort *reason* (which can be an arbitrary string)
// instead of an `Error`.
// Timeout takes priority: the timeout signal fires regardless of whether the request was
// also user-cancellable. Re-shape it as a `timeout` message so the shared classifier maps
// it to `PNTimeoutCategory`.
if (timeoutSignal === null || timeoutSignal === void 0 ? void 0 : timeoutSignal.aborted) {
this.logger.warn('NodeTransport', () => ({
messageType: 'network-request',
message: req,
details: 'Timeout',
canceled: true,
}));
throw pubnub_api_error_1.PubNubAPIError.create(new Error('Request timeout'));
}
// User-requested cancellation. An `AbortError` (name `'AbortError'`) is what downstream
// code keys off to recognise a cancelled request.
if (abortController === null || abortController === void 0 ? void 0 : abortController.signal.aborted) {
this.logger.debug('NodeTransport', () => ({
messageType: 'network-request',
message: req,
details: 'Aborted',
canceled: true,
}));
throw pubnub_api_error_1.PubNubAPIError.create(new abort_signal_1.AbortError());
}
// Network failure or an already-classified service error (HTTP >= 400 thrown above).
const apiError = pubnub_api_error_1.PubNubAPIError.create(NodeTransport.normalizeNetworkError(error));
if (apiError.category === categories_1.default.PNNetworkIssuesCategory) {
this.logger.warn('NodeTransport', () => ({
messageType: 'network-request',
message: req,
details: 'Network error',
failed: true,
}));
}
else {
this.logger.warn('NodeTransport', () => ({
messageType: 'network-request',
message: req,
details: apiError.message,
failed: true,
}));
}
throw apiError;
}),
controller,
];
}
request(req) {
return req;
}
/**
* Creates a Request object from a given {@link TransportRequest} object.
*
* @param req - The {@link TransportRequest} object containing request information.
*
* @returns Request object generated from the {@link TransportRequest} object.
*
* @internal
*/
requestFromTransportRequest(req) {
return __awaiter(this, void 0, void 0, function* () {
let headers = req.headers;
let body;
let path = req.path;
// Create multipart request body.
if (req.formData && req.formData.length > 0) {
// Reset query parameters to conform to signed URL
req.queryParameters = {};
const file = req.body;
const fileData = yield file.toArrayBuffer();
const formData = new FormData();
for (const { key, value } of req.formData)
formData.append(key, value);
// The Web `FormData` understood by `fetch` accepts a `Blob`/`File`, unlike the old `form-data`
// package which took a `Buffer` plus `{ contentType, filename }` metadata.
formData.append('file', new Blob([fileData], { type: 'application/octet-stream' }), file.name);
body = formData;
// Let `fetch` derive `Content-Type: multipart/form-data; boundary=...` from the FormData body.
// A pre-existing Content-Type header would override the generated boundary and corrupt the
// request, so any incoming Content-Type is stripped here.
if (headers) {
headers = Object.assign({}, headers);
for (const key of Object.keys(headers))
if (key.toLowerCase() === 'content-type')
delete headers[key];
}
}
// Handle regular body payload (if passed).
else if (req.body && (typeof req.body === 'string' || req.body instanceof ArrayBuffer)) {
let initialBodySize = 0;
if (req.compressible) {
initialBodySize =
typeof req.body === 'string' ? NodeTransport.encoder.encode(req.body).byteLength : req.body.byteLength;
}
// Compressing body (if required).
body = req.compressible ? zlib.deflateSync(req.body) : req.body;
if (req.compressible) {
this.logger.trace('NodeTransport', () => {
const compressedSize = body.byteLength;
const ratio = (compressedSize / initialBodySize).toFixed(2);
return {
messageType: 'text',
message: `Body of ${initialBodySize} bytes, compressed by ${ratio}x to ${compressedSize} bytes.`,
};
});
}
}
if (req.queryParameters && Object.keys(req.queryParameters).length !== 0)
path = `${path}?${(0, utils_1.queryStringFromObject)(req.queryParameters)}`;
return new Request(`${req.origin}${path}`, {
method: req.method,
headers,
redirect: 'follow',
body: body,
});
});
}
/**
* Determines the `undici` dispatcher to use for outgoing requests.
*
* A proxy dispatcher (when configured) takes precedence over the keep-alive dispatcher — the same
* precedence the previous `proxy-agent` implementation had over the keep-alive `http.Agent`.
*
* @returns Dispatcher carrying connection-pool / proxy settings and the HTTP/2 negotiation flag.
*
* @internal
*/
dispatcherForTransportRequest() {
var _a, _b;
if (this.proxyConfiguration)
return ((_a = this.proxyAgent) !== null && _a !== void 0 ? _a : (this.proxyAgent = new undici_1.ProxyAgent(NodeTransport.proxyAgentOptions(this.proxyConfiguration))));
return ((_b = this.keepAliveAgent) !== null && _b !== void 0 ? _b : (this.keepAliveAgent = this.makeKeepAliveAgent()));
}
/**
* Builds the keep-alive {@link Agent}, mapping the public {@link TransportKeepAlive} options (which
* use Node `http.Agent` semantics) onto `undici`'s connection-pool options.
*
* @returns Configured multi-origin dispatcher.
*
* @internal
*/
makeKeepAliveAgent() {
// `allowH2` is the single HTTP/2-facing switch: it lets `undici` speak HTTP/2 over a socket whose
// TLS ALPN negotiated `h2`. When an origin only advertises HTTP/1.1, `undici` transparently falls
// back, so this is safe for every origin and does not change behaviour against HTTP/1.1-only
// endpoints. (It must ALSO be set on the connector — see `makeConnector` — so the ALPN list
// actually advertises `h2`.)
const options = Object.assign({ allowH2: true }, NodeTransport.disabledInternalTimeouts());
const settings = this.keepAliveSettings;
let keepAliveInitialDelay;
if (this.keepAlive) {
// Map TransportKeepAlive (`http.Agent` semantics) -> undici:
// - `timeout` (ms before an idle socket is closed) -> `keepAliveTimeout`
// - `maxSockets` (max sockets per host) -> `connections`
// - `keepAliveMsecs` (TCP keep-alive probe delay ms) -> connector `keepAliveInitialDelay`
// - `maxFreeSockets` has NO undici equivalent (undici manages free sockets internally) and is
// intentionally left unmapped.
if (typeof settings.timeout === 'number')
options.keepAliveTimeout = settings.timeout;
if (typeof settings.maxSockets === 'number')
options.connections = settings.maxSockets;
if (typeof settings.keepAliveMsecs === 'number')
keepAliveInitialDelay = settings.keepAliveMsecs;
}
else {
// Keep-alive disabled. `pipelining: 0` disables HTTP/1.1 request pipelining (undici's documented
// replacement for the removed `keepAlive: false`).
//
// unlike `node-fetch`'s `http.Agent({keepAlive:
// false})` — which sent `Connection: close` and used a fresh socket per request — `undici`
// always pools and reuses sockets. We intentionally accept this connection reuse: it is a
// transparent latency improvement with no observable effect on the SDK's public API. We do NOT
// try to force per-request socket teardown.
options.pipelining = 0;
}
// A custom connector lets us observe the ALPN-negotiated protocol for logging. It REPLACES
// undici's built-in connector, so `makeConnector` re-applies `allowH2`, the disabled connect
// timeout, and the keep-alive probe delay that undici would otherwise have set itself.
options.connect = this.makeConnector(keepAliveInitialDelay);
return new undici_1.Agent(options);
}
/**
* Builds a TLS/TCP connector that records the application-layer protocol negotiated via ALPN.
*
* The WHATWG `fetch` `Response` carries no HTTP version, so the only place to read the negotiated
* protocol is the freshly-connected TLS socket's `alpnProtocol`. Supplying a custom `connect`
* function makes `undici` bypass its own `buildConnector`, so this connector must itself set the
* options undici would otherwise have applied: `allowH2: true` (so the ALPN list advertises `h2` —
* omitting it silently downgrades every connection to HTTP/1.1), `timeout: 0` (connect timeout
* disabled, matching {@link disabledInternalTimeouts}), and the keep-alive probe delay.
*
* The connector only fires on NEW connections; pooled/reused sockets keep the previously-recorded
* value, which is what we want to log.
*
* @param keepAliveInitialDelay - TCP keep-alive probe delay (ms), when keep-alive is configured.
*
* @returns Connector that populates {@link negotiatedProtocolByOrigin}.
*
* @internal
*/
makeConnector(keepAliveInitialDelay) {
const base = (0, undici_1.buildConnector)(Object.assign({ allowH2: true,
// The connect timeout lives HERE, and only here. undici only feeds the `Agent`'s
// `connectTimeout` into its own built-in connector; when a custom `connect` function is
// supplied (as it is here) undici skips that wiring entirely (see undici client.js: the
// `connectTimeout -> buildConnector({timeout})` branch runs only when `connect` is not a
// function). So `timeout: 0` below is what actually disables the connect timeout — do not
// remove it expecting the Agent's `connectTimeout` to cover it.
timeout: 0,
// node-fetch's `http.Agent({keepAlive:false})` did not enable OS-level TCP keep-alive probes;
// undici's `buildConnector` turns them on by default. Mirror the keep-alive setting onto the
// socket so the disabled case keeps node-fetch's "no SO_KEEPALIVE" behaviour.
keepAlive: this.keepAlive }, (typeof keepAliveInitialDelay === 'number' ? { keepAliveInitialDelay } : {})));
return (options, callback) => base(options, (error, socket) => {
if (!error && socket && 'alpnProtocol' in socket) {
// `alpnProtocol` is `'h2'`, `'http/1.1'`, or `false` when the server did not select a
// protocol via ALPN (e.g. an older TLS server). Normalise the `false` case to `http/1.x`.
const alpn = socket.alpnProtocol;
this.lastNegotiatedProtocol = alpn ? alpn : 'http/1.x';
}
// Forward undici's two-arm callback contract unchanged (`[null, socket]` | `[Error, null]`).
if (error)
callback(error, null);
else
callback(null, socket);
});
}
/**
* `undici` introduces internal headers / body timeouts that `node-fetch` never had (300s / 300s by
* default). These can fire *before* the SDK's own per-request timeout and surface as failure timing
* that did not exist under `node-fetch`. They are disabled here so that the SDK's
* `AbortSignal(req.timeout)` (see {@link makeSendable}) remains the single source of timeout truth,
* matching `node-fetch`'s single `{timeout}` option. `0` disables each timeout entirely.
*
* The connect timeout (undici's 10s default) is NOT included here: on the keep-alive `Agent` it is
* owned by the custom connector (`buildConnector({ timeout: 0 })` in {@link makeConnector}), because
* undici ignores the `Agent`'s `connectTimeout` whenever a custom `connect` function is supplied. The
* proxy path, which has no custom connector, sets `connectTimeout: 0` explicitly (see
* {@link proxyAgentOptions}).
*
* @returns Partial dispatcher options that switch the undici-internal headers/body timeouts off.
*
* @internal
*/
static disabledInternalTimeouts() {
return { headersTimeout: 0, bodyTimeout: 0 };
}
/**
* Adapts a {@link NodeTransportProxyConfiguration} to `undici` {@link ProxyAgent} options.
*
* @param configuration - Proxy configuration provided through {@link setProxy}.
*
* @returns `undici` `ProxyAgent` options.
*
* @internal
*/
static proxyAgentOptions(configuration) {
var _a, _b, _c;
// `ProxyAgent.Options extends Agent.Options`, so the same internal-timeout switches apply: proxied
// requests must also defer solely to the SDK `AbortSignal` for timeout (see
// {@link disabledInternalTimeouts}). The proxy path uses undici's built-in connector (no custom
// `connect` function), so `connectTimeout: 0` IS honoured here and is set explicitly to disable
// undici's 10s connect timeout.
const timeouts = Object.assign(Object.assign({}, NodeTransport.disabledInternalTimeouts()), { connectTimeout: 0 });
// A fully-formed proxy URI is used as-is.
if (typeof configuration === 'string')
return Object.assign({ uri: configuration }, timeouts);
const protocol = ((_a = configuration.protocol) !== null && _a !== void 0 ? _a : 'http').replace(/:$/, '');
const host = (_c = (_b = configuration.hostname) !== null && _b !== void 0 ? _b : configuration.host) !== null && _c !== void 0 ? _c : '';
const port = configuration.port;
const uri = `${protocol}://${host}${port != null ? `:${port}` : ''}`;
const options = Object.assign({ uri }, timeouts);
// Convert `user:password` credentials into a Basic-auth proxy token.
if (configuration.auth)
options.token = `Basic ${buffer_1.Buffer.from(configuration.auth).toString('base64')}`;
return options;
}
/**
* Re-shapes a low-level network error into a form the shared {@link PubNubAPIError} classifier
* understands.
*
* The global `fetch` (backed by `undici`) reports connection-level failures as
* `TypeError: fetch failed` with the real cause — including the POSIX error `code` — attached to
* `error.cause`. {@link PubNubAPIError} historically recognises `node-fetch`'s `FetchError` (matched
* by `name` + `code`), so undici's error is normalised into that shape to preserve network-error
* categorisation (e.g. `ECONNREFUSED` -> `PNNetworkIssuesCategory`, `ETIMEDOUT` -> timeout).
*
* @param error - Error thrown by `fetch`.
*
* @returns Original error, or a re-shaped error (`FetchError` with POSIX `code`, a `timeout`
* message, or a `network` message) that the shared classifier maps to the right category.
*
* @internal
*/
static normalizeNetworkError(error) {
// Only `fetch`'s `TypeError: fetch failed` wrapper carries a low-level `cause`. A `TypeError`
// WITHOUT a cause is a genuine request-construction / validation error and must keep falling
// through to `PNBadRequestCategory` (the classifier's `TypeError` branch) — do not reshape it.
const cause = error instanceof TypeError ? error.cause : undefined;
if (cause && typeof cause === 'object') {
const { message } = cause;
// Resolve the most specific POSIX/undici error code available. When connecting to a host that
// resolves to several addresses, undici/Node surface a single `AggregateError` whose top-level
// `code` is often absent while each wrapped error in `.errors` carries the real code (e.g. every
// address refused -> `ECONNREFUSED`). Reach into the wrapped errors so these still classify on
// their real code rather than falling through to the generic branch below.
const code = NodeTransport.networkErrorCode(cause);
if (typeof code === 'string') {
// undici expresses its internal connect / headers / body timeouts with `UND_ERR_*_TIMEOUT`
// codes (not the POSIX `ETIMEDOUT` the classifier knows). Map them to a timeout-shaped error
// so they classify as `PNTimeoutCategory`, matching `node-fetch`'s `ETIMEDOUT` -> timeout.
// (With undici's internal timeouts disabled these are rare, but the mapping keeps any that do
// surface — e.g. a server-aborted stream — correctly categorised.)
if (code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'UND_ERR_HEADERS_TIMEOUT' || code === 'UND_ERR_BODY_TIMEOUT')
return new Error('Request timeout');
const normalized = new Error(message !== null && message !== void 0 ? message : error.message);
normalized.name = 'FetchError';
normalized.code = code;
return normalized;
}
// A `fetch failed` whose cause carries no resolvable `code` (e.g. a TLS handshake failure that
// surfaces only a message). This is still a transport-layer failure — `fetch` only throws
// `fetch failed` once the request has left request construction — so it was a `node-fetch`
// `FetchError` -> network issue, not a request bug. The classifier's bare `TypeError` branch
// would otherwise mislabel it as a (non-retryable) `PNBadRequestCategory`, so reshape it into a
// network-issues error to preserve `PNNetworkIssuesCategory`. The SDK's retry policy bounds the
// resulting retries (capped attempts + exponential backoff), so a persistent failure does not
// retry forever.
return new Error('Network issues');
}
return error;
}
/**
* Resolves the most specific error `code` from an undici/Node `fetch` cause, unwrapping a single
* level of `AggregateError` (multi-address connect failures expose the real code on the wrapped
* errors rather than the aggregate itself).
*
* @param cause - `error.cause` attached to a `TypeError: fetch failed`.
*
* @returns POSIX/undici error code, or `undefined` when none is present.
*
* @internal
*/
static networkErrorCode(cause) {
const { code, errors } = cause;
if (typeof code === 'string')
return code;
if (Array.isArray(errors)) {
for (const inner of errors) {
const innerCode = inner != null ? inner.code : undefined;
if (typeof innerCode === 'string')
return innerCode;
}
}
return undefined;
}
}
exports.NodeTransport = NodeTransport;
/**
* {@link string|String} to {@link ArrayBuffer} response decoder.
*/
NodeTransport.encoder = new TextEncoder();