@decocms/start 2.26.0 → 2.27.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.
@@ -1528,3 +1528,64 @@ What's still ahead:
1528
1528
  - **C8 (state persistence between migration phases)**: moderate effort, value mostly in skipping `npm install` on phase-9 retries. Polish.
1529
1529
  - **`vibe-dex/*` orphan branches in apps-start**: ✅ all 5 cleaned this wave.
1530
1530
  - **Apps registry (apps-start#18 + deco-start#81)**: defer until clear consumer.
1531
+
1532
+ ### Wave 16 (2026-05-02 — baggagio as production canary, stacked-PR pitfall RECURRENCE)
1533
+
1534
+ User merged baggagio's PRs B1–B6 to use as guinea pig before applying the same patterns to casaevideo + lebiscuit (which ARE in production). Live validation found a critical fact: **only B1 (the bump) actually reached `main`**. PRs #13–#17 were all merged in GitHub UI but their merge commits ended up on the **previous PR's branch**, never on `main`.
1535
+
1536
+ #### What happened (the same pitfall as Wave 8, recurring)
1537
+
1538
+ Each PR was opened with `base = previous PR's branch`:
1539
+
1540
+ | PR | Title | base | Merge commit landed on |
1541
+ |---|---|---|---|
1542
+ | #12 | bump 2.10→2.26 + apps 1.7→1.9 | `main` | ✅ `main` |
1543
+ | #13 | drop `src/sdk/clx.ts` | `chore/bump-deco-2.26-apps-1.9` | ❌ that branch |
1544
+ | #14 | createUseSuggestions factory | `chore/drop-local-clx` | ❌ that branch |
1545
+ | #15 | canonical `relative()` | `chore/use-framework-suggestions-factory` | ❌ that branch |
1546
+ | #16 | canonical `Picture` | `refactor/use-canonical-relative-url` | ❌ that branch |
1547
+ | #17 | drop dead `useUser` stub | `refactor/use-canonical-picture` | ❌ that branch |
1548
+
1549
+ Each PR shows `state: MERGED` in GitHub. But the merge commit physically landed on each PR's source-branch tip, not on main. Result: all 5 cleanup PRs were silently orphaned.
1550
+
1551
+ Detection method that worked: file-existence check on `git show main:<deleted-file>` — `clx.ts`, `url.ts`, `Picture.tsx`, `useUser.ts` were all still present on main despite their PRs being "merged". Exists/absent is a faster signal than diff browsing.
1552
+
1553
+ #### Recovery: PR #18 — single consolidation
1554
+
1555
+ The deepest stacked branch (`chore/drop-dead-local-useuser`) cumulatively contained all 5 cleanups (B2–B6) linearly stacked on B1. Opened [`baggagio-tanstack#18`](https://github.com/deco-sites/baggagio-tanstack/pull/18) as `chore/consolidate-b2-b6-to-main` → `main`, replaying the exact contents of #13–#17 in order. Diff vs main: **59 files changed, +70 / −240, 4 files deleted**. Typecheck + build clean. Preview at `pr-18-baggagio-tanstack.deco-cx.workers.dev` rendered identical homepage / PLP / PDP / search to current main with zero new console errors. Merged to main, deploy succeeded.
1556
+
1557
+ #### Live validation post-merge (cumulative state on main)
1558
+
1559
+ Tested via Playwright (cursor-ide-browser MCP) on `https://baggagio-tanstack.deco-cx.workers.dev/`:
1560
+
1561
+ | Surface | Result | Notes |
1562
+ |---|---|---|
1563
+ | Homepage | ✅ Renders | Banner, categories, product carousel, footer all intact |
1564
+ | PLP `/s?q=mochila` | ✅ 927 produtos | Filter + sort UI present, all images load |
1565
+ | PDP `/mochila-masculina-executiva-para-notebook-horizonte/p` | ✅ Renders | Image gallery, prices, COMPRAR, frete calc, descrição all present |
1566
+ | Search suggestions endpoint | ✅ 200 | Empty `searches[]` confirmed pre-existing (matches www.bagaggio.com.br) |
1567
+ | `<picture>` HTML | ✅ 18 picture / 36 source | composable canonical pattern |
1568
+ | Console errors (filtered 3rd-party) | ✅ Same as before | `[inline-script polyfill]` + image preload warnings pre-existing on main |
1569
+
1570
+ **Bonus discovery**: PR-B5 (canonical Picture) now correctly emits `<link rel="preload" as="image" media="(max-width: 767px)" imageSrcSet="..." fetchPriority="high">` for LCP banners — a real Web Vitals improvement that was NOT visible before the consolidation because Picture.tsx (the local wrapper without preload) was still on main.
1571
+
1572
+ #### Each PR's safety verdict (for casaevideo + lebiscuit replay)
1573
+
1574
+ | PR | Status | Safe to replay? |
1575
+ |---|---|---|
1576
+ | B1 — bump 2.x → 2.26 + apps 1.x → 1.9 | ✅ | YES — zero regressions on real site |
1577
+ | B2 — drop `src/sdk/clx.ts` | ✅ | YES — pure rewrite, framework export is byte-equivalent |
1578
+ | B3 — `createUseSuggestions` factory | ✅ | YES — wiring works end-to-end (200 status, payload reaches store) |
1579
+ | B4 — canonical `relative()` with `stripSearchParams` | ✅ | YES — only affects PLPs with `?skuId=` URL params, no functional regression |
1580
+ | B5 — canonical `Picture` from apps | ✅ + bonus | YES — adds proper `<link rel="preload" as="image" media>` for LCP |
1581
+ | B6 — drop dead `src/hooks/useUser.ts` | ✅ | YES — file had 0 external imports |
1582
+
1583
+ **For casaevideo + lebiscuit**: same set of PRs is validated as safe. The replays (`C1`–`C11`, `L1`–`L11`) can proceed on production sites with confidence.
1584
+
1585
+ ### Wave 16 — discoveries
1586
+
1587
+ - **Stacked-PR pitfall recurred even after Wave 8 documented it.** The Wave 8 mitigation ("verify base is main before merging") was not enforced; the user merged B2–B6 with original stacked bases. Stronger mitigation needed: when opening a stacked PR, **default to a single consolidating PR at the end** rather than 5 separate stacked merges. Single PR is one merge button, one CI run, one deploy — not 5 chances to mis-target the base.
1588
+ - **File-existence check is the fastest "did the merge actually land on main?" probe.** Faster than reading PR-stats, faster than diffing branches. `git show main:<deleted-file> 2>&1` — empty stderr means the deletion didn't reach main.
1589
+ - **Preview deploys via `wrangler versions upload --preview-alias` are cheap, fast (90 s), and PR-scoped.** Used `https://pr-N-baggagio-tanstack.deco-cx.workers.dev` to validate cumulative state BEFORE merging. Should be the default validation step for any consolidation PR.
1590
+ - **The canonical Picture component's per-source `<link rel="preload" as="image" media="...">` injection is a real LCP win** — but it only triggers when `<Picture preload={true}>` is set on the call site. Baggagio's `BannerCarousel.tsx` already passes `preload={lcp}` from the CMS config; the local Picture.tsx wrapper just didn't honor it. Migration to canonical IS a perf upgrade, not just a code-cleanup.
1591
+ - **Canary-driven validation matters even when the changes are mechanical.** I had high confidence the cumulative state would work (typecheck + build clean), but the live test is what surfaced the "PR-B5 actually emits preload links now" finding. Without the canary loop the perf delta would have been invisible.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.26.0",
3
+ "version": "2.27.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -19,6 +19,9 @@
19
19
  "./sdk/useScript": "./src/sdk/useScript.ts",
20
20
  "./sdk/signal": "./src/sdk/signal.ts",
21
21
  "./sdk/clx": "./src/sdk/clx.ts",
22
+ "./sdk/cn": "./src/sdk/cn.ts",
23
+ "./sdk/encoding": "./src/sdk/encoding.ts",
24
+ "./sdk/http": "./src/sdk/http.ts",
22
25
  "./sdk/useSuggestions": "./src/sdk/useSuggestions.ts",
23
26
  "./sdk/retry": "./src/sdk/retry.ts",
24
27
  "./sdk/useId": "./src/sdk/useId.ts",
@@ -96,7 +99,9 @@
96
99
  },
97
100
  "dependencies": {
98
101
  "@deco-cx/warp-node": "^0.3.16",
102
+ "clsx": "^2.1.1",
99
103
  "fast-json-patch": "^3.1.0",
104
+ "tailwind-merge": "^3.3.1",
100
105
  "tsx": "^4.19.0",
101
106
  "ws": "^8.18.0"
102
107
  },
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clx, cn } from "./cn";
3
+
4
+ describe("cn", () => {
5
+ it("joins strings", () => {
6
+ expect(cn("a", "b", "c")).toBe("a b c");
7
+ });
8
+
9
+ it("supports conditional objects", () => {
10
+ expect(cn("a", { b: true, c: false }, "d")).toBe("a b d");
11
+ });
12
+
13
+ it("filters out falsy values", () => {
14
+ expect(cn("a", null, undefined, false, 0 as any, "b")).toBe("a b");
15
+ });
16
+
17
+ it("merges conflicting Tailwind utilities (last one wins)", () => {
18
+ expect(cn("p-2", "p-4")).toBe("p-4");
19
+ });
20
+
21
+ it("merges hover variants independently from base utilities", () => {
22
+ expect(cn("p-2", "hover:p-4")).toBe("p-2 hover:p-4");
23
+ });
24
+ });
25
+
26
+ describe("clx (re-exported)", () => {
27
+ it("filters falsy and joins with single spaces", () => {
28
+ expect(clx("a", null, "b", undefined, "c")).toBe("a b c");
29
+ });
30
+
31
+ it("does NOT merge conflicting utilities (that's cn's job)", () => {
32
+ expect(clx("p-2", "p-4")).toBe("p-2 p-4");
33
+ });
34
+ });
package/src/sdk/cn.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `cn(...inputs)` — the canonical Tailwind class-name combinator
3
+ * (clsx + tailwind-merge). Every storefront we audited shipped a
4
+ * site-local copy of this; we promote it to the framework so sites
5
+ * can drop the duplicate.
6
+ *
7
+ * Behaviour:
8
+ * - Accepts the full `clsx` input format (strings, objects, arrays,
9
+ * conditionals, falsy values).
10
+ * - De-duplicates conflicting Tailwind utilities via `tailwind-merge`
11
+ * (e.g. `cn("p-2", "p-4")` → `"p-4"`).
12
+ *
13
+ * The simpler `clx` (no tailwind-merge, just `filter+join`) is still
14
+ * exported from `@decocms/start/sdk/clx` for cases where you want to
15
+ * keep the literal class string. Re-exported here so a single import
16
+ * covers both.
17
+ */
18
+
19
+ import { clsx, type ClassValue } from "clsx";
20
+ import { twMerge } from "tailwind-merge";
21
+
22
+ export { clx } from "./clx";
23
+
24
+ export function cn(...inputs: ClassValue[]): string {
25
+ return twMerge(clsx(inputs));
26
+ }
27
+
28
+ export type { ClassValue };
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ deleteResponseCookie,
4
+ getCookies,
5
+ setResponseCookie,
6
+ } from "./cookie";
7
+
8
+ describe("getCookies", () => {
9
+ it("returns an empty object when no Cookie header is present", () => {
10
+ expect(getCookies(new Headers())).toEqual({});
11
+ });
12
+
13
+ it("parses a single cookie", () => {
14
+ const h = new Headers({ cookie: "session=abc" });
15
+ expect(getCookies(h)).toEqual({ session: "abc" });
16
+ });
17
+
18
+ it("parses multiple cookies separated by '; '", () => {
19
+ const h = new Headers({ cookie: "a=1; b=2; c=3" });
20
+ expect(getCookies(h)).toEqual({ a: "1", b: "2", c: "3" });
21
+ });
22
+
23
+ it("URL-decodes values", () => {
24
+ const h = new Headers({ cookie: "u=hello%20world" });
25
+ expect(getCookies(h)).toEqual({ u: "hello world" });
26
+ });
27
+
28
+ it("falls back to the raw value when decoding fails", () => {
29
+ // Lone '%' is invalid in URL encoding.
30
+ const h = new Headers({ cookie: "x=100%" });
31
+ expect(getCookies(h)).toEqual({ x: "100%" });
32
+ });
33
+
34
+ it("ignores entries without an '='", () => {
35
+ const h = new Headers({ cookie: "garbage; ok=yes" });
36
+ expect(getCookies(h)).toEqual({ ok: "yes" });
37
+ });
38
+
39
+ it("trims whitespace around names", () => {
40
+ const h = new Headers({ cookie: " a=1; b=2 " });
41
+ expect(getCookies(h)).toEqual({ a: "1", b: "2" });
42
+ });
43
+ });
44
+
45
+ describe("setResponseCookie", () => {
46
+ it("appends a Set-Cookie header with the cookie name and value", () => {
47
+ const h = new Headers();
48
+ setResponseCookie(h, { name: "session", value: "abc" });
49
+ expect(h.get("set-cookie")).toBe("session=abc");
50
+ });
51
+
52
+ it("serializes maxAge, path, secure, httpOnly, sameSite, domain", () => {
53
+ const h = new Headers();
54
+ setResponseCookie(h, {
55
+ name: "session",
56
+ value: "abc",
57
+ maxAge: 3600,
58
+ path: "/",
59
+ domain: "example.com",
60
+ secure: true,
61
+ httpOnly: true,
62
+ sameSite: "Lax",
63
+ });
64
+ const value = h.get("set-cookie")!;
65
+ expect(value).toContain("session=abc");
66
+ expect(value).toContain("Max-Age=3600");
67
+ expect(value).toContain("Domain=example.com");
68
+ expect(value).toContain("Path=/");
69
+ expect(value).toContain("Secure");
70
+ expect(value).toContain("HttpOnly");
71
+ expect(value).toContain("SameSite=Lax");
72
+ });
73
+
74
+ it("serializes expires as a UTC string", () => {
75
+ const h = new Headers();
76
+ const date = new Date("2030-01-01T00:00:00Z");
77
+ setResponseCookie(h, { name: "x", value: "y", expires: date });
78
+ expect(h.get("set-cookie")).toContain(`Expires=${date.toUTCString()}`);
79
+ });
80
+
81
+ it("appends multiple cookies (does not overwrite the first)", () => {
82
+ const h = new Headers();
83
+ setResponseCookie(h, { name: "a", value: "1" });
84
+ setResponseCookie(h, { name: "b", value: "2" });
85
+ // Headers.getAll isn't standard; getSetCookie() is the modern API.
86
+ const all = (h as any).getSetCookie?.() as string[] | undefined;
87
+ if (all) {
88
+ expect(all).toEqual(["a=1", "b=2"]);
89
+ } else {
90
+ // Fallback: the combined header value should mention both.
91
+ const v = h.get("set-cookie")!;
92
+ expect(v).toContain("a=1");
93
+ expect(v).toContain("b=2");
94
+ }
95
+ });
96
+ });
97
+
98
+ describe("deleteResponseCookie", () => {
99
+ it("emits a Set-Cookie that expires immediately", () => {
100
+ const h = new Headers();
101
+ deleteResponseCookie(h, "session", { path: "/" });
102
+ const value = h.get("set-cookie")!;
103
+ expect(value).toContain("session=");
104
+ expect(value).toContain("Max-Age=0");
105
+ expect(value).toContain(`Expires=${new Date(0).toUTCString()}`);
106
+ expect(value).toContain("Path=/");
107
+ });
108
+ });
package/src/sdk/cookie.ts CHANGED
@@ -37,3 +37,93 @@ export function decodeCookie(cookieValue: string): any {
37
37
  return null;
38
38
  }
39
39
  }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Server-side cookie helpers — Web-platform / Workers-friendly.
43
+ //
44
+ // These mirror the surface area of Deno's `@std/http/cookie`, which deco
45
+ // storefronts depended on heavily before TanStack/Workers migration. Sites
46
+ // can now import from "@decocms/start/sdk/cookie" instead of shipping a
47
+ // per-site shim or pulling JSR.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface Cookie {
51
+ name: string;
52
+ value: string;
53
+ expires?: Date | number;
54
+ maxAge?: number;
55
+ domain?: string;
56
+ path?: string;
57
+ secure?: boolean;
58
+ httpOnly?: boolean;
59
+ sameSite?: "Strict" | "Lax" | "None";
60
+ }
61
+
62
+ /**
63
+ * Parse all cookies from a Request's `Cookie` header into a plain object.
64
+ * Returns `{}` when no cookies are present. Values are URL-decoded.
65
+ *
66
+ * Equivalent to `getCookies(req.headers)` from `@std/http/cookie`.
67
+ */
68
+ export function getCookies(headers: Headers): Record<string, string> {
69
+ const cookie = headers.get("cookie");
70
+ if (!cookie) return {};
71
+ const out: Record<string, string> = {};
72
+ for (const pair of cookie.split(/;\s*/)) {
73
+ const eq = pair.indexOf("=");
74
+ if (eq === -1) continue;
75
+ const name = pair.slice(0, eq).trim();
76
+ if (!name) continue;
77
+ const value = pair.slice(eq + 1).trim();
78
+ try {
79
+ out[name] = decodeURIComponent(value);
80
+ } catch {
81
+ out[name] = value;
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Serialize a cookie spec and append a `Set-Cookie` header to a Response's
89
+ * `Headers`. Equivalent to `setCookie(headers, cookie)` from `@std/http/cookie`.
90
+ *
91
+ * Note: this uses `headers.append`, not `set`, so multiple cookies stack
92
+ * correctly (a single `Set-Cookie` header cannot represent multiple cookies).
93
+ */
94
+ export function setResponseCookie(headers: Headers, cookie: Cookie): void {
95
+ const parts = [`${cookie.name}=${cookie.value}`];
96
+ if (cookie.expires !== undefined) {
97
+ const date = cookie.expires instanceof Date
98
+ ? cookie.expires
99
+ : new Date(cookie.expires);
100
+ parts.push(`Expires=${date.toUTCString()}`);
101
+ }
102
+ if (cookie.maxAge !== undefined) parts.push(`Max-Age=${cookie.maxAge}`);
103
+ if (cookie.domain) parts.push(`Domain=${cookie.domain}`);
104
+ if (cookie.path) parts.push(`Path=${cookie.path}`);
105
+ if (cookie.secure) parts.push("Secure");
106
+ if (cookie.httpOnly) parts.push("HttpOnly");
107
+ if (cookie.sameSite) parts.push(`SameSite=${cookie.sameSite}`);
108
+ headers.append("Set-Cookie", parts.join("; "));
109
+ }
110
+
111
+ /**
112
+ * Append a delete instruction (`Max-Age=0` + epoch `Expires`) for a cookie.
113
+ * `path` and `domain` should match the original `setResponseCookie` call to
114
+ * actually clear the cookie in the browser.
115
+ */
116
+ export function deleteResponseCookie(
117
+ headers: Headers,
118
+ name: string,
119
+ attributes: { path?: string; domain?: string } = {},
120
+ ): void {
121
+ setResponseCookie(headers, {
122
+ name,
123
+ value: "",
124
+ expires: new Date(0),
125
+ maxAge: 0,
126
+ path: attributes.path,
127
+ domain: attributes.domain,
128
+ });
129
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ decodeBase64,
4
+ decodeBase64Url,
5
+ encodeBase64,
6
+ encodeBase64Url,
7
+ } from "./encoding";
8
+
9
+ describe("encodeBase64 / decodeBase64", () => {
10
+ it("round-trips ASCII strings", () => {
11
+ const data = "hello, world";
12
+ const b64 = encodeBase64(data);
13
+ expect(b64).toBe("aGVsbG8sIHdvcmxk");
14
+ const decoded = new TextDecoder().decode(decodeBase64(b64));
15
+ expect(decoded).toBe(data);
16
+ });
17
+
18
+ it("round-trips multi-byte UTF-8", () => {
19
+ const data = "São Paulo — café ☕";
20
+ const b64 = encodeBase64(data);
21
+ const decoded = new TextDecoder().decode(decodeBase64(b64));
22
+ expect(decoded).toBe(data);
23
+ });
24
+
25
+ it("accepts Uint8Array input", () => {
26
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
27
+ const b64 = encodeBase64(bytes);
28
+ expect(decodeBase64(b64)).toEqual(bytes);
29
+ });
30
+
31
+ it("accepts ArrayBuffer input", () => {
32
+ const buf = new Uint8Array([255, 254, 253]).buffer;
33
+ const b64 = encodeBase64(buf);
34
+ expect(Array.from(decodeBase64(b64))).toEqual([255, 254, 253]);
35
+ });
36
+
37
+ it("handles inputs larger than the chunking window", () => {
38
+ // 0x8000 + 7 bytes — forces the chunking branch.
39
+ const bytes = new Uint8Array(0x8000 + 7);
40
+ for (let i = 0; i < bytes.length; i++) bytes[i] = i & 0xff;
41
+ const b64 = encodeBase64(bytes);
42
+ expect(decodeBase64(b64)).toEqual(bytes);
43
+ });
44
+ });
45
+
46
+ describe("encodeBase64Url / decodeBase64Url", () => {
47
+ it("uses URL-safe alphabet and strips padding", () => {
48
+ // Inputs that produce '+', '/', and padding under standard base64.
49
+ const bytes = new Uint8Array([251, 255, 191, 251, 239, 254]);
50
+ const standard = encodeBase64(bytes);
51
+ const url = encodeBase64Url(bytes);
52
+ // Every '+' becomes '-', '/' becomes '_', trailing '=' is stripped.
53
+ expect(url).toBe(
54
+ standard.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""),
55
+ );
56
+ expect(url).not.toContain("+");
57
+ expect(url).not.toContain("/");
58
+ expect(url).not.toContain("=");
59
+ });
60
+
61
+ it("round-trips through the URL-safe pair", () => {
62
+ const data = new Uint8Array(64);
63
+ for (let i = 0; i < data.length; i++) data[i] = (i * 7) & 0xff;
64
+ expect(decodeBase64Url(encodeBase64Url(data))).toEqual(data);
65
+ });
66
+
67
+ it("decodes inputs missing their padding", () => {
68
+ // 'a' = 0x61. encodeBase64('a') = 'YQ==', URL form drops to 'YQ'.
69
+ expect(new TextDecoder().decode(decodeBase64Url("YQ"))).toBe("a");
70
+ });
71
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Web-platform base64 / base64url helpers.
3
+ *
4
+ * Replaces the surface area of Deno's `@std/encoding/base64` so deco
5
+ * storefronts on TanStack/Workers can drop the per-site shim.
6
+ *
7
+ * All implementations use the global `btoa` / `atob` (available in Workers,
8
+ * browsers, and Node 16+) so there is zero runtime dependency.
9
+ */
10
+
11
+ function toBytes(data: ArrayBuffer | Uint8Array | string): Uint8Array {
12
+ if (typeof data === "string") return new TextEncoder().encode(data);
13
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
14
+ return data;
15
+ }
16
+
17
+ export function encodeBase64(data: ArrayBuffer | Uint8Array | string): string {
18
+ const bytes = toBytes(data);
19
+ // Build the binary string in chunks to avoid blowing the call stack on
20
+ // large inputs (`String.fromCharCode(...bytes)` spreads the entire array).
21
+ let bin = "";
22
+ const CHUNK = 0x8000;
23
+ for (let i = 0; i < bytes.byteLength; i += CHUNK) {
24
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
25
+ }
26
+ return btoa(bin);
27
+ }
28
+
29
+ export function decodeBase64(b64: string): Uint8Array {
30
+ const bin = atob(b64);
31
+ const out = new Uint8Array(bin.length);
32
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
33
+ return out;
34
+ }
35
+
36
+ export function encodeBase64Url(data: ArrayBuffer | Uint8Array | string): string {
37
+ return encodeBase64(data)
38
+ .replace(/\+/g, "-")
39
+ .replace(/\//g, "_")
40
+ .replace(/=+$/, "");
41
+ }
42
+
43
+ export function decodeBase64Url(b64url: string): Uint8Array {
44
+ const padded = b64url.replace(/-/g, "+").replace(/_/g, "/");
45
+ const padLen = (4 - (padded.length % 4)) % 4;
46
+ return decodeBase64(padded + "=".repeat(padLen));
47
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { HttpError, STATUS_CODE, UserAgent } from "./http";
3
+
4
+ describe("STATUS_CODE", () => {
5
+ it("exposes common codes with the IANA-canonical names", () => {
6
+ expect(STATUS_CODE.OK).toBe(200);
7
+ expect(STATUS_CODE.MovedPermanently).toBe(301);
8
+ expect(STATUS_CODE.NotFound).toBe(404);
9
+ expect(STATUS_CODE.TooManyRequests).toBe(429);
10
+ expect(STATUS_CODE.InternalServerError).toBe(500);
11
+ });
12
+
13
+ it("is readonly at the type level", () => {
14
+ // @ts-expect-error — assigning into the const map should not type-check.
15
+ STATUS_CODE.OK = 999;
16
+ // Even though the assignment is allowed at runtime (frozen-by-convention),
17
+ // we just want the type to flag it. The value is whatever JS allows.
18
+ expect(typeof STATUS_CODE.OK).toBe("number");
19
+ });
20
+ });
21
+
22
+ describe("UserAgent", () => {
23
+ it("accepts a string and exposes it via toString", () => {
24
+ const ua = new UserAgent("Mozilla/5.0 (test)");
25
+ expect(ua.toString()).toBe("Mozilla/5.0 (test)");
26
+ });
27
+
28
+ it("treats null as an empty UA string", () => {
29
+ const ua = new UserAgent(null);
30
+ expect(ua.toString()).toBe("");
31
+ });
32
+
33
+ it("exposes empty browser/os/cpu/device/engine accessors", () => {
34
+ const ua = new UserAgent("anything");
35
+ expect(ua.browser).toEqual({});
36
+ expect(ua.os).toEqual({});
37
+ expect(ua.cpu).toEqual({});
38
+ expect(ua.device).toEqual({});
39
+ expect(ua.engine).toEqual({});
40
+ });
41
+ });
42
+
43
+ describe("HttpError", () => {
44
+ it("captures status and message", () => {
45
+ const err = new HttpError(404, "Missing");
46
+ expect(err).toBeInstanceOf(Error);
47
+ expect(err.name).toBe("HttpError");
48
+ expect(err.status).toBe(404);
49
+ expect(err.message).toBe("Missing");
50
+ });
51
+
52
+ it("defaults the message from the status when not provided", () => {
53
+ const err = new HttpError(503);
54
+ expect(err.message).toBe("HTTP 503");
55
+ });
56
+
57
+ it("preserves an optional body payload for downstream handling", () => {
58
+ const body = { code: "rate_limited" };
59
+ const err = new HttpError(429, "Too Many Requests", body);
60
+ expect(err.body).toEqual(body);
61
+ });
62
+
63
+ it("supports `instanceof` discrimination", () => {
64
+ const err = new HttpError(304);
65
+ function isNotModified(e: unknown): e is HttpError {
66
+ return e instanceof HttpError && e.status === 304;
67
+ }
68
+ expect(isNotModified(err)).toBe(true);
69
+ expect(isNotModified(new Error("nope"))).toBe(false);
70
+ });
71
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * HTTP constants and small typed helpers — replaces the parts of Deno's
3
+ * `@std/http` (other than cookies, which live in `./cookie.ts`) that deco
4
+ * storefronts touch.
5
+ *
6
+ * Currently exposes:
7
+ * - `STATUS_CODE` — full IANA status-code map (parity with @std/http).
8
+ * - `UserAgent` — minimal class with the same shape; does not parse
9
+ * the UA string (sites only used `.toString()` and
10
+ * basic browser/os accessors in dev). Replace with a
11
+ * real parser like `ua-parser-js` if you actually
12
+ * depend on the parsed fields.
13
+ */
14
+
15
+ export const STATUS_CODE = {
16
+ Continue: 100,
17
+ SwitchingProtocols: 101,
18
+ Processing: 102,
19
+ EarlyHints: 103,
20
+ OK: 200,
21
+ Created: 201,
22
+ Accepted: 202,
23
+ NonAuthoritativeInfo: 203,
24
+ NoContent: 204,
25
+ ResetContent: 205,
26
+ PartialContent: 206,
27
+ MultiStatus: 207,
28
+ AlreadyReported: 208,
29
+ IMUsed: 226,
30
+ MultipleChoices: 300,
31
+ MovedPermanently: 301,
32
+ Found: 302,
33
+ SeeOther: 303,
34
+ NotModified: 304,
35
+ UseProxy: 305,
36
+ TemporaryRedirect: 307,
37
+ PermanentRedirect: 308,
38
+ BadRequest: 400,
39
+ Unauthorized: 401,
40
+ PaymentRequired: 402,
41
+ Forbidden: 403,
42
+ NotFound: 404,
43
+ MethodNotAllowed: 405,
44
+ NotAcceptable: 406,
45
+ ProxyAuthRequired: 407,
46
+ RequestTimeout: 408,
47
+ Conflict: 409,
48
+ Gone: 410,
49
+ LengthRequired: 411,
50
+ PreconditionFailed: 412,
51
+ ContentTooLarge: 413,
52
+ URITooLong: 414,
53
+ UnsupportedMediaType: 415,
54
+ RangeNotSatisfiable: 416,
55
+ ExpectationFailed: 417,
56
+ Teapot: 418,
57
+ MisdirectedRequest: 421,
58
+ UnprocessableEntity: 422,
59
+ Locked: 423,
60
+ FailedDependency: 424,
61
+ TooEarly: 425,
62
+ UpgradeRequired: 426,
63
+ PreconditionRequired: 428,
64
+ TooManyRequests: 429,
65
+ RequestHeaderFieldsTooLarge: 431,
66
+ UnavailableForLegalReasons: 451,
67
+ InternalServerError: 500,
68
+ NotImplemented: 501,
69
+ BadGateway: 502,
70
+ ServiceUnavailable: 503,
71
+ GatewayTimeout: 504,
72
+ HTTPVersionNotSupported: 505,
73
+ VariantAlsoNegotiates: 506,
74
+ InsufficientStorage: 507,
75
+ LoopDetected: 508,
76
+ NotExtended: 510,
77
+ NetworkAuthenticationRequired: 511,
78
+ } as const;
79
+
80
+ export type StatusCode = typeof STATUS_CODE[keyof typeof STATUS_CODE];
81
+
82
+ /**
83
+ * Minimal stand-in for Deno's `@std/http`'s `UserAgent`. Captures the raw
84
+ * UA string and exposes the same field shape; does NOT parse. Replace with
85
+ * a real parser when you start depending on parsed fields.
86
+ */
87
+ export class UserAgent {
88
+ ua: string;
89
+ browser: { name?: string; version?: string };
90
+ os: { name?: string; version?: string };
91
+ device: { vendor?: string; model?: string; type?: string };
92
+ cpu: { architecture?: string };
93
+ engine: { name?: string; version?: string };
94
+
95
+ constructor(ua: string | null) {
96
+ this.ua = ua ?? "";
97
+ this.browser = {};
98
+ this.os = {};
99
+ this.device = {};
100
+ this.cpu = {};
101
+ this.engine = {};
102
+ }
103
+
104
+ toString(): string {
105
+ return this.ua;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Lightweight HTTP error class. Drop-in for the `HttpError` shape that
111
+ * `deco-cx/apps` exposes — sites use it as `error instanceof HttpError &&
112
+ * error.status === 304` and similar.
113
+ */
114
+ export class HttpError extends Error {
115
+ status: number;
116
+ body?: unknown;
117
+
118
+ constructor(status: number, message?: string, body?: unknown) {
119
+ super(message ?? `HTTP ${status}`);
120
+ this.name = "HttpError";
121
+ this.status = status;
122
+ this.body = body;
123
+ }
124
+ }
@@ -1,5 +1,11 @@
1
- import { describe, expect, it } from "vitest";
2
- import { inlineScript, useScript } from "./useScript";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ HTMX_LEGACY_URL,
4
+ inlineScript,
5
+ usePartialSection,
6
+ useScript,
7
+ useSection,
8
+ } from "./useScript";
3
9
 
4
10
  describe("inlineScript", () => {
5
11
  it("returns dangerouslySetInnerHTML with the provided string", () => {
@@ -51,3 +57,72 @@ describe("useScript", () => {
51
57
  expect(result).toMatch(/^\(.*\)\(\)$/);
52
58
  });
53
59
  });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Legacy HTMX stubs — useSection / usePartialSection
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe("useSection / usePartialSection (legacy HTMX stubs)", () => {
66
+ let warnSpy: ReturnType<typeof vi.spyOn>;
67
+ let prevNodeEnv: string | undefined;
68
+
69
+ beforeEach(() => {
70
+ // Reset the dedup set so each test sees a fresh warning fire.
71
+ delete (globalThis as any).__DECO_LEGACY_HTMX_WARNED;
72
+ prevNodeEnv = process.env.NODE_ENV;
73
+ process.env.NODE_ENV = "development";
74
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
75
+ });
76
+
77
+ afterEach(() => {
78
+ warnSpy.mockRestore();
79
+ if (prevNodeEnv === undefined) delete process.env.NODE_ENV;
80
+ else process.env.NODE_ENV = prevNodeEnv;
81
+ });
82
+
83
+ it("useSection returns a stable placeholder URL instead of throwing", () => {
84
+ expect(useSection()).toBe(HTMX_LEGACY_URL);
85
+ expect(useSection({ props: { x: 1 } })).toBe(HTMX_LEGACY_URL);
86
+ });
87
+
88
+ it("usePartialSection returns the same placeholder URL", () => {
89
+ expect(usePartialSection()).toBe(HTMX_LEGACY_URL);
90
+ });
91
+
92
+ it("warns on first call (in development)", () => {
93
+ useSection();
94
+ expect(warnSpy).toHaveBeenCalledTimes(1);
95
+ expect(warnSpy.mock.calls[0][0]).toMatch(/useSection/);
96
+ expect(warnSpy.mock.calls[0][0]).toMatch(/were removed/);
97
+ expect(warnSpy.mock.calls[0][0]).toMatch(/htmx-residue/);
98
+ });
99
+
100
+ it("dedups warnings: a second call to the same stub is silent", () => {
101
+ useSection();
102
+ useSection();
103
+ useSection();
104
+ expect(warnSpy).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("tracks warnings per-stub: useSection and usePartialSection warn independently", () => {
108
+ useSection();
109
+ usePartialSection();
110
+ expect(warnSpy).toHaveBeenCalledTimes(2);
111
+ const messages = warnSpy.mock.calls.map((c) => c[0] as string);
112
+ expect(messages.some((m) => m.includes("useSection"))).toBe(true);
113
+ expect(messages.some((m) => m.includes("usePartialSection"))).toBe(true);
114
+ });
115
+
116
+ it("does NOT warn in production", () => {
117
+ process.env.NODE_ENV = "production";
118
+ useSection();
119
+ usePartialSection();
120
+ expect(warnSpy).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("returns the placeholder URL even in production (still safe to embed)", () => {
124
+ process.env.NODE_ENV = "production";
125
+ expect(useSection()).toBe(HTMX_LEGACY_URL);
126
+ expect(usePartialSection()).toBe(HTMX_LEGACY_URL);
127
+ });
128
+ });
@@ -150,21 +150,61 @@ export function inlineScript(js: string) {
150
150
  * See: deco-to-tanstack-migration skill, "useComponent / partial sections"
151
151
  * section, for the per-pattern recipes.
152
152
  *
153
- * Both stubs throw at runtime (and at import time, if you call them at
154
- * module top level) so legacy code surfaces a clear error instead of a
155
- * silent no-op.
153
+ * ## SSR-safe stub behavior (since 2.27)
154
+ *
155
+ * Earlier versions threw on call. That broke SSR for the *entire page* if a
156
+ * single section still imported `useSection` — even sections that the user
157
+ * never interacts with (e.g. legacy login form on the homepage). React's
158
+ * error boundary would catch the throw and degrade the whole route to
159
+ * client rendering.
160
+ *
161
+ * The current behavior:
162
+ * - returns a stable placeholder URL (`HTMX_LEGACY_URL`) so it can be
163
+ * embedded in `hx-get` / `hx-post` attributes without crashing the
164
+ * SSR pass,
165
+ * - logs a deduped `console.warn` (in dev only) per call site so the
166
+ * migration signal stays loud,
167
+ * - the `htmx-residue` audit rule still catalogues every call site for
168
+ * systematic rewrite.
169
+ *
170
+ * This is a deliberate trade-off: SSR success > strict-throw enforcement.
171
+ * Audit + skill docs do the enforcement instead.
156
172
  */
157
173
  const DEPRECATION_MESSAGE =
158
174
  "[@decocms/start] useSection / usePartialSection were removed. " +
159
175
  "The Fresh/Deno HTMX partial-section pattern does not apply on " +
160
176
  "TanStack Start / Cloudflare Workers. Replace call-sites with " +
161
177
  "createServerFn + useMutation, or local React state. See the " +
162
- "deco-to-tanstack-migration skill for per-pattern recipes.";
178
+ "deco-to-tanstack-migration skill for per-pattern recipes. " +
179
+ "Run `deco-post-cleanup` and look for rule [9] htmx-residue to find " +
180
+ "every site call-site that still depends on this.";
181
+
182
+ /**
183
+ * Stable placeholder URL returned by the legacy `useSection` /
184
+ * `usePartialSection` stubs. Hitting this URL surfaces a clear error.
185
+ * Exported so frameworks/tests can match against it.
186
+ */
187
+ export const HTMX_LEGACY_URL = "/__deco_legacy_htmx_section__";
188
+
189
+ function warnLegacyHtmx(name: string) {
190
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") {
191
+ return;
192
+ }
193
+ if (typeof (globalThis as any).__DECO_LEGACY_HTMX_WARNED === "undefined") {
194
+ (globalThis as any).__DECO_LEGACY_HTMX_WARNED = new Set<string>();
195
+ }
196
+ const set = (globalThis as any).__DECO_LEGACY_HTMX_WARNED as Set<string>;
197
+ if (set.has(name)) return;
198
+ set.add(name);
199
+ console.warn(`[${name}] ${DEPRECATION_MESSAGE}`);
200
+ }
163
201
 
164
- export function usePartialSection(_props?: Record<string, unknown>): never {
165
- throw new Error(DEPRECATION_MESSAGE);
202
+ export function usePartialSection(_props?: Record<string, unknown>): string {
203
+ warnLegacyHtmx("usePartialSection");
204
+ return HTMX_LEGACY_URL;
166
205
  }
167
206
 
168
- export function useSection(_props?: Record<string, unknown>): never {
169
- throw new Error(DEPRECATION_MESSAGE);
207
+ export function useSection(_props?: Record<string, unknown>): string {
208
+ warnLegacyHtmx("useSection");
209
+ return HTMX_LEGACY_URL;
170
210
  }