@lobehub/edge-contract 0.1.0 → 0.3.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 +39 -7
- package/dist/acquisition-context.d.ts +12 -0
- package/dist/acquisition-context.js +175 -0
- package/dist/cache-tags.d.ts +19 -0
- package/dist/cache-tags.js +56 -0
- package/dist/last-workspace-cookie.d.ts +7 -0
- package/dist/last-workspace-cookie.js +30 -0
- package/dist/request-locale.d.ts +13 -0
- package/dist/request-locale.js +97 -0
- package/package.json +18 -3
package/README.md
CHANGED
|
@@ -1,16 +1,48 @@
|
|
|
1
1
|
# @lobehub/edge-contract
|
|
2
2
|
|
|
3
3
|
Edge logic that more than one LobeHub repository has to agree on, kept in exactly one place.
|
|
4
|
+
The gateway, landing, lobehub-cloud and lobe-chat import it instead of carrying copies.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
6
|
+
| Subpath | What it owns |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `./cache-tags` | Business cache tags, raw-identifier builders, URL-segment decoding and canonical detail-tag recognition. |
|
|
9
|
+
| `./landing-locales` | The URL path prefixes landing serves, and the default (`en`, at the bare path). |
|
|
10
|
+
| `./seo-topics` | The discover topics landing server-renders for `?q=`. |
|
|
11
|
+
| `./request-locale` | The app locale list and how a request picks one (`hl`, then the `LOBE_LOCALE` cookie, then Accept-Language). |
|
|
12
|
+
| `./last-workspace-cookie` | The `LOBE_LAST_WORKSPACE` cookie format. |
|
|
13
|
+
| `./acquisition-context` | The signed `lh_acq` first-touch cookie, landing click ids and the attribution params lifted onto `/signin`. |
|
|
14
|
+
|
|
15
|
+
`fixtures/*.json` are golden cases (`{ cases: [{ name, input, expected }] }`) captured from the
|
|
16
|
+
implementations this package replaced. They are frozen: a change that fails one is a behavior
|
|
17
|
+
change every consumer will see, so update the case on purpose, in the same commit.
|
|
9
18
|
|
|
10
19
|
Design: `lobehub-gateway/docs/ryugi/specs/2026-11-06-logic-dedupe-design.md`.
|
|
11
20
|
|
|
12
|
-
|
|
21
|
+
## Cache tag identities
|
|
22
|
+
|
|
23
|
+
Import from `@lobehub/edge-contract/cache-tags`. `marketplaceItemTag(kind, identifier)`
|
|
24
|
+
and `blogPostTag(slug)` take **raw** identifiers and encode them once. Invalid values
|
|
25
|
+
(empty, over 255 UTF-16 code units, ASCII controls or unpaired surrogates) return
|
|
26
|
+
`undefined`.
|
|
27
|
+
|
|
28
|
+
For a URL path segment, call `decodeCacheTagPathSegment` first. It decodes once and
|
|
29
|
+
returns the validated raw identifier, or `undefined` for malformed input:
|
|
13
30
|
|
|
14
|
-
```
|
|
15
|
-
|
|
31
|
+
```ts
|
|
32
|
+
marketplaceItemTag('skill', 'owner/repo');
|
|
33
|
+
// landing:marketplace:skill:item:owner%2Frepo
|
|
34
|
+
marketplaceItemTag('skill', 'owner%2Frepo');
|
|
35
|
+
// landing:marketplace:skill:item:owner%252Frepo
|
|
36
|
+
|
|
37
|
+
const identifier = decodeCacheTagPathSegment('owner%2Frepo');
|
|
38
|
+
const tag = identifier === undefined ? undefined : marketplaceItemTag('skill', identifier);
|
|
39
|
+
// landing:marketplace:skill:item:owner%2Frepo
|
|
16
40
|
```
|
|
41
|
+
|
|
42
|
+
`isCanonicalBlogPostTag` and `isCanonicalMarketplaceItemTag` recognize complete,
|
|
43
|
+
canonically encoded detail tags. They do not grant purge permission: entrypoint,
|
|
44
|
+
deployment, token and environment policy stay with the consumer. URL-to-tag matching,
|
|
45
|
+
origin hosts, Cloudflare calls and the gateway's `torii:*` cache identities also stay
|
|
46
|
+
in the gateway.
|
|
47
|
+
|
|
48
|
+
Releases: bump `version`, push a `v<version>` tag; CI publishes to npm.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const ACQUISITION_CONTEXT_COOKIE = "lh_acq";
|
|
2
|
+
export declare const ACQUISITION_CONTEXT_MAX_AGE_SECONDS: number;
|
|
3
|
+
export interface AcquisitionContext {
|
|
4
|
+
capturedAt: string;
|
|
5
|
+
lhCid: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const parseLandingClickId: (value: unknown) => string | undefined;
|
|
8
|
+
export declare const captureAcquisitionContext: (request: Pick<Request, "url">, now?: Date) => AcquisitionContext | null;
|
|
9
|
+
export declare const signAcquisitionContext: (context: AcquisitionContext, secret: string) => Promise<string>;
|
|
10
|
+
export declare const verifyAcquisitionContext: (token: string, secret: string, now?: Date) => Promise<AcquisitionContext | null>;
|
|
11
|
+
export declare const readAcquisitionContextCookie: (cookieHeader: string | null) => string | null;
|
|
12
|
+
export declare const ATTRIBUTION_PARAMS: ReadonlySet<string>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export const ACQUISITION_CONTEXT_COOKIE = 'lh_acq';
|
|
2
|
+
export const ACQUISITION_CONTEXT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
|
3
|
+
const ACQUISITION_CONTEXT_VERSION = 1;
|
|
4
|
+
const MAX_COOKIE_LENGTH = 1024;
|
|
5
|
+
const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
6
|
+
const SIGNING_SCOPE = 'lh-acquisition-context.v1:';
|
|
7
|
+
const textDecoder = new TextDecoder();
|
|
8
|
+
const textEncoder = new TextEncoder();
|
|
9
|
+
const AUTH_PATHS = new Set(['/signin', '/signup']);
|
|
10
|
+
// Landing issues lh_cid with crypto.randomUUID(). Treat it only as an opaque correlation ID.
|
|
11
|
+
const LH_CID_PATTERN = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i;
|
|
12
|
+
export const parseLandingClickId = (value) => {
|
|
13
|
+
if (typeof value !== 'string' || !LH_CID_PATTERN.test(value))
|
|
14
|
+
return;
|
|
15
|
+
return value.toLowerCase();
|
|
16
|
+
};
|
|
17
|
+
const sanitizeCapturedAt = (value) => {
|
|
18
|
+
if (typeof value !== 'string')
|
|
19
|
+
return;
|
|
20
|
+
const timestamp = Date.parse(value);
|
|
21
|
+
if (!Number.isFinite(timestamp))
|
|
22
|
+
return;
|
|
23
|
+
return new Date(timestamp).toISOString();
|
|
24
|
+
};
|
|
25
|
+
const sanitizeContext = (value) => {
|
|
26
|
+
if (!value || typeof value !== 'object')
|
|
27
|
+
return null;
|
|
28
|
+
const candidate = value;
|
|
29
|
+
if (candidate.version !== ACQUISITION_CONTEXT_VERSION)
|
|
30
|
+
return null;
|
|
31
|
+
const capturedAt = sanitizeCapturedAt(candidate.capturedAt);
|
|
32
|
+
const lhCid = parseLandingClickId(candidate.lhCid);
|
|
33
|
+
if (!capturedAt || !lhCid)
|
|
34
|
+
return null;
|
|
35
|
+
return { capturedAt, lhCid };
|
|
36
|
+
};
|
|
37
|
+
const serializeContext = (context) => ({
|
|
38
|
+
capturedAt: context.capturedAt,
|
|
39
|
+
lhCid: context.lhCid,
|
|
40
|
+
version: ACQUISITION_CONTEXT_VERSION,
|
|
41
|
+
});
|
|
42
|
+
const encodeBase64Url = (value) => {
|
|
43
|
+
let binary = '';
|
|
44
|
+
for (const byte of value)
|
|
45
|
+
binary += String.fromCharCode(byte);
|
|
46
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
47
|
+
};
|
|
48
|
+
const decodeBase64Url = (value) => {
|
|
49
|
+
if (!/^[\w-]+$/.test(value))
|
|
50
|
+
throw new Error('Invalid base64url value');
|
|
51
|
+
const base64 = value.replaceAll('-', '+').replaceAll('_', '/');
|
|
52
|
+
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
|
53
|
+
const binary = atob(padded);
|
|
54
|
+
const decoded = new Uint8Array(binary.length);
|
|
55
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
56
|
+
decoded[index] = binary.charCodeAt(index);
|
|
57
|
+
}
|
|
58
|
+
return decoded;
|
|
59
|
+
};
|
|
60
|
+
const importSigningKey = (secret, usages) => crypto.subtle.importKey('raw', textEncoder.encode(secret), { hash: 'SHA-256', name: 'HMAC' }, false, usages);
|
|
61
|
+
const getSigningInput = (payload) => textEncoder.encode(`${SIGNING_SCOPE}${payload}`).buffer;
|
|
62
|
+
const getCaptureUrl = (requestUrl) => {
|
|
63
|
+
if (!AUTH_PATHS.has(requestUrl.pathname))
|
|
64
|
+
return requestUrl;
|
|
65
|
+
const callbackUrl = requestUrl.searchParams.get('callbackUrl');
|
|
66
|
+
if (!callbackUrl)
|
|
67
|
+
return requestUrl;
|
|
68
|
+
try {
|
|
69
|
+
const parsed = new URL(callbackUrl, requestUrl.origin);
|
|
70
|
+
return parsed.origin === requestUrl.origin ? parsed : requestUrl;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return requestUrl;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
export const captureAcquisitionContext = (request, now = new Date()) => {
|
|
77
|
+
const requestUrl = new URL(request.url);
|
|
78
|
+
const captureUrl = getCaptureUrl(requestUrl);
|
|
79
|
+
const lhCid = parseLandingClickId(captureUrl.searchParams.get('lh_cid')) ??
|
|
80
|
+
parseLandingClickId(requestUrl.searchParams.get('lh_cid'));
|
|
81
|
+
if (!lhCid)
|
|
82
|
+
return null;
|
|
83
|
+
return {
|
|
84
|
+
capturedAt: now.toISOString(),
|
|
85
|
+
lhCid,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
export const signAcquisitionContext = async (context, secret) => {
|
|
89
|
+
if (!secret)
|
|
90
|
+
throw new Error('Acquisition context signing secret is required');
|
|
91
|
+
const sanitized = sanitizeContext({
|
|
92
|
+
...context,
|
|
93
|
+
version: ACQUISITION_CONTEXT_VERSION,
|
|
94
|
+
});
|
|
95
|
+
if (!sanitized)
|
|
96
|
+
throw new Error('Invalid acquisition context');
|
|
97
|
+
const payload = encodeBase64Url(textEncoder.encode(JSON.stringify(serializeContext(sanitized))));
|
|
98
|
+
const key = await importSigningKey(secret, ['sign']);
|
|
99
|
+
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, getSigningInput(payload)));
|
|
100
|
+
const token = `${payload}.${encodeBase64Url(signature)}`;
|
|
101
|
+
if (token.length > MAX_COOKIE_LENGTH)
|
|
102
|
+
throw new Error('Acquisition context cookie is too large');
|
|
103
|
+
return token;
|
|
104
|
+
};
|
|
105
|
+
export const verifyAcquisitionContext = async (token, secret, now = new Date()) => {
|
|
106
|
+
if (!token || !secret || token.length > MAX_COOKIE_LENGTH)
|
|
107
|
+
return null;
|
|
108
|
+
try {
|
|
109
|
+
const parts = token.split('.');
|
|
110
|
+
if (parts.length !== 2)
|
|
111
|
+
return null;
|
|
112
|
+
const [payload, encodedSignature] = parts;
|
|
113
|
+
const key = await importSigningKey(secret, ['verify']);
|
|
114
|
+
const valid = await crypto.subtle.verify('HMAC', key, decodeBase64Url(encodedSignature), getSigningInput(payload));
|
|
115
|
+
if (!valid)
|
|
116
|
+
return null;
|
|
117
|
+
const context = sanitizeContext(JSON.parse(textDecoder.decode(decodeBase64Url(payload))));
|
|
118
|
+
if (!context)
|
|
119
|
+
return null;
|
|
120
|
+
const capturedAt = Date.parse(context.capturedAt);
|
|
121
|
+
const nowTimestamp = now.getTime();
|
|
122
|
+
if (capturedAt > nowTimestamp + MAX_FUTURE_SKEW_MS ||
|
|
123
|
+
nowTimestamp - capturedAt > ACQUISITION_CONTEXT_MAX_AGE_SECONDS * 1000) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return context;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
export const readAcquisitionContextCookie = (cookieHeader) => {
|
|
133
|
+
if (!cookieHeader)
|
|
134
|
+
return null;
|
|
135
|
+
for (const part of cookieHeader.split(';')) {
|
|
136
|
+
const separator = part.indexOf('=');
|
|
137
|
+
if (separator < 0)
|
|
138
|
+
continue;
|
|
139
|
+
const name = part.slice(0, separator).trim();
|
|
140
|
+
if (name !== ACQUISITION_CONTEXT_COOKIE)
|
|
141
|
+
continue;
|
|
142
|
+
const value = part.slice(separator + 1).trim();
|
|
143
|
+
try {
|
|
144
|
+
return decodeURIComponent(value);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
// Non-utm_* attribution params posthog-js auto-captures (its CAMPAIGN_PARAMS minus the five
|
|
153
|
+
// utm_* keys). Whoever redirects a visitor to /signin lifts these, plus every utm_*, onto the
|
|
154
|
+
// top-level URL so PostHog's $pageview there records them.
|
|
155
|
+
export const ATTRIBUTION_PARAMS = new Set([
|
|
156
|
+
'gad_source',
|
|
157
|
+
'mc_cid',
|
|
158
|
+
'gclid',
|
|
159
|
+
'gclsrc',
|
|
160
|
+
'dclid',
|
|
161
|
+
'gbraid',
|
|
162
|
+
'wbraid',
|
|
163
|
+
'fbclid',
|
|
164
|
+
'msclkid',
|
|
165
|
+
'twclid',
|
|
166
|
+
'li_fat_id',
|
|
167
|
+
'igshid',
|
|
168
|
+
'ttclid',
|
|
169
|
+
'rdt_cid',
|
|
170
|
+
'epik',
|
|
171
|
+
'qclid',
|
|
172
|
+
'sccid',
|
|
173
|
+
'irclid',
|
|
174
|
+
'_kx',
|
|
175
|
+
]);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Business cache identities shared by response tagging and purge callers. */
|
|
2
|
+
export declare const LANDING_ALL_TAG = "landing:all";
|
|
3
|
+
export declare const BLOG_SECTION_TAG = "landing:blog";
|
|
4
|
+
export declare const BLOG_LIST_TAG = "landing:blog:list";
|
|
5
|
+
export declare const PRICING_TAG = "landing:pricing";
|
|
6
|
+
export declare const MARKETPLACE_KINDS: readonly ["mcp", "skill"];
|
|
7
|
+
export type MarketplaceKind = (typeof MARKETPLACE_KINDS)[number];
|
|
8
|
+
/** Decode a URL path segment once. Builders always take the resulting raw value. */
|
|
9
|
+
export declare const decodeCacheTagPathSegment: (segment: string) => string | undefined;
|
|
10
|
+
export declare const deploymentTag: (deployment: string) => string;
|
|
11
|
+
export declare const marketplaceListTag: (kind: MarketplaceKind) => string;
|
|
12
|
+
/** The identifier is raw: a literal `%2F` is distinct from a slash. */
|
|
13
|
+
export declare const marketplaceItemTag: (kind: MarketplaceKind, identifier: string) => string | undefined;
|
|
14
|
+
/** The slug is raw, including any literal percent characters. */
|
|
15
|
+
export declare const blogPostTag: (slug: string) => string | undefined;
|
|
16
|
+
/** Format recognition only; entrypoint and purge permissions belong to the caller. */
|
|
17
|
+
export declare const isCanonicalBlogPostTag: (tag: string) => boolean;
|
|
18
|
+
/** Recognize only canonical detail tags for a supported marketplace kind. */
|
|
19
|
+
export declare const isCanonicalMarketplaceItemTag: (tag: string) => boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Business cache identities shared by response tagging and purge callers. */
|
|
2
|
+
export const LANDING_ALL_TAG = 'landing:all';
|
|
3
|
+
export const BLOG_SECTION_TAG = 'landing:blog';
|
|
4
|
+
export const BLOG_LIST_TAG = 'landing:blog:list';
|
|
5
|
+
export const PRICING_TAG = 'landing:pricing';
|
|
6
|
+
export const MARKETPLACE_KINDS = ['mcp', 'skill'];
|
|
7
|
+
const BLOG_POST_PREFIX = 'landing:blog:post:';
|
|
8
|
+
const encodeIdentifier = (identifier) => {
|
|
9
|
+
if (!identifier || identifier.length > 255 || /[\u0000-\u001F\u007F]/.test(identifier)) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return encodeURIComponent(identifier);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Lone UTF-16 surrogates cannot name a URI resource.
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/** Decode a URL path segment once. Builders always take the resulting raw value. */
|
|
21
|
+
export const decodeCacheTagPathSegment = (segment) => {
|
|
22
|
+
try {
|
|
23
|
+
const raw = decodeURIComponent(segment);
|
|
24
|
+
return encodeIdentifier(raw) === undefined ? undefined : raw;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
export const deploymentTag = (deployment) => `landing:deployment:${deployment}`;
|
|
31
|
+
export const marketplaceListTag = (kind) => `landing:marketplace:${kind}:list`;
|
|
32
|
+
/** The identifier is raw: a literal `%2F` is distinct from a slash. */
|
|
33
|
+
export const marketplaceItemTag = (kind, identifier) => {
|
|
34
|
+
const encoded = encodeIdentifier(identifier);
|
|
35
|
+
return encoded === undefined ? undefined : `landing:marketplace:${kind}:item:${encoded}`;
|
|
36
|
+
};
|
|
37
|
+
/** The slug is raw, including any literal percent characters. */
|
|
38
|
+
export const blogPostTag = (slug) => {
|
|
39
|
+
const encoded = encodeIdentifier(slug);
|
|
40
|
+
return encoded === undefined ? undefined : `${BLOG_POST_PREFIX}${encoded}`;
|
|
41
|
+
};
|
|
42
|
+
/** Format recognition only; entrypoint and purge permissions belong to the caller. */
|
|
43
|
+
export const isCanonicalBlogPostTag = (tag) => {
|
|
44
|
+
if (!tag.startsWith(BLOG_POST_PREFIX))
|
|
45
|
+
return false;
|
|
46
|
+
const slug = decodeCacheTagPathSegment(tag.slice(BLOG_POST_PREFIX.length));
|
|
47
|
+
return slug !== undefined && blogPostTag(slug) === tag;
|
|
48
|
+
};
|
|
49
|
+
/** Recognize only canonical detail tags for a supported marketplace kind. */
|
|
50
|
+
export const isCanonicalMarketplaceItemTag = (tag) => {
|
|
51
|
+
const match = /^landing:marketplace:(mcp|skill):item:(.+)$/.exec(tag);
|
|
52
|
+
if (!match)
|
|
53
|
+
return false;
|
|
54
|
+
const identifier = decodeCacheTagPathSegment(match[2]);
|
|
55
|
+
return identifier !== undefined && marketplaceItemTag(match[1], identifier) === tag;
|
|
56
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const LAST_WORKSPACE_COOKIE = "LOBE_LAST_WORKSPACE";
|
|
2
|
+
export declare const LAST_WORKSPACE_COOKIE_MAX_AGE: number;
|
|
3
|
+
export declare const LAST_WORKSPACE_SLUG_PATTERN: RegExp;
|
|
4
|
+
export declare const LAST_WORKSPACE_SLUG_MIN = 3;
|
|
5
|
+
export declare const LAST_WORKSPACE_SLUG_MAX = 32;
|
|
6
|
+
export declare const formatLastWorkspaceCookie: (userId: string, slug: string) => string | null;
|
|
7
|
+
export declare const parseLastWorkspaceCookie: (raw: string | undefined | null, userId: string | undefined | null) => string | null;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Not httpOnly: the client rewrites it on every explicit workspace switch. It carries no
|
|
2
|
+
// authority (workspace scope is the `X-Workspace-Id` header, checked against membership), so
|
|
3
|
+
// a forged value only sends its own browser to a URL that 404s.
|
|
4
|
+
export const LAST_WORKSPACE_COOKIE = 'LOBE_LAST_WORKSPACE';
|
|
5
|
+
export const LAST_WORKSPACE_COOKIE_MAX_AGE = 60 * 60 * 24 * 90;
|
|
6
|
+
// The same shape as lobehub-cloud's WORKSPACE_SLUG_PATTERN / _MIN / _MAX, kept here rather than
|
|
7
|
+
// imported because that module also carries the reserved-slug tables, which no edge bundle
|
|
8
|
+
// should pay for. Anything failing it is ignored rather than sanitized, so a tampered cookie
|
|
9
|
+
// cannot smuggle `../`, a protocol or CRLF into a Location header.
|
|
10
|
+
export const LAST_WORKSPACE_SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
11
|
+
export const LAST_WORKSPACE_SLUG_MIN = 3;
|
|
12
|
+
export const LAST_WORKSPACE_SLUG_MAX = 32;
|
|
13
|
+
const isValidSlug = (slug) => slug.length >= LAST_WORKSPACE_SLUG_MIN &&
|
|
14
|
+
slug.length <= LAST_WORKSPACE_SLUG_MAX &&
|
|
15
|
+
LAST_WORKSPACE_SLUG_PATTERN.test(slug);
|
|
16
|
+
export const formatLastWorkspaceCookie = (userId, slug) => isValidSlug(slug) ? `${userId}:${slug}` : null;
|
|
17
|
+
// Format and ownership only: whether the slug still names a workspace the user belongs to
|
|
18
|
+
// needs the workspace list, so staleness is healed on the client.
|
|
19
|
+
export const parseLastWorkspaceCookie = (raw, userId) => {
|
|
20
|
+
if (!raw || !userId)
|
|
21
|
+
return null;
|
|
22
|
+
// A userId never contains `:`, so the first one is the separator.
|
|
23
|
+
const separator = raw.indexOf(':');
|
|
24
|
+
if (separator <= 0)
|
|
25
|
+
return null;
|
|
26
|
+
if (raw.slice(0, separator) !== userId)
|
|
27
|
+
return null;
|
|
28
|
+
const slug = raw.slice(separator + 1);
|
|
29
|
+
return isValidSlug(slug) ? slug : null;
|
|
30
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const appLocales: readonly ["ar", "bg-BG", "de-DE", "en-US", "es-ES", "fr-FR", "ja-JP", "ko-KR", "pt-BR", "ru-RU", "tr-TR", "zh-CN", "zh-TW", "vi-VN", "fa-IR", "it-IT", "pl-PL", "nl-NL"];
|
|
2
|
+
export type AppLocale = (typeof appLocales)[number];
|
|
3
|
+
export declare const DEFAULT_APP_LOCALE = "en-US";
|
|
4
|
+
export declare const LOCALE_QUERY_PARAM = "hl";
|
|
5
|
+
export declare const LOCALE_COOKIE = "LOBE_LOCALE";
|
|
6
|
+
export interface LocaleVocabulary<T extends string> {
|
|
7
|
+
fallback: T;
|
|
8
|
+
tags: readonly T[];
|
|
9
|
+
}
|
|
10
|
+
export declare function matchLocaleTag(tag: string | undefined): AppLocale | undefined;
|
|
11
|
+
export declare function matchLocaleTag<T extends string>(tag: string | undefined, tags: readonly T[]): T | undefined;
|
|
12
|
+
export declare function resolveRequestLocale(request: Request): AppLocale;
|
|
13
|
+
export declare function resolveRequestLocale<T extends string>(request: Request, vocabulary: LocaleVocabulary<T>): T;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export const appLocales = [
|
|
2
|
+
'ar',
|
|
3
|
+
'bg-BG',
|
|
4
|
+
'de-DE',
|
|
5
|
+
'en-US',
|
|
6
|
+
'es-ES',
|
|
7
|
+
'fr-FR',
|
|
8
|
+
'ja-JP',
|
|
9
|
+
'ko-KR',
|
|
10
|
+
'pt-BR',
|
|
11
|
+
'ru-RU',
|
|
12
|
+
'tr-TR',
|
|
13
|
+
'zh-CN',
|
|
14
|
+
'zh-TW',
|
|
15
|
+
'vi-VN',
|
|
16
|
+
'fa-IR',
|
|
17
|
+
'it-IT',
|
|
18
|
+
'pl-PL',
|
|
19
|
+
'nl-NL',
|
|
20
|
+
];
|
|
21
|
+
export const DEFAULT_APP_LOCALE = 'en-US';
|
|
22
|
+
export const LOCALE_QUERY_PARAM = 'hl';
|
|
23
|
+
export const LOCALE_COOKIE = 'LOBE_LOCALE';
|
|
24
|
+
const AUTO_LOCALE = 'auto';
|
|
25
|
+
const APP_VOCABULARY = { fallback: DEFAULT_APP_LOCALE, tags: appLocales };
|
|
26
|
+
const decodeCookieValue = (value) => {
|
|
27
|
+
try {
|
|
28
|
+
return decodeURIComponent(value);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const readCookie = (header, name) => {
|
|
35
|
+
if (!header)
|
|
36
|
+
return undefined;
|
|
37
|
+
for (const part of header.split(';')) {
|
|
38
|
+
const separator = part.indexOf('=');
|
|
39
|
+
if (separator === -1 || part.slice(0, separator).trim() !== name)
|
|
40
|
+
continue;
|
|
41
|
+
return decodeCookieValue(part.slice(separator + 1).trim());
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
};
|
|
45
|
+
// Accept-Language is case-insensitive but the vocabulary is canonical BCP 47: without this,
|
|
46
|
+
// `zh-cn` misses `zh-CN` and silently degrades to the fallback.
|
|
47
|
+
const canonicalizeTag = (tag) => {
|
|
48
|
+
const [language = '', ...rest] = tag.split('-');
|
|
49
|
+
return [
|
|
50
|
+
language.toLowerCase(),
|
|
51
|
+
...rest.map((part) => (part.length === 2 ? part.toUpperCase() : part)),
|
|
52
|
+
].join('-');
|
|
53
|
+
};
|
|
54
|
+
// Aliases resolve to a prefix rather than a fixed tag, so a vocabulary without the aliased
|
|
55
|
+
// language simply finds nothing.
|
|
56
|
+
const aliasPrefix = (lowerTag) => {
|
|
57
|
+
if (lowerTag.startsWith('ar'))
|
|
58
|
+
return 'ar';
|
|
59
|
+
if (lowerTag.startsWith('fa'))
|
|
60
|
+
return 'fa';
|
|
61
|
+
if (lowerTag.startsWith('cn'))
|
|
62
|
+
return 'zh-CN';
|
|
63
|
+
if (lowerTag.startsWith('zh-hans'))
|
|
64
|
+
return 'zh-CN';
|
|
65
|
+
if (lowerTag.startsWith('zh-hant'))
|
|
66
|
+
return 'zh-TW';
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
export function matchLocaleTag(tag, tags = appLocales) {
|
|
70
|
+
if (!tag)
|
|
71
|
+
return undefined;
|
|
72
|
+
const candidate = aliasPrefix(tag.toLowerCase()) ?? canonicalizeTag(tag);
|
|
73
|
+
return tags.find((supported) => supported.startsWith(candidate));
|
|
74
|
+
}
|
|
75
|
+
const parseAcceptLanguage = (header) => (header ?? '')
|
|
76
|
+
.split(',')
|
|
77
|
+
.map((part) => {
|
|
78
|
+
const [tag = '', ...params] = part.trim().split(';');
|
|
79
|
+
const quality = params.map((param) => param.trim()).find((param) => param.startsWith('q='));
|
|
80
|
+
const weight = quality ? Number(quality.slice(2)) : 1;
|
|
81
|
+
return { tag: tag.trim(), weight: Number.isFinite(weight) ? weight : 0 };
|
|
82
|
+
})
|
|
83
|
+
.filter((candidate) => candidate.tag.length > 0 && candidate.tag !== '*')
|
|
84
|
+
.sort((a, b) => b.weight - a.weight)
|
|
85
|
+
.map((candidate) => candidate.tag);
|
|
86
|
+
export function resolveRequestLocale(request, { fallback, tags } = APP_VOCABULARY) {
|
|
87
|
+
const explicit = new URL(request.url).searchParams.get(LOCALE_QUERY_PARAM) ||
|
|
88
|
+
readCookie(request.headers.get('cookie'), LOCALE_COOKIE);
|
|
89
|
+
if (explicit && explicit !== AUTO_LOCALE)
|
|
90
|
+
return matchLocaleTag(explicit, tags) ?? fallback;
|
|
91
|
+
for (const tag of parseAcceptLanguage(request.headers.get('accept-language'))) {
|
|
92
|
+
const matched = matchLocaleTag(tag, tags);
|
|
93
|
+
if (matched)
|
|
94
|
+
return matched;
|
|
95
|
+
}
|
|
96
|
+
return fallback;
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/edge-contract",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "One implementation of the edge logic shared by the LobeHub gateway, landing, cloud and lobe-chat, plus golden fixtures every copy is tested against.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"sideEffects": false,
|
|
12
12
|
"exports": {
|
|
13
|
+
"./cache-tags": {
|
|
14
|
+
"types": "./dist/cache-tags.d.ts",
|
|
15
|
+
"default": "./dist/cache-tags.js"
|
|
16
|
+
},
|
|
13
17
|
"./landing-locales": {
|
|
14
18
|
"types": "./dist/landing-locales.d.ts",
|
|
15
19
|
"default": "./dist/landing-locales.js"
|
|
@@ -18,6 +22,18 @@
|
|
|
18
22
|
"types": "./dist/seo-topics.d.ts",
|
|
19
23
|
"default": "./dist/seo-topics.js"
|
|
20
24
|
},
|
|
25
|
+
"./request-locale": {
|
|
26
|
+
"types": "./dist/request-locale.d.ts",
|
|
27
|
+
"default": "./dist/request-locale.js"
|
|
28
|
+
},
|
|
29
|
+
"./last-workspace-cookie": {
|
|
30
|
+
"types": "./dist/last-workspace-cookie.d.ts",
|
|
31
|
+
"default": "./dist/last-workspace-cookie.js"
|
|
32
|
+
},
|
|
33
|
+
"./acquisition-context": {
|
|
34
|
+
"types": "./dist/acquisition-context.d.ts",
|
|
35
|
+
"default": "./dist/acquisition-context.js"
|
|
36
|
+
},
|
|
21
37
|
"./fixtures/*": "./fixtures/*"
|
|
22
38
|
},
|
|
23
39
|
"files": [
|
|
@@ -35,7 +51,6 @@
|
|
|
35
51
|
"scripts": {
|
|
36
52
|
"build": "tsc -p tsconfig.build.json",
|
|
37
53
|
"test": "vitest run",
|
|
38
|
-
"typecheck": "tsc --noEmit"
|
|
39
|
-
"generate:fixtures": "bun scripts/generate-fixtures.ts"
|
|
54
|
+
"typecheck": "tsc --noEmit"
|
|
40
55
|
}
|
|
41
56
|
}
|