@graphile/subscriptions-lds
Version:
Subscriptions plugin for PostGraphile using PostgreSQL logicial decoding
269 lines • 9.51 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LDSLiveSource = void 0;
const WebSocket = require("ws");
const lds_1 = require("@graphile/lds");
const LETTERS = "abcdefghijklmnopqrstuvwxyz";
function generateRandomString(length) {
let str = "";
for (let i = 0; i < length; i++) {
str += LETTERS[Math.floor(Math.random() * LETTERS.length)];
}
return str;
}
class LDSLiveSource {
/**
* @param url - If not specified, we'll spawn our own LDS listener
*/
constructor(options) {
this.handleAnnouncement = (announcement) => {
switch (announcement._) {
case "insertC": {
const { schema, table, data } = announcement;
const topic = JSON.stringify([schema, table]);
this.announce(topic, data);
return;
}
case "updateC": {
const { schema, table, data } = announcement;
const topic = JSON.stringify([schema, table]);
this.announce(topic, data);
return;
}
case "update": {
const { schema, table, keys, data } = announcement;
const topic = JSON.stringify([schema, table, keys]);
this.announce(topic, data);
return;
}
case "delete": {
const { schema, table, keys } = announcement;
const topic = JSON.stringify([schema, table, keys]);
this.announce(topic, keys);
return;
}
default: {
console.warn("Unhandled announcement type: ",
// @ts-ignore Unhandled
announcement && announcement._);
}
}
};
this.handleMessage = (message) => {
try {
// @ts-ignore Buffer, string, whatever -> toString("utf8") is safe.
const messageString = message.toString("utf8");
const payload = JSON.parse(messageString);
switch (payload._) {
case "insertC":
case "updateC":
case "update":
case "delete":
return this.handleAnnouncement(payload);
case "ACK":
// Connected, no action necessary.
return;
case "KA":
// Keep alive, no action necessary.
return;
default:
console.warn("Unhandled message:", payload);
}
}
catch (e) {
console.error("Error occurred when processing message,", message, ":", e.message);
}
};
const { ldsURL, connectionString, sleepDuration, tablePattern } = options;
if (!ldsURL && !connectionString) {
throw new Error("No LDS URL or connectionString was passed to LDSLiveSource; this likely means that you don't have `ownerConnectionString` specified in the PostGraphile library call.");
}
this.url = ldsURL || null;
this.connectionString = connectionString || null;
this.sleepDuration = sleepDuration;
this.tablePattern = tablePattern;
this.lds = null;
this.slotName = this.url ? null : generateRandomString(30);
this.ws = null;
this.reconnecting = false;
this.live = true;
this.subscriptions = {};
}
async init() {
if (this.url) {
await this.connect();
}
else {
if (!this.connectionString) {
throw new Error("No PG connection string given");
}
if (!this.slotName) {
throw new Error("this.slotName is blank");
}
this.lds = await (0, lds_1.default)(this.connectionString, this.handleAnnouncement, {
slotName: this.slotName,
temporary: true,
sleepDuration: this.sleepDuration,
tablePattern: this.tablePattern,
});
}
}
subscribeCollection(callback, collectionIdentifier, predicate) {
return this.sub(JSON.stringify([
collectionIdentifier.namespaceName,
collectionIdentifier.name,
]), callback, predicate);
}
subscribeRecord(callback, collectionIdentifier, recordIdentifier) {
return this.sub(JSON.stringify([
collectionIdentifier.namespaceName,
collectionIdentifier.name,
recordIdentifier,
]), callback);
}
async close() {
if (!this.live) {
return;
}
this.live = false;
if (this.ws) {
this.ws.close();
this.ws = null;
}
if (this.lds) {
await this.lds.close();
this.lds = null;
}
}
connect() {
if (!this.url) {
throw new Error("No connection URL provided");
}
if (!this.url.match(/^wss?:\/\//)) {
throw new Error(`Invalid URL, must be a websocket ws:// or wss:// URL, you passed '${this.url}'`);
}
this.ws = new WebSocket(this.url);
this.ws.on("error", err => {
console.error("Websocket error: ", err.message);
this.reconnect();
});
this.ws.on("open", () => {
// Resubscribe
for (const topic of Object.keys(this.subscriptions)) {
if (this.subscriptions[topic] && this.subscriptions[topic].length) {
this.ws.send("SUB " + topic);
}
}
});
this.ws.on("message", this.handleMessage);
this.ws.on("close", () => {
if (this.live) {
this.reconnect();
}
});
// Even if the first connection fails, we'll keep trying.
return Promise.resolve();
}
reconnect() {
if (this.reconnecting) {
return;
}
this.reconnecting = true;
setTimeout(() => {
this.reconnecting = false;
this.connect();
}, 1000);
}
sub(topic, cb, predicate) {
if (!this.live) {
return null;
}
const entry = [
cb,
predicate,
];
if (!this.subscriptions[topic]) {
this.subscriptions[topic] = [];
}
const newLength = this.subscriptions[topic].push(entry);
if (this.ws && newLength === 1) {
this.ws.send("SUB " + topic);
}
let done = false;
return () => {
if (done) {
console.warn("Double release?!");
return;
}
done = true;
const i = this.subscriptions[topic].indexOf(entry);
this.subscriptions[topic].splice(i, 1);
if (this.subscriptions[topic].length === 0) {
// Solve potential memory leak
delete this.subscriptions[topic];
if (this.ws) {
this.ws.send("UNSUB " + topic);
}
}
};
}
announce(topic, dataOrKey) {
const subs = this.subscriptions[topic];
if (subs) {
subs.forEach(([callback, predicate]) => {
if (predicate && !predicate(dataOrKey)) {
return;
}
callback();
});
}
}
}
exports.LDSLiveSource = LDSLiveSource;
async function makeLDSLiveSource(options) {
const ldsLiveSource = new LDSLiveSource(options);
await ldsLiveSource.init();
return ldsLiveSource;
}
function getSafeNumber(str) {
if (str) {
const n = parseInt(str, 10);
if (n && isFinite(n) && n > 0) {
return n;
}
}
return undefined;
}
const PgLDSSourcePlugin = async function (builder, { pgLDSUrl = process.env.LDS_URL, pgOwnerConnectionString, ldsSleepDuration = getSafeNumber(process.env.LD_WAIT), ldsTablePattern = process.env.LD_TABLE_PATTERN, }) {
// Connect to LDS server
try {
const source = await makeLDSLiveSource({
ldsURL: typeof pgLDSUrl === "string" ? pgLDSUrl : undefined,
// @ts-ignore Illicit cast 👀
connectionString: pgOwnerConnectionString,
sleepDuration: ldsSleepDuration,
tablePattern: ldsTablePattern,
});
builder.hook("build", build => {
build.liveCoordinator.registerSource("pg", source);
return build;
}, ["PgLDSSource"]);
if (process.env.NODE_ENV === "test") {
// Need a way of releasing it
builder.hook("finalize", schema => {
Object.defineProperty(schema, "__pgLdsSource", {
value: source,
enumerable: false,
configurable: false,
});
return schema;
});
}
}
catch (e) {
console.error("Could not Initiate PgLDSSourcePlugin, continuing without LDS live queries. Error:", e.message);
return;
}
};
exports.default = PgLDSSourcePlugin;
//# sourceMappingURL=PgLDSSourcePlugin.js.map