Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Each export is built from the source file below (`dist/<same path>.js`, see abov
|---|---|
| `@decocms/blocks` | `src/index.ts` — the v8 runtime API only (`createCMS`, `currentClient`, `matchRoute`, `remoteLoader`, `parseDraftPointer`, `Blocks`, `Lazy`, …; a client is `cms.client({ version, visitor, date, experiments, campaigns })`, with `forRelease()`/`forDraft(p)` as shortcuts; the preview helpers are `cms.readVersion` (→ `{ version, date?, experiments?, campaigns? }`, spread into `cms.client`; `cms.draftPointer` is its `version` alone)/`cms.draftCookie`, since they check the preview hosts (the current release's `previewHosts`, which Studio writes into the release metadata, plus `createCMS({ preview: { hosts } })`; every host while a site has published no list, or with no `site` and no `preview.hosts`); `parseVersion` is the same reading without the host check, for the bindings), re-exported from `src/v8/index.ts` as one explicit `export { … } from "./v8/index.ts"` block (a conformance test parses that form) |
| `@decocms/blocks/analytics` | `src/v8/analytics.ts` — `AnalyticsScript`, `track` |
| `@decocms/blocks/analytics/client` | `src/v8/analytics/client.ts` — `startAnalytics(options)`: the Deco Analytics client for a v8 site, without the external tag. Importing it touches nothing; in the browser it fills `window.__dq` with the fields the collector writes ahead of `/_dq/a.js`, imports core and then each module named in `options.modules` (one chunk each, so a site downloads only what it starts), and replays what the page queued once every module registered. Resolves to the client's API, or null on the server, when collection is off for the visitor, when anything failed (it never rejects), or the tag's API when the tag booted first. Events go to `/_dq/e` on the page's own host by default, so the collector resolves the site from `Host`. `client.test.ts` holds that its events equal the standalone tag's |
| `@decocms/blocks/experiments` | `src/v8/experiments.ts` — who is in which A/B test, pure and isomorphic (no React, no `node:`, no CMS runtime, no module state, no environment reads): `assign(experiments, visitor)` → `{ [name]: boolean }`, `newVisitor`, `parseVisitor`, `serializeVisitor`, `readVisitor(request)` → `{ visitor, cookie }` (the web helper; `cookie` only when the visitor had no id), and the types `Visitor`, `Experiments`, `Assignments` (`experiments.test.ts` pins the runtime names). Nothing calls them for you: the app reads the visitor and passes it to `cms.client({ visitor })`, and the built-in `split-traffic` calls `assign`. A decision is arithmetic (`roll = murmur3_32("name:id") % 10000 / 100`, in when it is under the rounded percentage), so one visitor id serves every test and raising a percentage only adds people. The hash, the roll and the id live in the internal `src/v8/bucketing.ts` (not exported; the tests import it by path). `Visitor` is v7's `deco_segment` (same fields and encoding) plus an `id`, written once when it has none; v7's stored assignments are honoured while their `pct` holds (a missing or `-1` `pct` included), never rewritten. Changing the hash or the pinned vectors in `experiments.test.ts` moves every visitor already allocated: never a test to update |
| `@decocms/blocks/fetch` | `src/v8/fetch.ts` — `createInstrumentedFetch` |
| `@decocms/blocks/image` | `src/v8/image.ts` — the Deco image CDN's URLs (`decoCdn`), `createImageUrl`, `srcSet`, `parseSrcSet`, `preloadLink` and the generic `ImageProvider` type. Platform-neutral only: no commerce platform's URLs, hosts or detection here (a platform's provider lives in its client package, e.g. `@decocms/apps-vtex/image`). Pure functions, no React, no module state: a site builds one `ImageUrl`; the bindings render it |
Expand Down Expand Up @@ -144,6 +145,8 @@ v7's pure helpers, for sites moved from v7: each one gives v7's output (latest 7

### Key boundaries

- **The Deco Analytics client's source lives in `src/v8/analytics/` (`runtime/`, `shared/`), one implementation for two consumers.** decocms/analytics bundles each entry with esbuild into a standalone script that its collector Worker serves at `/_dq/a.js` to every site (v7, checkout and sites outside deco included), and `AnalyticsScript` renders that `<script>` tag; a v8 site can instead run the same files through `@decocms/blocks/analytics/client` (`client.ts`, the only export). Nothing in the CMS runtime imports them. The files under `runtime/` and `shared/` are scripts first: each runs on load, which is what the standalone needs, so the client imports them in order rather than calling into them. The folder's `tsconfig.json` ships with `src` and sets `strict`, so the consumer's bundles keep the `"use strict"` the production ones carry. Biome formats the folder but doesn't lint it or organize its imports: those fixes change the bytes every site is served (reordering imports reorders the bundle). `analytics/tag.test.ts` bundles every entry and holds that nothing outside the folder reaches it.
- **A blocks release does not reach the standalone tag by itself.** Publishing `@decocms/blocks` changes nothing in `/_dq/a.js`: decocms/analytics pins blocks at an exact version and its Worker serves what it built from that. After a release that changes anything under `src/v8/analytics/runtime/` or `shared/`, open a PR in decocms/analytics that bumps `@decocms/blocks` to that exact version and runs `npm run tag:lock`; its CI checks the bundles against the lock and runs the e2e, and the merge deploys. Say in that PR which bundles changed and why, since every site loads them. A v8 site on `@decocms/blocks/analytics/client` gets the change with its own next bump and deploy. Until decocms/analytics builds from this package (task 3.7 of its execution plan), its own `runtime/` is what production serves: make the same change there instead.
- **The runtime never imports the protocol or the CLI.** `src/v8` modules import only each other (no Node built-ins, no React at runtime except `/analytics`); `src/v8/browserBundle.test.ts` proves it with a real esbuild bundle. The one exception is `#async-store` (package.json `imports`): its `node`/`workerd`/`bun`/`deno` condition loads `src/async/node.ts` (`AsyncLocalStorage` from `node:async_hooks`, so Workers need `nodejs_compat`), its `default` `src/async/default.ts` (a global `AsyncLocalStorage` if the runtime has one, else none). The CLI may import the protocol.
- **Clients depend only on `@decocms/blocks`**, with no peers and no framework binding. A client takes settings as arguments (no env reads), never caches, has no hooks, converters or `"use client"` code: those belong in the site's platform template (`tests/upstream-clients.conformance.test.ts`).
- **Platform helpers live in the platform's package, not in blocks.** Besides its client, an `apps-*` package may export pure helper subpaths that compose with `@decocms/blocks` (`@decocms/apps-vtex/image` and `@decocms/apps-shopify/image`: image URL providers for `createImageUrl`). Pure: no network, no React, no env reads, no runtime imports (`tests/upstream-helpers.guardrail.test.ts`, which also bundles each for a browser and workerd). The product owner's call: `@decocms/blocks` holds framework code only, nothing that knows VTEX, Shopify or another commerce platform. Bindings never depend on an `apps-*` package: sites wire the helper in.
Expand Down
8 changes: 8 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@
]
},
"overrides": [
{
"includes": [
"packages/blocks/src/v8/analytics/runtime/**",
"packages/blocks/src/v8/analytics/shared/**"
],
"linter": { "enabled": false },
"assist": { "enabled": false }
},
{
"includes": [
"packages/apps-*/src/**",
Expand Down
6 changes: 5 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@
],
"entry": [
"src/v8/cli/main.ts",
"src/v8/cli/__tests__/folderCdn.ts"
"src/v8/cli/__tests__/folderCdn.ts",
"src/v8/analytics/runtime/src/index.ts",
"src/v8/analytics/runtime/modules/*.ts",
"src/v8/analytics/runtime/view/*.ts",
"src/v8/analytics/shared/*.ts"
]
},
"packages/compat-v7": {
Expand Down
5 changes: 5 additions & 0 deletions packages/blocks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@
"source": "./src/v8/analytics.ts",
"default": "./dist/v8/analytics.js"
},
"./analytics/client": {
"types": "./dist/v8/analytics/client.d.ts",
"source": "./src/v8/analytics/client.ts",
"default": "./dist/v8/analytics/client.js"
},
"./cli": {
"types": "./dist/v8/cli/index.d.ts",
"source": "./src/v8/cli/index.ts",
Expand Down
9 changes: 9 additions & 0 deletions packages/blocks/src/v8/analytics/client.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// @vitest-environment node
/** On the server, `@decocms/blocks/analytics/client` imports cleanly and does nothing. */
import { expect, it } from "vitest";
import { startAnalytics } from "./client.ts";

it("importing and starting it on the server touch no browser API", async () => {
expect(await startAnalytics({ modules: ["click", "heatmap"] })).toBeNull();
expect((globalThis as Record<string, unknown>).__dq).toBeUndefined();
});
172 changes: 172 additions & 0 deletions packages/blocks/src/v8/analytics/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// @vitest-environment jsdom
/**
* `@decocms/blocks/analytics/client`: a v8 site running the Deco Analytics client
* without the external tag. It must collect what the tag collects, by itself, and
* send it to the collector in the same wire format, because both run the same files.
*/
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WIRE } from "./shared/contract.ts";

type Event = Record<string, unknown>;
type Api = Record<string, (...args: any[]) => unknown>;
type Post = { url: string; body: { site: string; key?: string; events: Event[] } };

const here = dirname(fileURLToPath(import.meta.url));
const w = window as unknown as Record<string, any>;
let posts: Post[] = [];

const events = () => posts.flatMap((p) => p.body.events);
const names = () => events().map((e) => e[WIRE.name]);

/** A fresh page: the client's globals gone and a fresh copy of every module. */
beforeEach(() => {
vi.resetModules();
for (const k of ["__dq", "__dqBooted", "__dqRegister", "__dqCid"]) delete w[k];
posts = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: RequestInit) => {
posts.push({ url: String(url), body: JSON.parse(String(init.body)) });
return new Response(null, { status: 204 });
}),
);
});
afterEach(() => vi.unstubAllGlobals());

const start = async (options: Record<string, unknown> = {}) => {
const { startAnalytics } = await import("./client.ts");
// `dev`: jsdom's page is on localhost, which the client rightly skips otherwise.
return (await startAnalytics({ dev: true, ...options })) as Api | null;
};

describe("startAnalytics", () => {
it("collects a page view by itself and sends it to /_dq/e on the page's own host", async () => {
const api = await start();
expect(typeof api?.track).toBe("function");
expect(posts).toHaveLength(1);
// Relative, so the collector resolves the site from this page's Host (rule 1).
expect(posts[0].url).toBe("/_dq/e");
expect(posts[0].body.events[0]).toMatchObject({
[WIRE.name]: "$pageview",
[WIRE.path]: location.pathname,
});
});

it("counts a client-side navigation as a page view, with no track() call", async () => {
const api = await start();
history.pushState({}, "", "/category/shoes");
api!.flush();
expect(
events()
.filter((e) => e[WIRE.name] === "$pageview")
.map((e) => e[WIRE.path]),
).toEqual(["/", "/category/shoes"]);
history.pushState({}, "", "/");
});

it("loads the modules it is given, after core, and they collect by themselves", async () => {
const api = await start({ modules: ["click", "commerce"] });
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
api!.addToCart({ value: 10, currency: "BRL", itemIds: ["sku-1"] });
api!.flush();
expect(names()).toEqual(["$pageview", "experiment_click", "add_to_cart"]);
expect(events()[2]).toMatchObject({ [WIRE.value]: 10, [WIRE.currency]: "BRL" });
});

it("delivers a helper the page queued before it started, once that helper's module is in", async () => {
w.__dq = { q: [{ m: "addToCart", a: [{ value: 5 }] }] };
const api = await start({ modules: ["commerce"] });
api!.flush();
expect(names()).toEqual(["$pageview", "add_to_cart"]);
});

it("passes the site's settings to core the way the collector's bundle does", async () => {
await start({ key: "dq_SiteKey000000000000000", endpoint: "https://collector.example/_dq/e" });
expect(posts[0].url).toBe("https://collector.example/_dq/e");
expect(posts[0].body.key).toBe("dq_SiteKey000000000000000");
});

it("leaves the external tag alone when it booted first: one client per page", async () => {
const track = vi.fn();
w.__dqBooted = true;
w.__dq = { track };
const api = await start({ modules: ["click"] });
expect(api?.track).toBe(track);
expect(posts).toEqual([]);
});

it("collects nothing, and loads no module, when collection is off for this visitor", async () => {
const { startAnalytics } = await import("./client.ts");
// A development host without `dev`: the same skip the tag applies.
expect(await startAnalytics({ modules: ["click"] })).toBeNull();
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(posts).toEqual([]);
expect(w.__dq.track).toBeUndefined();
});
});

describe("the same events as the standalone tag", () => {
const out = mkdtempSync(join(tmpdir(), "dq-client-"));
afterAll(() => rmSync(out, { recursive: true, force: true }));
const esbuildBin = join(here, "../../../../../node_modules/.bin/esbuild");

/** The bundle decocms/analytics serves as `/_dq/a.js` for a site with `click`: core, then the module. */
function standalone(): string {
return ["runtime/src/index.ts", "runtime/modules/click.ts"]
.map((entry) => {
const file = join(out, entry.replaceAll("/", "_"));
execFileSync(esbuildBin, [
join(here, entry),
"--bundle",
"--format=iife",
"--minify",
"--target=es2020",
`--outfile=${file}.js`,
"--log-level=warning",
]);
return readFileSync(`${file}.js`, "utf-8");
})
.join("\n");
}

/** The keys of each event, the values that don't vary by run (`event_id`, `ts`) left out. */
const shape = (es: Event[]) =>
es.map((e) => {
const { [WIRE.event_id]: _id, [WIRE.ts]: _ts, ...rest } = e;
return rest;
});

it("a page view and a click reach the collector identically from either", async () => {
const api = await start({ modules: ["click"] });
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
api!.flush();
const native = posts.map((p) => ({ ...p.body, events: shape(p.body.events) }));

const dom = new JSDOM("<!doctype html><body></body>", {
url: location.href,
runScripts: "outside-only",
pretendToBeVisual: true,
});
const tagPosts: Post["body"][] = [];
dom.window.fetch = (async (_url: string, init: RequestInit) => {
tagPosts.push(JSON.parse(String(init.body)));
return new Response(null, { status: 204 });
}) as never;
dom.window.eval(`window.__dq={s:"unknown",e:"/_dq/e",r:1,tier:0,dev:true};${standalone()}`);
dom.window.document.body.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
dom.window.__dq.flush();
dom.window.close();

expect(native).toEqual(tagPosts.map((b) => ({ ...b, events: shape(b.events) })));
expect(native.flatMap((b) => b.events).map((e) => e[WIRE.name])).toEqual([
"$pageview",
"experiment_click",
]);
});
});
Loading
Loading