@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 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
+ });
@@ -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 };
@@ -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
+ }