@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 +15 -0
- package/README.md +103 -0
- package/dist/cjs/client.js +10 -0
- package/dist/cjs/index.js +252 -0
- package/dist/cjs/package.json +3 -0
- package/dist/esm/client.d.ts +1 -0
- package/dist/esm/client.js +3 -0
- package/dist/esm/index.d.ts +120 -0
- package/dist/esm/index.js +238 -0
- package/package.json +72 -0
- package/src/client.ts +8 -0
- package/src/index.ts +403 -0
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 @@
|
|
|
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';
|