# 305. Third-party scripts

The question this chapter answers: where do an analytics tag, a consent banner, a font and a favicon go, given that an application has no document template, no head component and writes no `<script>` tag?

**For:** app developers.

## The document head

A conventional site keeps a document template with the fixed part of the head in it: the base, the robots meta, a preconnect to the font host, the icons, the analytics tag. fsr has no such template. The host writes the document (chapter 200) and what it writes is data from two places: what it inferred from the app directory and what the routes said in their `meta` exports (chapter 100). There is no third place and no configuration key that takes an element.

So the fixed part of the head is the root layout's `meta`. It runs for every route beneath it, an inner route overrides one row at a time by naming the same element and the rows are data the build can read. The helpers in `@snapfire/fsr/head` spell the common ones; an element they do not spell is an object literal with a `tag` and its attributes:

```ts
import { linkTag, metaTag, robots } from "@snapfire/fsr/head";

export const meta = () => ({
  head: [
    { tag: "base", href: "/" },
    robots("index,follow"),
    metaTag("format-detection", "telephone=no"),
    linkTag("preconnect", "https://fonts.example"),
    linkTag("stylesheet", "https://fonts.example/inter/inter.css"),
    { tag: "link", rel: "icon", type: "image/png", sizes: "32x32", href: "/static/favicon-32x32.png" },
    { tag: "link", rel: "icon", type: "image/x-icon", href: "/static/favicon.ico" },
  ],
});
```

Two rows are the same element when they share the attribute a head element is identified by: `rel`, `name`, `property`, `http-equiv`, `itemprop` or `id`, qualified by `sizes`, `media`, `type` and `hreflang`. A `link` whose `rel` names a resource, a stylesheet, a preconnect or a preload, is qualified by its `href` as well, so two preconnects are two elements. A `canonical` or an `icon` is a role and an inner route replaces it by naming it again. The host's own inferred icons sit under the layout's rows and are overridden the same way. An application with no `icons/` directory gets `<link rel="icon" href="data:,">`, an empty icon that keeps the browser from requesting `/favicon.ico` and logging the 404. It is dropped as soon as a route's `meta` names any icon, like the two above. `[document] empty_icon = false` turns it off.

A `script` is not in that list and is a row of its own kind, the next section but one.

## Scripts as modules

Anything the page runs is a module the entry imports. A cookie banner, a theme toggle, a keyboard shortcut: each is a file under `src/` that `main.ts` imports for its effect and the build compiles it with everything else.

```ts
import { boot, enableNavigation } from "@snapfire/fsr/client";
import { registerIslands } from "@generated/islands.js";

import "./consent";
import "./theme";

registerIslands();
boot();
enableNavigation();
```

A third-party library arrives the way React did in chapter 301: its ESM build under `vendor/`, an entry in the import map and `fsr types` for its declarations. Take the ESM build. A UMD file loads as a side-effect import and assigns a global, but exports nothing, so the module that imports it has no binding to type or to call. The vendored file and the fetched declarations come from different places unless the same registry version feeds both, so read the version in each before you trust the types.

A library that has to be a `<script src>` because its vendor says so is loaded with `loadScript` or declared as a head row when a route needs it. Those are the next two sections.

## Loading a vendor's script

`loadScript` from `@snapfire/fsr/client` adds a classic script once per document and resolves when it has run. A second call for the same URL returns the first call's promise and adds nothing, so a module can ask for its vendor on every navigation:

```ts
import { loadScript } from "@snapfire/fsr/client";

await loadScript("https://maps.example/widget.js", { when: "interaction", attrs: { crossorigin: "anonymous" } });
window.mapsWidget.draw();
```

`when` is `"load"` by default, which adds the element straight away. `"idle"` waits for the main thread to be idle, which suits analytics. `"interaction"` waits for the visitor's first key, pointer, touch or scroll, which suits a chat widget or a map nobody has looked at yet. The promise rejects with the source named when the script fails to load. The element is `async` unless `attrs` says otherwise.

## Declaring a route's script

A script one route needs belongs in that route's `meta`, the way its preloads do. `scriptTag` spells the row:

```ts
// routes/venue/page.loader.ts
import { scriptTag } from "@snapfire/fsr/head";

export const meta = () => ({
  head: [scriptTag("/static/demo/venue-map.js", "interaction")],
});
```

The document carries the row as an inert element, `<script type="text/plain" data-sf-script="interaction" data-src="/static/demo/venue-map.js">`, which the client loads through `loadScript` once it boots. A navigation adds no head markup, so the payload's head row lists the same rows and the navigator loads them the same way. A script inserted as markup by a navigation never runs, which is why a live `<script>` tag would work on the first load and silently not after one. Leaving the route does not unload a script. Coming back does not load it again. The conference app's venue page does this with a stand-in map widget and the storefront's root layout does it with a stand-in analytics tag on `"idle"`.

A row written out takes the same keys: `{ tag: "script", src, when, crossorigin: "anonymous" }`, any attribute beyond `src` and `when` kept as written. A row needs a `src`. One with inline code in `children` is refused by the build, since that code would run on a document load and never after a navigation; the code belongs in a module `src/main.ts` imports. A data block is not code: `jsonLd(json)` writes `{ tag: "script", type: "application/ld+json", children: json }`, which the document carries as written. So is a row of any other type the browser does not run.

The build reads each row's `src` and the host adds the origin of every one on another origin to `script-src`, the way it adds the import map's hash. The boot report lists them, which on the stock host is `fsr serve --verbose`:

```
scripts   venue                  /static/demo/venue-map.js
```

A `src` computed at request time, from `[public]` for one, has no origin the build can read. The report says so on that row. That origin goes in `[document.csp]` by hand.

## Loading a tag after consent

The usual pattern for consent-gated analytics is to write the tag with `type="text/plain"` and a category attribute, so the banner library rewrites it into a real script once the category is accepted. That pattern exists because the tag was HTML and the banner had to stop it running. With no tag, the module loads the vendor from the banner's own callbacks, which say when a category is accepted.

Keep the banner and each vendor apart. One module handles consent and each vendor gets its own module that knows how that vendor is loaded.

```ts
// src/consent.ts
import { acceptedCategory, run } from "vanilla-cookieconsent";

import { loadAnalytics } from "./analytics";

function onConsent() {
  if (acceptedCategory("analytics")) {
    loadAnalytics();
  }
}

run({ onConsent, onChange: onConsent, categories: { necessary: { enabled: true, readOnly: true }, analytics: {} }, language: { /* ... */ } });
```

```ts
// src/analytics.ts
import { loadScript } from "@snapfire/fsr/client";
import { get } from "@snapfire/fsr/client/store";

import { analytics } from "./store";

declare global {
  interface Window {
    dataLayer?: unknown[];
  }
}

export function loadAnalytics() {
  const id = get(analytics);
  if (!id || window.dataLayer) {
    return;
  }
  const dataLayer: unknown[] = [];
  window.dataLayer = dataLayer;
  // gtag.js reads only `Arguments` entries off the queue and skips arrays.
  function gtag(..._: unknown[]) {
    dataLayer.push(arguments);
  }
  gtag("js", new Date());
  gtag("config", id);
  void loadScript(`https://www.googletagmanager.com/gtag/js?id=${id}`, { when: "idle" });
}
```

Google's snippet pushes `arguments` and gtag.js checks for exactly that; a rest parameter is an array and an array on the queue is silently ignored, so the page view never goes out and nothing says so. `onChange` runs `onConsent` again so a visitor who accepts later is counted from then on; the `dataLayer` guard makes the second call a no-op.

## A per-deployment id

The deployment owns the id in that module and a deployment's values live in `[public]` in the configuration, chapter 200. Declare it in `app.toml` with the value development runs under, which is also what types it, then let the region's overlay set the real one:

```toml
# config/app.toml
[public]
analytics_id = ""
```

```toml
# config/app.sfo1.toml
[public]
analytics_id = "G-65TR8XV7YE"
```

A loader reads it as `ctx.config.analytics_id`, typed `string` because the declaration was a string. The root layout's `store` seeds the browser with it under a key the module reads:

```ts
// routes/layout.loader.ts
export async function load({ config }: Ctx) {
  return { analyticsId: config.analytics_id };
}

export const store = ({ data }: { data: { analyticsId: string } }) => ({ "site/analytics": data.analyticsId });
```

```ts
// src/store.ts
import { key } from "@snapfire/fsr/client/store";

export const analytics = key<string>("site/analytics");
```

With an empty id in development, `loadAnalytics` returns before it touches the page, so nothing about the banner or the module changes between a laptop and production. A `[public]` value reaches the browser, as the section's name says. A key that must stay on the server does not go in `[public]`.

## Content-Security-Policy

A Content-Security-Policy governs every script on the page, so a tag that loads fine without one stops the moment there is one. `[document.csp]` is that policy, written as directives and their sources rather than a string:

```toml
[document.csp]
default-src = ["'self'"]
script-src = ["'self'", "https://www.googletagmanager.com"]
img-src = ["'self'", "data:", "https://*.google-analytics.com"]
connect-src = ["'self'", "https://*.google-analytics.com"]
frame-src = ["https://www.youtube.com"]
object-src = ["'none'"]
```

The host merges in the sources only it knows. The inline import map's hash goes into `script-src`, because an import map has to be inline and the merged one matches no file on disk. Each live HTML response under a policy that restricts scripts gets a fresh nonce in `script-src`, which the development refresh script carries, since that script holds the bundle id it was rendered against and has no stable hash. The scripts that fill a streamed segment go in by hash as well: each resolution's data rides in an inert JSON block, so what runs is the fill script and `__sfFill(n)` for each slot a route can stream, fixed text known at boot. The hash of each `<style>` a lowered component writes with literal content, a web component's shadow template among them, goes into `style-src`. A browser ignores `'unsafe-inline'` beside a hash or a nonce, so the host adds none of these to a directive that already allows `'unsafe-inline'`: the inline content runs under that instead and a policy written for inline third-party tags keeps them. Nothing in the policy names any of these. A development host enforces what a production one does, so a missing origin shows up in development.

A page or layout with an inline script of its own reads the nonce as its `csp_nonce` prop and puts it on the tag:

```tsx
export default function Page({ csp_nonce }: IndexProps) {
  return <script nonce={csp_nonce}>{"window.dataLayer = window.dataLayer || [];"}</script>;
}
```

Reading `csp_nonce` renders that page or layout live on every request, since a nonce is good for one response, so it keeps the route out of the prerender list. A prerendered document and a preview carry no nonce and their inline scripts need a hash in the policy.

A policy that restricts styles, through `style-src` or `default-src`, gets `style-src-attr 'unsafe-inline'` unless it declares `style-src-attr` itself. A lowered component's `style={{ width }}` is printed as a `style` attribute, which has no hash a policy written at boot can name. A fragment client navigation swaps in is checked against the first document's policy. The directive admits attributes only: a `<style>` whose content the render computes and every script stay as the policy wrote them. A policy that wants attributes blocked declares `style-src-attr = ["'none'"]` and gives up server-rendered inline styles.

One `script-src` source breaks the page: `'strict-dynamic'`.

`'strict-dynamic'` is the usual answer for a tag manager, because it trusts whatever a trusted script loads through `document.createElement` so the allowlist stops mattering. It does not work here: it also makes the browser ignore `'self'` and every host in that directive. The entry module is a `<script src>` in the markup carrying no hash or nonce, so the page loads nothing at all. `fsr doctor` reports that too.

An allowlist covers analytics, whose origins are stable. It cannot cover an ad network, which injects scripts from origins that change per impression and often still uses inline script and `document.write`. For that, write the policy into `[document.csp_report_only]` first, which is the same shape sent as `Content-Security-Policy-Report-Only`. A browser reports against it and enforces nothing, so a deployment learns what would break before anything does. Both keys may be set at once. An application `fsr new` made already starts on a report-only policy. Under `fsr dev` each violation is listed in a panel on the page with the source that would admit it.

## What the build can check

Every part of the arrangement is something the build reads. The head rows are data the report lists and a test asserts on. The vendor is a committed file with a recorded version. The id is a typed field, so a misspelt read is a build error and `fsr doctor` reports a loader reading a key no `[public]` declares. The consent decision is a function call in one module. The policy is a table the build and `fsr doctor` read, so the two traps above are caught before a deployment ships rather than by a blank page.

## A script that reads the markup

A tag reads events. A library that reads the markup, one that wires attributes it finds when it processes a node, needs to know that the navigator writes markup after the document loaded. It dispatches `sf:navigate` on `document` once a payload's eager wave is applied and `sf:fill` for every deferred segment it fills, so such a library processes the document again on both. Chapter 105 does this for htmx, in both directions.

## The lab

In the storefront, add `[public] analytics_id = ""` to `config/app.toml` and read it in `routes/layout.loader.ts` as above. Boot the host: the report gains a `public` row with the empty value and the page's store seed carries `"site/analytics": ""`. Set it in `config/local.toml` and boot again: the seed carries the value and nothing else changed. Now misspell the key in the loader, `config.analytic_id`, then run `fsr build`: the typecheck refuses it, since `Config` has no such field. Put the misspelling in the overlay instead and run `fsr doctor`: the `ctx.config` check names the key the loader reads that no `[public]` declares.
