@caelo-cms/edge-router 0.10.23
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 +25 -0
- package/dist/assignment.d.ts +70 -0
- package/dist/assignment.d.ts.map +1 -0
- package/dist/assignment.js +78 -0
- package/dist/assignment.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +56 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +49 -0
- package/dist/manifest.js.map +1 -0
- package/dist/router.d.ts +31 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +39 -0
- package/dist/router.js.map +1 -0
- package/package.json +38 -0
- package/src/assignment.ts +128 -0
- package/src/index.test.ts +254 -0
- package/src/index.ts +28 -0
- package/src/manifest.ts +94 -0
- package/src/router.ts +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
|
2
|
+
|
|
3
|
+
# @caelo-cms/edge-router
|
|
4
|
+
|
|
5
|
+
Locale-aware edge request router for [Caelo CMS](https://github.com/caelo-cms/caelo-cms)
|
|
6
|
+
deployments. Maps incoming visitor URLs onto the static site's locale/URL
|
|
7
|
+
strategy (prefix, domain, or hybrid) from the deploy manifest — the same
|
|
8
|
+
routing logic everywhere a request first lands:
|
|
9
|
+
|
|
10
|
+
- the provisioning stacks' edge handlers bundle it at provision time
|
|
11
|
+
(Lambda@Edge on AWS; the equivalent edge hooks on the GCP and Azure
|
|
12
|
+
stacks),
|
|
13
|
+
- the static generator uses it to precompute per-locale routes.
|
|
14
|
+
|
|
15
|
+
It is published as part of Caelo's lockstep release because the shipped
|
|
16
|
+
`@caelo-cms/provisioning` package depends on it at provision time. It has no
|
|
17
|
+
runtime dependencies.
|
|
18
|
+
|
|
19
|
+
You normally don't install this directly — it comes in through
|
|
20
|
+
`bunx @caelo-cms/provisioning`. See the
|
|
21
|
+
[main repository](https://github.com/caelo-cms/caelo-cms) for documentation.
|
|
22
|
+
|
|
23
|
+
## License
|
|
24
|
+
|
|
25
|
+
MPL-2.0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P15 — stable-hash A/B variant assignment.
|
|
3
|
+
*
|
|
4
|
+
* MUST produce byte-identical results across all four runtimes:
|
|
5
|
+
* - self-hosted (P13 Caddy gateway)
|
|
6
|
+
* - GCP edge router (Cloud Run header rule)
|
|
7
|
+
* - AWS edge router (Lambda@Edge)
|
|
8
|
+
* - Azure edge router (Front Door rules engine)
|
|
9
|
+
*
|
|
10
|
+
* Drift between runtimes means a visitor sees different variants
|
|
11
|
+
* depending on which provider serves them — silent A/B contamination.
|
|
12
|
+
* The byte-identity test in `index.test.ts` asserts a fixed corpus of
|
|
13
|
+
* (visitorId, manifestVersion, experimentId) tuples maps to the same
|
|
14
|
+
* variant label on every implementation.
|
|
15
|
+
*
|
|
16
|
+
* Algorithm: FNV-1a 32-bit hash of (visitorId + ":" + experimentId +
|
|
17
|
+
* ":" + manifestVersion). The 32-bit space mod 100 gives a bucket in
|
|
18
|
+
* [0, 99]; cumulative variant weights determine the variant. Pure +
|
|
19
|
+
* deterministic; no randomness, no time, no provider-specific code.
|
|
20
|
+
*/
|
|
21
|
+
import type { ManifestExperiment, ManifestVariant } from "./manifest.js";
|
|
22
|
+
/**
|
|
23
|
+
* FNV-1a 32-bit. Standard offset basis 0x811c9dc5, prime 0x01000193.
|
|
24
|
+
* Implemented in plain TypeScript so every runtime (V8 / SpiderMonkey
|
|
25
|
+
* / Node L@E sandbox / Bun) produces byte-identical output.
|
|
26
|
+
*/
|
|
27
|
+
export declare function fnv1a32(input: string): number;
|
|
28
|
+
/**
|
|
29
|
+
* Assign a variant to a visitor for one experiment. Inputs:
|
|
30
|
+
* - visitorId: stable opaque string from the `caelo_visitor_id` cookie.
|
|
31
|
+
* - manifestVersion: bumped per deploy so a re-bucketing rolls out
|
|
32
|
+
* atomically (operators occasionally need to re-randomize after a
|
|
33
|
+
* biased early sample).
|
|
34
|
+
* - experiment: the matched ManifestExperiment.
|
|
35
|
+
*
|
|
36
|
+
* Returns the chosen variant + its rewrite path. Pure; no I/O.
|
|
37
|
+
*/
|
|
38
|
+
export declare function assignVariant(opts: {
|
|
39
|
+
readonly visitorId: string;
|
|
40
|
+
readonly manifestVersion: string;
|
|
41
|
+
readonly experiment: ManifestExperiment;
|
|
42
|
+
}): ManifestVariant;
|
|
43
|
+
/**
|
|
44
|
+
* Format the assignment-log entry every runtime emits. Same JSON shape
|
|
45
|
+
* across providers so the P12A analytics plugin's per-provider log
|
|
46
|
+
* adapters can normalise into one `ab_assignment_aggregates` query.
|
|
47
|
+
*/
|
|
48
|
+
export interface AssignmentLogEntry {
|
|
49
|
+
readonly kind: "ab_assignment";
|
|
50
|
+
readonly experimentId: string;
|
|
51
|
+
readonly variantLabel: string;
|
|
52
|
+
readonly visitorId: string;
|
|
53
|
+
readonly manifestVersion: string;
|
|
54
|
+
readonly tsMs: number;
|
|
55
|
+
}
|
|
56
|
+
export declare function buildAssignmentLog(opts: {
|
|
57
|
+
readonly experimentId: string;
|
|
58
|
+
readonly variant: ManifestVariant;
|
|
59
|
+
readonly visitorId: string;
|
|
60
|
+
readonly manifestVersion: string;
|
|
61
|
+
readonly nowMs?: number;
|
|
62
|
+
}): AssignmentLogEntry;
|
|
63
|
+
/**
|
|
64
|
+
* Mint a stable opaque visitor id when the request didn't carry the
|
|
65
|
+
* `caelo_visitor_id` cookie. Format: 16 hex bytes (128 bits — collision
|
|
66
|
+
* resistant for any plausible visitor population). Edge routers set
|
|
67
|
+
* this on the response cookie so the next request inherits the same id.
|
|
68
|
+
*/
|
|
69
|
+
export declare function mintVisitorId(): string;
|
|
70
|
+
//# sourceMappingURL=assignment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assignment.d.ts","sourceRoot":"","sources":["../src/assignment.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEzE;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAU7C;AAcD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,kBAAkB,CAAC;CACzC,GAAG,eAAe,CAclB;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACvC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB,GAAG,kBAAkB,CASrB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAMtC"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* FNV-1a 32-bit. Standard offset basis 0x811c9dc5, prime 0x01000193.
|
|
4
|
+
* Implemented in plain TypeScript so every runtime (V8 / SpiderMonkey
|
|
5
|
+
* / Node L@E sandbox / Bun) produces byte-identical output.
|
|
6
|
+
*/
|
|
7
|
+
export function fnv1a32(input) {
|
|
8
|
+
let hash = 0x811c9dc5;
|
|
9
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
10
|
+
hash ^= input.charCodeAt(i);
|
|
11
|
+
// Multiply by FNV prime, keeping in 32-bit unsigned range. Math.imul
|
|
12
|
+
// is the cross-runtime way to do 32-bit-truncated integer multiply.
|
|
13
|
+
hash = Math.imul(hash, 0x01000193);
|
|
14
|
+
}
|
|
15
|
+
// Coerce back to unsigned 32-bit.
|
|
16
|
+
return hash >>> 0;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Compute the cumulative bucket boundaries for an experiment's variants.
|
|
20
|
+
* Stable: same input order → same boundaries → same assignment.
|
|
21
|
+
*/
|
|
22
|
+
function bucketBoundaries(variants) {
|
|
23
|
+
let cum = 0;
|
|
24
|
+
return variants.map((v) => {
|
|
25
|
+
cum += v.weight;
|
|
26
|
+
return cum;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Assign a variant to a visitor for one experiment. Inputs:
|
|
31
|
+
* - visitorId: stable opaque string from the `caelo_visitor_id` cookie.
|
|
32
|
+
* - manifestVersion: bumped per deploy so a re-bucketing rolls out
|
|
33
|
+
* atomically (operators occasionally need to re-randomize after a
|
|
34
|
+
* biased early sample).
|
|
35
|
+
* - experiment: the matched ManifestExperiment.
|
|
36
|
+
*
|
|
37
|
+
* Returns the chosen variant + its rewrite path. Pure; no I/O.
|
|
38
|
+
*/
|
|
39
|
+
export function assignVariant(opts) {
|
|
40
|
+
const key = `${opts.visitorId}:${opts.experiment.experimentId}:${opts.manifestVersion}`;
|
|
41
|
+
const hash = fnv1a32(key);
|
|
42
|
+
const bucket = hash % 100;
|
|
43
|
+
const boundaries = bucketBoundaries(opts.experiment.variants);
|
|
44
|
+
for (let i = 0; i < boundaries.length; i += 1) {
|
|
45
|
+
if (bucket < (boundaries[i] ?? 0)) {
|
|
46
|
+
const v = opts.experiment.variants[i];
|
|
47
|
+
if (v)
|
|
48
|
+
return v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Fallthrough (weights summed to 100 but float drift) — return last.
|
|
52
|
+
// Schema-level validation guarantees this doesn't happen in practice.
|
|
53
|
+
return opts.experiment.variants[opts.experiment.variants.length - 1];
|
|
54
|
+
}
|
|
55
|
+
export function buildAssignmentLog(opts) {
|
|
56
|
+
return {
|
|
57
|
+
kind: "ab_assignment",
|
|
58
|
+
experimentId: opts.experimentId,
|
|
59
|
+
variantLabel: opts.variant.label,
|
|
60
|
+
visitorId: opts.visitorId,
|
|
61
|
+
manifestVersion: opts.manifestVersion,
|
|
62
|
+
tsMs: opts.nowMs ?? Date.now(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Mint a stable opaque visitor id when the request didn't carry the
|
|
67
|
+
* `caelo_visitor_id` cookie. Format: 16 hex bytes (128 bits — collision
|
|
68
|
+
* resistant for any plausible visitor population). Edge routers set
|
|
69
|
+
* this on the response cookie so the next request inherits the same id.
|
|
70
|
+
*/
|
|
71
|
+
export function mintVisitorId() {
|
|
72
|
+
const buf = new Uint8Array(16);
|
|
73
|
+
// crypto.getRandomValues is available in V8 / Node / L@E / Bun /
|
|
74
|
+
// every modern runtime; no Node Buffer fallback needed.
|
|
75
|
+
crypto.getRandomValues(buf);
|
|
76
|
+
return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=assignment.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assignment.js","sourceRoot":"","sources":["../src/assignment.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAyBnC;;;;GAIG;AACH,MAAM,UAAU,OAAO,CAAC,KAAa;IACnC,IAAI,IAAI,GAAG,UAAU,CAAC;IACtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACzC,IAAI,IAAI,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC5B,qEAAqE;QACrE,oEAAoE;QACpE,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;IACrC,CAAC;IACD,kCAAkC;IAClC,OAAO,IAAI,KAAK,CAAC,CAAC;AACpB,CAAC;AAED;;;GAGG;AACH,SAAS,gBAAgB,CAAC,QAAwC;IAChE,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACxB,GAAG,IAAI,CAAC,CAAC,MAAM,CAAC;QAChB,OAAO,GAAG,CAAC;IACb,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAAC,IAI7B;IACC,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,UAAU,CAAC,YAAY,IAAI,IAAI,CAAC,eAAe,EAAE,CAAC;IACxF,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAC1B,MAAM,MAAM,GAAG,IAAI,GAAG,GAAG,CAAC;IAC1B,MAAM,UAAU,GAAG,gBAAgB,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;IAC9D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC9C,IAAI,MAAM,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YAClC,MAAM,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;YACtC,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IACD,qEAAqE;IACrE,sEAAsE;IACtE,OAAO,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAoB,CAAC;AAC1F,CAAC;AAgBD,MAAM,UAAU,kBAAkB,CAAC,IAMlC;IACC,OAAO;QACL,IAAI,EAAE,eAAe;QACrB,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,YAAY,EAAE,IAAI,CAAC,OAAO,CAAC,KAAK;QAChC,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,eAAe,EAAE,IAAI,CAAC,eAAe;QACrC,IAAI,EAAE,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,EAAE;KAC/B,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;IAC/B,iEAAiE;IACjE,wDAAwD;IACxD,MAAM,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;IAC5B,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACvE,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @caelo-cms/edge-router — P15.
|
|
3
|
+
*
|
|
4
|
+
* Provider-agnostic edge-routing logic for A/B experiments. The same
|
|
5
|
+
* hash → same variant → same rewrite path holds across self-hosted
|
|
6
|
+
* (P13 Caddy gateway) + GCP + AWS + Azure. Provider-specific shims
|
|
7
|
+
* land in `packages/provisioning/stacks/<provider>/edge-handler.ts`
|
|
8
|
+
* and import this package's `routeRequest` for the actual decision.
|
|
9
|
+
*/
|
|
10
|
+
export { type AssignmentLogEntry, assignVariant, buildAssignmentLog, fnv1a32, mintVisitorId, } from "./assignment.js";
|
|
11
|
+
export { EMPTY_MANIFEST, findExperimentForUrl, type ManifestExperiment, type ManifestVariant, type RoutingManifest, validateManifest, } from "./manifest.js";
|
|
12
|
+
export { type EdgeRequestSummary, type EdgeRouteDecision, routeRequest } from "./router.js";
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,OAAO,EACL,KAAK,kBAAkB,EACvB,aAAa,EACb,kBAAkB,EAClB,OAAO,EACP,aAAa,GACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,kBAAkB,EAAE,KAAK,iBAAiB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* @caelo-cms/edge-router — P15.
|
|
4
|
+
*
|
|
5
|
+
* Provider-agnostic edge-routing logic for A/B experiments. The same
|
|
6
|
+
* hash → same variant → same rewrite path holds across self-hosted
|
|
7
|
+
* (P13 Caddy gateway) + GCP + AWS + Azure. Provider-specific shims
|
|
8
|
+
* land in `packages/provisioning/stacks/<provider>/edge-handler.ts`
|
|
9
|
+
* and import this package's `routeRequest` for the actual decision.
|
|
10
|
+
*/
|
|
11
|
+
export { assignVariant, buildAssignmentLog, fnv1a32, mintVisitorId, } from "./assignment.js";
|
|
12
|
+
export { EMPTY_MANIFEST, findExperimentForUrl, validateManifest, } from "./manifest.js";
|
|
13
|
+
export { routeRequest } from "./router.js";
|
|
14
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;;;;GAQG;AAEH,OAAO,EAEL,aAAa,EACb,kBAAkB,EAClB,OAAO,EACP,aAAa,GACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,cAAc,EACd,oBAAoB,EAIpB,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAmD,YAAY,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P15 — A/B routing manifest. Static-generator emits this file at deploy
|
|
3
|
+
* time (P6 + P13 contract). Per-provider edge routers (Cloud CDN /
|
|
4
|
+
* CloudFront L@E / Front Door rules / self-hosted Caddy gateway) all
|
|
5
|
+
* read this single shape so behaviour is identical across runtimes.
|
|
6
|
+
*
|
|
7
|
+
* Variant URL paths follow the convention:
|
|
8
|
+
* control: /<page-slug>
|
|
9
|
+
* variant: /_caelo-variant/<experimentId>/<variantLabel>/<page-slug>
|
|
10
|
+
*
|
|
11
|
+
* (Picked path-based over query-string-based so CDN cache keys aren't
|
|
12
|
+
* affected by query-string normalisation. See plans/phases/phase_15.)
|
|
13
|
+
*/
|
|
14
|
+
export interface ManifestVariant {
|
|
15
|
+
/** Owner-readable label (e.g. "A", "B", "control"). Stable across deploys. */
|
|
16
|
+
readonly label: string;
|
|
17
|
+
/** Bucket weight in [0, 100]. All variants for one experiment must sum to 100. */
|
|
18
|
+
readonly weight: number;
|
|
19
|
+
/**
|
|
20
|
+
* Path the edge router rewrites to when this variant wins. Includes
|
|
21
|
+
* the /_caelo-variant prefix for non-control variants; equal to
|
|
22
|
+
* `pageSlug` for control.
|
|
23
|
+
*/
|
|
24
|
+
readonly path: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ManifestExperiment {
|
|
27
|
+
/** Page slug the experiment runs on (e.g. "/home", "/pricing"). */
|
|
28
|
+
readonly pageSlug: string;
|
|
29
|
+
/** UUID — also the cookie partition key so two experiments don't collide. */
|
|
30
|
+
readonly experimentId: string;
|
|
31
|
+
readonly variants: ReadonlyArray<ManifestVariant>;
|
|
32
|
+
}
|
|
33
|
+
export interface RoutingManifest {
|
|
34
|
+
/** Bumped by the static generator on every deploy that changes routing. */
|
|
35
|
+
readonly manifestVersion: string;
|
|
36
|
+
/** All active experiments (only `status='active'` rows from cms_admin.experiments). */
|
|
37
|
+
readonly experiments: ReadonlyArray<ManifestExperiment>;
|
|
38
|
+
}
|
|
39
|
+
export declare const EMPTY_MANIFEST: RoutingManifest;
|
|
40
|
+
/**
|
|
41
|
+
* Look up the active experiment for a given request URL. Returns null
|
|
42
|
+
* when no experiment matches the URL's page slug — caller should pass
|
|
43
|
+
* the request through unchanged.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findExperimentForUrl(manifest: RoutingManifest, pathname: string): ManifestExperiment | null;
|
|
46
|
+
/**
|
|
47
|
+
* Validates a manifest's invariants: every experiment's variant weights
|
|
48
|
+
* sum to 100, every variant has a non-empty label, the experimentId is
|
|
49
|
+
* a UUID, weights are non-negative. Returns null on success or a
|
|
50
|
+
* structured error string the static generator surfaces in audit. The
|
|
51
|
+
* runtime callers (edge routers) do NOT re-validate per request — they
|
|
52
|
+
* trust the deploy-time validation. This function is primarily for
|
|
53
|
+
* tests + the deploy step.
|
|
54
|
+
*/
|
|
55
|
+
export declare function validateManifest(manifest: RoutingManifest): string | null;
|
|
56
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,uFAAuF;IACvF,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED,eAAO,MAAM,cAAc,EAAE,eAG5B,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,eAAe,EACzB,QAAQ,EAAE,MAAM,GACf,kBAAkB,GAAG,IAAI,CAK3B;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAoBzE"}
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
export const EMPTY_MANIFEST = {
|
|
3
|
+
manifestVersion: "0",
|
|
4
|
+
experiments: [],
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Look up the active experiment for a given request URL. Returns null
|
|
8
|
+
* when no experiment matches the URL's page slug — caller should pass
|
|
9
|
+
* the request through unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export function findExperimentForUrl(manifest, pathname) {
|
|
12
|
+
for (const ex of manifest.experiments) {
|
|
13
|
+
if (ex.pageSlug === pathname)
|
|
14
|
+
return ex;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Validates a manifest's invariants: every experiment's variant weights
|
|
20
|
+
* sum to 100, every variant has a non-empty label, the experimentId is
|
|
21
|
+
* a UUID, weights are non-negative. Returns null on success or a
|
|
22
|
+
* structured error string the static generator surfaces in audit. The
|
|
23
|
+
* runtime callers (edge routers) do NOT re-validate per request — they
|
|
24
|
+
* trust the deploy-time validation. This function is primarily for
|
|
25
|
+
* tests + the deploy step.
|
|
26
|
+
*/
|
|
27
|
+
export function validateManifest(manifest) {
|
|
28
|
+
for (const ex of manifest.experiments) {
|
|
29
|
+
if (!ex.experimentId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
|
30
|
+
return `experiment ${ex.experimentId} is not a UUID`;
|
|
31
|
+
}
|
|
32
|
+
if (ex.variants.length === 0) {
|
|
33
|
+
return `experiment ${ex.experimentId} has no variants`;
|
|
34
|
+
}
|
|
35
|
+
let total = 0;
|
|
36
|
+
for (const v of ex.variants) {
|
|
37
|
+
if (!v.label)
|
|
38
|
+
return `experiment ${ex.experimentId}: variant has empty label`;
|
|
39
|
+
if (v.weight < 0)
|
|
40
|
+
return `experiment ${ex.experimentId}: variant ${v.label} has negative weight`;
|
|
41
|
+
total += v.weight;
|
|
42
|
+
}
|
|
43
|
+
if (total !== 100) {
|
|
44
|
+
return `experiment ${ex.experimentId}: variant weights sum to ${total}, expected 100`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,mCAAmC;AA4CnC,MAAM,CAAC,MAAM,cAAc,GAAoB;IAC7C,eAAe,EAAE,GAAG;IACpB,WAAW,EAAE,EAAE;CAChB,CAAC;AAEF;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAClC,QAAyB,EACzB,QAAgB;IAEhB,KAAK,MAAM,EAAE,IAAI,QAAQ,CAAC,WAAW,EAAE,CAAC;QACtC,IAAI,EAAE,CAAC,QAAQ,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;IAC1C,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAAyB;IACxD,KAAK,MAAM,EAAE,IAAI,QAAQ,CAAC,WAAW,EAAE,CAAC;QACtC,IAAI,CAAC,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,iEAAiE,CAAC,EAAE,CAAC;YAC9F,OAAO,cAAc,EAAE,CAAC,YAAY,gBAAgB,CAAC;QACvD,CAAC;QACD,IAAI,EAAE,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC7B,OAAO,cAAc,EAAE,CAAC,YAAY,kBAAkB,CAAC;QACzD,CAAC;QACD,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,CAAC;YAC5B,IAAI,CAAC,CAAC,CAAC,KAAK;gBAAE,OAAO,cAAc,EAAE,CAAC,YAAY,2BAA2B,CAAC;YAC9E,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC;gBACd,OAAO,cAAc,EAAE,CAAC,YAAY,aAAa,CAAC,CAAC,KAAK,sBAAsB,CAAC;YACjF,KAAK,IAAI,CAAC,CAAC,MAAM,CAAC;QACpB,CAAC;QACD,IAAI,KAAK,KAAK,GAAG,EAAE,CAAC;YAClB,OAAO,cAAc,EAAE,CAAC,YAAY,4BAA4B,KAAK,gBAAgB,CAAC;QACxF,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P15 — generic edge-router request handler. The provider-specific
|
|
3
|
+
* shims (gcp.ts, aws.ts, azure.ts) wrap this in their respective
|
|
4
|
+
* runtime contracts (Cloud Run handler, Lambda@Edge handler, Front
|
|
5
|
+
* Door rules engine), but the routing decision itself is identical.
|
|
6
|
+
*
|
|
7
|
+
* One function. Three runtimes. Same hash → same variant → same path
|
|
8
|
+
* rewrite, every time.
|
|
9
|
+
*/
|
|
10
|
+
import { buildAssignmentLog } from "./assignment.js";
|
|
11
|
+
import { type RoutingManifest } from "./manifest.js";
|
|
12
|
+
export interface EdgeRequestSummary {
|
|
13
|
+
/** URL pathname only (e.g. "/about"). Query string ignored for routing. */
|
|
14
|
+
readonly pathname: string;
|
|
15
|
+
/** The current request's `caelo_visitor_id` cookie value, if any. */
|
|
16
|
+
readonly visitorIdCookie: string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface EdgeRouteDecision {
|
|
19
|
+
/** Final pathname the runtime should serve (control or variant). */
|
|
20
|
+
readonly rewritePathname: string;
|
|
21
|
+
/** Visitor id the runtime should set as `caelo_visitor_id` (mint when absent). */
|
|
22
|
+
readonly setVisitorId: string;
|
|
23
|
+
/**
|
|
24
|
+
* Assignment-log payload to emit through the runtime's logger.
|
|
25
|
+
* Null when the request didn't match any experiment — runtime should
|
|
26
|
+
* NOT emit anything in that case (avoids log spam on every static asset).
|
|
27
|
+
*/
|
|
28
|
+
readonly logEntry: ReturnType<typeof buildAssignmentLog> | null;
|
|
29
|
+
}
|
|
30
|
+
export declare function routeRequest(manifest: RoutingManifest, req: EdgeRequestSummary): EdgeRouteDecision;
|
|
31
|
+
//# sourceMappingURL=router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,OAAO,EAAiB,kBAAkB,EAAiB,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAE3E,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,qEAAqE;IACrE,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,kFAAkF;IAClF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,UAAU,CAAC,OAAO,kBAAkB,CAAC,GAAG,IAAI,CAAC;CACjE;AAED,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,kBAAkB,GACtB,iBAAiB,CAyBnB"}
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* P15 — generic edge-router request handler. The provider-specific
|
|
4
|
+
* shims (gcp.ts, aws.ts, azure.ts) wrap this in their respective
|
|
5
|
+
* runtime contracts (Cloud Run handler, Lambda@Edge handler, Front
|
|
6
|
+
* Door rules engine), but the routing decision itself is identical.
|
|
7
|
+
*
|
|
8
|
+
* One function. Three runtimes. Same hash → same variant → same path
|
|
9
|
+
* rewrite, every time.
|
|
10
|
+
*/
|
|
11
|
+
import { assignVariant, buildAssignmentLog, mintVisitorId } from "./assignment.js";
|
|
12
|
+
import { findExperimentForUrl } from "./manifest.js";
|
|
13
|
+
export function routeRequest(manifest, req) {
|
|
14
|
+
const visitorId = req.visitorIdCookie ?? mintVisitorId();
|
|
15
|
+
const experiment = findExperimentForUrl(manifest, req.pathname);
|
|
16
|
+
if (!experiment) {
|
|
17
|
+
return {
|
|
18
|
+
rewritePathname: req.pathname,
|
|
19
|
+
setVisitorId: visitorId,
|
|
20
|
+
logEntry: null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const variant = assignVariant({
|
|
24
|
+
visitorId,
|
|
25
|
+
manifestVersion: manifest.manifestVersion,
|
|
26
|
+
experiment,
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
rewritePathname: variant.path,
|
|
30
|
+
setVisitorId: visitorId,
|
|
31
|
+
logEntry: buildAssignmentLog({
|
|
32
|
+
experimentId: experiment.experimentId,
|
|
33
|
+
variant,
|
|
34
|
+
visitorId,
|
|
35
|
+
manifestVersion: manifest.manifestVersion,
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAE,oBAAoB,EAAwB,MAAM,eAAe,CAAC;AAsB3E,MAAM,UAAU,YAAY,CAC1B,QAAyB,EACzB,GAAuB;IAEvB,MAAM,SAAS,GAAG,GAAG,CAAC,eAAe,IAAI,aAAa,EAAE,CAAC;IACzD,MAAM,UAAU,GAAG,oBAAoB,CAAC,QAAQ,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IAChE,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO;YACL,eAAe,EAAE,GAAG,CAAC,QAAQ;YAC7B,YAAY,EAAE,SAAS;YACvB,QAAQ,EAAE,IAAI;SACf,CAAC;IACJ,CAAC;IACD,MAAM,OAAO,GAAG,aAAa,CAAC;QAC5B,SAAS;QACT,eAAe,EAAE,QAAQ,CAAC,eAAe;QACzC,UAAU;KACX,CAAC,CAAC;IACH,OAAO;QACL,eAAe,EAAE,OAAO,CAAC,IAAI;QAC7B,YAAY,EAAE,SAAS;QACvB,QAAQ,EAAE,kBAAkB,CAAC;YAC3B,YAAY,EAAE,UAAU,CAAC,YAAY;YACrC,OAAO;YACP,SAAS;YACT,eAAe,EAAE,QAAQ,CAAC,eAAe;SAC1C,CAAC;KACH,CAAC;AACJ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@caelo-cms/edge-router",
|
|
3
|
+
"version": "0.10.23",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MPL-2.0",
|
|
6
|
+
"description": "Locale-aware edge request router for Caelo CMS deployments — shared by the provisioning stacks' edge handlers (CDN URL-strategy routing) and the static generator.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/caelo-cms/caelo-cms.git",
|
|
10
|
+
"directory": "packages/edge-router"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/caelo-cms/caelo-cms#readme",
|
|
13
|
+
"bugs": "https://github.com/caelo-cms/caelo-cms/issues",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"bun": "./src/index.ts",
|
|
21
|
+
"development": "./src/index.ts",
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/",
|
|
28
|
+
"src/",
|
|
29
|
+
"README.md",
|
|
30
|
+
"package.json"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"typecheck": "tsc -b",
|
|
34
|
+
"build": "tsc -b --force",
|
|
35
|
+
"prepublishOnly": "tsc -b --force"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {}
|
|
38
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P15 — stable-hash A/B variant assignment.
|
|
5
|
+
*
|
|
6
|
+
* MUST produce byte-identical results across all four runtimes:
|
|
7
|
+
* - self-hosted (P13 Caddy gateway)
|
|
8
|
+
* - GCP edge router (Cloud Run header rule)
|
|
9
|
+
* - AWS edge router (Lambda@Edge)
|
|
10
|
+
* - Azure edge router (Front Door rules engine)
|
|
11
|
+
*
|
|
12
|
+
* Drift between runtimes means a visitor sees different variants
|
|
13
|
+
* depending on which provider serves them — silent A/B contamination.
|
|
14
|
+
* The byte-identity test in `index.test.ts` asserts a fixed corpus of
|
|
15
|
+
* (visitorId, manifestVersion, experimentId) tuples maps to the same
|
|
16
|
+
* variant label on every implementation.
|
|
17
|
+
*
|
|
18
|
+
* Algorithm: FNV-1a 32-bit hash of (visitorId + ":" + experimentId +
|
|
19
|
+
* ":" + manifestVersion). The 32-bit space mod 100 gives a bucket in
|
|
20
|
+
* [0, 99]; cumulative variant weights determine the variant. Pure +
|
|
21
|
+
* deterministic; no randomness, no time, no provider-specific code.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ManifestExperiment, ManifestVariant } from "./manifest.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* FNV-1a 32-bit. Standard offset basis 0x811c9dc5, prime 0x01000193.
|
|
28
|
+
* Implemented in plain TypeScript so every runtime (V8 / SpiderMonkey
|
|
29
|
+
* / Node L@E sandbox / Bun) produces byte-identical output.
|
|
30
|
+
*/
|
|
31
|
+
export function fnv1a32(input: string): number {
|
|
32
|
+
let hash = 0x811c9dc5;
|
|
33
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
34
|
+
hash ^= input.charCodeAt(i);
|
|
35
|
+
// Multiply by FNV prime, keeping in 32-bit unsigned range. Math.imul
|
|
36
|
+
// is the cross-runtime way to do 32-bit-truncated integer multiply.
|
|
37
|
+
hash = Math.imul(hash, 0x01000193);
|
|
38
|
+
}
|
|
39
|
+
// Coerce back to unsigned 32-bit.
|
|
40
|
+
return hash >>> 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compute the cumulative bucket boundaries for an experiment's variants.
|
|
45
|
+
* Stable: same input order → same boundaries → same assignment.
|
|
46
|
+
*/
|
|
47
|
+
function bucketBoundaries(variants: ReadonlyArray<ManifestVariant>): number[] {
|
|
48
|
+
let cum = 0;
|
|
49
|
+
return variants.map((v) => {
|
|
50
|
+
cum += v.weight;
|
|
51
|
+
return cum;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Assign a variant to a visitor for one experiment. Inputs:
|
|
57
|
+
* - visitorId: stable opaque string from the `caelo_visitor_id` cookie.
|
|
58
|
+
* - manifestVersion: bumped per deploy so a re-bucketing rolls out
|
|
59
|
+
* atomically (operators occasionally need to re-randomize after a
|
|
60
|
+
* biased early sample).
|
|
61
|
+
* - experiment: the matched ManifestExperiment.
|
|
62
|
+
*
|
|
63
|
+
* Returns the chosen variant + its rewrite path. Pure; no I/O.
|
|
64
|
+
*/
|
|
65
|
+
export function assignVariant(opts: {
|
|
66
|
+
readonly visitorId: string;
|
|
67
|
+
readonly manifestVersion: string;
|
|
68
|
+
readonly experiment: ManifestExperiment;
|
|
69
|
+
}): ManifestVariant {
|
|
70
|
+
const key = `${opts.visitorId}:${opts.experiment.experimentId}:${opts.manifestVersion}`;
|
|
71
|
+
const hash = fnv1a32(key);
|
|
72
|
+
const bucket = hash % 100;
|
|
73
|
+
const boundaries = bucketBoundaries(opts.experiment.variants);
|
|
74
|
+
for (let i = 0; i < boundaries.length; i += 1) {
|
|
75
|
+
if (bucket < (boundaries[i] ?? 0)) {
|
|
76
|
+
const v = opts.experiment.variants[i];
|
|
77
|
+
if (v) return v;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Fallthrough (weights summed to 100 but float drift) — return last.
|
|
81
|
+
// Schema-level validation guarantees this doesn't happen in practice.
|
|
82
|
+
return opts.experiment.variants[opts.experiment.variants.length - 1] as ManifestVariant;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format the assignment-log entry every runtime emits. Same JSON shape
|
|
87
|
+
* across providers so the P12A analytics plugin's per-provider log
|
|
88
|
+
* adapters can normalise into one `ab_assignment_aggregates` query.
|
|
89
|
+
*/
|
|
90
|
+
export interface AssignmentLogEntry {
|
|
91
|
+
readonly kind: "ab_assignment";
|
|
92
|
+
readonly experimentId: string;
|
|
93
|
+
readonly variantLabel: string;
|
|
94
|
+
readonly visitorId: string;
|
|
95
|
+
readonly manifestVersion: string;
|
|
96
|
+
readonly tsMs: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildAssignmentLog(opts: {
|
|
100
|
+
readonly experimentId: string;
|
|
101
|
+
readonly variant: ManifestVariant;
|
|
102
|
+
readonly visitorId: string;
|
|
103
|
+
readonly manifestVersion: string;
|
|
104
|
+
readonly nowMs?: number;
|
|
105
|
+
}): AssignmentLogEntry {
|
|
106
|
+
return {
|
|
107
|
+
kind: "ab_assignment",
|
|
108
|
+
experimentId: opts.experimentId,
|
|
109
|
+
variantLabel: opts.variant.label,
|
|
110
|
+
visitorId: opts.visitorId,
|
|
111
|
+
manifestVersion: opts.manifestVersion,
|
|
112
|
+
tsMs: opts.nowMs ?? Date.now(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mint a stable opaque visitor id when the request didn't carry the
|
|
118
|
+
* `caelo_visitor_id` cookie. Format: 16 hex bytes (128 bits — collision
|
|
119
|
+
* resistant for any plausible visitor population). Edge routers set
|
|
120
|
+
* this on the response cookie so the next request inherits the same id.
|
|
121
|
+
*/
|
|
122
|
+
export function mintVisitorId(): string {
|
|
123
|
+
const buf = new Uint8Array(16);
|
|
124
|
+
// crypto.getRandomValues is available in V8 / Node / L@E / Bun /
|
|
125
|
+
// every modern runtime; no Node Buffer fallback needed.
|
|
126
|
+
crypto.getRandomValues(buf);
|
|
127
|
+
return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
128
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Edge-router byte-identity tests. These assert a fixed corpus of
|
|
5
|
+
* (visitorId, manifestVersion, experimentId) → (bucket, variantLabel)
|
|
6
|
+
* tuples. The numbers in this file are the ground truth; if you change
|
|
7
|
+
* the assignment algorithm, you change the contract for ALL FOUR
|
|
8
|
+
* runtime implementations (self-hosted P13 + GCP + AWS + Azure) and
|
|
9
|
+
* silently re-bucket every existing visitor's variant assignment. Don't
|
|
10
|
+
* change without bumping the manifestVersion in the same commit.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "bun:test";
|
|
14
|
+
import { assignVariant, buildAssignmentLog, fnv1a32, mintVisitorId } from "./assignment.js";
|
|
15
|
+
import { findExperimentForUrl, validateManifest } from "./manifest.js";
|
|
16
|
+
import { routeRequest } from "./router.js";
|
|
17
|
+
|
|
18
|
+
describe("fnv1a32", () => {
|
|
19
|
+
it("matches the published FNV-1a-32 reference for a fixed corpus", () => {
|
|
20
|
+
// Reference values produced via the canonical algorithm —
|
|
21
|
+
// independent reimplementations against this list will catch
|
|
22
|
+
// off-by-one + signed-vs-unsigned bugs.
|
|
23
|
+
expect(fnv1a32("")).toBe(0x811c9dc5);
|
|
24
|
+
expect(fnv1a32("a")).toBe(0xe40c292c);
|
|
25
|
+
expect(fnv1a32("foobar")).toBe(0xbf9cf968);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is stable for identical input", () => {
|
|
29
|
+
const a = fnv1a32("visitor-12345:exp-abc:v3");
|
|
30
|
+
const b = fnv1a32("visitor-12345:exp-abc:v3");
|
|
31
|
+
expect(a).toBe(b);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("assignVariant", () => {
|
|
36
|
+
const experiment = {
|
|
37
|
+
pageSlug: "/home",
|
|
38
|
+
experimentId: "11111111-2222-3333-4444-555555555555",
|
|
39
|
+
variants: [
|
|
40
|
+
{ label: "A", weight: 50, path: "/home" },
|
|
41
|
+
{
|
|
42
|
+
label: "B",
|
|
43
|
+
weight: 50,
|
|
44
|
+
path: "/_caelo-variant/11111111-2222-3333-4444-555555555555/B/home",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
it("returns the same variant for the same (visitorId, manifestVersion, experimentId)", () => {
|
|
50
|
+
const a = assignVariant({
|
|
51
|
+
visitorId: "v-stable-1",
|
|
52
|
+
manifestVersion: "7",
|
|
53
|
+
experiment,
|
|
54
|
+
});
|
|
55
|
+
const b = assignVariant({
|
|
56
|
+
visitorId: "v-stable-1",
|
|
57
|
+
manifestVersion: "7",
|
|
58
|
+
experiment,
|
|
59
|
+
});
|
|
60
|
+
expect(a.label).toBe(b.label);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("re-buckets when manifestVersion bumps (operator-triggered re-randomization)", () => {
|
|
64
|
+
// The whole point of bumping manifestVersion is to roll the dice
|
|
65
|
+
// afresh; for at least ONE visitor in 100, the assignment should
|
|
66
|
+
// differ between v=1 and v=2. Assert across a small population.
|
|
67
|
+
let differs = 0;
|
|
68
|
+
for (let i = 0; i < 100; i += 1) {
|
|
69
|
+
const v1 = assignVariant({
|
|
70
|
+
visitorId: `vis-${i}`,
|
|
71
|
+
manifestVersion: "1",
|
|
72
|
+
experiment,
|
|
73
|
+
});
|
|
74
|
+
const v2 = assignVariant({
|
|
75
|
+
visitorId: `vis-${i}`,
|
|
76
|
+
manifestVersion: "2",
|
|
77
|
+
experiment,
|
|
78
|
+
});
|
|
79
|
+
if (v1.label !== v2.label) differs += 1;
|
|
80
|
+
}
|
|
81
|
+
expect(differs).toBeGreaterThan(20);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("respects bucket weights — 70/30 split lands within ±10pp over 1000 visitors", () => {
|
|
85
|
+
const skewed = {
|
|
86
|
+
pageSlug: "/pricing",
|
|
87
|
+
experimentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
|
88
|
+
variants: [
|
|
89
|
+
{ label: "A", weight: 70, path: "/pricing" },
|
|
90
|
+
{
|
|
91
|
+
label: "B",
|
|
92
|
+
weight: 30,
|
|
93
|
+
path: "/_caelo-variant/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/B/pricing",
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
} as const;
|
|
97
|
+
let aCount = 0;
|
|
98
|
+
for (let i = 0; i < 1000; i += 1) {
|
|
99
|
+
const v = assignVariant({
|
|
100
|
+
visitorId: `vis-${i}`,
|
|
101
|
+
manifestVersion: "1",
|
|
102
|
+
experiment: skewed,
|
|
103
|
+
});
|
|
104
|
+
if (v.label === "A") aCount += 1;
|
|
105
|
+
}
|
|
106
|
+
expect(aCount).toBeGreaterThan(600);
|
|
107
|
+
expect(aCount).toBeLessThan(800);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("byte-identity corpus — these exact assignments must hold across all runtimes", () => {
|
|
111
|
+
// If this test starts failing because someone "improved" the hash,
|
|
112
|
+
// STOP. Every runtime's edge router (self-hosted Caddy gateway,
|
|
113
|
+
// GCP Cloud Run, AWS L@E, Azure Front Door) consumes this same
|
|
114
|
+
// contract. Change here = silent re-bucketing of every live
|
|
115
|
+
// experiment.
|
|
116
|
+
const corpus = [
|
|
117
|
+
{ visitorId: "v-aaaaaaaa", manifestVersion: "1", expectedLabel: "A" },
|
|
118
|
+
{ visitorId: "v-bbbbbbbb", manifestVersion: "1", expectedLabel: "A" },
|
|
119
|
+
{ visitorId: "v-cccccccc", manifestVersion: "1", expectedLabel: "B" },
|
|
120
|
+
{ visitorId: "v-dddddddd", manifestVersion: "1", expectedLabel: "A" },
|
|
121
|
+
{ visitorId: "v-eeeeeeee", manifestVersion: "1", expectedLabel: "B" },
|
|
122
|
+
];
|
|
123
|
+
for (const tc of corpus) {
|
|
124
|
+
const v = assignVariant({
|
|
125
|
+
visitorId: tc.visitorId,
|
|
126
|
+
manifestVersion: tc.manifestVersion,
|
|
127
|
+
experiment,
|
|
128
|
+
});
|
|
129
|
+
expect(v.label).toBe(tc.expectedLabel);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("validateManifest", () => {
|
|
135
|
+
const baseExperiment = {
|
|
136
|
+
pageSlug: "/p",
|
|
137
|
+
experimentId: "22222222-3333-4444-5555-666666666666",
|
|
138
|
+
variants: [
|
|
139
|
+
{ label: "A", weight: 50, path: "/p" },
|
|
140
|
+
{ label: "B", weight: 50, path: "/_caelo-variant/22222222-3333-4444-5555-666666666666/B/p" },
|
|
141
|
+
],
|
|
142
|
+
} as const;
|
|
143
|
+
|
|
144
|
+
it("accepts a clean manifest", () => {
|
|
145
|
+
expect(validateManifest({ manifestVersion: "1", experiments: [baseExperiment] })).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("rejects non-UUID experimentId", () => {
|
|
149
|
+
const bad = { ...baseExperiment, experimentId: "not-a-uuid" };
|
|
150
|
+
expect(validateManifest({ manifestVersion: "1", experiments: [bad] })).toContain("not a UUID");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("rejects weights that don't sum to 100", () => {
|
|
154
|
+
const bad = {
|
|
155
|
+
...baseExperiment,
|
|
156
|
+
variants: [
|
|
157
|
+
{ label: "A", weight: 60, path: "/p" },
|
|
158
|
+
{ label: "B", weight: 30, path: "/p-b" },
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
expect(validateManifest({ manifestVersion: "1", experiments: [bad] })).toContain("sum to 90");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("routeRequest", () => {
|
|
166
|
+
const manifest = {
|
|
167
|
+
manifestVersion: "1",
|
|
168
|
+
experiments: [
|
|
169
|
+
{
|
|
170
|
+
pageSlug: "/home",
|
|
171
|
+
experimentId: "33333333-4444-5555-6666-777777777777",
|
|
172
|
+
variants: [
|
|
173
|
+
{ label: "A", weight: 50, path: "/home" },
|
|
174
|
+
{
|
|
175
|
+
label: "B",
|
|
176
|
+
weight: 50,
|
|
177
|
+
path: "/_caelo-variant/33333333-4444-5555-6666-777777777777/B/home",
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
it("passes-through requests that don't match any experiment", () => {
|
|
185
|
+
const out = routeRequest(manifest, { pathname: "/about", visitorIdCookie: "v-1" });
|
|
186
|
+
expect(out.rewritePathname).toBe("/about");
|
|
187
|
+
expect(out.setVisitorId).toBe("v-1");
|
|
188
|
+
expect(out.logEntry).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("rewrites + emits log when the request matches", () => {
|
|
192
|
+
const out = routeRequest(manifest, { pathname: "/home", visitorIdCookie: "v-aaaaaaaa" });
|
|
193
|
+
expect(["/home", "/_caelo-variant/33333333-4444-5555-6666-777777777777/B/home"]).toContain(
|
|
194
|
+
out.rewritePathname,
|
|
195
|
+
);
|
|
196
|
+
expect(out.logEntry).not.toBeNull();
|
|
197
|
+
expect(out.logEntry?.kind).toBe("ab_assignment");
|
|
198
|
+
expect(out.logEntry?.experimentId).toBe("33333333-4444-5555-6666-777777777777");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("mints a visitor id when the cookie is absent", () => {
|
|
202
|
+
const out = routeRequest(manifest, { pathname: "/home", visitorIdCookie: null });
|
|
203
|
+
expect(out.setVisitorId).toMatch(/^[0-9a-f]{32}$/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("findExperimentForUrl", () => {
|
|
208
|
+
it("finds by exact pageSlug match", () => {
|
|
209
|
+
const m = {
|
|
210
|
+
manifestVersion: "1",
|
|
211
|
+
experiments: [
|
|
212
|
+
{
|
|
213
|
+
pageSlug: "/home",
|
|
214
|
+
experimentId: "44444444-5555-6666-7777-888888888888",
|
|
215
|
+
variants: [{ label: "A", weight: 100, path: "/home" }],
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
expect(findExperimentForUrl(m, "/home")?.experimentId).toBe(
|
|
220
|
+
"44444444-5555-6666-7777-888888888888",
|
|
221
|
+
);
|
|
222
|
+
expect(findExperimentForUrl(m, "/about")).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("buildAssignmentLog", () => {
|
|
227
|
+
it("produces the canonical log shape", () => {
|
|
228
|
+
const entry = buildAssignmentLog({
|
|
229
|
+
experimentId: "55555555-6666-7777-8888-999999999999",
|
|
230
|
+
variant: { label: "B", weight: 50, path: "/x" },
|
|
231
|
+
visitorId: "v-1",
|
|
232
|
+
manifestVersion: "3",
|
|
233
|
+
nowMs: 1700000000000,
|
|
234
|
+
});
|
|
235
|
+
expect(entry).toEqual({
|
|
236
|
+
kind: "ab_assignment",
|
|
237
|
+
experimentId: "55555555-6666-7777-8888-999999999999",
|
|
238
|
+
variantLabel: "B",
|
|
239
|
+
visitorId: "v-1",
|
|
240
|
+
manifestVersion: "3",
|
|
241
|
+
tsMs: 1700000000000,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("mintVisitorId", () => {
|
|
247
|
+
it("returns 32 hex chars", () => {
|
|
248
|
+
expect(mintVisitorId()).toMatch(/^[0-9a-f]{32}$/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("each call is unique", () => {
|
|
252
|
+
expect(mintVisitorId()).not.toBe(mintVisitorId());
|
|
253
|
+
});
|
|
254
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @caelo-cms/edge-router — P15.
|
|
5
|
+
*
|
|
6
|
+
* Provider-agnostic edge-routing logic for A/B experiments. The same
|
|
7
|
+
* hash → same variant → same rewrite path holds across self-hosted
|
|
8
|
+
* (P13 Caddy gateway) + GCP + AWS + Azure. Provider-specific shims
|
|
9
|
+
* land in `packages/provisioning/stacks/<provider>/edge-handler.ts`
|
|
10
|
+
* and import this package's `routeRequest` for the actual decision.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
type AssignmentLogEntry,
|
|
15
|
+
assignVariant,
|
|
16
|
+
buildAssignmentLog,
|
|
17
|
+
fnv1a32,
|
|
18
|
+
mintVisitorId,
|
|
19
|
+
} from "./assignment.js";
|
|
20
|
+
export {
|
|
21
|
+
EMPTY_MANIFEST,
|
|
22
|
+
findExperimentForUrl,
|
|
23
|
+
type ManifestExperiment,
|
|
24
|
+
type ManifestVariant,
|
|
25
|
+
type RoutingManifest,
|
|
26
|
+
validateManifest,
|
|
27
|
+
} from "./manifest.js";
|
|
28
|
+
export { type EdgeRequestSummary, type EdgeRouteDecision, routeRequest } from "./router.js";
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P15 — A/B routing manifest. Static-generator emits this file at deploy
|
|
5
|
+
* time (P6 + P13 contract). Per-provider edge routers (Cloud CDN /
|
|
6
|
+
* CloudFront L@E / Front Door rules / self-hosted Caddy gateway) all
|
|
7
|
+
* read this single shape so behaviour is identical across runtimes.
|
|
8
|
+
*
|
|
9
|
+
* Variant URL paths follow the convention:
|
|
10
|
+
* control: /<page-slug>
|
|
11
|
+
* variant: /_caelo-variant/<experimentId>/<variantLabel>/<page-slug>
|
|
12
|
+
*
|
|
13
|
+
* (Picked path-based over query-string-based so CDN cache keys aren't
|
|
14
|
+
* affected by query-string normalisation. See plans/phases/phase_15.)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ManifestVariant {
|
|
18
|
+
/** Owner-readable label (e.g. "A", "B", "control"). Stable across deploys. */
|
|
19
|
+
readonly label: string;
|
|
20
|
+
/** Bucket weight in [0, 100]. All variants for one experiment must sum to 100. */
|
|
21
|
+
readonly weight: number;
|
|
22
|
+
/**
|
|
23
|
+
* Path the edge router rewrites to when this variant wins. Includes
|
|
24
|
+
* the /_caelo-variant prefix for non-control variants; equal to
|
|
25
|
+
* `pageSlug` for control.
|
|
26
|
+
*/
|
|
27
|
+
readonly path: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ManifestExperiment {
|
|
31
|
+
/** Page slug the experiment runs on (e.g. "/home", "/pricing"). */
|
|
32
|
+
readonly pageSlug: string;
|
|
33
|
+
/** UUID — also the cookie partition key so two experiments don't collide. */
|
|
34
|
+
readonly experimentId: string;
|
|
35
|
+
readonly variants: ReadonlyArray<ManifestVariant>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RoutingManifest {
|
|
39
|
+
/** Bumped by the static generator on every deploy that changes routing. */
|
|
40
|
+
readonly manifestVersion: string;
|
|
41
|
+
/** All active experiments (only `status='active'` rows from cms_admin.experiments). */
|
|
42
|
+
readonly experiments: ReadonlyArray<ManifestExperiment>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const EMPTY_MANIFEST: RoutingManifest = {
|
|
46
|
+
manifestVersion: "0",
|
|
47
|
+
experiments: [],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Look up the active experiment for a given request URL. Returns null
|
|
52
|
+
* when no experiment matches the URL's page slug — caller should pass
|
|
53
|
+
* the request through unchanged.
|
|
54
|
+
*/
|
|
55
|
+
export function findExperimentForUrl(
|
|
56
|
+
manifest: RoutingManifest,
|
|
57
|
+
pathname: string,
|
|
58
|
+
): ManifestExperiment | null {
|
|
59
|
+
for (const ex of manifest.experiments) {
|
|
60
|
+
if (ex.pageSlug === pathname) return ex;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validates a manifest's invariants: every experiment's variant weights
|
|
67
|
+
* sum to 100, every variant has a non-empty label, the experimentId is
|
|
68
|
+
* a UUID, weights are non-negative. Returns null on success or a
|
|
69
|
+
* structured error string the static generator surfaces in audit. The
|
|
70
|
+
* runtime callers (edge routers) do NOT re-validate per request — they
|
|
71
|
+
* trust the deploy-time validation. This function is primarily for
|
|
72
|
+
* tests + the deploy step.
|
|
73
|
+
*/
|
|
74
|
+
export function validateManifest(manifest: RoutingManifest): string | null {
|
|
75
|
+
for (const ex of manifest.experiments) {
|
|
76
|
+
if (!ex.experimentId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
|
77
|
+
return `experiment ${ex.experimentId} is not a UUID`;
|
|
78
|
+
}
|
|
79
|
+
if (ex.variants.length === 0) {
|
|
80
|
+
return `experiment ${ex.experimentId} has no variants`;
|
|
81
|
+
}
|
|
82
|
+
let total = 0;
|
|
83
|
+
for (const v of ex.variants) {
|
|
84
|
+
if (!v.label) return `experiment ${ex.experimentId}: variant has empty label`;
|
|
85
|
+
if (v.weight < 0)
|
|
86
|
+
return `experiment ${ex.experimentId}: variant ${v.label} has negative weight`;
|
|
87
|
+
total += v.weight;
|
|
88
|
+
}
|
|
89
|
+
if (total !== 100) {
|
|
90
|
+
return `experiment ${ex.experimentId}: variant weights sum to ${total}, expected 100`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P15 — generic edge-router request handler. The provider-specific
|
|
5
|
+
* shims (gcp.ts, aws.ts, azure.ts) wrap this in their respective
|
|
6
|
+
* runtime contracts (Cloud Run handler, Lambda@Edge handler, Front
|
|
7
|
+
* Door rules engine), but the routing decision itself is identical.
|
|
8
|
+
*
|
|
9
|
+
* One function. Three runtimes. Same hash → same variant → same path
|
|
10
|
+
* rewrite, every time.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { assignVariant, buildAssignmentLog, mintVisitorId } from "./assignment.js";
|
|
14
|
+
import { findExperimentForUrl, type RoutingManifest } from "./manifest.js";
|
|
15
|
+
|
|
16
|
+
export interface EdgeRequestSummary {
|
|
17
|
+
/** URL pathname only (e.g. "/about"). Query string ignored for routing. */
|
|
18
|
+
readonly pathname: string;
|
|
19
|
+
/** The current request's `caelo_visitor_id` cookie value, if any. */
|
|
20
|
+
readonly visitorIdCookie: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EdgeRouteDecision {
|
|
24
|
+
/** Final pathname the runtime should serve (control or variant). */
|
|
25
|
+
readonly rewritePathname: string;
|
|
26
|
+
/** Visitor id the runtime should set as `caelo_visitor_id` (mint when absent). */
|
|
27
|
+
readonly setVisitorId: string;
|
|
28
|
+
/**
|
|
29
|
+
* Assignment-log payload to emit through the runtime's logger.
|
|
30
|
+
* Null when the request didn't match any experiment — runtime should
|
|
31
|
+
* NOT emit anything in that case (avoids log spam on every static asset).
|
|
32
|
+
*/
|
|
33
|
+
readonly logEntry: ReturnType<typeof buildAssignmentLog> | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function routeRequest(
|
|
37
|
+
manifest: RoutingManifest,
|
|
38
|
+
req: EdgeRequestSummary,
|
|
39
|
+
): EdgeRouteDecision {
|
|
40
|
+
const visitorId = req.visitorIdCookie ?? mintVisitorId();
|
|
41
|
+
const experiment = findExperimentForUrl(manifest, req.pathname);
|
|
42
|
+
if (!experiment) {
|
|
43
|
+
return {
|
|
44
|
+
rewritePathname: req.pathname,
|
|
45
|
+
setVisitorId: visitorId,
|
|
46
|
+
logEntry: null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const variant = assignVariant({
|
|
50
|
+
visitorId,
|
|
51
|
+
manifestVersion: manifest.manifestVersion,
|
|
52
|
+
experiment,
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
rewritePathname: variant.path,
|
|
56
|
+
setVisitorId: visitorId,
|
|
57
|
+
logEntry: buildAssignmentLog({
|
|
58
|
+
experimentId: experiment.experimentId,
|
|
59
|
+
variant,
|
|
60
|
+
visitorId,
|
|
61
|
+
manifestVersion: manifest.manifestVersion,
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|