@koryla/node 0.1.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/README.md +80 -0
- package/dist/index.cjs +63 -0
- package/dist/index.d.cts +55 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +38 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @koryla/node
|
|
2
|
+
|
|
3
|
+
A/B testing middleware for Express and any Node.js server.
|
|
4
|
+
Works on Railway, Render, Fly.io, Heroku, AWS EC2/ECS, or any Node host.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @koryla/node
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Setup (Express)
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import express from 'express'
|
|
16
|
+
import { korylaMiddleware } from '@koryla/node'
|
|
17
|
+
|
|
18
|
+
const app = express()
|
|
19
|
+
|
|
20
|
+
// Add Koryla before your routes
|
|
21
|
+
app.use(korylaMiddleware({
|
|
22
|
+
apiKey: process.env.KORYLA_API_KEY!,
|
|
23
|
+
apiUrl: process.env.KORYLA_API_URL!, // your deployed Koryla app URL
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
// Variant pages must exist as real routes in the same app
|
|
27
|
+
app.get('/', (req, res) => res.render('home-control'))
|
|
28
|
+
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
|
+
```
|
|
32
|
+
|
|
33
|
+
### Environment variables
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
KORYLA_API_KEY=sk_live_...
|
|
37
|
+
KORYLA_API_URL=https://your-koryla-app.vercel.app
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Important: variant pages must be in the same app
|
|
41
|
+
|
|
42
|
+
Unlike edge adapters that can proxy to any URL, the Node middleware rewrites
|
|
43
|
+
`req.url` internally and calls `next()`. This means:
|
|
44
|
+
|
|
45
|
+
- ✅ Your variant pages are routes in the same Express app
|
|
46
|
+
- ❌ Your variants are on a different domain/server
|
|
47
|
+
|
|
48
|
+
If your variants are on a different origin you'll need a reverse proxy
|
|
49
|
+
(nginx, Caddy) in front of your app instead.
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
User visits /
|
|
55
|
+
│
|
|
56
|
+
▼
|
|
57
|
+
Express middleware (in-process, ~1ms overhead)
|
|
58
|
+
│
|
|
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
|
|
63
|
+
│
|
|
64
|
+
▼
|
|
65
|
+
Express routes /variant-b to its handler
|
|
66
|
+
Browser sees original URL (/) — no redirect, no flicker
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Difference vs edge adapters
|
|
70
|
+
|
|
71
|
+
| | @koryla/node | @koryla/next / @koryla/netlify |
|
|
72
|
+
|--|--|--|
|
|
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.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
korylaMiddleware: () => korylaMiddleware
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
var import_core = require("@koryla/core");
|
|
27
|
+
var engine = null;
|
|
28
|
+
function korylaMiddleware(options) {
|
|
29
|
+
return async function middleware(req, res, next) {
|
|
30
|
+
if (!engine) engine = (0, import_core.createSplitEngine)(options);
|
|
31
|
+
try {
|
|
32
|
+
const protocol = req.protocol ?? "http";
|
|
33
|
+
const host = req.headers["x-forwarded-host"] ?? req.headers["host"] ?? "localhost";
|
|
34
|
+
const path = req.originalUrl ?? req.url ?? "/";
|
|
35
|
+
const fullUrl = `${protocol}://${host}${path}`;
|
|
36
|
+
const cookieHeader = req.headers["cookie"] ?? "";
|
|
37
|
+
const result = await engine.process(fullUrl, cookieHeader);
|
|
38
|
+
if (!result) return next();
|
|
39
|
+
const variantPath = new URL(result.targetUrl).pathname;
|
|
40
|
+
req.url = variantPath;
|
|
41
|
+
if (req.originalUrl) req.originalUrl = variantPath;
|
|
42
|
+
if (result.isNewAssignment) {
|
|
43
|
+
const maxAge = 60 * 60 * 24 * 30;
|
|
44
|
+
const cookie = `${result.cookieName}=${result.variantId}; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
|
|
45
|
+
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
|
+
}
|
|
54
|
+
next();
|
|
55
|
+
} catch {
|
|
56
|
+
next();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
61
|
+
0 && (module.exports = {
|
|
62
|
+
korylaMiddleware
|
|
63
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { SplitEngineOptions } from '@koryla/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @koryla/node
|
|
5
|
+
*
|
|
6
|
+
* Express / Node.js middleware adapter for Koryla A/B testing.
|
|
7
|
+
* Works anywhere Node.js runs: Railway, Render, Fly.io, Heroku, AWS EC2/ECS,
|
|
8
|
+
* self-hosted servers, or any Express-compatible framework (Fastify via @fastify/express, etc).
|
|
9
|
+
*
|
|
10
|
+
* Usage (Express):
|
|
11
|
+
* import express from 'express'
|
|
12
|
+
* import { korylaMiddleware } from '@koryla/node'
|
|
13
|
+
*
|
|
14
|
+
* const app = express()
|
|
15
|
+
*
|
|
16
|
+
* app.use(korylaMiddleware({
|
|
17
|
+
* apiKey: process.env.KORYLA_API_KEY!,
|
|
18
|
+
* apiUrl: process.env.KORYLA_API_URL!,
|
|
19
|
+
* }))
|
|
20
|
+
*
|
|
21
|
+
* // Your routes come after — they receive the rewritten req.url
|
|
22
|
+
* app.get('/', handler)
|
|
23
|
+
* app.get('/variant-b', handler)
|
|
24
|
+
*
|
|
25
|
+
* How it works:
|
|
26
|
+
* Unlike edge runtimes, Node.js middleware cannot transparently proxy to another
|
|
27
|
+
* origin. Instead it rewrites req.url to the variant's pathname, then calls next()
|
|
28
|
+
* so Express routes the request to the correct handler.
|
|
29
|
+
*
|
|
30
|
+
* This means your variant pages must exist as routes in the SAME app:
|
|
31
|
+
* Control: GET / → renders homepage v1
|
|
32
|
+
* Variant B: GET /variant-b → renders homepage v2
|
|
33
|
+
*
|
|
34
|
+
* The browser always sees the original URL (/) — the rewrite is internal.
|
|
35
|
+
*
|
|
36
|
+
* Note on latency:
|
|
37
|
+
* Unlike CF Workers / Vercel Edge / Netlify Edge, this runs in your app server
|
|
38
|
+
* process — not at the CDN edge. Variant assignment adds ~1ms of in-process work.
|
|
39
|
+
* The Koryla API call is cached for 60s so it doesn't add per-request latency.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
interface NodeRequest {
|
|
43
|
+
url?: string;
|
|
44
|
+
originalUrl?: string;
|
|
45
|
+
protocol?: string;
|
|
46
|
+
headers: Record<string, string | string[] | undefined>;
|
|
47
|
+
}
|
|
48
|
+
interface NodeResponse {
|
|
49
|
+
setHeader(name: string, value: string | string[]): void;
|
|
50
|
+
getHeader(name: string): string | string[] | number | undefined;
|
|
51
|
+
}
|
|
52
|
+
type NextFunction = (err?: unknown) => void;
|
|
53
|
+
declare function korylaMiddleware(options: SplitEngineOptions): (req: NodeRequest, res: NodeResponse, next: NextFunction) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
export { type NextFunction, type NodeRequest, type NodeResponse, korylaMiddleware };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { SplitEngineOptions } from '@koryla/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @koryla/node
|
|
5
|
+
*
|
|
6
|
+
* Express / Node.js middleware adapter for Koryla A/B testing.
|
|
7
|
+
* Works anywhere Node.js runs: Railway, Render, Fly.io, Heroku, AWS EC2/ECS,
|
|
8
|
+
* self-hosted servers, or any Express-compatible framework (Fastify via @fastify/express, etc).
|
|
9
|
+
*
|
|
10
|
+
* Usage (Express):
|
|
11
|
+
* import express from 'express'
|
|
12
|
+
* import { korylaMiddleware } from '@koryla/node'
|
|
13
|
+
*
|
|
14
|
+
* const app = express()
|
|
15
|
+
*
|
|
16
|
+
* app.use(korylaMiddleware({
|
|
17
|
+
* apiKey: process.env.KORYLA_API_KEY!,
|
|
18
|
+
* apiUrl: process.env.KORYLA_API_URL!,
|
|
19
|
+
* }))
|
|
20
|
+
*
|
|
21
|
+
* // Your routes come after — they receive the rewritten req.url
|
|
22
|
+
* app.get('/', handler)
|
|
23
|
+
* app.get('/variant-b', handler)
|
|
24
|
+
*
|
|
25
|
+
* How it works:
|
|
26
|
+
* Unlike edge runtimes, Node.js middleware cannot transparently proxy to another
|
|
27
|
+
* origin. Instead it rewrites req.url to the variant's pathname, then calls next()
|
|
28
|
+
* so Express routes the request to the correct handler.
|
|
29
|
+
*
|
|
30
|
+
* This means your variant pages must exist as routes in the SAME app:
|
|
31
|
+
* Control: GET / → renders homepage v1
|
|
32
|
+
* Variant B: GET /variant-b → renders homepage v2
|
|
33
|
+
*
|
|
34
|
+
* The browser always sees the original URL (/) — the rewrite is internal.
|
|
35
|
+
*
|
|
36
|
+
* Note on latency:
|
|
37
|
+
* Unlike CF Workers / Vercel Edge / Netlify Edge, this runs in your app server
|
|
38
|
+
* process — not at the CDN edge. Variant assignment adds ~1ms of in-process work.
|
|
39
|
+
* The Koryla API call is cached for 60s so it doesn't add per-request latency.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
interface NodeRequest {
|
|
43
|
+
url?: string;
|
|
44
|
+
originalUrl?: string;
|
|
45
|
+
protocol?: string;
|
|
46
|
+
headers: Record<string, string | string[] | undefined>;
|
|
47
|
+
}
|
|
48
|
+
interface NodeResponse {
|
|
49
|
+
setHeader(name: string, value: string | string[]): void;
|
|
50
|
+
getHeader(name: string): string | string[] | number | undefined;
|
|
51
|
+
}
|
|
52
|
+
type NextFunction = (err?: unknown) => void;
|
|
53
|
+
declare function korylaMiddleware(options: SplitEngineOptions): (req: NodeRequest, res: NodeResponse, next: NextFunction) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
export { type NextFunction, type NodeRequest, type NodeResponse, korylaMiddleware };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createSplitEngine } from "@koryla/core";
|
|
3
|
+
var engine = null;
|
|
4
|
+
function korylaMiddleware(options) {
|
|
5
|
+
return async function middleware(req, res, next) {
|
|
6
|
+
if (!engine) engine = createSplitEngine(options);
|
|
7
|
+
try {
|
|
8
|
+
const protocol = req.protocol ?? "http";
|
|
9
|
+
const host = req.headers["x-forwarded-host"] ?? req.headers["host"] ?? "localhost";
|
|
10
|
+
const path = req.originalUrl ?? req.url ?? "/";
|
|
11
|
+
const fullUrl = `${protocol}://${host}${path}`;
|
|
12
|
+
const cookieHeader = req.headers["cookie"] ?? "";
|
|
13
|
+
const result = await engine.process(fullUrl, cookieHeader);
|
|
14
|
+
if (!result) return next();
|
|
15
|
+
const variantPath = new URL(result.targetUrl).pathname;
|
|
16
|
+
req.url = variantPath;
|
|
17
|
+
if (req.originalUrl) req.originalUrl = variantPath;
|
|
18
|
+
if (result.isNewAssignment) {
|
|
19
|
+
const maxAge = 60 * 60 * 24 * 30;
|
|
20
|
+
const cookie = `${result.cookieName}=${result.variantId}; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
|
|
21
|
+
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
|
+
}
|
|
29
|
+
}
|
|
30
|
+
next();
|
|
31
|
+
} catch {
|
|
32
|
+
next();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
korylaMiddleware
|
|
38
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@koryla/node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
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
|
+
"dependencies": {
|
|
26
|
+
"@koryla/core": "^0.2.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"tsup": "^8.0.0",
|
|
30
|
+
"typescript": "^5"
|
|
31
|
+
}
|
|
32
|
+
}
|