Rakenna pysyvä paikallinen saapumisjono
Tämä yhden tilauksen vastaanotin tarkistaa täsmälleen vastaanotetut tavut SDK:lla, hylkää virheellisen tai vanhentuneen allekirjoituksen ja tallentaa tapahtumatunnisteen sekä yhden paikallisen ilmoitusrivin atomisesti SQLiteen. Duplikaatti kuitataan ilman uutta ilmoitusta. webhook.test tarkistetaan ja kirjataan, mutta se ei luo ilmoitusta. Tuntemattomat tapahtumatyypit kuitataan ilman liiketoimintamuutosta. Esimerkki ei lähetä sähköpostia eikä muuta ulkoista järjestelmää.
Asenna ja määritä
Käytä erillistä tyhjää hakemistoa. Node.js 22.13+ sisältää node:sqlite-moduulin ja Python 3.11+ sqlite3-moduulin. Aseta WEBHOOK_SECRET yksityisesti tilauksen luonnissa palautetuksi allekirjoitussalaisuudeksi, ei API-avaimeksi. WEBHOOK_DB osoittaa pysyvään kirjoitettavaan paikalliseen tiedostoon. Tietokanta ja salaisuudet kuuluvat yhdelle tilaukselle; älä sekoita asiakkaiden työtiloja. Käytä paikallisessa varmennuksessa kertakäyttöistä tietokantaa ja vähintään 32-merkkistä vain testiä varten luotua salaisuutta.
npm init -y
npm install https://hintadata.fi/sdk/price-compare-typescript-3.1.0.tgz
npm install --save-dev typescript tsx @types/nodeTallenna vastaanotin
Tallenna nimellä receiver.ts. Vastaanotin kuuntelee vain paikallista loopback-osoitetta portissa 8080.
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { verifyWebhook } from '@pricecompare/sdk';
const secrets = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_PREVIOUS_SECRET].filter((value): value is string => Boolean(value));
if (!secrets.length || secrets.some(secret => secret.length < 32)) throw new Error('Configure the webhook signing secret.');
const db = new DatabaseSync(process.env.WEBHOOK_DB || 'receiver.sqlite');
db.exec('PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS received (id TEXT PRIMARY KEY); CREATE TABLE IF NOT EXISTS alerts (event_id TEXT PRIMARY KEY, product_id TEXT NOT NULL);');
const server = createServer(async (request, response) => {
const reply = (status: number) => { response.writeHead(status); response.end(); };
if (request.method !== 'POST' || request.url !== '/events') { reply(404); return; }
const signature = request.headers['x-webhook-signature'];
if (typeof signature !== 'string') { reply(400); return; }
let raw: Buffer;
try {
const chunks: Buffer[] = []; let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 262144) { reply(413); return; }
chunks.push(chunk);
}
raw = Buffer.concat(chunks);
} catch { reply(400); return; }
let event: Awaited<ReturnType<typeof verifyWebhook>> | undefined;
for (const secret of secrets) {
try { event = await verifyWebhook(raw, signature, secret); break; } catch { /* Try the explicitly configured overlap key. */ }
}
if (!event) { reply(400); return; }
if (!['watchlist.triggered', 'webhook.test'].includes(event.type)) { reply(204); return; }
if (event.type === 'watchlist.triggered' && (typeof event.data.canonical_product_id !== 'string' || !event.data.canonical_product_id)) { reply(400); return; }
try {
db.exec('BEGIN IMMEDIATE');
const inserted = db.prepare('INSERT OR IGNORE INTO received (id) VALUES (?)').run(event.id);
if (inserted.changes && event.type === 'watchlist.triggered') {
// This local inbox row IS the effect. No external call inside the transaction.
db.prepare('INSERT INTO alerts (event_id, product_id) VALUES (?, ?)').run(event.id, event.data.canonical_product_id as string);
}
db.exec('COMMIT');
} catch {
try { db.exec('ROLLBACK'); } catch { /* BEGIN may have failed. */ }
reply(503); return; // No success acknowledgement before durable commit.
}
reply(204); // Duplicates and synthetic tests are acknowledged without another alert.
});
server.requestTimeout = 10000;
server.headersTimeout = 10000;
server.listen(Number(process.env.PORT || 8080), '127.0.0.1');
process.once('SIGTERM', () => server.close(() => { db.close(); }));
process.once('SIGINT', () => server.close(() => { db.close(); }));
npx tsx receiver.tsVarmenna ennen julkista käyttöönottoa
Lähetä paikallisella testisalaisuudella allekirjoitettu watchlist.triggered-tapahtuma, jossa on canonical_product_id. Odota 204-vastausta ja yhtä riviä sekä received- että alerts-taulussa. Lähetä sama tapahtuma uudelleen, käynnistä vastaanotin sitten uudelleen samalla tietokannalla ja lähetä vielä kerran: vastausten pitää olla 204 ja molempien rivimäärien edelleen yksi. Muuta yhtä rungon tavua allekirjoittamatta uudelleen: odota 400-vastausta ja muuttumattomia rivimääriä. Allekirjoita yli viisi minuuttia vanhalla aikaleimalla: odota 400-vastausta. Allekirjoitettu webhook.test palauttaa 204:n ilman ilmoitusta. Pysyvän tallennuksen epäonnistuessa odota 503:a, älä 204:ää. Nämä tarkistukset ajetaan myös repossa täsmälleen julkaistulle koodille.
Sijoita HTTPS-palvelusi taakse
Paikallinen kuuntelija ei ole julkinen tuotantopalvelin. Sijoita se hallitun HTTPS-käänteisvälityspalvelimen taakse porttiin 443. Välityksen on säilytettävä rungon tavut, rajattava runko 256 KiB:iin ja pyynnön kesto sekä välitettävä Content-Length (Python-esimerkki ei hyväksy chunked-siirtoa). Aja tässä esimerkissä yhtä prosessia pysyvällä tallennuksella, rajatuilla tiedosto-oikeuksilla, varmuuskopioinnilla ja valvotulla uudelleenkäynnistyksellä. Rekisteröi julkinen /events-osoite vasta käyttöönoton jälkeen; localhost- tai yksityisosoitteita ei voi rekisteröidä. Älä julkaise tietokantaa tai lokita salaisuuksia ja pyyntörunkoja.
Laajenna säilyttäen duplikaattisuojaus
Ilmoitusrivi on tarkoituksella koko liiketoimintavaikutus. Jos lisäät sähköpostin tai ulkoisen toiminnon, tallenna pysyvä lähetysjonon rivi samaan transaktioon ja anna työntekijän toimittaa se idempotenssiavaimella. Älä merkitse tapahtumaa vastaanotetuksi ja tee sitten suojaamatonta ulkoista kutsua. Tapahtumat voivat saapua epäjärjestyksessä: tämä esimerkki lisää ilmoituksia eikä ylikirjoita vahdin nykytilaa. Säilytä vastaanotetut tunnisteet koko uusintajakson, vähintään seitsemän päivää; esimerkki ei poista niitä automaattisesti.
Hyväksy kaksi avainta vain vaihdon aikana
Aseta dokumentoidun prepare/activate-vaihdon ajaksi toinen salaisuus WEBHOOK_SECRET-arvoksi ja toinen WEBHOOK_PREVIOUS_SECRET-arvoksi, ja käynnistä uudelleen. Molemmat hyväksytään, joten uuden avaimen voi asentaa ennen aktivointia. Aktivoinnin ja vähintään 60 sekunnin päällekkäisyyden jälkeen säilytä vain uusi WEBHOOK_SECRET ja käynnistä uudelleen. Älä jätä vanhaa avainta pysyvästi käyttöön. Jonotettu testi ei todista toimitusta: varmista delivered-tila ja received-rivi.