UNPKG

@graphile/subscriptions-lds

Version:

Subscriptions plugin for PostGraphile using PostgreSQL logicial decoding

269 lines 9.51 kB
"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