@kismet-tech/telemetry-node 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@kismet-tech/telemetry-node`. Each entry names the tracking contract version the package conforms to.
4
+
5
+ ## 1.0.0 (unreleased)
6
+
7
+ Conforms to contract 1.0, on `@kismet-tech/telemetry` ^1.0.0.
8
+
9
+ - `kismetTelemetry(config)`: Express and Connect middleware doing the whole contract on every page request, with local mint plus after-response reconcile as the default, dotted-domain cookies, `private, no-store`, and the seed left on `req.kismet` and `res.locals.kismet` for the template. Works on the raw `node:http` request and response, so Fastify, Koa and a plain server use it too. A middleware error serves the page without telemetry.
10
+ - `createKismetResolver(config)`: the same decision as a function of a web `Request`, for frameworks that apply headers themselves. `applyKismetDecision` and `toWebRequest` are the two halves the middleware is made of.
11
+ - `kismetSeedHtml`: one inline script (seed, then the k.js tag appended by the script) for any template engine.
12
+ - `readKismetState`, `bookingBridge`, `quoteCapture`.
13
+ - `./client`: the browser helpers, re-exported from the core.
14
+ - ESM and CommonJS builds.
15
+ - Tests: the conformance suite through a real Express app on a real HTTP server behind forwarded headers, plus unit tests on the raw request and response.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # @kismet-tech/telemetry-node
2
+
3
+ Kismet Telemetry for Node servers. A thin adapter over [`@kismet-tech/telemetry`](../telemetry): the contract (version 1.0) is the core's; this package wires it to the `node:http` request and response, which is what Express, Connect, Fastify and Koa hand you.
4
+
5
+ Docs: https://developers.kismet.travel/telemetry/node/
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @kismet-tech/telemetry-node
11
+ ```
12
+
13
+ Node 20 or later. ESM and CommonJS.
14
+
15
+ Environment: `KISMET_COLLECTION_SLUG` and `KISMET_TRACKING_KEY` (the collection's `ctk_` key, server-side only).
16
+
17
+ ## Express
18
+
19
+ ```js
20
+ import express from 'express';
21
+ import { kismetTelemetry, consentFromCookie } from '@kismet-tech/telemetry-node';
22
+
23
+ const app = express();
24
+
25
+ app.use(
26
+ kismetTelemetry({
27
+ collectionSlug: process.env.KISMET_COLLECTION_SLUG,
28
+ trackingKey: process.env.KISMET_TRACKING_KEY,
29
+ consent: consentFromCookie('CookieConsent', /statistics:true/),
30
+ profile: {
31
+ property: { pattern: /^\/stays\/[^/]+\/([^/]+)\/?$/, as: 'externalListingId' },
32
+ searchPaths: ['/stays', /^\/stays\/in\//],
33
+ intent: { path: '/stays/checkout', checkinParam: 'checkin', checkoutParam: 'checkout', guestsParam: 'guests' },
34
+ },
35
+ })
36
+ );
37
+
38
+ app.get('/stays/:town/:house', (req, res) => {
39
+ res.render('property', { kismetSeed: res.locals.kismet?.seed ?? '' });
40
+ });
41
+ ```
42
+
43
+ In the template, print the seed first thing in `<head>`, unescaped:
44
+
45
+ ```html
46
+ <head>
47
+ <%- kismetSeed %>
48
+ <title>...</title>
49
+ </head>
50
+ ```
51
+
52
+ Mount it before your routes and before any static middleware you want excluded (assets are excluded by the route profile anyway). Behind a reverse proxy, make sure it forwards `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For`; the adapter reads them to rebuild the public URL, decide `Secure`, and take the visitor's IP.
53
+
54
+ ## The checkout call
55
+
56
+ ```js
57
+ import { bookingBridge } from '@kismet-tech/telemetry-node';
58
+
59
+ await bookingBridge(config, { kidSid, confirmationCode, bookingEngine: 'custom', domain: 'example.co.uk' });
60
+ ```
61
+
62
+ `kidSid` is the `_kid_sid` cookie on the checkout request (`req.kismet.kidSid` on any page request); carry it through your booking pipeline to wherever the confirmation code is known. Bounded, never throws, must not affect the booking.
63
+
64
+ ## Fastify, Koa, plain node:http
65
+
66
+ The middleware only needs the raw request and response.
67
+
68
+ ```js
69
+ // Fastify
70
+ fastify.addHook('onRequest', (request, reply, done) => middleware(request.raw, reply.raw, done));
71
+ // then request.raw.kismet.seed in the handler
72
+
73
+ // Koa
74
+ app.use((ctx, next) => new Promise((r) => middleware(ctx.req, ctx.res, r)).then(next));
75
+ // then ctx.req.kismet.seed
76
+
77
+ // node:http
78
+ createServer((req, res) => middleware(req, res, () => render(req, res)));
79
+ ```
80
+
81
+ ## Frameworks that speak Request and Response
82
+
83
+ `createKismetResolver(config).resolve(request)` returns the decision (state, headers, Set-Cookie lines) without applying it, for Hono, Cloudflare Workers, Deno and similar. The core's reference adapter is the worked example for the same shape.
84
+
85
+ ## Browser signals
86
+
87
+ ```js
88
+ import { createTracker } from '@kismet-tech/telemetry-node/client';
89
+ const track = createTracker({ collectionSlug: 'your-collection' });
90
+ track.propertyView({ externalListingId: listing.id, checkIn, checkOut, guests, stayTotalCents });
91
+ track.save({ externalListingId: listing.id });
92
+ track.bookIntent({ externalListingId: listing.id, checkIn, checkOut, guests, stayTotalCents });
93
+ ```
94
+
95
+ ## What the middleware does on every page request
96
+
97
+ Resolves the visitor (threaded id, cookie, suppressed for bots and visitors without consent, else a locally minted id reconciled with Kismet after the response), emits one server-plane event (agent surfaces as `fetch` with no identity), sets `_kid_sid` and `_kid_vid` on the dotted serving domain, marks the response `private, no-store`, and leaves `{ kidSid, suppressed, tier, classification, seed }` on `req.kismet` and `res.locals.kismet`. The page never waits on Kismet, and a middleware error serves the page without telemetry rather than failing it.
98
+
99
+ Caching: a page that prints the seed is per visitor and is marked `private, no-store` for that reason. If you serve HTML from a shared cache, do not print the seed into it; use the cache-safe bootstrap pattern in the contract (section 7) instead.
100
+
101
+ ## Conformance
102
+
103
+ `npm test` builds the package and runs it through the contract's conformance suite as a real Express app on a real HTTP server behind forwarded headers, plus unit tests on the raw request and response.
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.postBookingBridgeFromBrowser = exports.emitVisitorEvent = exports.currentKidSid = exports.createTracker = void 0;
4
+ // Browser helpers, re-exported from the core so a Node app imports one package.
5
+ // import { createTracker } from '@kismet-tech/telemetry-node/client';
6
+ var client_1 = require("@kismet-tech/telemetry/client");
7
+ Object.defineProperty(exports, "createTracker", { enumerable: true, get: function () { return client_1.createTracker; } });
8
+ Object.defineProperty(exports, "currentKidSid", { enumerable: true, get: function () { return client_1.currentKidSid; } });
9
+ Object.defineProperty(exports, "emitVisitorEvent", { enumerable: true, get: function () { return client_1.emitVisitorEvent; } });
10
+ Object.defineProperty(exports, "postBookingBridgeFromBrowser", { enumerable: true, get: function () { return client_1.postBookingBridgeFromBrowser; } });
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+ // @kismet-tech/telemetry-node: the Node server adapter over @kismet-tech/telemetry.
3
+ //
4
+ // kismetTelemetry(config) returns an Express or Connect middleware (req, res, next)
5
+ // that does the whole contract on every page request: resolve the visitor
6
+ // (threaded, cookie, suppressed, cold with a local mint and an after-response
7
+ // reconcile), emit the server-plane event, set the first-party cookies on the
8
+ // dotted serving domain, mark the response private, and leave the seed on
9
+ // `req.kismet` and `res.locals.kismet` for the template. It works on the raw
10
+ // node:http request and response, so Fastify (`req.raw`, `reply.raw`), Koa
11
+ // (`ctx.req`, `ctx.res`) and a plain `createServer` handler use it too.
12
+ //
13
+ // resolveKismetRequest(config) is the same decision as a function of a web
14
+ // `Request`, for frameworks that speak Request and Response (Hono, Workers,
15
+ // Deno) and want to apply the headers themselves.
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.SEED_HEADERS = exports.countryAllowsCookies = exports.consentFromCookie = exports.CONTRACT_VERSION = void 0;
18
+ exports.kismetSeedHtml = kismetSeedHtml;
19
+ exports.createKismetResolver = createKismetResolver;
20
+ exports.toWebRequest = toWebRequest;
21
+ exports.applyKismetDecision = applyKismetDecision;
22
+ exports.kismetTelemetry = kismetTelemetry;
23
+ exports.readKismetState = readKismetState;
24
+ exports.bookingBridge = bookingBridge;
25
+ exports.quoteCapture = quoteCapture;
26
+ const telemetry_1 = require("@kismet-tech/telemetry");
27
+ exports.CONTRACT_VERSION = '1.0';
28
+ function eventEnv(cfg) {
29
+ return {
30
+ COLLECTION_KEY: cfg.trackingKey,
31
+ ...(cfg.endpoints?.track ? { TRACKING_ENDPOINT: cfg.endpoints.track } : {}),
32
+ };
33
+ }
34
+ function apiEnv(cfg) {
35
+ return {
36
+ COLLECTION_KEY: cfg.trackingKey,
37
+ ...(cfg.endpoints?.apiOrigin ? { KISMET_API_ORIGIN: cfg.endpoints.apiOrigin } : {}),
38
+ };
39
+ }
40
+ /**
41
+ * The seed as one inline script: the assignment, then the k.js tag appended by
42
+ * the script itself, so no template engine or renderer can reorder them
43
+ * (contract section 7). Empty when there is neither an id nor a suppression.
44
+ */
45
+ function kismetSeedHtml(input, collectionSlug, kjsUrl) {
46
+ const seed = (0, telemetry_1.renderSeedScript)({ kidSid: input.kidSid, suppressed: input.suppressed });
47
+ if (!seed)
48
+ return '';
49
+ const assignment = seed.replace(/^<script>/, '').replace(/<\/script>$/, '');
50
+ const src = `${kjsUrl || telemetry_1.DEFAULT_KJS_URL}?c=${encodeURIComponent(collectionSlug)}`;
51
+ return ('<script>' +
52
+ assignment +
53
+ `(function(){var s=document.createElement('script');s.async=true;s.src=${JSON.stringify(src)};` +
54
+ `(document.head||document.documentElement).appendChild(s);})();` +
55
+ '</script>');
56
+ }
57
+ /**
58
+ * The contract as a function of a web `Request`. Returns what to do with the
59
+ * response; applies nothing itself. `flush()` awaits the background work
60
+ * (reconcile, events), for tests and for graceful shutdown.
61
+ */
62
+ function createKismetResolver(cfg) {
63
+ if (!cfg.collectionSlug)
64
+ throw new Error('@kismet-tech/telemetry-node: collectionSlug is required');
65
+ if (!cfg.trackingKey) {
66
+ console.warn('[kismet-telemetry] trackingKey is empty: sessions resolve locally, nothing is sent to Kismet');
67
+ }
68
+ let pending = [];
69
+ const ctx = {
70
+ waitUntil(p) {
71
+ const tracked = Promise.resolve(p).catch(() => undefined);
72
+ pending.push(tracked);
73
+ void tracked.then(() => {
74
+ pending = pending.filter((x) => x !== tracked);
75
+ });
76
+ },
77
+ };
78
+ const env = eventEnv(cfg);
79
+ async function resolve(request) {
80
+ const headers = request.headers;
81
+ const url = (0, telemetry_1.publicUrl)(headers, new URL(request.url));
82
+ const classification = (0, telemetry_1.classifyRequest)(url, cfg.profile, {
83
+ accept: headers.get('accept'),
84
+ method: request.method,
85
+ });
86
+ if (classification.kind === 'excluded')
87
+ return { kind: 'excluded' };
88
+ const country = cfg.country ? (cfg.country(headers) ?? null) : (0, telemetry_1.readCountry)(headers);
89
+ const common = {
90
+ pageUrl: url.toString(),
91
+ domain: (0, telemetry_1.normalizeServingDomain)(url.host),
92
+ collectionSlug: cfg.collectionSlug,
93
+ userAgent: headers.get('user-agent'),
94
+ clientIp: (0, telemetry_1.visitorIp)(headers),
95
+ country,
96
+ referrer: headers.get('referer'),
97
+ };
98
+ if (classification.kind === 'agent') {
99
+ (0, telemetry_1.fireContentEvent)(ctx, env, {
100
+ ...common,
101
+ vrSlug: null,
102
+ clientSessionId: null,
103
+ agentFetch: true,
104
+ });
105
+ return { kind: 'agent' };
106
+ }
107
+ const resolved = await (0, telemetry_1.resolveVisitor)({
108
+ url,
109
+ headers,
110
+ collectionSlug: cfg.collectionSlug,
111
+ trackingKey: cfg.trackingKey,
112
+ consent: cfg.consent ?? null,
113
+ country,
114
+ authorityFirst: cfg.authorityFirst,
115
+ suppressGenericClients: cfg.suppressGenericClients,
116
+ resolveEndpoint: cfg.endpoints?.resolveAnchor ?? null,
117
+ apiOrigin: cfg.endpoints?.apiOrigin ?? null,
118
+ waitUntil: ctx.waitUntil,
119
+ });
120
+ const visitor = { ...resolved, classification };
121
+ (0, telemetry_1.fireContentEvent)(ctx, env, {
122
+ ...common,
123
+ vrSlug: classification.vrSlug,
124
+ ...(classification.externalListingId
125
+ ? { externalListingId: classification.externalListingId }
126
+ : {}),
127
+ clientSessionId: visitor.isBot ? null : visitor.kidSid,
128
+ ctaIntent: classification.kind === 'intent',
129
+ stayCheckIn: classification.stayCheckIn,
130
+ stayCheckOut: classification.stayCheckOut,
131
+ guestCount: classification.guestCount,
132
+ promoCode: classification.promoCode,
133
+ });
134
+ const state = {
135
+ kidSid: visitor.kidSid,
136
+ kidVid: visitor.kidVid,
137
+ suppressed: visitor.suppressed,
138
+ isBot: visitor.isBot,
139
+ tier: visitor.tier,
140
+ classification,
141
+ seed: kismetSeedHtml(visitor, cfg.collectionSlug, cfg.endpoints?.kjs),
142
+ };
143
+ // A response that carries a per-visitor seed must never be cached by a shared cache.
144
+ const outHeaders = {
145
+ 'cache-control': 'private, no-store',
146
+ [telemetry_1.SEED_HEADERS.tier]: visitor.tier,
147
+ };
148
+ const domain = (0, telemetry_1.cookieDomainFor)(url.host, cfg.cookieDomain ?? null);
149
+ const secure = (0, telemetry_1.isHttps)(headers, url);
150
+ const setCookies = [];
151
+ if (visitor.kidSid && visitor.setSid) {
152
+ setCookies.push((0, telemetry_1.buildCookie)(telemetry_1.SID_COOKIE, visitor.kidSid, { maxAge: telemetry_1.SID_MAX_AGE, domain, secure }));
153
+ }
154
+ if (visitor.kidVid && visitor.setVid) {
155
+ setCookies.push((0, telemetry_1.buildCookie)(telemetry_1.VID_COOKIE, visitor.kidVid, { maxAge: telemetry_1.VID_MAX_AGE, domain, secure }));
156
+ }
157
+ return { kind: 'page', visitor, state, headers: outHeaders, setCookies };
158
+ }
159
+ async function flush() {
160
+ while (pending.length) {
161
+ const p = pending;
162
+ pending = [];
163
+ await Promise.allSettled(p);
164
+ }
165
+ }
166
+ return { resolve, flush };
167
+ }
168
+ /** Build a web `Request` from a Node request, with the public URL rebuilt from forwarded headers. */
169
+ function toWebRequest(req) {
170
+ const headers = new Headers();
171
+ for (const [name, value] of Object.entries(req.headers)) {
172
+ if (value === undefined)
173
+ continue;
174
+ headers.set(name, Array.isArray(value) ? value.join(', ') : value);
175
+ }
176
+ const socket = req.socket;
177
+ const scheme = socket?.encrypted ? 'https' : 'http';
178
+ const host = headers.get('host') || 'localhost';
179
+ const path = req.originalUrl || req.url || '/';
180
+ return new Request(new URL(path, `${scheme}://${host}`), {
181
+ method: req.method || 'GET',
182
+ headers,
183
+ });
184
+ }
185
+ /** Apply a page decision to a Node response and stash the state on the request. */
186
+ function applyKismetDecision(req, res, decision) {
187
+ if (decision.kind !== 'page')
188
+ return;
189
+ req.kismet = decision.state;
190
+ if (res.locals && typeof res.locals === 'object')
191
+ res.locals.kismet = decision.state;
192
+ for (const [name, value] of Object.entries(decision.headers))
193
+ res.setHeader(name, value);
194
+ if (decision.setCookies.length) {
195
+ const prev = res.getHeader('set-cookie');
196
+ const existing = Array.isArray(prev) ? prev : typeof prev === 'string' ? [prev] : [];
197
+ res.setHeader('Set-Cookie', [...existing, ...decision.setCookies]);
198
+ }
199
+ }
200
+ /**
201
+ * Build the middleware. `app.use(kismetTelemetry(config))` before your routes;
202
+ * then print `res.locals.kismet.seed` (or `req.kismet.seed`) in `<head>`.
203
+ */
204
+ function kismetTelemetry(cfg) {
205
+ const resolver = createKismetResolver(cfg);
206
+ const middleware = function kismetTelemetryMiddleware(req, res, next) {
207
+ let request;
208
+ try {
209
+ request = toWebRequest(req);
210
+ }
211
+ catch (err) {
212
+ warn(err);
213
+ next();
214
+ return;
215
+ }
216
+ resolver.resolve(request).then((decision) => {
217
+ try {
218
+ applyKismetDecision(req, res, decision);
219
+ }
220
+ catch (err) {
221
+ warn(err);
222
+ }
223
+ next();
224
+ }, (err) => {
225
+ // The page is always served. Telemetry degrades to "nothing on this request".
226
+ warn(err);
227
+ next();
228
+ });
229
+ };
230
+ middleware.flush = resolver.flush;
231
+ middleware.resolve = resolver.resolve;
232
+ return middleware;
233
+ }
234
+ function warn(err) {
235
+ console.warn('[kismet-telemetry] middleware error, serving without telemetry:', err instanceof Error ? err.message : err);
236
+ }
237
+ /** Read the state the middleware attached, from a route handler or template helper. */
238
+ function readKismetState(req) {
239
+ return req.kismet ?? null;
240
+ }
241
+ /** The conversion join, from the server code that knows the booking succeeded. */
242
+ function bookingBridge(cfg, body) {
243
+ return (0, telemetry_1.postBookingBridge)(apiEnv(cfg), body);
244
+ }
245
+ /** A quote the guest saw, for the fallback match on property and dates. */
246
+ function quoteCapture(cfg, body) {
247
+ return (0, telemetry_1.postQuoteCapture)(apiEnv(cfg), { collectionSlug: cfg.collectionSlug, ...body });
248
+ }
249
+ var telemetry_2 = require("@kismet-tech/telemetry");
250
+ Object.defineProperty(exports, "consentFromCookie", { enumerable: true, get: function () { return telemetry_2.consentFromCookie; } });
251
+ Object.defineProperty(exports, "countryAllowsCookies", { enumerable: true, get: function () { return telemetry_2.countryAllowsCookies; } });
252
+ Object.defineProperty(exports, "SEED_HEADERS", { enumerable: true, get: function () { return telemetry_2.SEED_HEADERS; } });
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1 @@
1
+ export { createTracker, currentKidSid, emitVisitorEvent, postBookingBridgeFromBrowser, } from '@kismet-tech/telemetry/client';
@@ -0,0 +1,3 @@
1
+ // Browser helpers, re-exported from the core so a Node app imports one package.
2
+ // import { createTracker } from '@kismet-tech/telemetry-node/client';
3
+ export { createTracker, currentKidSid, emitVisitorEvent, postBookingBridgeFromBrowser, } from '@kismet-tech/telemetry/client';
@@ -0,0 +1,120 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { BookingBridgeBody, Classification, ConsentHook, ConversionResult, QuoteCaptureBody, ResolveResult, RouteProfile } from '@kismet-tech/telemetry';
3
+ export declare const CONTRACT_VERSION = "1.0";
4
+ export interface KismetTelemetryConfig {
5
+ /** The collection this site's pages belong to. */
6
+ collectionSlug: string;
7
+ /** The collection's `ctk_` tracking key. Server-side only; read it from the environment. */
8
+ trackingKey: string;
9
+ /** Which URLs are properties, search, the checkout path, agent surfaces. */
10
+ profile?: RouteProfile;
11
+ /**
12
+ * The site's consent decision. Without it the country default applies, which
13
+ * reads a Cloudflare or Vercel country header; a Node server behind Apache or
14
+ * nginx has neither, so the default would set cookies for everyone. Wire your
15
+ * consent manager here (`consentFromCookie` covers the common case).
16
+ */
17
+ consent?: ConsentHook | null;
18
+ /** Override the dotted-domain rule, e.g. '.example.com' for a deeper serving host. */
19
+ cookieDomain?: string | null;
20
+ /** Ask the authority before responding on a cold visit (1.5 s cap). Off by default. */
21
+ authorityFirst?: boolean;
22
+ /** Suppress generic clients (curl, headless browsers) as well as the bot vocabulary. Default true. */
23
+ suppressGenericClients?: boolean;
24
+ /**
25
+ * Your own country source when the platform sets one under a different header
26
+ * (a GeoIP module, a load balancer). Return null when unknown. Default reads
27
+ * `cf-ipcountry` then `x-vercel-ip-country`.
28
+ */
29
+ country?: (headers: Headers) => string | null | undefined;
30
+ /** Overrides for lab and staging. */
31
+ endpoints?: {
32
+ resolveAnchor?: string;
33
+ track?: string;
34
+ apiOrigin?: string;
35
+ kjs?: string;
36
+ };
37
+ }
38
+ export interface KismetVisitor extends ResolveResult {
39
+ /** What the request was classified as by the route profile. */
40
+ classification: Classification;
41
+ }
42
+ /** What the middleware leaves on `req.kismet` and `res.locals.kismet`. */
43
+ export interface KismetRequestState {
44
+ kidSid: string | null;
45
+ kidVid: string | null;
46
+ suppressed: boolean;
47
+ isBot: boolean;
48
+ tier: string;
49
+ classification: Classification;
50
+ /**
51
+ * One inline `<script>` for `<head>`: the seed assignment, then the k.js tag
52
+ * appended by the script itself. Empty when there is nothing to seed. Print it
53
+ * verbatim before any other script in the head.
54
+ */
55
+ seed: string;
56
+ }
57
+ export type KismetDecision = {
58
+ kind: 'excluded';
59
+ } | {
60
+ kind: 'agent';
61
+ } | {
62
+ kind: 'page';
63
+ visitor: KismetVisitor;
64
+ state: KismetRequestState;
65
+ /** Response headers to set: cache-control, the tier header. */
66
+ headers: Record<string, string>;
67
+ /** Set-Cookie lines to append, zero to two. */
68
+ setCookies: string[];
69
+ };
70
+ /**
71
+ * The seed as one inline script: the assignment, then the k.js tag appended by
72
+ * the script itself, so no template engine or renderer can reorder them
73
+ * (contract section 7). Empty when there is neither an id nor a suppression.
74
+ */
75
+ export declare function kismetSeedHtml(input: {
76
+ kidSid: string | null;
77
+ suppressed: boolean;
78
+ }, collectionSlug: string, kjsUrl?: string): string;
79
+ /**
80
+ * The contract as a function of a web `Request`. Returns what to do with the
81
+ * response; applies nothing itself. `flush()` awaits the background work
82
+ * (reconcile, events), for tests and for graceful shutdown.
83
+ */
84
+ export declare function createKismetResolver(cfg: KismetTelemetryConfig): {
85
+ resolve: (request: Request) => Promise<KismetDecision>;
86
+ flush: () => Promise<void>;
87
+ };
88
+ /** The request the middleware accepts: node:http, Express, Connect, Fastify raw, Koa raw. */
89
+ export type NodeRequest = IncomingMessage & {
90
+ originalUrl?: string;
91
+ kismet?: KismetRequestState;
92
+ };
93
+ export type NodeResponse = ServerResponse & {
94
+ locals?: Record<string, unknown>;
95
+ };
96
+ export type NextFunction = (err?: unknown) => void;
97
+ /** Build a web `Request` from a Node request, with the public URL rebuilt from forwarded headers. */
98
+ export declare function toWebRequest(req: NodeRequest): Request;
99
+ /** Apply a page decision to a Node response and stash the state on the request. */
100
+ export declare function applyKismetDecision(req: NodeRequest, res: NodeResponse, decision: KismetDecision): void;
101
+ export interface KismetMiddleware {
102
+ (req: NodeRequest, res: NodeResponse, next: NextFunction): void;
103
+ /** Await background work (reconcile, events). For tests and graceful shutdown. */
104
+ flush(): Promise<void>;
105
+ /** The decision as a function of a web Request, for frameworks that apply headers themselves. */
106
+ resolve(request: Request): Promise<KismetDecision>;
107
+ }
108
+ /**
109
+ * Build the middleware. `app.use(kismetTelemetry(config))` before your routes;
110
+ * then print `res.locals.kismet.seed` (or `req.kismet.seed`) in `<head>`.
111
+ */
112
+ export declare function kismetTelemetry(cfg: KismetTelemetryConfig): KismetMiddleware;
113
+ /** Read the state the middleware attached, from a route handler or template helper. */
114
+ export declare function readKismetState(req: NodeRequest): KismetRequestState | null;
115
+ /** The conversion join, from the server code that knows the booking succeeded. */
116
+ export declare function bookingBridge(cfg: KismetTelemetryConfig, body: BookingBridgeBody): Promise<ConversionResult>;
117
+ /** A quote the guest saw, for the fallback match on property and dates. */
118
+ export declare function quoteCapture(cfg: KismetTelemetryConfig, body: QuoteCaptureBody): Promise<ConversionResult>;
119
+ export { consentFromCookie, countryAllowsCookies, SEED_HEADERS } from '@kismet-tech/telemetry';
120
+ export type { RouteProfile, ConsentHook, Classification, BookingBridgeBody, QuoteCaptureBody, ConversionResult, } from '@kismet-tech/telemetry';
@@ -0,0 +1,238 @@
1
+ // @kismet-tech/telemetry-node: the Node server adapter over @kismet-tech/telemetry.
2
+ //
3
+ // kismetTelemetry(config) returns an Express or Connect middleware (req, res, next)
4
+ // that does the whole contract on every page request: resolve the visitor
5
+ // (threaded, cookie, suppressed, cold with a local mint and an after-response
6
+ // reconcile), emit the server-plane event, set the first-party cookies on the
7
+ // dotted serving domain, mark the response private, and leave the seed on
8
+ // `req.kismet` and `res.locals.kismet` for the template. It works on the raw
9
+ // node:http request and response, so Fastify (`req.raw`, `reply.raw`), Koa
10
+ // (`ctx.req`, `ctx.res`) and a plain `createServer` handler use it too.
11
+ //
12
+ // resolveKismetRequest(config) is the same decision as a function of a web
13
+ // `Request`, for frameworks that speak Request and Response (Hono, Workers,
14
+ // Deno) and want to apply the headers themselves.
15
+ import { buildCookie, classifyRequest, cookieDomainFor, DEFAULT_KJS_URL, fireContentEvent, isHttps, normalizeServingDomain, postBookingBridge, postQuoteCapture, publicUrl, readCountry, renderSeedScript, resolveVisitor, SEED_HEADERS, SID_COOKIE, SID_MAX_AGE, VID_COOKIE, VID_MAX_AGE, visitorIp, } from '@kismet-tech/telemetry';
16
+ export const CONTRACT_VERSION = '1.0';
17
+ function eventEnv(cfg) {
18
+ return {
19
+ COLLECTION_KEY: cfg.trackingKey,
20
+ ...(cfg.endpoints?.track ? { TRACKING_ENDPOINT: cfg.endpoints.track } : {}),
21
+ };
22
+ }
23
+ function apiEnv(cfg) {
24
+ return {
25
+ COLLECTION_KEY: cfg.trackingKey,
26
+ ...(cfg.endpoints?.apiOrigin ? { KISMET_API_ORIGIN: cfg.endpoints.apiOrigin } : {}),
27
+ };
28
+ }
29
+ /**
30
+ * The seed as one inline script: the assignment, then the k.js tag appended by
31
+ * the script itself, so no template engine or renderer can reorder them
32
+ * (contract section 7). Empty when there is neither an id nor a suppression.
33
+ */
34
+ export function kismetSeedHtml(input, collectionSlug, kjsUrl) {
35
+ const seed = renderSeedScript({ kidSid: input.kidSid, suppressed: input.suppressed });
36
+ if (!seed)
37
+ return '';
38
+ const assignment = seed.replace(/^<script>/, '').replace(/<\/script>$/, '');
39
+ const src = `${kjsUrl || DEFAULT_KJS_URL}?c=${encodeURIComponent(collectionSlug)}`;
40
+ return ('<script>' +
41
+ assignment +
42
+ `(function(){var s=document.createElement('script');s.async=true;s.src=${JSON.stringify(src)};` +
43
+ `(document.head||document.documentElement).appendChild(s);})();` +
44
+ '</script>');
45
+ }
46
+ /**
47
+ * The contract as a function of a web `Request`. Returns what to do with the
48
+ * response; applies nothing itself. `flush()` awaits the background work
49
+ * (reconcile, events), for tests and for graceful shutdown.
50
+ */
51
+ export function createKismetResolver(cfg) {
52
+ if (!cfg.collectionSlug)
53
+ throw new Error('@kismet-tech/telemetry-node: collectionSlug is required');
54
+ if (!cfg.trackingKey) {
55
+ console.warn('[kismet-telemetry] trackingKey is empty: sessions resolve locally, nothing is sent to Kismet');
56
+ }
57
+ let pending = [];
58
+ const ctx = {
59
+ waitUntil(p) {
60
+ const tracked = Promise.resolve(p).catch(() => undefined);
61
+ pending.push(tracked);
62
+ void tracked.then(() => {
63
+ pending = pending.filter((x) => x !== tracked);
64
+ });
65
+ },
66
+ };
67
+ const env = eventEnv(cfg);
68
+ async function resolve(request) {
69
+ const headers = request.headers;
70
+ const url = publicUrl(headers, new URL(request.url));
71
+ const classification = classifyRequest(url, cfg.profile, {
72
+ accept: headers.get('accept'),
73
+ method: request.method,
74
+ });
75
+ if (classification.kind === 'excluded')
76
+ return { kind: 'excluded' };
77
+ const country = cfg.country ? (cfg.country(headers) ?? null) : readCountry(headers);
78
+ const common = {
79
+ pageUrl: url.toString(),
80
+ domain: normalizeServingDomain(url.host),
81
+ collectionSlug: cfg.collectionSlug,
82
+ userAgent: headers.get('user-agent'),
83
+ clientIp: visitorIp(headers),
84
+ country,
85
+ referrer: headers.get('referer'),
86
+ };
87
+ if (classification.kind === 'agent') {
88
+ fireContentEvent(ctx, env, {
89
+ ...common,
90
+ vrSlug: null,
91
+ clientSessionId: null,
92
+ agentFetch: true,
93
+ });
94
+ return { kind: 'agent' };
95
+ }
96
+ const resolved = await resolveVisitor({
97
+ url,
98
+ headers,
99
+ collectionSlug: cfg.collectionSlug,
100
+ trackingKey: cfg.trackingKey,
101
+ consent: cfg.consent ?? null,
102
+ country,
103
+ authorityFirst: cfg.authorityFirst,
104
+ suppressGenericClients: cfg.suppressGenericClients,
105
+ resolveEndpoint: cfg.endpoints?.resolveAnchor ?? null,
106
+ apiOrigin: cfg.endpoints?.apiOrigin ?? null,
107
+ waitUntil: ctx.waitUntil,
108
+ });
109
+ const visitor = { ...resolved, classification };
110
+ fireContentEvent(ctx, env, {
111
+ ...common,
112
+ vrSlug: classification.vrSlug,
113
+ ...(classification.externalListingId
114
+ ? { externalListingId: classification.externalListingId }
115
+ : {}),
116
+ clientSessionId: visitor.isBot ? null : visitor.kidSid,
117
+ ctaIntent: classification.kind === 'intent',
118
+ stayCheckIn: classification.stayCheckIn,
119
+ stayCheckOut: classification.stayCheckOut,
120
+ guestCount: classification.guestCount,
121
+ promoCode: classification.promoCode,
122
+ });
123
+ const state = {
124
+ kidSid: visitor.kidSid,
125
+ kidVid: visitor.kidVid,
126
+ suppressed: visitor.suppressed,
127
+ isBot: visitor.isBot,
128
+ tier: visitor.tier,
129
+ classification,
130
+ seed: kismetSeedHtml(visitor, cfg.collectionSlug, cfg.endpoints?.kjs),
131
+ };
132
+ // A response that carries a per-visitor seed must never be cached by a shared cache.
133
+ const outHeaders = {
134
+ 'cache-control': 'private, no-store',
135
+ [SEED_HEADERS.tier]: visitor.tier,
136
+ };
137
+ const domain = cookieDomainFor(url.host, cfg.cookieDomain ?? null);
138
+ const secure = isHttps(headers, url);
139
+ const setCookies = [];
140
+ if (visitor.kidSid && visitor.setSid) {
141
+ setCookies.push(buildCookie(SID_COOKIE, visitor.kidSid, { maxAge: SID_MAX_AGE, domain, secure }));
142
+ }
143
+ if (visitor.kidVid && visitor.setVid) {
144
+ setCookies.push(buildCookie(VID_COOKIE, visitor.kidVid, { maxAge: VID_MAX_AGE, domain, secure }));
145
+ }
146
+ return { kind: 'page', visitor, state, headers: outHeaders, setCookies };
147
+ }
148
+ async function flush() {
149
+ while (pending.length) {
150
+ const p = pending;
151
+ pending = [];
152
+ await Promise.allSettled(p);
153
+ }
154
+ }
155
+ return { resolve, flush };
156
+ }
157
+ /** Build a web `Request` from a Node request, with the public URL rebuilt from forwarded headers. */
158
+ export function toWebRequest(req) {
159
+ const headers = new Headers();
160
+ for (const [name, value] of Object.entries(req.headers)) {
161
+ if (value === undefined)
162
+ continue;
163
+ headers.set(name, Array.isArray(value) ? value.join(', ') : value);
164
+ }
165
+ const socket = req.socket;
166
+ const scheme = socket?.encrypted ? 'https' : 'http';
167
+ const host = headers.get('host') || 'localhost';
168
+ const path = req.originalUrl || req.url || '/';
169
+ return new Request(new URL(path, `${scheme}://${host}`), {
170
+ method: req.method || 'GET',
171
+ headers,
172
+ });
173
+ }
174
+ /** Apply a page decision to a Node response and stash the state on the request. */
175
+ export function applyKismetDecision(req, res, decision) {
176
+ if (decision.kind !== 'page')
177
+ return;
178
+ req.kismet = decision.state;
179
+ if (res.locals && typeof res.locals === 'object')
180
+ res.locals.kismet = decision.state;
181
+ for (const [name, value] of Object.entries(decision.headers))
182
+ res.setHeader(name, value);
183
+ if (decision.setCookies.length) {
184
+ const prev = res.getHeader('set-cookie');
185
+ const existing = Array.isArray(prev) ? prev : typeof prev === 'string' ? [prev] : [];
186
+ res.setHeader('Set-Cookie', [...existing, ...decision.setCookies]);
187
+ }
188
+ }
189
+ /**
190
+ * Build the middleware. `app.use(kismetTelemetry(config))` before your routes;
191
+ * then print `res.locals.kismet.seed` (or `req.kismet.seed`) in `<head>`.
192
+ */
193
+ export function kismetTelemetry(cfg) {
194
+ const resolver = createKismetResolver(cfg);
195
+ const middleware = function kismetTelemetryMiddleware(req, res, next) {
196
+ let request;
197
+ try {
198
+ request = toWebRequest(req);
199
+ }
200
+ catch (err) {
201
+ warn(err);
202
+ next();
203
+ return;
204
+ }
205
+ resolver.resolve(request).then((decision) => {
206
+ try {
207
+ applyKismetDecision(req, res, decision);
208
+ }
209
+ catch (err) {
210
+ warn(err);
211
+ }
212
+ next();
213
+ }, (err) => {
214
+ // The page is always served. Telemetry degrades to "nothing on this request".
215
+ warn(err);
216
+ next();
217
+ });
218
+ };
219
+ middleware.flush = resolver.flush;
220
+ middleware.resolve = resolver.resolve;
221
+ return middleware;
222
+ }
223
+ function warn(err) {
224
+ console.warn('[kismet-telemetry] middleware error, serving without telemetry:', err instanceof Error ? err.message : err);
225
+ }
226
+ /** Read the state the middleware attached, from a route handler or template helper. */
227
+ export function readKismetState(req) {
228
+ return req.kismet ?? null;
229
+ }
230
+ /** The conversion join, from the server code that knows the booking succeeded. */
231
+ export function bookingBridge(cfg, body) {
232
+ return postBookingBridge(apiEnv(cfg), body);
233
+ }
234
+ /** A quote the guest saw, for the fallback match on property and dates. */
235
+ export function quoteCapture(cfg, body) {
236
+ return postQuoteCapture(apiEnv(cfg), { collectionSlug: cfg.collectionSlug, ...body });
237
+ }
238
+ export { consentFromCookie, countryAllowsCookies, SEED_HEADERS } from '@kismet-tech/telemetry';
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@kismet-tech/telemetry-node",
3
+ "version": "1.0.0",
4
+ "description": "Kismet Telemetry for Node servers: kismetTelemetry() Express and Connect middleware on the @kismet-tech/telemetry core (contract 1.0), the page seed for your template, booking-bridge and quote-capture helpers, and the browser helpers. Works with Express, Connect, Fastify (raw request and reply), Koa and plain node:http. Ships with the contract conformance suite.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "./dist/cjs/index.js",
8
+ "module": "./dist/esm/index.js",
9
+ "types": "./dist/esm/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/esm/index.d.ts",
13
+ "import": "./dist/esm/index.js",
14
+ "require": "./dist/cjs/index.js"
15
+ },
16
+ "./client": {
17
+ "types": "./dist/esm/client.d.ts",
18
+ "import": "./dist/esm/client.js",
19
+ "require": "./dist/cjs/client.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src",
26
+ "README.md",
27
+ "CHANGELOG.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "scripts": {
33
+ "typecheck": "tsc -p tsconfig.json --noEmit",
34
+ "build": "rm -rf dist && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node scripts/write-cjs-package.mjs",
35
+ "test": "npm run build && node --test test/*.test.mjs",
36
+ "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.mjs\" \"scripts/**/*.mjs\"",
37
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.mjs\" \"scripts/**/*.mjs\"",
38
+ "prepublishOnly": "npm run typecheck && npm test"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "registry": "https://registry.npmjs.org/"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/kismet-tech/kismet-telemetry.git",
47
+ "directory": "packages/telemetry-node"
48
+ },
49
+ "homepage": "https://developers.kismet.travel/telemetry/node/",
50
+ "keywords": [
51
+ "kismet",
52
+ "telemetry",
53
+ "tracking",
54
+ "express",
55
+ "connect",
56
+ "fastify",
57
+ "middleware",
58
+ "attribution",
59
+ "ai-visibility"
60
+ ],
61
+ "author": "Kismet Technologies",
62
+ "license": "UNLICENSED",
63
+ "dependencies": {
64
+ "@kismet-tech/telemetry": "^1.0.0"
65
+ },
66
+ "devDependencies": {
67
+ "@types/node": "^20.19.0",
68
+ "express": "^5.1.0",
69
+ "prettier": "^3.8.1",
70
+ "typescript": "^5.8.3"
71
+ }
72
+ }
package/src/client.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Browser helpers, re-exported from the core so a Node app imports one package.
2
+ // import { createTracker } from '@kismet-tech/telemetry-node/client';
3
+ export {
4
+ createTracker,
5
+ currentKidSid,
6
+ emitVisitorEvent,
7
+ postBookingBridgeFromBrowser,
8
+ } from '@kismet-tech/telemetry/client';
package/src/index.ts ADDED
@@ -0,0 +1,403 @@
1
+ // @kismet-tech/telemetry-node: the Node server adapter over @kismet-tech/telemetry.
2
+ //
3
+ // kismetTelemetry(config) returns an Express or Connect middleware (req, res, next)
4
+ // that does the whole contract on every page request: resolve the visitor
5
+ // (threaded, cookie, suppressed, cold with a local mint and an after-response
6
+ // reconcile), emit the server-plane event, set the first-party cookies on the
7
+ // dotted serving domain, mark the response private, and leave the seed on
8
+ // `req.kismet` and `res.locals.kismet` for the template. It works on the raw
9
+ // node:http request and response, so Fastify (`req.raw`, `reply.raw`), Koa
10
+ // (`ctx.req`, `ctx.res`) and a plain `createServer` handler use it too.
11
+ //
12
+ // resolveKismetRequest(config) is the same decision as a function of a web
13
+ // `Request`, for frameworks that speak Request and Response (Hono, Workers,
14
+ // Deno) and want to apply the headers themselves.
15
+
16
+ import type { IncomingMessage, ServerResponse } from 'node:http';
17
+ import {
18
+ buildCookie,
19
+ classifyRequest,
20
+ cookieDomainFor,
21
+ DEFAULT_KJS_URL,
22
+ fireContentEvent,
23
+ isHttps,
24
+ normalizeServingDomain,
25
+ postBookingBridge,
26
+ postQuoteCapture,
27
+ publicUrl,
28
+ readCountry,
29
+ renderSeedScript,
30
+ resolveVisitor,
31
+ SEED_HEADERS,
32
+ SID_COOKIE,
33
+ SID_MAX_AGE,
34
+ VID_COOKIE,
35
+ VID_MAX_AGE,
36
+ visitorIp,
37
+ } from '@kismet-tech/telemetry';
38
+ import type {
39
+ BookingBridgeBody,
40
+ Classification,
41
+ ConsentHook,
42
+ ConversionResult,
43
+ QuoteCaptureBody,
44
+ ResolveResult,
45
+ RouteProfile,
46
+ } from '@kismet-tech/telemetry';
47
+
48
+ export const CONTRACT_VERSION = '1.0';
49
+
50
+ export interface KismetTelemetryConfig {
51
+ /** The collection this site's pages belong to. */
52
+ collectionSlug: string;
53
+ /** The collection's `ctk_` tracking key. Server-side only; read it from the environment. */
54
+ trackingKey: string;
55
+ /** Which URLs are properties, search, the checkout path, agent surfaces. */
56
+ profile?: RouteProfile;
57
+ /**
58
+ * The site's consent decision. Without it the country default applies, which
59
+ * reads a Cloudflare or Vercel country header; a Node server behind Apache or
60
+ * nginx has neither, so the default would set cookies for everyone. Wire your
61
+ * consent manager here (`consentFromCookie` covers the common case).
62
+ */
63
+ consent?: ConsentHook | null;
64
+ /** Override the dotted-domain rule, e.g. '.example.com' for a deeper serving host. */
65
+ cookieDomain?: string | null;
66
+ /** Ask the authority before responding on a cold visit (1.5 s cap). Off by default. */
67
+ authorityFirst?: boolean;
68
+ /** Suppress generic clients (curl, headless browsers) as well as the bot vocabulary. Default true. */
69
+ suppressGenericClients?: boolean;
70
+ /**
71
+ * Your own country source when the platform sets one under a different header
72
+ * (a GeoIP module, a load balancer). Return null when unknown. Default reads
73
+ * `cf-ipcountry` then `x-vercel-ip-country`.
74
+ */
75
+ country?: (headers: Headers) => string | null | undefined;
76
+ /** Overrides for lab and staging. */
77
+ endpoints?: { resolveAnchor?: string; track?: string; apiOrigin?: string; kjs?: string };
78
+ }
79
+
80
+ export interface KismetVisitor extends ResolveResult {
81
+ /** What the request was classified as by the route profile. */
82
+ classification: Classification;
83
+ }
84
+
85
+ /** What the middleware leaves on `req.kismet` and `res.locals.kismet`. */
86
+ export interface KismetRequestState {
87
+ kidSid: string | null;
88
+ kidVid: string | null;
89
+ suppressed: boolean;
90
+ isBot: boolean;
91
+ tier: string;
92
+ classification: Classification;
93
+ /**
94
+ * One inline `<script>` for `<head>`: the seed assignment, then the k.js tag
95
+ * appended by the script itself. Empty when there is nothing to seed. Print it
96
+ * verbatim before any other script in the head.
97
+ */
98
+ seed: string;
99
+ }
100
+
101
+ export type KismetDecision =
102
+ | { kind: 'excluded' }
103
+ | { kind: 'agent' }
104
+ | {
105
+ kind: 'page';
106
+ visitor: KismetVisitor;
107
+ state: KismetRequestState;
108
+ /** Response headers to set: cache-control, the tier header. */
109
+ headers: Record<string, string>;
110
+ /** Set-Cookie lines to append, zero to two. */
111
+ setCookies: string[];
112
+ };
113
+
114
+ function eventEnv(cfg: KismetTelemetryConfig) {
115
+ return {
116
+ COLLECTION_KEY: cfg.trackingKey,
117
+ ...(cfg.endpoints?.track ? { TRACKING_ENDPOINT: cfg.endpoints.track } : {}),
118
+ };
119
+ }
120
+
121
+ function apiEnv(cfg: KismetTelemetryConfig) {
122
+ return {
123
+ COLLECTION_KEY: cfg.trackingKey,
124
+ ...(cfg.endpoints?.apiOrigin ? { KISMET_API_ORIGIN: cfg.endpoints.apiOrigin } : {}),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * The seed as one inline script: the assignment, then the k.js tag appended by
130
+ * the script itself, so no template engine or renderer can reorder them
131
+ * (contract section 7). Empty when there is neither an id nor a suppression.
132
+ */
133
+ export function kismetSeedHtml(
134
+ input: { kidSid: string | null; suppressed: boolean },
135
+ collectionSlug: string,
136
+ kjsUrl?: string
137
+ ): string {
138
+ const seed = renderSeedScript({ kidSid: input.kidSid, suppressed: input.suppressed });
139
+ if (!seed) return '';
140
+ const assignment = seed.replace(/^<script>/, '').replace(/<\/script>$/, '');
141
+ const src = `${kjsUrl || DEFAULT_KJS_URL}?c=${encodeURIComponent(collectionSlug)}`;
142
+ return (
143
+ '<script>' +
144
+ assignment +
145
+ `(function(){var s=document.createElement('script');s.async=true;s.src=${JSON.stringify(src)};` +
146
+ `(document.head||document.documentElement).appendChild(s);})();` +
147
+ '</script>'
148
+ );
149
+ }
150
+
151
+ /**
152
+ * The contract as a function of a web `Request`. Returns what to do with the
153
+ * response; applies nothing itself. `flush()` awaits the background work
154
+ * (reconcile, events), for tests and for graceful shutdown.
155
+ */
156
+ export function createKismetResolver(cfg: KismetTelemetryConfig) {
157
+ if (!cfg.collectionSlug)
158
+ throw new Error('@kismet-tech/telemetry-node: collectionSlug is required');
159
+ if (!cfg.trackingKey) {
160
+ console.warn(
161
+ '[kismet-telemetry] trackingKey is empty: sessions resolve locally, nothing is sent to Kismet'
162
+ );
163
+ }
164
+ let pending: Promise<unknown>[] = [];
165
+ const ctx = {
166
+ waitUntil(p: Promise<unknown>) {
167
+ const tracked = Promise.resolve(p).catch(() => undefined);
168
+ pending.push(tracked);
169
+ void tracked.then(() => {
170
+ pending = pending.filter((x) => x !== tracked);
171
+ });
172
+ },
173
+ };
174
+ const env = eventEnv(cfg);
175
+
176
+ async function resolve(request: Request): Promise<KismetDecision> {
177
+ const headers = request.headers;
178
+ const url = publicUrl(headers, new URL(request.url));
179
+ const classification = classifyRequest(url, cfg.profile, {
180
+ accept: headers.get('accept'),
181
+ method: request.method,
182
+ });
183
+ if (classification.kind === 'excluded') return { kind: 'excluded' };
184
+
185
+ const country = cfg.country ? (cfg.country(headers) ?? null) : readCountry(headers);
186
+ const common = {
187
+ pageUrl: url.toString(),
188
+ domain: normalizeServingDomain(url.host),
189
+ collectionSlug: cfg.collectionSlug,
190
+ userAgent: headers.get('user-agent'),
191
+ clientIp: visitorIp(headers),
192
+ country,
193
+ referrer: headers.get('referer'),
194
+ };
195
+
196
+ if (classification.kind === 'agent') {
197
+ fireContentEvent(ctx, env, {
198
+ ...common,
199
+ vrSlug: null,
200
+ clientSessionId: null,
201
+ agentFetch: true,
202
+ });
203
+ return { kind: 'agent' };
204
+ }
205
+
206
+ const resolved = await resolveVisitor({
207
+ url,
208
+ headers,
209
+ collectionSlug: cfg.collectionSlug,
210
+ trackingKey: cfg.trackingKey,
211
+ consent: cfg.consent ?? null,
212
+ country,
213
+ authorityFirst: cfg.authorityFirst,
214
+ suppressGenericClients: cfg.suppressGenericClients,
215
+ resolveEndpoint: cfg.endpoints?.resolveAnchor ?? null,
216
+ apiOrigin: cfg.endpoints?.apiOrigin ?? null,
217
+ waitUntil: ctx.waitUntil,
218
+ });
219
+ const visitor: KismetVisitor = { ...resolved, classification };
220
+
221
+ fireContentEvent(ctx, env, {
222
+ ...common,
223
+ vrSlug: classification.vrSlug,
224
+ ...(classification.externalListingId
225
+ ? { externalListingId: classification.externalListingId }
226
+ : {}),
227
+ clientSessionId: visitor.isBot ? null : visitor.kidSid,
228
+ ctaIntent: classification.kind === 'intent',
229
+ stayCheckIn: classification.stayCheckIn,
230
+ stayCheckOut: classification.stayCheckOut,
231
+ guestCount: classification.guestCount,
232
+ promoCode: classification.promoCode,
233
+ });
234
+
235
+ const state: KismetRequestState = {
236
+ kidSid: visitor.kidSid,
237
+ kidVid: visitor.kidVid,
238
+ suppressed: visitor.suppressed,
239
+ isBot: visitor.isBot,
240
+ tier: visitor.tier,
241
+ classification,
242
+ seed: kismetSeedHtml(visitor, cfg.collectionSlug, cfg.endpoints?.kjs),
243
+ };
244
+
245
+ // A response that carries a per-visitor seed must never be cached by a shared cache.
246
+ const outHeaders: Record<string, string> = {
247
+ 'cache-control': 'private, no-store',
248
+ [SEED_HEADERS.tier]: visitor.tier,
249
+ };
250
+ const domain = cookieDomainFor(url.host, cfg.cookieDomain ?? null);
251
+ const secure = isHttps(headers, url);
252
+ const setCookies: string[] = [];
253
+ if (visitor.kidSid && visitor.setSid) {
254
+ setCookies.push(
255
+ buildCookie(SID_COOKIE, visitor.kidSid, { maxAge: SID_MAX_AGE, domain, secure })
256
+ );
257
+ }
258
+ if (visitor.kidVid && visitor.setVid) {
259
+ setCookies.push(
260
+ buildCookie(VID_COOKIE, visitor.kidVid, { maxAge: VID_MAX_AGE, domain, secure })
261
+ );
262
+ }
263
+ return { kind: 'page', visitor, state, headers: outHeaders, setCookies };
264
+ }
265
+
266
+ async function flush(): Promise<void> {
267
+ while (pending.length) {
268
+ const p = pending;
269
+ pending = [];
270
+ await Promise.allSettled(p);
271
+ }
272
+ }
273
+
274
+ return { resolve, flush };
275
+ }
276
+
277
+ /** The request the middleware accepts: node:http, Express, Connect, Fastify raw, Koa raw. */
278
+ export type NodeRequest = IncomingMessage & {
279
+ originalUrl?: string;
280
+ kismet?: KismetRequestState;
281
+ };
282
+ export type NodeResponse = ServerResponse & { locals?: Record<string, unknown> };
283
+ export type NextFunction = (err?: unknown) => void;
284
+
285
+ /** Build a web `Request` from a Node request, with the public URL rebuilt from forwarded headers. */
286
+ export function toWebRequest(req: NodeRequest): Request {
287
+ const headers = new Headers();
288
+ for (const [name, value] of Object.entries(req.headers)) {
289
+ if (value === undefined) continue;
290
+ headers.set(name, Array.isArray(value) ? value.join(', ') : value);
291
+ }
292
+ const socket = req.socket as { encrypted?: boolean } | undefined;
293
+ const scheme = socket?.encrypted ? 'https' : 'http';
294
+ const host = headers.get('host') || 'localhost';
295
+ const path = req.originalUrl || req.url || '/';
296
+ return new Request(new URL(path, `${scheme}://${host}`), {
297
+ method: req.method || 'GET',
298
+ headers,
299
+ });
300
+ }
301
+
302
+ /** Apply a page decision to a Node response and stash the state on the request. */
303
+ export function applyKismetDecision(
304
+ req: NodeRequest,
305
+ res: NodeResponse,
306
+ decision: KismetDecision
307
+ ): void {
308
+ if (decision.kind !== 'page') return;
309
+ req.kismet = decision.state;
310
+ if (res.locals && typeof res.locals === 'object') res.locals.kismet = decision.state;
311
+ for (const [name, value] of Object.entries(decision.headers)) res.setHeader(name, value);
312
+ if (decision.setCookies.length) {
313
+ const prev = res.getHeader('set-cookie');
314
+ const existing = Array.isArray(prev) ? prev : typeof prev === 'string' ? [prev] : [];
315
+ res.setHeader('Set-Cookie', [...existing, ...decision.setCookies]);
316
+ }
317
+ }
318
+
319
+ export interface KismetMiddleware {
320
+ (req: NodeRequest, res: NodeResponse, next: NextFunction): void;
321
+ /** Await background work (reconcile, events). For tests and graceful shutdown. */
322
+ flush(): Promise<void>;
323
+ /** The decision as a function of a web Request, for frameworks that apply headers themselves. */
324
+ resolve(request: Request): Promise<KismetDecision>;
325
+ }
326
+
327
+ /**
328
+ * Build the middleware. `app.use(kismetTelemetry(config))` before your routes;
329
+ * then print `res.locals.kismet.seed` (or `req.kismet.seed`) in `<head>`.
330
+ */
331
+ export function kismetTelemetry(cfg: KismetTelemetryConfig): KismetMiddleware {
332
+ const resolver = createKismetResolver(cfg);
333
+ const middleware = function kismetTelemetryMiddleware(
334
+ req: NodeRequest,
335
+ res: NodeResponse,
336
+ next: NextFunction
337
+ ): void {
338
+ let request: Request;
339
+ try {
340
+ request = toWebRequest(req);
341
+ } catch (err) {
342
+ warn(err);
343
+ next();
344
+ return;
345
+ }
346
+ resolver.resolve(request).then(
347
+ (decision) => {
348
+ try {
349
+ applyKismetDecision(req, res, decision);
350
+ } catch (err) {
351
+ warn(err);
352
+ }
353
+ next();
354
+ },
355
+ (err) => {
356
+ // The page is always served. Telemetry degrades to "nothing on this request".
357
+ warn(err);
358
+ next();
359
+ }
360
+ );
361
+ } as KismetMiddleware;
362
+ middleware.flush = resolver.flush;
363
+ middleware.resolve = resolver.resolve;
364
+ return middleware;
365
+ }
366
+
367
+ function warn(err: unknown): void {
368
+ console.warn(
369
+ '[kismet-telemetry] middleware error, serving without telemetry:',
370
+ err instanceof Error ? err.message : err
371
+ );
372
+ }
373
+
374
+ /** Read the state the middleware attached, from a route handler or template helper. */
375
+ export function readKismetState(req: NodeRequest): KismetRequestState | null {
376
+ return req.kismet ?? null;
377
+ }
378
+
379
+ /** The conversion join, from the server code that knows the booking succeeded. */
380
+ export function bookingBridge(
381
+ cfg: KismetTelemetryConfig,
382
+ body: BookingBridgeBody
383
+ ): Promise<ConversionResult> {
384
+ return postBookingBridge(apiEnv(cfg), body);
385
+ }
386
+
387
+ /** A quote the guest saw, for the fallback match on property and dates. */
388
+ export function quoteCapture(
389
+ cfg: KismetTelemetryConfig,
390
+ body: QuoteCaptureBody
391
+ ): Promise<ConversionResult> {
392
+ return postQuoteCapture(apiEnv(cfg), { collectionSlug: cfg.collectionSlug, ...body });
393
+ }
394
+
395
+ export { consentFromCookie, countryAllowsCookies, SEED_HEADERS } from '@kismet-tech/telemetry';
396
+ export type {
397
+ RouteProfile,
398
+ ConsentHook,
399
+ Classification,
400
+ BookingBridgeBody,
401
+ QuoteCaptureBody,
402
+ ConversionResult,
403
+ } from '@kismet-tech/telemetry';