@koryla/node 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -9,44 +9,59 @@ Works on Railway, Render, Fly.io, Heroku, AWS EC2/ECS, or any Node host.
9
9
  npm install @koryla/node
10
10
  ```
11
11
 
12
- ## Setup (Express)
12
+ ## Setup
13
+
14
+ ### 1. Add environment variables
15
+
16
+ ```bash
17
+ KORYLA_API_KEY=sk_live_...
18
+ KORYLA_API_URL=https://koryla.com
19
+ ```
20
+
21
+ ### 2. Add the middleware
22
+
23
+ Place it **before** your routes — Koryla rewrites `req.url` internally so Express routes to the correct handler:
13
24
 
14
25
  ```ts
26
+ // server.ts
15
27
  import express from 'express'
16
28
  import { korylaMiddleware } from '@koryla/node'
17
29
 
18
30
  const app = express()
19
31
 
20
- // Add Koryla before your routes
21
32
  app.use(korylaMiddleware({
22
33
  apiKey: process.env.KORYLA_API_KEY!,
23
- apiUrl: process.env.KORYLA_API_URL!, // your deployed Koryla app URL
34
+ apiUrl: process.env.KORYLA_API_URL!,
24
35
  }))
25
36
 
26
- // Variant pages must exist as real routes in the same app
37
+ // Routes come after — they receive the rewritten req.url
27
38
  app.get('/', (req, res) => res.render('home-control'))
28
39
  app.get('/variant-b', (req, res) => res.render('home-variant-b'))
29
- app.get('/pricing', (req, res) => res.render('pricing-control'))
30
- app.get('/pricing-b', (req, res) => res.render('pricing-variant-b'))
31
40
  ```
32
41
 
33
- ### Environment variables
42
+ ### 3. Create your variant pages
34
43
 
35
- ```bash
36
- KORYLA_API_KEY=sk_live_...
37
- KORYLA_API_URL=https://your-koryla-app.vercel.app
44
+ Variant pages must be real routes in the same Express app:
45
+
46
+ ```
47
+ app/
48
+ views/
49
+ home-control.ejs ← control (base_url: https://acme.com/)
50
+ home-variant-b.ejs ← variant (target_url: https://acme.com/variant-b)
38
51
  ```
39
52
 
40
- ## Important: variant pages must be in the same app
53
+ ### 4. Create an experiment in Koryla
41
54
 
42
- Unlike edge adapters that can proxy to any URL, the Node middleware rewrites
43
- `req.url` internally and calls `next()`. This means:
55
+ Go to your Koryla dashboard → New experiment:
44
56
 
45
- - ✅ Your variant pages are routes in the same Express app
46
- - ❌ Your variants are on a different domain/server
57
+ | Field | Value |
58
+ |-------|-------|
59
+ | Base URL | `https://acme.com/` |
60
+ | Variant A — Control | `https://acme.com/` · 50% |
61
+ | Variant B | `https://acme.com/variant-b` · 50% |
62
+ | Conversion URL | `https://acme.com/thank-you` |
47
63
 
48
- If your variants are on a different origin you'll need a reverse proxy
49
- (nginx, Caddy) in front of your app instead.
64
+ Set it to **Active** and you're done.
50
65
 
51
66
  ## How it works
52
67
 
@@ -56,25 +71,25 @@ User visits /
56
71
  ▼
57
72
  Express middleware (in-process, ~1ms overhead)
58
73
  │
59
- ├── fetches Koryla config once every 60s (cached in memory)
60
- ├── reads sp_<experimentId> cookie
61
- ├── assigns variant, rewrites req.url to /variant-b
62
- └── sets Set-Cookie header on response
74
+ ├── calls Koryla API once every 60s to get experiment config
75
+ ├── reads koryla_sid cookie
76
+ │ ├── cookie present → use existing variant (sticky)
77
+ │ └── no cookie → assign variant by traffic weight
78
+ │
79
+ ├── rewrites req.url to /variant-b
80
+ └── sets Set-Cookie header for 30 days
63
81
  │
64
82
  ▼
65
83
  Express routes /variant-b to its handler
66
84
  Browser sees original URL (/) — no redirect, no flicker
67
85
  ```
68
86
 
69
- ## Difference vs edge adapters
87
+ ## Why this is better than VWO / Optimizely
70
88
 
71
- | | @koryla/node | @koryla/next / @koryla/netlify |
89
+ | | VWO / Optimizely | @koryla/node |
72
90
  |--|--|--|
73
- | Runs at | Your app server | CDN edge node near the user |
74
- | Latency added | ~1ms (in-process) | ~1ms (in-process) |
75
- | Koryla API call | Cached 60s | Cached 60s |
76
- | Variants on different origin | ❌ (same app only) | ✅ |
77
- | Zero flicker | ✅ | ✅ |
78
-
79
- Both approaches are server-side — neither has the flicker problem of client-side tools.
80
- The only difference is where the server runs.
91
+ | When variant is decided | In the browser, after JS loads | On the server, before any HTML |
92
+ | Flicker | Yes (page hides while swapping) | No |
93
+ | Extra JS on page | ~80–150 KB | 0 KB |
94
+ | Blockable by ad blockers | Yes | No |
95
+ | Works without JS | No | Yes |
package/dist/index.cjs CHANGED
@@ -20,7 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- korylaMiddleware: () => korylaMiddleware
23
+ korylaMiddleware: () => korylaMiddleware,
24
+ trackConversion: () => trackConversion
24
25
  });
25
26
  module.exports = __toCommonJS(index_exports);
26
27
  var import_core = require("@koryla/core");
@@ -39,17 +40,18 @@ function korylaMiddleware(options) {
39
40
  const variantPath = new URL(result.targetUrl).pathname;
40
41
  req.url = variantPath;
41
42
  if (req.originalUrl) req.originalUrl = variantPath;
43
+ const maxAge = 60 * 60 * 24 * 30;
44
+ const newCookies = [];
42
45
  if (result.isNewAssignment) {
43
- const maxAge = 60 * 60 * 24 * 30;
44
- const cookie = `${result.cookieName}=${result.variantId}; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
46
+ newCookies.push(`${result.cookieName}=${result.variantId}; Path=/; Max-Age=${maxAge}; SameSite=Lax`);
47
+ }
48
+ if (result.isNewSession) {
49
+ newCookies.push(`ky_sid=${result.sessionId}; Path=/; Max-Age=${maxAge}; SameSite=Lax`);
50
+ }
51
+ if (newCookies.length) {
45
52
  const existing = res.getHeader("Set-Cookie");
46
- if (Array.isArray(existing)) {
47
- res.setHeader("Set-Cookie", [...existing, cookie]);
48
- } else if (existing) {
49
- res.setHeader("Set-Cookie", [existing, cookie]);
50
- } else {
51
- res.setHeader("Set-Cookie", cookie);
52
- }
53
+ const all = [...Array.isArray(existing) ? existing : existing ? [existing] : [], ...newCookies];
54
+ res.setHeader("Set-Cookie", all.length === 1 ? all[0] : all);
53
55
  }
54
56
  next();
55
57
  } catch {
@@ -57,7 +59,28 @@ function korylaMiddleware(options) {
57
59
  }
58
60
  };
59
61
  }
62
+ async function trackConversion(req, options) {
63
+ const cookieHeader = req.headers["cookie"] ?? "";
64
+ const cookies = Object.fromEntries(
65
+ cookieHeader.split(";").map((c) => c.trim().split("=").map((s) => s.trim())).filter(([k]) => k?.length > 0)
66
+ );
67
+ const sessionId = cookies["ky_sid"];
68
+ if (!sessionId) return;
69
+ for (const [key, value] of Object.entries(cookies)) {
70
+ if (!key.startsWith("ky_") || key === "ky_sid") continue;
71
+ const experimentId = key.slice(3);
72
+ try {
73
+ await fetch(`${options.apiUrl}/api/worker/event`, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
76
+ body: JSON.stringify({ experiment_id: experimentId, variant_id: value, session_id: sessionId, event_type: "conversion" })
77
+ });
78
+ } catch {
79
+ }
80
+ }
81
+ }
60
82
  // Annotate the CommonJS export names for ESM import in node:
61
83
  0 && (module.exports = {
62
- korylaMiddleware
84
+ korylaMiddleware,
85
+ trackConversion
63
86
  });
package/dist/index.d.cts CHANGED
@@ -51,5 +51,13 @@ interface NodeResponse {
51
51
  }
52
52
  type NextFunction = (err?: unknown) => void;
53
53
  declare function korylaMiddleware(options: SplitEngineOptions): (req: NodeRequest, res: NodeResponse, next: NextFunction) => Promise<void>;
54
+ /**
55
+ * Manually fire a conversion event. Use this when the conversion happens
56
+ * via an API call or form POST rather than a page visit.
57
+ *
58
+ * For page-visit conversions, set the conversion URL on the experiment in
59
+ * the Koryla dashboard — the middleware fires the event automatically.
60
+ */
61
+ declare function trackConversion(req: NodeRequest, options: SplitEngineOptions): Promise<void>;
54
62
 
55
- export { type NextFunction, type NodeRequest, type NodeResponse, korylaMiddleware };
63
+ export { type NextFunction, type NodeRequest, type NodeResponse, korylaMiddleware, trackConversion };
package/dist/index.d.ts CHANGED
@@ -51,5 +51,13 @@ interface NodeResponse {
51
51
  }
52
52
  type NextFunction = (err?: unknown) => void;
53
53
  declare function korylaMiddleware(options: SplitEngineOptions): (req: NodeRequest, res: NodeResponse, next: NextFunction) => Promise<void>;
54
+ /**
55
+ * Manually fire a conversion event. Use this when the conversion happens
56
+ * via an API call or form POST rather than a page visit.
57
+ *
58
+ * For page-visit conversions, set the conversion URL on the experiment in
59
+ * the Koryla dashboard — the middleware fires the event automatically.
60
+ */
61
+ declare function trackConversion(req: NodeRequest, options: SplitEngineOptions): Promise<void>;
54
62
 
55
- export { type NextFunction, type NodeRequest, type NodeResponse, korylaMiddleware };
63
+ export { type NextFunction, type NodeRequest, type NodeResponse, korylaMiddleware, trackConversion };
package/dist/index.js CHANGED
@@ -15,17 +15,18 @@ function korylaMiddleware(options) {
15
15
  const variantPath = new URL(result.targetUrl).pathname;
16
16
  req.url = variantPath;
17
17
  if (req.originalUrl) req.originalUrl = variantPath;
18
+ const maxAge = 60 * 60 * 24 * 30;
19
+ const newCookies = [];
18
20
  if (result.isNewAssignment) {
19
- const maxAge = 60 * 60 * 24 * 30;
20
- const cookie = `${result.cookieName}=${result.variantId}; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
21
+ newCookies.push(`${result.cookieName}=${result.variantId}; Path=/; Max-Age=${maxAge}; SameSite=Lax`);
22
+ }
23
+ if (result.isNewSession) {
24
+ newCookies.push(`ky_sid=${result.sessionId}; Path=/; Max-Age=${maxAge}; SameSite=Lax`);
25
+ }
26
+ if (newCookies.length) {
21
27
  const existing = res.getHeader("Set-Cookie");
22
- if (Array.isArray(existing)) {
23
- res.setHeader("Set-Cookie", [...existing, cookie]);
24
- } else if (existing) {
25
- res.setHeader("Set-Cookie", [existing, cookie]);
26
- } else {
27
- res.setHeader("Set-Cookie", cookie);
28
- }
28
+ const all = [...Array.isArray(existing) ? existing : existing ? [existing] : [], ...newCookies];
29
+ res.setHeader("Set-Cookie", all.length === 1 ? all[0] : all);
29
30
  }
30
31
  next();
31
32
  } catch {
@@ -33,6 +34,27 @@ function korylaMiddleware(options) {
33
34
  }
34
35
  };
35
36
  }
37
+ async function trackConversion(req, options) {
38
+ const cookieHeader = req.headers["cookie"] ?? "";
39
+ const cookies = Object.fromEntries(
40
+ cookieHeader.split(";").map((c) => c.trim().split("=").map((s) => s.trim())).filter(([k]) => k?.length > 0)
41
+ );
42
+ const sessionId = cookies["ky_sid"];
43
+ if (!sessionId) return;
44
+ for (const [key, value] of Object.entries(cookies)) {
45
+ if (!key.startsWith("ky_") || key === "ky_sid") continue;
46
+ const experimentId = key.slice(3);
47
+ try {
48
+ await fetch(`${options.apiUrl}/api/worker/event`, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
51
+ body: JSON.stringify({ experiment_id: experimentId, variant_id: value, session_id: sessionId, event_type: "conversion" })
52
+ });
53
+ } catch {
54
+ }
55
+ }
56
+ }
36
57
  export {
37
- korylaMiddleware
58
+ korylaMiddleware,
59
+ trackConversion
38
60
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koryla/node",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -18,15 +18,15 @@
18
18
  "publishConfig": {
19
19
  "access": "public"
20
20
  },
21
- "scripts": {
22
- "build": "tsup src/index.ts --format esm,cjs --dts --clean",
23
- "dev": "tsup src/index.ts --format esm,cjs --dts --watch"
24
- },
25
21
  "dependencies": {
26
22
  "@koryla/core": "^0.2.0"
27
23
  },
28
24
  "devDependencies": {
29
25
  "tsup": "^8.0.0",
30
26
  "typescript": "^5"
27
+ },
28
+ "scripts": {
29
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
30
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch"
31
31
  }
32
- }
32
+ }