@czap/edge 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/LICENSE +21 -0
- package/README.md +19 -0
- package/dist/client-hints.d.ts +145 -0
- package/dist/client-hints.d.ts.map +1 -0
- package/dist/client-hints.js +268 -0
- package/dist/client-hints.js.map +1 -0
- package/dist/edge-tier.d.ts +67 -0
- package/dist/edge-tier.d.ts.map +1 -0
- package/dist/edge-tier.js +60 -0
- package/dist/edge-tier.js.map +1 -0
- package/dist/host-adapter.d.ts +139 -0
- package/dist/host-adapter.d.ts.map +1 -0
- package/dist/host-adapter.js +89 -0
- package/dist/host-adapter.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/kv-cache.d.ts +105 -0
- package/dist/kv-cache.d.ts.map +1 -0
- package/dist/kv-cache.js +133 -0
- package/dist/kv-cache.js.map +1 -0
- package/dist/theme-compiler.d.ts +69 -0
- package/dist/theme-compiler.d.ts.map +1 -0
- package/dist/theme-compiler.js +94 -0
- package/dist/theme-compiler.js.map +1 -0
- package/package.json +55 -0
- package/src/client-hints.ts +338 -0
- package/src/edge-tier.ts +93 -0
- package/src/host-adapter.ts +217 -0
- package/src/index.ts +33 -0
- package/src/kv-cache.ts +189 -0
- package/src/theme-compiler.ts +151 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant theme compilation at the edge.
|
|
3
|
+
*
|
|
4
|
+
* Takes a flat map of design token definitions and produces CSS custom
|
|
5
|
+
* property declarations suitable for injection into the `<html>` element
|
|
6
|
+
* or a `<style>` block.
|
|
7
|
+
*
|
|
8
|
+
* This is a pure function with no side effects -- safe for edge runtime use.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
const SAFE_PREFIX_PATTERN = /^[a-z0-9-]+$/;
|
|
13
|
+
const UNSAFE_CSS_VALUE_PATTERN = /[;{}<>]/;
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Internal helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/**
|
|
18
|
+
* Convert a token name to a valid CSS custom property name.
|
|
19
|
+
* Replaces dots and spaces with hyphens, lowercases, strips invalid chars.
|
|
20
|
+
*/
|
|
21
|
+
function tokenToProperty(prefix, name) {
|
|
22
|
+
const sanitised = name
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[.\s]+/g, '-')
|
|
25
|
+
.replace(/[^a-z0-9-_]/g, '');
|
|
26
|
+
return `--${prefix}-${sanitised}`;
|
|
27
|
+
}
|
|
28
|
+
function normalizePrefix(prefix) {
|
|
29
|
+
const normalized = prefix.toLowerCase();
|
|
30
|
+
if (!SAFE_PREFIX_PATTERN.test(normalized)) {
|
|
31
|
+
throw new Error(`Invalid theme prefix "${prefix}". Prefixes must contain only lowercase letters, digits, and hyphens.`);
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Format a token value for CSS output.
|
|
37
|
+
* Numbers are emitted bare (no unit) so consumers can apply their own units.
|
|
38
|
+
*/
|
|
39
|
+
function formatValue(value) {
|
|
40
|
+
const formatted = typeof value === 'number' ? String(value) : value;
|
|
41
|
+
if (UNSAFE_CSS_VALUE_PATTERN.test(formatted)) {
|
|
42
|
+
throw new Error(`Unsafe theme token value "${formatted}" cannot be serialized into CSS safely.`);
|
|
43
|
+
}
|
|
44
|
+
return formatted;
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Public API
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
/**
|
|
50
|
+
* Compile a set of design tokens into CSS custom property declarations.
|
|
51
|
+
*
|
|
52
|
+
* @param config - Token definitions and optional prefix.
|
|
53
|
+
* @returns CSS string and inline style string.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* const result = compileTheme({
|
|
58
|
+
* tokens: { 'color.primary': '#3b82f6', 'spacing.base': 16 },
|
|
59
|
+
* prefix: 'czap',
|
|
60
|
+
* });
|
|
61
|
+
* // result.css =>
|
|
62
|
+
* // :root {
|
|
63
|
+
* // --czap-color-primary: #3b82f6;
|
|
64
|
+
* // --czap-spacing-base: 16;
|
|
65
|
+
* // }
|
|
66
|
+
* // result.inlineStyle =>
|
|
67
|
+
* // --czap-color-primary:#3b82f6;--czap-spacing-base:16
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function compileTheme(config) {
|
|
71
|
+
const prefix = normalizePrefix(config.prefix ?? 'czap');
|
|
72
|
+
const entries = Object.entries(config.tokens);
|
|
73
|
+
if (entries.length === 0) {
|
|
74
|
+
return { declarations: [], css: ':root {}', inlineStyle: '' };
|
|
75
|
+
}
|
|
76
|
+
const declarations = [];
|
|
77
|
+
const cssDeclarations = [];
|
|
78
|
+
const inlineParts = [];
|
|
79
|
+
for (const [name, value] of entries) {
|
|
80
|
+
const prop = tokenToProperty(prefix, name);
|
|
81
|
+
const formatted = formatValue(value);
|
|
82
|
+
declarations.push({ property: prop, value: formatted });
|
|
83
|
+
cssDeclarations.push(` ${prop}: ${formatted};`);
|
|
84
|
+
inlineParts.push(`${prop}:${formatted}`);
|
|
85
|
+
}
|
|
86
|
+
const css = `:root {\n${cssDeclarations.join('\n')}\n}`;
|
|
87
|
+
const inlineStyle = inlineParts.join(';');
|
|
88
|
+
return {
|
|
89
|
+
declarations: Object.freeze(declarations),
|
|
90
|
+
css,
|
|
91
|
+
inlineStyle,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=theme-compiler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"theme-compiler.js","sourceRoot":"","sources":["../src/theme-compiler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AA4CH,MAAM,mBAAmB,GAAG,cAAc,CAAC;AAC3C,MAAM,wBAAwB,GAAG,SAAS,CAAC;AAE3C,8EAA8E;AAC9E,mBAAmB;AACnB,8EAA8E;AAE9E;;;GAGG;AACH,SAAS,eAAe,CAAC,MAAc,EAAE,IAAY;IACnD,MAAM,SAAS,GAAG,IAAI;SACnB,WAAW,EAAE;SACb,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC;SACvB,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC;IAC/B,OAAO,KAAK,MAAM,IAAI,SAAS,EAAE,CAAC;AACpC,CAAC;AAED,SAAS,eAAe,CAAC,MAAc;IACrC,MAAM,UAAU,GAAG,MAAM,CAAC,WAAW,EAAE,CAAC;IACxC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CACb,yBAAyB,MAAM,uEAAuE,CACvG,CAAC;IACJ,CAAC;IAED,OAAO,UAAU,CAAC;AACpB,CAAC;AAED;;;GAGG;AACH,SAAS,WAAW,CAAC,KAAsB;IACzC,MAAM,SAAS,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IACpE,IAAI,wBAAwB,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;QAC7C,MAAM,IAAI,KAAK,CAAC,6BAA6B,SAAS,yCAAyC,CAAC,CAAC;IACnG,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,8EAA8E;AAC9E,aAAa;AACb,8EAA8E;AAE9E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,YAAY,CAAC,MAA0B;IACrD,MAAM,MAAM,GAAG,eAAe,CAAC,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC;IACxD,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAE9C,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,EAAE,YAAY,EAAE,EAAE,EAAE,GAAG,EAAE,UAAU,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC;IAChE,CAAC;IAED,MAAM,YAAY,GAAuB,EAAE,CAAC;IAC5C,MAAM,eAAe,GAAa,EAAE,CAAC;IACrC,MAAM,WAAW,GAAa,EAAE,CAAC;IAEjC,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,OAAO,EAAE,CAAC;QACpC,MAAM,IAAI,GAAG,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC3C,MAAM,SAAS,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QACrC,YAAY,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QACxD,eAAe,CAAC,IAAI,CAAC,KAAK,IAAI,KAAK,SAAS,GAAG,CAAC,CAAC;QACjD,WAAW,CAAC,IAAI,CAAC,GAAG,IAAI,IAAI,SAAS,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,MAAM,GAAG,GAAG,YAAY,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;IACxD,MAAM,WAAW,GAAG,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAE1C,OAAO;QACL,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC;QACzC,GAAG;QACH,WAAW;KACZ,CAAC;AACJ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@czap/edge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CDN-edge client hints, tier detection, and KV cache",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eassa Ayoub <eassa@heyoub.dev>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/heyoub/LiteShip",
|
|
10
|
+
"directory": "packages/edge"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/heyoub/LiteShip/issues",
|
|
13
|
+
"homepage": "https://github.com/heyoub/LiteShip#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"czap",
|
|
16
|
+
"edge",
|
|
17
|
+
"cdn",
|
|
18
|
+
"client-hints",
|
|
19
|
+
"kv-cache",
|
|
20
|
+
"tier-detection",
|
|
21
|
+
"typescript"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"development": "./src/index.ts"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@czap/core": "0.1.0",
|
|
41
|
+
"@czap/detect": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"effect": ">=4.0.0-beta.0"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22.0.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Hints header parsing for edge-side device capability detection.
|
|
3
|
+
*
|
|
4
|
+
* Converts HTTP Client Hints headers into the same `ExtendedDeviceCapabilities`
|
|
5
|
+
* structure that `@czap/detect` uses, enabling reuse of the pure tier mapping
|
|
6
|
+
* functions at the edge without browser APIs.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtendedDeviceCapabilities, GPUTier } from '@czap/detect';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Plain-object header bag accepted by {@link ClientHints.parseClientHints}.
|
|
19
|
+
*
|
|
20
|
+
* All names are lowercased because Client Hints headers are always lowercase
|
|
21
|
+
* in spec. Values that are missing simply fall back to conservative
|
|
22
|
+
* defaults during parsing.
|
|
23
|
+
*/
|
|
24
|
+
export interface ClientHintsHeaders {
|
|
25
|
+
/** `Sec-CH-UA-Platform` (e.g. `"macOS"`, `"Windows"`). */
|
|
26
|
+
readonly 'sec-ch-ua-platform'?: string;
|
|
27
|
+
/** `Sec-CH-Device-Memory` in GiB (one of the standard buckets). */
|
|
28
|
+
readonly 'sec-ch-device-memory'?: string;
|
|
29
|
+
/** `Sec-CH-DPR` — devicePixelRatio as a decimal string. */
|
|
30
|
+
readonly 'sec-ch-dpr'?: string;
|
|
31
|
+
/** `Sec-CH-Viewport-Width` in CSS pixels. */
|
|
32
|
+
readonly 'sec-ch-viewport-width'?: string;
|
|
33
|
+
/** `Sec-CH-Viewport-Height` in CSS pixels. */
|
|
34
|
+
readonly 'sec-ch-viewport-height'?: string;
|
|
35
|
+
/** `Sec-CH-Prefers-Reduced-Motion` (`reduce` / `no-preference`). */
|
|
36
|
+
readonly 'sec-ch-prefers-reduced-motion'?: string;
|
|
37
|
+
/** `Sec-CH-Prefers-Color-Scheme` (`light` / `dark`). */
|
|
38
|
+
readonly 'sec-ch-prefers-color-scheme'?: string;
|
|
39
|
+
/** `Sec-CH-UA-Mobile` as a structured boolean (`?1` / `?0`). */
|
|
40
|
+
readonly 'sec-ch-ua-mobile'?: string;
|
|
41
|
+
/** `Sec-CH-UA` — full user-agent brand list. */
|
|
42
|
+
readonly 'sec-ch-ua'?: string;
|
|
43
|
+
/** `Save-Data` (`on`). */
|
|
44
|
+
readonly 'save-data'?: string;
|
|
45
|
+
/** `Downlink` estimate in Mb/s. */
|
|
46
|
+
readonly downlink?: string;
|
|
47
|
+
/** `ECT` effective connection type. */
|
|
48
|
+
readonly ect?: string;
|
|
49
|
+
/** `RTT` round-trip-time estimate in ms. */
|
|
50
|
+
readonly rtt?: string;
|
|
51
|
+
/** `User-Agent` fallback for GPU-tier heuristics. */
|
|
52
|
+
readonly 'user-agent'?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Internal helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Type guard for Web API Headers objects (fetch Headers).
|
|
61
|
+
* Checks for the `get` method that distinguishes Headers from plain objects.
|
|
62
|
+
*/
|
|
63
|
+
function isWebHeaders(value: ClientHintsHeaders | Headers): value is Headers {
|
|
64
|
+
return typeof (value as Record<string, unknown>).get === 'function';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Normalise a Headers-like input (Web API Headers or plain object) into
|
|
69
|
+
* a case-insensitive getter function.
|
|
70
|
+
*/
|
|
71
|
+
function headerGetter(headers: ClientHintsHeaders | Headers): (name: string) => string | undefined {
|
|
72
|
+
if (isWebHeaders(headers)) {
|
|
73
|
+
return (name: string) => headers.get(name) ?? undefined;
|
|
74
|
+
}
|
|
75
|
+
// Client Hints headers are always lowercase in spec, but normalise anyway
|
|
76
|
+
const lower: Record<string, string> = {};
|
|
77
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
78
|
+
if (v !== undefined) lower[k.toLowerCase()] = v;
|
|
79
|
+
}
|
|
80
|
+
return (name: string) => lower[name.toLowerCase()];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse a numeric header, returning undefined for missing / malformed values.
|
|
85
|
+
*/
|
|
86
|
+
function parseFloat_(get: (name: string) => string | undefined, name: string): number | undefined {
|
|
87
|
+
const raw = get(name);
|
|
88
|
+
if (raw === undefined || raw === '') return undefined;
|
|
89
|
+
const n = Number.parseFloat(raw);
|
|
90
|
+
return Number.isFinite(n) ? n : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clamp device memory to the set of valid values browsers actually report.
|
|
95
|
+
*/
|
|
96
|
+
function clampMemory(raw: number): number {
|
|
97
|
+
const buckets = [0.25, 0.5, 1, 2, 4, 8] as const;
|
|
98
|
+
let closest: number = buckets[0]!;
|
|
99
|
+
for (const b of buckets) {
|
|
100
|
+
if (Math.abs(b - raw) < Math.abs(closest - raw)) closest = b;
|
|
101
|
+
}
|
|
102
|
+
return closest;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Crude GPU tier heuristic from User-Agent string.
|
|
107
|
+
* Without WebGL renderer info we can only make rough guesses.
|
|
108
|
+
*/
|
|
109
|
+
function gpuTierFromUA(ua: string | undefined): GPUTier {
|
|
110
|
+
if (!ua) return 1;
|
|
111
|
+
const lower = ua.toLowerCase();
|
|
112
|
+
|
|
113
|
+
// Very low-end indicators
|
|
114
|
+
if (/kaios|nokia|feature/i.test(lower)) return 0;
|
|
115
|
+
|
|
116
|
+
// High-end mobile
|
|
117
|
+
if (/iphone\s*1[4-9]|iphone\s*[2-9]\d/i.test(lower)) return 2;
|
|
118
|
+
if (/sm-s9|sm-s2[4-9]|pixel\s*[8-9]/i.test(lower)) return 2;
|
|
119
|
+
|
|
120
|
+
// Desktop with common high-end hints
|
|
121
|
+
if (/windows nt.*win64|macintosh.*mac os x 1[4-9]/i.test(lower)) return 2;
|
|
122
|
+
|
|
123
|
+
// Default to low-mid -- conservative
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Map the ECT (effective connection type) string to a normalised form.
|
|
129
|
+
*/
|
|
130
|
+
function normaliseECT(ect: string | undefined): string {
|
|
131
|
+
if (!ect) return '4g';
|
|
132
|
+
const lower = ect.toLowerCase().trim();
|
|
133
|
+
if (['slow-2g', '2g', '3g', '4g'].includes(lower)) return lower;
|
|
134
|
+
return '4g';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Accept-CH / Critical-CH header values
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
const ALL_HINTS = [
|
|
142
|
+
'Sec-CH-Device-Memory',
|
|
143
|
+
'Sec-CH-DPR',
|
|
144
|
+
'Sec-CH-Viewport-Width',
|
|
145
|
+
'Sec-CH-Viewport-Height',
|
|
146
|
+
'Sec-CH-Prefers-Reduced-Motion',
|
|
147
|
+
'Sec-CH-Prefers-Color-Scheme',
|
|
148
|
+
'Sec-CH-UA-Mobile',
|
|
149
|
+
'Sec-CH-UA',
|
|
150
|
+
'Sec-CH-UA-Platform',
|
|
151
|
+
'Save-Data',
|
|
152
|
+
'Downlink',
|
|
153
|
+
'ECT',
|
|
154
|
+
'RTT',
|
|
155
|
+
] as const;
|
|
156
|
+
|
|
157
|
+
const CRITICAL_HINTS = [
|
|
158
|
+
'Sec-CH-Prefers-Reduced-Motion',
|
|
159
|
+
'Sec-CH-Prefers-Color-Scheme',
|
|
160
|
+
'Sec-CH-UA-Mobile',
|
|
161
|
+
'Sec-CH-Device-Memory',
|
|
162
|
+
] as const;
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Public API -- namespace object pattern
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse Client Hints headers into an {@link ExtendedDeviceCapabilities} structure.
|
|
170
|
+
*
|
|
171
|
+
* For properties that cannot be determined from headers (GPU tier, WebGPU
|
|
172
|
+
* support, CPU cores), conservative defaults are used.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* import { ClientHints } from '@czap/edge';
|
|
177
|
+
*
|
|
178
|
+
* const caps = ClientHints.parseClientHints({
|
|
179
|
+
* 'sec-ch-device-memory': '8',
|
|
180
|
+
* 'sec-ch-dpr': '2',
|
|
181
|
+
* 'sec-ch-viewport-width': '1440',
|
|
182
|
+
* 'sec-ch-prefers-color-scheme': 'dark',
|
|
183
|
+
* 'sec-ch-ua-mobile': '?0',
|
|
184
|
+
* });
|
|
185
|
+
* console.log(caps.memory); // 8
|
|
186
|
+
* console.log(caps.devicePixelRatio); // 2
|
|
187
|
+
* console.log(caps.prefersColorScheme); // 'dark'
|
|
188
|
+
* ```
|
|
189
|
+
*
|
|
190
|
+
* @param headers - Client Hints headers (plain object or Web API Headers)
|
|
191
|
+
* @returns An {@link ExtendedDeviceCapabilities} structure
|
|
192
|
+
*/
|
|
193
|
+
function parseClientHints(headers: ClientHintsHeaders | Headers): ExtendedDeviceCapabilities {
|
|
194
|
+
const get = headerGetter(headers);
|
|
195
|
+
|
|
196
|
+
// Memory
|
|
197
|
+
const rawMemory = parseFloat_(get, 'sec-ch-device-memory');
|
|
198
|
+
const memory = rawMemory !== undefined ? clampMemory(rawMemory) : 4;
|
|
199
|
+
|
|
200
|
+
// DPR
|
|
201
|
+
const dpr = parseFloat_(get, 'sec-ch-dpr') ?? 1;
|
|
202
|
+
|
|
203
|
+
// Viewport
|
|
204
|
+
const viewportWidth = parseFloat_(get, 'sec-ch-viewport-width') ?? 1920;
|
|
205
|
+
const viewportHeight = parseFloat_(get, 'sec-ch-viewport-height') ?? 1080;
|
|
206
|
+
|
|
207
|
+
// Preferences
|
|
208
|
+
const reducedMotionRaw = get('sec-ch-prefers-reduced-motion');
|
|
209
|
+
const prefersReducedMotion = reducedMotionRaw === 'reduce' || reducedMotionRaw === '"reduce"';
|
|
210
|
+
|
|
211
|
+
const colorSchemeRaw = get('sec-ch-prefers-color-scheme');
|
|
212
|
+
const prefersColorScheme: 'light' | 'dark' =
|
|
213
|
+
colorSchemeRaw === 'dark' || colorSchemeRaw === '"dark"' ? 'dark' : 'light';
|
|
214
|
+
|
|
215
|
+
// Touch (mobile hint)
|
|
216
|
+
const mobileRaw = get('sec-ch-ua-mobile');
|
|
217
|
+
const touchPrimary = mobileRaw === '?1' || mobileRaw === 'true';
|
|
218
|
+
|
|
219
|
+
// Save-Data
|
|
220
|
+
const saveDataRaw = get('save-data');
|
|
221
|
+
const saveData = saveDataRaw === 'on' || saveDataRaw === '1' || saveDataRaw === 'true';
|
|
222
|
+
|
|
223
|
+
// Network
|
|
224
|
+
const downlink = parseFloat_(get, 'downlink') ?? 10;
|
|
225
|
+
const ect = normaliseECT(get('ect'));
|
|
226
|
+
|
|
227
|
+
// GPU tier heuristic from UA
|
|
228
|
+
const gpu = gpuTierFromUA(get('user-agent'));
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
// Base DeviceCapabilities
|
|
232
|
+
gpu,
|
|
233
|
+
cores: 4, // Conservative default -- not available via Client Hints
|
|
234
|
+
memory,
|
|
235
|
+
webgpu: false, // Cannot determine from headers
|
|
236
|
+
touchPrimary,
|
|
237
|
+
prefersReducedMotion,
|
|
238
|
+
prefersColorScheme,
|
|
239
|
+
viewportWidth,
|
|
240
|
+
viewportHeight,
|
|
241
|
+
devicePixelRatio: dpr,
|
|
242
|
+
connection: {
|
|
243
|
+
effectiveType: ect,
|
|
244
|
+
downlink,
|
|
245
|
+
saveData,
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
// Extended properties -- conservative defaults for edge
|
|
249
|
+
prefersContrast: 'no-preference',
|
|
250
|
+
forcedColors: false,
|
|
251
|
+
prefersReducedTransparency: false,
|
|
252
|
+
dynamicRange: 'standard',
|
|
253
|
+
colorGamut: 'srgb',
|
|
254
|
+
updateRate: 'fast',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Generate the `Accept-CH` header value for requesting all useful Client Hints
|
|
260
|
+
* on subsequent requests.
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* ```ts
|
|
264
|
+
* import { ClientHints } from '@czap/edge';
|
|
265
|
+
*
|
|
266
|
+
* const response = new Response('OK', {
|
|
267
|
+
* headers: { 'Accept-CH': ClientHints.acceptCHHeader() },
|
|
268
|
+
* });
|
|
269
|
+
* ```
|
|
270
|
+
*
|
|
271
|
+
* @returns A comma-separated list of Client Hint header names
|
|
272
|
+
*/
|
|
273
|
+
function acceptCHHeader(): string {
|
|
274
|
+
return ALL_HINTS.join(', ');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Generate the `Critical-CH` header value for hints needed on the very first
|
|
279
|
+
* request (triggers a browser retry if missing).
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```ts
|
|
283
|
+
* import { ClientHints } from '@czap/edge';
|
|
284
|
+
*
|
|
285
|
+
* const response = new Response('OK', {
|
|
286
|
+
* headers: {
|
|
287
|
+
* 'Accept-CH': ClientHints.acceptCHHeader(),
|
|
288
|
+
* 'Critical-CH': ClientHints.criticalCHHeader(),
|
|
289
|
+
* },
|
|
290
|
+
* });
|
|
291
|
+
* ```
|
|
292
|
+
*
|
|
293
|
+
* @returns A comma-separated list of critical Client Hint header names
|
|
294
|
+
*/
|
|
295
|
+
function criticalCHHeader(): string {
|
|
296
|
+
return CRITICAL_HINTS.join(', ');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Namespace export
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Client Hints namespace.
|
|
305
|
+
*
|
|
306
|
+
* Parses HTTP Client Hints headers into the same
|
|
307
|
+
* {@link ExtendedDeviceCapabilities} structure used by `@czap/detect`,
|
|
308
|
+
* enabling server-side / edge-side tier mapping without browser APIs.
|
|
309
|
+
* Also generates the `Accept-CH` and `Critical-CH` response headers needed
|
|
310
|
+
* to request hints from the browser.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* import { ClientHints } from '@czap/edge';
|
|
315
|
+
*
|
|
316
|
+
* // In an edge handler:
|
|
317
|
+
* const caps = ClientHints.parseClientHints(request.headers);
|
|
318
|
+
* const response = new Response(body, {
|
|
319
|
+
* headers: {
|
|
320
|
+
* 'Accept-CH': ClientHints.acceptCHHeader(),
|
|
321
|
+
* 'Critical-CH': ClientHints.criticalCHHeader(),
|
|
322
|
+
* },
|
|
323
|
+
* });
|
|
324
|
+
* ```
|
|
325
|
+
*/
|
|
326
|
+
export const ClientHints = {
|
|
327
|
+
/** Parse Client Hints headers into {@link ExtendedDeviceCapabilities}. */
|
|
328
|
+
parseClientHints,
|
|
329
|
+
/** Produce the `Accept-CH` response header value listing all useful hints. */
|
|
330
|
+
acceptCHHeader,
|
|
331
|
+
/** Produce the `Critical-CH` response header value listing boot-required hints. */
|
|
332
|
+
criticalCHHeader,
|
|
333
|
+
} as const;
|
|
334
|
+
|
|
335
|
+
export declare namespace ClientHints {
|
|
336
|
+
/** Alias for {@link ClientHintsHeaders} — plain-object header bag shape. */
|
|
337
|
+
export type Headers = ClientHintsHeaders;
|
|
338
|
+
}
|
package/src/edge-tier.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge-side tier detection -- wraps the pure tier mapping functions from
|
|
3
|
+
* `@czap/detect` for use with HTTP Client Hints headers at the edge.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CapLevel } from '@czap/core';
|
|
9
|
+
import { tierFromCapabilities, motionTierFromCapabilities, designTierFromCapabilities } from '@czap/detect';
|
|
10
|
+
import type { DesignTier, MotionTier } from '@czap/detect';
|
|
11
|
+
import { ClientHints } from './client-hints.js';
|
|
12
|
+
import type { ClientHintsHeaders } from './client-hints.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Outcome of an edge-side tier detection sweep.
|
|
20
|
+
*
|
|
21
|
+
* All three fields use the same branded tier types as the client runtime,
|
|
22
|
+
* so downstream boundary evaluation and output gating reuse the exact
|
|
23
|
+
* code paths from `@czap/detect`.
|
|
24
|
+
*/
|
|
25
|
+
export interface EdgeTierResult {
|
|
26
|
+
/** Highest {@link CapLevel} the device qualifies for. */
|
|
27
|
+
readonly capLevel: CapLevel;
|
|
28
|
+
/** Motion complexity tier permitted for this device. */
|
|
29
|
+
readonly motionTier: MotionTier;
|
|
30
|
+
/** Visual fidelity tier permitted for this device. */
|
|
31
|
+
readonly designTier: DesignTier;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Public API
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Detect capability tiers from HTTP headers using Client Hints parsing
|
|
40
|
+
* and the same pure tier mapping functions used on the client.
|
|
41
|
+
*/
|
|
42
|
+
function detectTier(headers: Headers | ClientHintsHeaders): EdgeTierResult {
|
|
43
|
+
const caps = ClientHints.parseClientHints(headers);
|
|
44
|
+
const capLevel = tierFromCapabilities(caps);
|
|
45
|
+
const motionTier = motionTierFromCapabilities(caps);
|
|
46
|
+
const designTier = designTierFromCapabilities(caps);
|
|
47
|
+
return { capLevel, motionTier, designTier };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate HTML data attribute string for injection into the `<html>` element.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```
|
|
55
|
+
* tierDataAttributes(result)
|
|
56
|
+
* // => 'data-czap-cap="reactive" data-czap-motion="animations" data-czap-design="enhanced"'
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
function tierDataAttributes(result: EdgeTierResult): string {
|
|
60
|
+
return `data-czap-cap="${result.capLevel}" data-czap-motion="${result.motionTier}" data-czap-design="${result.designTier}"`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Namespace export
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Edge tier detection namespace.
|
|
69
|
+
*
|
|
70
|
+
* Pairs {@link ClientHints.parseClientHints} with the pure tier-mapping
|
|
71
|
+
* functions from `@czap/detect` so the edge and the browser produce the
|
|
72
|
+
* same `capLevel`/`motionTier`/`designTier` triple for a given device.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* import { EdgeTier } from '@czap/edge';
|
|
77
|
+
*
|
|
78
|
+
* const result = EdgeTier.detectTier(request.headers);
|
|
79
|
+
* const html = `<html ${EdgeTier.tierDataAttributes(result)}>`;
|
|
80
|
+
* // `<html data-czap-cap="reactive" data-czap-motion="animations" data-czap-design="enhanced">`
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export const EdgeTier = {
|
|
84
|
+
/** Detect {@link EdgeTierResult} from a `Headers`-like bag. */
|
|
85
|
+
detectTier,
|
|
86
|
+
/** Render an `EdgeTierResult` into `data-czap-*` attributes for the root HTML element. */
|
|
87
|
+
tierDataAttributes,
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
90
|
+
export declare namespace EdgeTier {
|
|
91
|
+
/** Alias for {@link EdgeTierResult}. */
|
|
92
|
+
export type Result = EdgeTierResult;
|
|
93
|
+
}
|