@decocms/blocks 7.0.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/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Timing header builder.
|
|
3
|
+
*
|
|
4
|
+
* Tracks named timing entries during a request and serializes them
|
|
5
|
+
* into the `Server-Timing` HTTP header for visibility in DevTools.
|
|
6
|
+
*
|
|
7
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* const timings = createServerTimings();
|
|
12
|
+
* const end = timings.start("resolve-cms");
|
|
13
|
+
* await resolvePage();
|
|
14
|
+
* end();
|
|
15
|
+
* response.headers.set("Server-Timing", timings.toHeader());
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export interface ServerTimings {
|
|
19
|
+
/** Start a named timing. Returns a function to call when done. */
|
|
20
|
+
start(name: string, description?: string): () => void;
|
|
21
|
+
/** Record a completed timing with a known duration. */
|
|
22
|
+
record(name: string, durationMs: number, description?: string): void;
|
|
23
|
+
/** Serialize all timings to a `Server-Timing` header value. */
|
|
24
|
+
toHeader(): string;
|
|
25
|
+
/** Get all recorded entries for diagnostics. */
|
|
26
|
+
entries(): TimingEntry[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TimingEntry {
|
|
30
|
+
name: string;
|
|
31
|
+
durationMs: number;
|
|
32
|
+
description?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createServerTimings(): ServerTimings {
|
|
36
|
+
const timingEntries: TimingEntry[] = [];
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
start(name: string, description?: string) {
|
|
40
|
+
const startTime = performance.now();
|
|
41
|
+
return () => {
|
|
42
|
+
const durationMs = performance.now() - startTime;
|
|
43
|
+
timingEntries.push({ name, durationMs, description });
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
record(name: string, durationMs: number, description?: string) {
|
|
48
|
+
timingEntries.push({ name, durationMs, description });
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
toHeader(): string {
|
|
52
|
+
return timingEntries
|
|
53
|
+
.map((entry) => {
|
|
54
|
+
let value = entry.name;
|
|
55
|
+
if (entry.description) {
|
|
56
|
+
value += `;desc="${entry.description}"`;
|
|
57
|
+
}
|
|
58
|
+
value += `;dur=${entry.durationMs.toFixed(1)}`;
|
|
59
|
+
return value;
|
|
60
|
+
})
|
|
61
|
+
.join(", ");
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
entries() {
|
|
65
|
+
return [...timingEntries];
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive signal backed by @tanstack/store.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for the preact signals shim.
|
|
5
|
+
* Preserves the .value getter/setter API so consumers don't need to change.
|
|
6
|
+
*
|
|
7
|
+
* For React components that need to re-render on state changes,
|
|
8
|
+
* use the useStore() hook from @tanstack/react-store:
|
|
9
|
+
*
|
|
10
|
+
* import { useStore } from "@tanstack/react-store";
|
|
11
|
+
* const value = useStore(mySignal.store);
|
|
12
|
+
*/
|
|
13
|
+
import { Store } from "@tanstack/store";
|
|
14
|
+
|
|
15
|
+
export interface ReactiveSignal<T> {
|
|
16
|
+
readonly store: Store<T>;
|
|
17
|
+
value: T;
|
|
18
|
+
peek(): T;
|
|
19
|
+
subscribe(fn: () => void): () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function signal<T>(initialValue: T): ReactiveSignal<T> {
|
|
23
|
+
const store = new Store<T>(initialValue);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
store,
|
|
27
|
+
get value(): T {
|
|
28
|
+
return store.state;
|
|
29
|
+
},
|
|
30
|
+
set value(v: T) {
|
|
31
|
+
store.setState(() => v);
|
|
32
|
+
},
|
|
33
|
+
peek(): T {
|
|
34
|
+
return store.state;
|
|
35
|
+
},
|
|
36
|
+
subscribe(fn: () => void): () => void {
|
|
37
|
+
const sub = store.subscribe(() => fn());
|
|
38
|
+
return () => sub.unsubscribe();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap generation utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides XML sitemap generation from CMS page blocks and arbitrary
|
|
5
|
+
* URL lists. Designed to be composed with commerce-specific sitemap
|
|
6
|
+
* sources (VTEX, Shopify) in a TanStack Start API route.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // src/routes/sitemap[.]xml.ts
|
|
11
|
+
* import { createAPIFileRoute } from "@tanstack/react-start/api";
|
|
12
|
+
* import { getCMSSitemapEntries, generateSitemapXml } from "@decocms/start/sdk/sitemap";
|
|
13
|
+
* import { getVtexSitemapEntries } from "@decocms/apps/vtex/utils/sitemap";
|
|
14
|
+
*
|
|
15
|
+
* export const APIRoute = createAPIFileRoute("/sitemap.xml")({
|
|
16
|
+
* GET: async ({ request }) => {
|
|
17
|
+
* const origin = new URL(request.url).origin;
|
|
18
|
+
* const cmsEntries = getCMSSitemapEntries(origin);
|
|
19
|
+
* const vtexEntries = await getVtexSitemapEntries(origin);
|
|
20
|
+
* const xml = generateSitemapXml([...cmsEntries, ...vtexEntries]);
|
|
21
|
+
* return new Response(xml, {
|
|
22
|
+
* headers: {
|
|
23
|
+
* "Content-Type": "application/xml",
|
|
24
|
+
* "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
* },
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { getAllPages, loadBlocks } from "../cms/loader";
|
|
33
|
+
|
|
34
|
+
// -------------------------------------------------------------------------
|
|
35
|
+
// Types
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export interface SitemapEntry {
|
|
39
|
+
loc: string;
|
|
40
|
+
lastmod?: string;
|
|
41
|
+
changefreq?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
42
|
+
priority?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SitemapOptions {
|
|
46
|
+
/** Maximum entries per sitemap (Google limit is 50,000). @default 50000 */
|
|
47
|
+
maxEntries?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CMSSitemapOptions {
|
|
51
|
+
/** Default changefreq for non-home pages. @default "weekly" */
|
|
52
|
+
defaultChangefreq?: SitemapEntry["changefreq"];
|
|
53
|
+
/** Default priority for non-home pages (0.0–1.0). @default 0.7 */
|
|
54
|
+
defaultPriority?: number;
|
|
55
|
+
/** Changefreq for the home page. @default "daily" */
|
|
56
|
+
homeChangefreq?: SitemapEntry["changefreq"];
|
|
57
|
+
/** Priority for the home page (0.0–1.0). @default 1.0 */
|
|
58
|
+
homePriority?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
// CMS page entries
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract sitemap entries from CMS page blocks.
|
|
67
|
+
*
|
|
68
|
+
* Reads all pages from the block store and generates URLs from their
|
|
69
|
+
* path patterns (excluding wildcard-only patterns like `/*`).
|
|
70
|
+
*/
|
|
71
|
+
export function getCMSSitemapEntries(origin: string, options?: CMSSitemapOptions): SitemapEntry[] {
|
|
72
|
+
const pages = getAllPages();
|
|
73
|
+
const entries: SitemapEntry[] = [];
|
|
74
|
+
const today = new Date().toISOString().split("T")[0];
|
|
75
|
+
|
|
76
|
+
const defaultChangefreq = options?.defaultChangefreq ?? "weekly";
|
|
77
|
+
const defaultPriority = options?.defaultPriority ?? 0.7;
|
|
78
|
+
const homeChangefreq = options?.homeChangefreq ?? "daily";
|
|
79
|
+
const homePriority = options?.homePriority ?? 1.0;
|
|
80
|
+
|
|
81
|
+
for (const { page } of pages) {
|
|
82
|
+
if (!page.path) continue;
|
|
83
|
+
|
|
84
|
+
if (page.path.includes("*") || page.path.includes(":")) continue;
|
|
85
|
+
|
|
86
|
+
const isHome = page.path === "/";
|
|
87
|
+
const loc = `${origin}${isHome ? "" : page.path}`;
|
|
88
|
+
entries.push({
|
|
89
|
+
loc: loc || origin,
|
|
90
|
+
lastmod: today,
|
|
91
|
+
changefreq: isHome ? homeChangefreq : defaultChangefreq,
|
|
92
|
+
priority: isHome ? homePriority : defaultPriority,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// -------------------------------------------------------------------------
|
|
100
|
+
// XML generation
|
|
101
|
+
// -------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function escapeXml(str: string): string {
|
|
104
|
+
return str
|
|
105
|
+
.replace(/&/g, "&")
|
|
106
|
+
.replace(/</g, "<")
|
|
107
|
+
.replace(/>/g, ">")
|
|
108
|
+
.replace(/"/g, """)
|
|
109
|
+
.replace(/'/g, "'");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate a sitemap XML string from entries.
|
|
114
|
+
*/
|
|
115
|
+
export function generateSitemapXml(entries: SitemapEntry[], options?: SitemapOptions): string {
|
|
116
|
+
const max = options?.maxEntries ?? 50000;
|
|
117
|
+
const limited = entries.slice(0, max);
|
|
118
|
+
|
|
119
|
+
const urls = limited.map((entry) => {
|
|
120
|
+
let url = ` <url>\n <loc>${escapeXml(entry.loc)}</loc>`;
|
|
121
|
+
if (entry.lastmod) url += `\n <lastmod>${entry.lastmod}</lastmod>`;
|
|
122
|
+
if (entry.changefreq) url += `\n <changefreq>${entry.changefreq}</changefreq>`;
|
|
123
|
+
if (entry.priority != null) url += `\n <priority>${entry.priority.toFixed(1)}</priority>`;
|
|
124
|
+
url += "\n </url>";
|
|
125
|
+
return url;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return [
|
|
129
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
130
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
131
|
+
...urls,
|
|
132
|
+
"</urlset>",
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate a sitemap index XML for splitting large sitemaps.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* const index = generateSitemapIndexXml([
|
|
142
|
+
* `${origin}/sitemap-pages.xml`,
|
|
143
|
+
* `${origin}/sitemap-products.xml`,
|
|
144
|
+
* ]);
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function generateSitemapIndexXml(sitemapUrls: string[]): string {
|
|
148
|
+
const today = new Date().toISOString().split("T")[0];
|
|
149
|
+
const sitemaps = sitemapUrls.map(
|
|
150
|
+
(url) =>
|
|
151
|
+
` <sitemap>\n <loc>${escapeXml(url)}</loc>\n <lastmod>${today}</lastmod>\n </sitemap>`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return [
|
|
155
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
156
|
+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
157
|
+
...sitemaps,
|
|
158
|
+
"</sitemapindex>",
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { redactUrl } from "./urlRedaction";
|
|
3
|
+
|
|
4
|
+
describe("redactUrl", () => {
|
|
5
|
+
it("redacts every query value by default", () => {
|
|
6
|
+
expect(redactUrl("https://api.test/path?token=abc&page=2")).toBe(
|
|
7
|
+
"https://api.test/path?token=REDACTED&page=REDACTED",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("preserves query keys listed in keepQueryKeys", () => {
|
|
12
|
+
expect(
|
|
13
|
+
redactUrl("https://api.test/path?token=abc&page=2&sort=name", {
|
|
14
|
+
keepQueryKeys: ["page", "sort"],
|
|
15
|
+
}),
|
|
16
|
+
).toBe("https://api.test/path?token=REDACTED&page=2&sort=name");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("preserves empty query values verbatim", () => {
|
|
20
|
+
expect(redactUrl("https://api.test/path?page=&token=abc")).toBe(
|
|
21
|
+
"https://api.test/path?page=&token=REDACTED",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("strips userinfo from authority component", () => {
|
|
26
|
+
expect(redactUrl("https://user:secret@api.test/path?x=1")).toBe(
|
|
27
|
+
"https://api.test/path?x=REDACTED",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("drops the fragment", () => {
|
|
32
|
+
expect(redactUrl("https://api.test/path?x=1#client-only-state-token=abc")).toBe(
|
|
33
|
+
"https://api.test/path?x=REDACTED",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("preserves host and path verbatim", () => {
|
|
38
|
+
expect(redactUrl("https://api.test/products/12345/details?x=1")).toBe(
|
|
39
|
+
"https://api.test/products/12345/details?x=REDACTED",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls back to substring-before-? on unparseable URLs", () => {
|
|
44
|
+
// Missing scheme — URL constructor throws.
|
|
45
|
+
expect(redactUrl("not-a-url?token=abc")).toBe("not-a-url");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to substring-before-# on unparseable URLs (no `?`)", () => {
|
|
49
|
+
// Fragments can also carry secrets (`#access_token=…` in OAuth implicit
|
|
50
|
+
// grant). The defensive path must drop them too.
|
|
51
|
+
expect(redactUrl("not-a-url#access_token=abc")).toBe("not-a-url");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("falls back to whichever of `?` / `#` appears first on unparseable URLs", () => {
|
|
55
|
+
expect(redactUrl("not-a-url?x=1#y=2")).toBe("not-a-url");
|
|
56
|
+
expect(redactUrl("not-a-url#frag?then=secret")).toBe("not-a-url");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns the raw value when there is no query and the URL is unparseable", () => {
|
|
60
|
+
expect(redactUrl("relative/path")).toBe("relative/path");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("handles URLs without query string unchanged", () => {
|
|
64
|
+
expect(redactUrl("https://api.test/products")).toBe("https://api.test/products");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("redacts multi-value query parameters (e.g. ?fq=a&fq=b)", () => {
|
|
68
|
+
// URLSearchParams collapses repeated keys to a single value when .set() is
|
|
69
|
+
// called — the OTel guideline accepts this as acceptable cardinality loss.
|
|
70
|
+
const out = redactUrl("https://api.test/path?fq=a&fq=b");
|
|
71
|
+
expect(out).toBe("https://api.test/path?fq=REDACTED");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL redaction for observability surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Strips secrets from outbound URLs before they land on a log line or a
|
|
5
|
+
* span attribute. The redacted output preserves enough of the URL for
|
|
6
|
+
* operators to recognize the call (host, path, which query keys were
|
|
7
|
+
* present) without leaking values that may carry tokens, session IDs,
|
|
8
|
+
* personal data, or other regulated content.
|
|
9
|
+
*
|
|
10
|
+
* Why a separate helper (instead of doing it in the ingestor):
|
|
11
|
+
*
|
|
12
|
+
* - Spans land in CF Workers Tracing before they ever reach our
|
|
13
|
+
* ingestor; once the unredacted value is stamped on `http.url`, it's
|
|
14
|
+
* in the CF dashboard whether we like it or not.
|
|
15
|
+
* - Logs go through `console.log` which Cloudflare captures into
|
|
16
|
+
* Workers Logs immediately — same problem.
|
|
17
|
+
* - The ingestor's `redactSensitiveHeaders` only covers headers, not
|
|
18
|
+
* URLs. Redacting URLs at the emit site is the only way to avoid
|
|
19
|
+
* landing a token on a platform we don't control.
|
|
20
|
+
*
|
|
21
|
+
* Redaction rules:
|
|
22
|
+
*
|
|
23
|
+
* - Strip userinfo (`https://user:pass@host`) entirely.
|
|
24
|
+
* - Drop the fragment (`#...`) — fragments are client-side only and
|
|
25
|
+
* may carry SPA state that includes tokens.
|
|
26
|
+
* - Replace every query value with `"REDACTED"` unless its key is in
|
|
27
|
+
* `keepQueryKeys`. Empty values stay empty so dashboards can still
|
|
28
|
+
* distinguish `?foo=` from `?foo=secret`.
|
|
29
|
+
* - Preserve the host and path verbatim. Path normalization (e.g.
|
|
30
|
+
* collapsing `/p/[slug]`) lives in `observability.ts:normalizePath`
|
|
31
|
+
* on the metrics side and is intentionally NOT bundled here.
|
|
32
|
+
* - Unparseable inputs fall back to a defensive substring before the
|
|
33
|
+
* `?`, so a malformed URL never accidentally surfaces a query string.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const REDACTED = "REDACTED";
|
|
37
|
+
|
|
38
|
+
export interface RedactUrlOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Query parameter names whose value should be left intact. Useful for
|
|
41
|
+
* structural / debugging params like `page`, `sort`, `view` that don't
|
|
42
|
+
* carry secrets. Names are case-sensitive (matches `URLSearchParams`).
|
|
43
|
+
*
|
|
44
|
+
* By default this is empty — every value is redacted.
|
|
45
|
+
*/
|
|
46
|
+
keepQueryKeys?: ReadonlyArray<string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function redactUrl(input: string, options: RedactUrlOptions = {}): string {
|
|
50
|
+
const keep = options.keepQueryKeys ? new Set(options.keepQueryKeys) : null;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(input);
|
|
54
|
+
// userinfo — never preserve.
|
|
55
|
+
u.username = "";
|
|
56
|
+
u.password = "";
|
|
57
|
+
// fragment — drop. Browsers don't send it, but it can sneak into
|
|
58
|
+
// server-side URLs via misconfigured proxies.
|
|
59
|
+
u.hash = "";
|
|
60
|
+
|
|
61
|
+
// searchParams.set() mutates URLSearchParams in place. Iterating a
|
|
62
|
+
// snapshot via [...entries()] is the supported way to avoid mutating
|
|
63
|
+
// the iterator we're walking.
|
|
64
|
+
const entries = [...u.searchParams.entries()];
|
|
65
|
+
for (const [key, value] of entries) {
|
|
66
|
+
if (keep && keep.has(key)) continue;
|
|
67
|
+
// Preserve empty values (`?k=` stays `?k=`) so dashboards can still
|
|
68
|
+
// distinguish empty from redacted-with-content.
|
|
69
|
+
if (value.length === 0) continue;
|
|
70
|
+
u.searchParams.set(key, REDACTED);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return u.toString();
|
|
74
|
+
} catch {
|
|
75
|
+
// Unparseable URL — defensively drop everything from the first `?`
|
|
76
|
+
// OR `#` onwards. Either can carry a secret (`?token=…`,
|
|
77
|
+
// `#access_token=…`); cutting at whichever appears first preserves
|
|
78
|
+
// the most context while leaking nothing.
|
|
79
|
+
const idx = input.search(/[?#]/);
|
|
80
|
+
return idx >= 0 ? input.slice(0, idx) : input;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL utility functions.
|
|
3
|
+
*
|
|
4
|
+
* Handles UTM parameter stripping for cache-friendly URLs,
|
|
5
|
+
* canonical URL generation, and other URL manipulation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const UTM_PARAMS = new Set([
|
|
9
|
+
"utm_source",
|
|
10
|
+
"utm_medium",
|
|
11
|
+
"utm_campaign",
|
|
12
|
+
"utm_term",
|
|
13
|
+
"utm_content",
|
|
14
|
+
"utmi_campaign",
|
|
15
|
+
"utmi_page",
|
|
16
|
+
"utmi_part",
|
|
17
|
+
"fbclid",
|
|
18
|
+
"gclid",
|
|
19
|
+
"gclsrc",
|
|
20
|
+
"dclid",
|
|
21
|
+
"msclkid",
|
|
22
|
+
"twclid",
|
|
23
|
+
"li_fat_id",
|
|
24
|
+
"mc_cid",
|
|
25
|
+
"mc_eid",
|
|
26
|
+
"ttclid",
|
|
27
|
+
"srsltid",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register additional tracking parameters to strip from cache keys.
|
|
32
|
+
* Useful for proprietary attribution params (e.g., custom analytics tags).
|
|
33
|
+
*/
|
|
34
|
+
export function registerTrackingParam(param: string): void {
|
|
35
|
+
UTM_PARAMS.add(param.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register multiple additional tracking parameters at once.
|
|
40
|
+
*/
|
|
41
|
+
export function registerTrackingParams(params: string[]): void {
|
|
42
|
+
for (const param of params) {
|
|
43
|
+
UTM_PARAMS.add(param.toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strip UTM and tracking parameters from a URL.
|
|
49
|
+
*
|
|
50
|
+
* Used to normalize URLs for caching -- two requests that differ
|
|
51
|
+
* only in UTM params should hit the same cache entry.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* stripTrackingParams("https://example.com/shoes?color=red&utm_source=google")
|
|
56
|
+
* // => "https://example.com/shoes?color=red"
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function stripTrackingParams(urlString: string): string {
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(urlString);
|
|
62
|
+
let changed = false;
|
|
63
|
+
|
|
64
|
+
for (const param of [...url.searchParams.keys()]) {
|
|
65
|
+
if (UTM_PARAMS.has(param.toLowerCase())) {
|
|
66
|
+
url.searchParams.delete(param);
|
|
67
|
+
changed = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return changed ? url.toString() : urlString;
|
|
72
|
+
} catch {
|
|
73
|
+
return urlString;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Strip tracking params from a URL and return just the pathname + non-UTM search.
|
|
79
|
+
* Useful for cache key generation.
|
|
80
|
+
*/
|
|
81
|
+
export function cleanPathForCacheKey(urlString: string): string {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(urlString);
|
|
84
|
+
|
|
85
|
+
for (const param of [...url.searchParams.keys()]) {
|
|
86
|
+
if (UTM_PARAMS.has(param.toLowerCase())) {
|
|
87
|
+
url.searchParams.delete(param);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const search = url.searchParams.toString();
|
|
92
|
+
return search ? `${url.pathname}?${search}` : url.pathname;
|
|
93
|
+
} catch {
|
|
94
|
+
return urlString;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build a canonical URL from a request, stripping tracking params
|
|
100
|
+
* and normalizing the protocol/host.
|
|
101
|
+
*
|
|
102
|
+
* @param request - The incoming request
|
|
103
|
+
* @param baseUrl - Override origin (useful when behind a proxy)
|
|
104
|
+
*/
|
|
105
|
+
export function canonicalUrl(request: Request, baseUrl?: string): string {
|
|
106
|
+
const url = new URL(request.url);
|
|
107
|
+
const origin = baseUrl ?? url.origin;
|
|
108
|
+
|
|
109
|
+
for (const param of [...url.searchParams.keys()]) {
|
|
110
|
+
if (UTM_PARAMS.has(param.toLowerCase())) {
|
|
111
|
+
url.searchParams.delete(param);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const search = url.searchParams.toString();
|
|
116
|
+
const path = search ? `${url.pathname}?${search}` : url.pathname;
|
|
117
|
+
|
|
118
|
+
return `${origin}${path}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if a URL has any tracking/UTM parameters.
|
|
123
|
+
*/
|
|
124
|
+
export function hasTrackingParams(urlString: string): boolean {
|
|
125
|
+
try {
|
|
126
|
+
const url = new URL(urlString);
|
|
127
|
+
for (const param of url.searchParams.keys()) {
|
|
128
|
+
if (UTM_PARAMS.has(param.toLowerCase())) return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { DeviceProvider, detectDevice, isMobileUA, useDevice } from "./useDevice";
|
|
5
|
+
|
|
6
|
+
describe("detectDevice", () => {
|
|
7
|
+
it("detects iPhone as mobile", () => {
|
|
8
|
+
expect(
|
|
9
|
+
detectDevice(
|
|
10
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
11
|
+
),
|
|
12
|
+
).toBe("mobile");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("detects Android phone as mobile", () => {
|
|
16
|
+
expect(
|
|
17
|
+
detectDevice(
|
|
18
|
+
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
|
19
|
+
),
|
|
20
|
+
).toBe("mobile");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("detects iPad as tablet", () => {
|
|
24
|
+
expect(
|
|
25
|
+
detectDevice(
|
|
26
|
+
"Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
27
|
+
),
|
|
28
|
+
).toBe("tablet");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("detects Android tablet as tablet", () => {
|
|
32
|
+
expect(
|
|
33
|
+
detectDevice(
|
|
34
|
+
"Mozilla/5.0 (Linux; Android 14; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
35
|
+
),
|
|
36
|
+
).toBe("tablet");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("detects Chrome desktop as desktop", () => {
|
|
40
|
+
expect(
|
|
41
|
+
detectDevice(
|
|
42
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
43
|
+
),
|
|
44
|
+
).toBe("desktop");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns desktop for empty UA", () => {
|
|
48
|
+
expect(detectDevice("")).toBe("desktop");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("detects Googlebot as desktop", () => {
|
|
52
|
+
expect(
|
|
53
|
+
detectDevice(
|
|
54
|
+
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
|
55
|
+
),
|
|
56
|
+
).toBe("desktop");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("detects Googlebot mobile as mobile", () => {
|
|
60
|
+
expect(
|
|
61
|
+
detectDevice(
|
|
62
|
+
"Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 (compatible; Googlebot/2.1)",
|
|
63
|
+
),
|
|
64
|
+
).toBe("mobile");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("isMobileUA", () => {
|
|
69
|
+
it("returns true for mobile UAs", () => {
|
|
70
|
+
expect(isMobileUA("iPhone")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns true for tablet UAs", () => {
|
|
74
|
+
expect(isMobileUA("iPad")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns false for desktop UAs", () => {
|
|
78
|
+
expect(
|
|
79
|
+
isMobileUA(
|
|
80
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
81
|
+
),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("useDevice (isomorphic)", () => {
|
|
87
|
+
it("uses navigator.userAgent on client, not viewport width", () => {
|
|
88
|
+
// In jsdom, document exists → useDevice takes the client path.
|
|
89
|
+
// It should use navigator.userAgent (same mechanism as server-side)
|
|
90
|
+
// to ensure consistent values between SSR and hydration.
|
|
91
|
+
// jsdom's navigator.userAgent is a desktop-like string.
|
|
92
|
+
const device = useDevice();
|
|
93
|
+
const expected = detectDevice(navigator.userAgent);
|
|
94
|
+
expect(device).toBe(expected);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns consistent result with detectDevice(navigator.userAgent)", () => {
|
|
98
|
+
// This is the key SSR/hydration consistency check:
|
|
99
|
+
// server calls detectDevice(req.headers["user-agent"])
|
|
100
|
+
// client calls detectDevice(navigator.userAgent)
|
|
101
|
+
// Both use the same detectDevice() function → same result for same UA.
|
|
102
|
+
const clientResult = useDevice();
|
|
103
|
+
const directResult = detectDevice(navigator.userAgent);
|
|
104
|
+
expect(clientResult).toBe(directResult);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("DeviceProvider seeds useDevice() with the serialized value (#278)", () => {
|
|
109
|
+
function Probe() {
|
|
110
|
+
// Inside a render, useDevice() reads DeviceContext — this is the path that
|
|
111
|
+
// makes hydration stable when the value comes from the page loader.
|
|
112
|
+
return createElement("span", null, useDevice());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it("useDevice() returns the provider value, overriding runtime detection", () => {
|
|
116
|
+
// jsdom's navigator is desktop-like, so a "tablet" result can only come
|
|
117
|
+
// from the provider value (the server-resolved device), not runtime.
|
|
118
|
+
const html = renderToStaticMarkup(
|
|
119
|
+
createElement(DeviceProvider, { value: "tablet", children: createElement(Probe) }),
|
|
120
|
+
);
|
|
121
|
+
expect(html).toBe("<span>tablet</span>");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("falls back to runtime resolution when no value is provided", () => {
|
|
125
|
+
const html = renderToStaticMarkup(
|
|
126
|
+
createElement(DeviceProvider, { children: createElement(Probe) }),
|
|
127
|
+
);
|
|
128
|
+
expect(html).toBe(`<span>${detectDevice(navigator.userAgent)}</span>`);
|
|
129
|
+
});
|
|
130
|
+
});
|