redioactive
Version:
Reactive streams for chaining overlapping promises.
579 lines (578 loc) • 27.3 kB
JavaScript
;
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 (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.httpTarget = void 0;
/* eslint-disable @typescript-eslint/no-extra-semi */
const redio_1 = require("./redio");
const http_common_1 = require("./http-common");
const http_1 = __importStar(require("http"));
const https_1 = __importStar(require("https"));
const url_1 = require("url");
const dns_1 = require("dns");
const servers = {};
const serversS = {};
const streamIDs = {};
function isPush(c) {
return c.type === 'push';
}
function noMatch(req, res) {
if (res.writableEnded)
return;
const message = {};
if (req.url && req.method && (req.method === 'GET' || req.method === 'POST')) {
const url = new url_1.URL(req.url);
if (!Object.keys(streamIDs).find((x) => url.pathname.startsWith(x))) {
message.status = 404;
message.message = `Redioactive: HTTP/S target: No stream available for pathname "${url.pathname}".`;
}
}
else {
message.status = req.method ? 405 : 500;
message.message = req.method
? `Redioactive: HTTP/S target: Method ${req.method} not allowed for resource`
: `Redioactive: HTTP/S target: Cannot determine method type`;
}
if (message.status) {
res.statusCode = message.status;
const result = JSON.stringify(message, null, 2);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Length', Buffer.byteLength(result, 'utf8'));
res.end(result, 'utf8');
}
}
function httpTarget(uri, options) {
if (!options)
throw new Error('HTTP options must be specified - for now.');
// Assume pull for now
const url = new url_1.URL(uri, `http${options.httpsPort ? 's' : ''}://localhost:${options.httpPort || options.httpsPort}`);
let info;
let nextExpectedId = -1;
let pushResolver;
let pushPull = () => {
/* void */
};
let pushPullP = Promise.resolve();
url.pathname = url.pathname.replace(/\/+/g, '/');
if (url.pathname.endsWith('/')) {
url.pathname = url.pathname.slice(0, -1);
}
if (uri.toLowerCase().startsWith('http')) {
info = (0, redio_1.literal)({
type: 'pull',
protocol: uri.toLowerCase().startsWith('https') ? http_common_1.ProtocolType.https : http_common_1.ProtocolType.http,
root: url.pathname,
idType: http_common_1.IdType.counter,
body: http_common_1.BodyType.primitive,
delta: http_common_1.DeltaType.one,
manifest: {}
});
let currentId = 0;
let nextId = 0;
let initDone = () => {
/* void */
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let initError = () => {
/* void */
};
const initialized = new Promise((resolve, reject) => {
initDone = resolve;
initError = reject;
});
let [starter, manifestly] = [false, false];
const protocol = info.protocol === http_common_1.ProtocolType.http ? http_1.default : https_1.default;
const agent = new protocol.Agent(Object.assign({ keepAlive: true, host: url.hostname }, (options && options.requestOptions) || {}));
dns_1.promises
.lookup(url.hostname)
.then((host) => {
url.hostname = host.address;
}, () => {
/* Does not matter - fall back on given hostname and default DNS behaviour */
})
.then(() => {
const startReq = protocol.request(Object.assign((options && options.requestOptions) || {}, {
hostname: url.hostname,
protocol: url.protocol,
port: url.port,
path: `${info.root}/start`,
method: 'GET',
agent
}), (res) => {
const location = res.headers['location'];
if (res.statusCode !== 302 || location === undefined) {
throw new Error(`Redioactive: HTTP/S target: Failed to retrieve stream start details for "${info.root}".`);
}
res.on('error', initError);
currentId = location.slice(location.lastIndexOf('/') + 1);
nextId = currentId;
let idType = res.headers['redioactive-idtype'];
if (Array.isArray(idType)) {
idType = idType[0];
}
info.idType = idType || http_common_1.IdType.counter;
let delta = res.headers['redioactive-deltatype'];
if (Array.isArray(delta)) {
delta = delta[0];
}
info.delta = delta || http_common_1.DeltaType.one;
let body = res.headers['redioactive-bodytype'];
if (Array.isArray(body)) {
body = body[0];
}
info.body = body || http_common_1.BodyType.primitive;
starter = true;
if (manifestly) {
initDone();
}
});
startReq.on('error', initError);
startReq.end();
const maniReq = protocol.request(Object.assign((options && options.requestOptions) || {}, {
hostname: url.hostname,
protocol: url.protocol,
port: url.port,
path: `${info.root}/manifest.json`,
method: 'GET',
agent
}), (res) => {
if (res.statusCode !== 200 || res.headers['content-type'] !== 'application/json') {
throw new Error(`Redioactive: HTTP/S target: Failed to retrieve manifest for stream "${info.root}".`);
}
res.setEncoding('utf8');
let manifestStr = '';
res.on('data', (chunk) => {
manifestStr += chunk;
});
res.on('end', () => {
info.manifest = JSON.parse(manifestStr);
manifestly = true;
if (starter) {
initDone();
}
});
});
maniReq.on('error', initError);
maniReq.end();
});
// 1. Do all the initialization and options checks
// 2. Make the /start request and set up state
// 2a. Get the manifest
let streamCounter = 0;
return () => new Promise((resolve, reject) => {
streamCounter++;
// 3. Pull the value
// 4. Create the object
// 5. Get ready for the next pull or detect end
// 6. resolve
initialized.then(() => {
const valueReq = protocol.request(Object.assign((options && options.requestOptions) || {}, {
hostname: url.hostname,
protocol: url.protocol,
port: url.port,
path: `${info.root}/${nextId.toString()}`,
method: 'GET',
agent
}), (res) => {
if (res.statusCode !== 200) {
throw new Error(`Redioactive: HTTP/S target: Unexpected response code ${res.statusCode}.`);
}
currentId = nextId;
if (!res.headers['content-length']) {
throw new Error('Redioactive: HTTP/S target: Content-Length header expected');
}
let value = info.body === http_common_1.BodyType.blob
? Buffer.allocUnsafe(+res.headers['content-length'])
: '';
const streamSaysNextIs = res.headers['redioactive-nextid'];
nextId = Array.isArray(streamSaysNextIs)
? +streamSaysNextIs[0]
: streamSaysNextIs
? +streamSaysNextIs
: currentId;
if (info.body !== http_common_1.BodyType.blob) {
res.setEncoding('utf8');
}
let bufferPos = 0;
res.on('data', (chunk) => {
if (!chunkIsString(value)) {
bufferPos += chunk.copy(value, bufferPos);
}
else {
value += chunk;
}
});
res.on('end', () => {
let t;
if (info.body === http_common_1.BodyType.blob) {
const s = {};
if (value.length > 0) {
s[(options && options.blob) || 'blob'] = value;
}
let details = res.headers['redioactive-details'] || '{}';
if (Array.isArray(details)) {
details = details[0];
}
t = Object.assign(s, JSON.parse(details));
}
else {
t = JSON.parse(value);
}
if (typeof t === 'object') {
if (Object.keys(t).length === 1 &&
Object.prototype.hasOwnProperty.call(t, 'end') &&
t['end'] === true) {
resolve(redio_1.end);
return;
}
if (options && typeof options.manifest === 'string') {
;
t[options.manifest] = info.manifest;
}
if (options && options.seqId) {
;
t[options.seqId] = currentId;
}
if (options && options.debug) {
;
t['debug_streamCounter'] = streamCounter;
t['debug_status'] = res.statusCode;
}
if (options && typeof options.delta === 'string') {
switch (info.delta) {
case http_common_1.DeltaType.one:
;
t[options.delta] = 1;
break;
case http_common_1.DeltaType.variable:
case http_common_1.DeltaType.fixed:
;
t[options.delta] =
nextId - currentId;
break;
default:
break;
}
}
}
resolve(t);
});
res.on('error', reject);
});
valueReq.on('error', reject);
valueReq.end();
}, reject); // initialized promise complete
});
}
else {
// PUSH
let server = undefined;
let serverS = undefined;
const root = url.pathname;
if (options.httpPort) {
if (options && !options.extraStreamRoot) {
// Set with first element
streamIDs[root] = { httpPort: options.httpPort, httpsPort: options.httpsPort };
}
server = servers[options.httpPort];
if (!server) {
server = options.serverOptions ? (0, http_1.createServer)(options.serverOptions) : (0, http_1.createServer)();
server.keepAliveTimeout = (options && options.keepAliveTimeout) || 5000;
servers[options.httpPort] = server;
server.listen(options.httpPort, () => {
console.log(`Redioactive: HTTP/S target: HTTP push server for stream ${root} listening on ${options.httpPort}`);
});
}
server.on('request', pushRequest);
server.on('error', (err) => {
// TODO interrupt and push error?
console.error(err);
});
}
if (options.httpsPort) {
if (options && !options.extraStreamRoot) {
streamIDs[root] = { httpPort: options.httpPort, httpsPort: options.httpsPort };
}
serverS = serversS[options.httpsPort];
if (!serverS) {
serverS = options.serverOptions ? (0, https_1.createServer)(options.serverOptions) : (0, https_1.createServer)();
serverS.keepAliveTimeout = (options && options.keepAliveTimeout) || 5000;
serversS[options.httpsPort] = serverS;
serverS.listen(options.httpsPort, () => {
console.log(`Redioactive: HTTP/S source: HTTPS server push for stream ${root} listening on ${options.httpsPort}`);
});
}
serverS.on('request', pushRequest);
serverS.on('error', (err) => {
// TODO interrupt and push error?
console.error(err);
});
}
info = (0, redio_1.literal)({
type: 'push',
protocol: url.protocol === 'https:' ? http_common_1.ProtocolType.https : http_common_1.ProtocolType.http,
root,
idType: http_common_1.IdType.counter,
body: http_common_1.BodyType.primitive,
delta: http_common_1.DeltaType.one,
manifest: {},
server,
serverS,
httpPort: options.httpPort,
httpsPort: options.httpsPort
});
return () =>
// eslint-disable-next-line @typescript-eslint/no-unused-vars
new Promise((resolve, _reject) => {
// console.log('Calling pushPull()')
pushPull();
pushResolver = resolve;
});
} // end PUSH
function pushRequest(req, res) {
// TODO make sure error processings works as expected
req.on('error', console.error);
res.on('error', console.error);
if (req.url && isPush(info)) {
let path = req.url.replace(/\/+/g, '/');
if (path.endsWith('/')) {
path = path.slice(0, -1);
}
if (path.startsWith(info.root)) {
const id = path.slice(info.root.length + 1);
console.log(`Processing ${req.method} with url ${req.url} and ${typeof id} id ${id}, expected ${nextExpectedId}`);
if (req.method === 'POST') {
if (id === 'end') {
return endStream(req, res);
}
if (id === 'manifest.json') {
let value = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
value += chunk;
});
let idType = req.headers['redioactive-idtype'];
if (Array.isArray(idType)) {
idType = idType[0];
}
info.idType = idType || http_common_1.IdType.counter;
let delta = req.headers['redioactive-deltatype'];
if (Array.isArray(delta)) {
delta = delta[0];
}
info.delta = delta || http_common_1.DeltaType.one;
let body = req.headers['redioactive-bodytype'];
if (Array.isArray(body)) {
body = body[0];
}
info.body = body || http_common_1.BodyType.primitive;
let nextId = req.headers['redioactive-nextid'];
if (Array.isArray(nextId)) {
nextId = nextId[0];
}
if (nextId) {
nextExpectedId = info.idType === 'string' ? nextId : +nextId;
}
req.on('end', () => {
info.manifest = JSON.parse(value);
res.statusCode = 201;
res.setHeader('Location', `${info.root}/manifest.json`);
res.end();
});
return;
}
if (id === nextExpectedId.toString()) {
if (!req.headers['content-length']) {
throw new Error('Redioactive: HTTP/S target: Content-Length header expected');
}
let value = info.body === http_common_1.BodyType.blob ? Buffer.allocUnsafe(+req.headers['content-length']) : '';
const streamSaysNextIs = req.headers['redioactive-nextid'];
nextExpectedId = Array.isArray(streamSaysNextIs)
? +streamSaysNextIs[0]
: streamSaysNextIs
? +streamSaysNextIs
: id;
if (info.body !== http_common_1.BodyType.blob) {
req.setEncoding('utf8');
}
let bufferPos = 0;
// console.log('About to wait on pushPull', req.url, pushPullP)
pushPullP.then(() => {
pushPullP = new Promise((resolve) => {
pushPull = resolve;
});
req.on('data', (chunk) => {
if (!chunkIsString(value)) {
bufferPos += chunk.copy(value, bufferPos);
}
else {
value += chunk;
}
});
req.on('end', () => {
let t;
if (info.body === http_common_1.BodyType.blob) {
const s = {};
if (value.length > 0) {
s[(options && options.blob) || 'blob'] = value;
}
let details = req.headers['redioactive-details'] || '{}';
if (Array.isArray(details)) {
details = details[0];
}
t = Object.assign(s, JSON.parse(details));
}
else {
t = JSON.parse(value);
}
if (typeof t === 'object') {
if (Object.keys(t).length === 1 &&
Object.prototype.hasOwnProperty.call(t, 'end') &&
t['end'] === true) {
console.log('This is the end my friend!');
pushResolver(redio_1.end);
endStream(req);
res.statusCode = 200;
res.end();
return;
}
if (options && typeof options.manifest === 'string') {
;
t[options.manifest] = info.manifest;
}
if (options && options.seqId) {
;
t[options.seqId] = id;
}
if (options && typeof options.delta === 'string') {
switch (info.delta) {
case http_common_1.DeltaType.one:
;
t[options.delta] = 1;
break;
case http_common_1.DeltaType.variable:
case http_common_1.DeltaType.fixed:
;
t[options.delta] =
nextExpectedId - +id;
break;
default:
break;
}
}
}
pushResolver(t);
res.statusCode = 201;
res.setHeader('Location', `${info.root}/$id`);
res.end();
});
}); // Read data only when ready
}
return;
}
if (req.method === 'GET') {
if (id === 'debug.json') {
return debug(res);
}
if (id === 'end') {
return endStream(req, res);
}
if (id === 'manifest.json') {
return sendManifest(res);
}
}
}
}
noMatch(req, res);
}
function debug(res) {
const keys = [];
// for (const key of tChest.keys()) {
// keys.push(key)
// }
const debugInfo = {
info,
uri,
url,
streamIDs,
options,
// ended,
// lowestOfTheLow,
// lowWaterMark,
// highWaterMark,
// nextId,
keys
};
const debugString = JSON.stringify(debugInfo, null, 2);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Length', `${Buffer.byteLength(debugString, 'utf8')}`);
res.end(debugString, 'utf8');
}
function sendManifest(res) {
const maniString = JSON.stringify(info.manifest);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Length', `${Buffer.byteLength(maniString, 'utf8')}`);
res.end(maniString, 'utf8');
}
function endStream(req, res) {
const isSSL = Object.prototype.hasOwnProperty.call(req.socket, 'encrypted');
const port = req.socket.localPort;
pushPull();
pushPullP = Promise.resolve();
if (isPush(info)) {
try {
info.server &&
info.server.close(() => {
isPush(info) && delete streamIDs[info.root];
console.log(`Redioactive: HTTP/S target: ${isSSL ? 'HTTPS' : 'HTTP'} push server for stream ${(isPush(info) && info.root) || 'unknown'} on port ${port} closed.`);
});
info.serverS &&
info.serverS.close(() => {
isPush(info) && delete streamIDs[info.root];
console.log(`Redioactive: HTTP/S target: ${isSSL ? 'HTTPS' : 'HTTP'} push server for stream ${(isPush(info) && info.root) || 'unknown'} on port ${port} closed.`);
});
if (res) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Length', 2);
res.end('OK', 'utf8');
}
delete streamIDs[info.root];
if (!Object.values(streamIDs).some((x) => isPush(info) &&
((x.httpPort && x.httpPort === info.httpPort) ||
(x.httpsPort && x.httpsPort === info.httpsPort)))) {
isPush(info) && info.httpPort && delete servers[info.httpPort];
isPush(info) && info.httpsPort && delete serversS[info.httpsPort];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (err) {
console.error(`Redioactive: HTTP/S target: error closing ${info.protocol} ${info.type} stream: ${err.message}`);
}
}
}
function chunkIsString(_x) {
return info.body !== http_common_1.BodyType.blob;
}
}
exports.httpTarget = httpTarget;