Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ export async function GET(request: Request) {

> **Caching:** because the proxy URL is stable, don't mark it `immutable`. If you'd rather cache at the edge, `s-maxage` + `stale-while-revalidate` (e.g. `public, s-maxage=86400, stale-while-revalidate=604800`) is a good alternative that bounds staleness while still revalidating. The static (non-proxy) sprite can be hashed with [`--hash`](#options) and served `immutable`; see [Caching](#caching).

> **Using `--hash`?** The on-disk sprite is then named `symbolstore-<hash>.svg`, so the proxy handler must resolve that filename rather than hardcoding `symbolstore.svg` — e.g. `readdir("public")` and match `^symbolstore(-[0-9a-f]+)?\.svg$`. The demo route ([`test/src/app/api/symbol-store/route.ts`](test/src/app/api/symbol-store/route.ts)) does exactly this, so it works with or without `--hash`.

### Alternative: inline the sprite (no proxy)

Inlining bakes the sprite into the document: the `<symbol>` definitions are injected once so `<use href="#icon">` resolves against the same document. Because nothing is loaded cross-origin, this **works from any origin with no proxy**, and because the definitions ship in the server-rendered HTML there's **no extra request and no flash of missing icons** (see [First paint](#first-paint)).
Expand Down Expand Up @@ -213,6 +215,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })

`SymbolStoreSprite` is a server component that injects static markup, so there's no client JS and no flash: the sprite ships in the initial HTML and persists across client navigations. `<Icon node="…" />` then resolves against it from any origin.

> **Content-Security-Policy.** `--inline` injects the sprite into your document, and the injected markup hides the sprite container with inline `style` attributes — deliberately, because a plain `display:none` can stop some browsers from resolving `<use>`. Under a strict CSP, inline mode therefore needs `style-src 'unsafe-inline'` (or a per-request nonce). If that isn't acceptable, prefer the default static-file or [proxy](#cross-origin-requests) mode: there the sprite lives in a **separate file** referenced by `<use>`, so none of its markup — inline styles included — enters your document.
>
> The sprite is your own build-time input, so the `dangerouslySetInnerHTML` in `SymbolStoreSprite` is safe. If your icon pipeline ever ingests untrusted SVG, sanitize it before generating.

## First paint

Because icons are referenced from an external file via `<use href="…">`, the browser must fetch the sprite before it can paint any icon — the glyphs aren't part of the server-rendered HTML. On a cold cache this means icons appear a moment after the rest of the page (a brief flash of no-icon), and the [proxy](#cross-origin-requests) can add a server round-trip (e.g. fetching from your CDN) in front of that request.
Expand Down
6 changes: 1 addition & 5 deletions package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,8 @@
"package.json",
"README.md",
"LICENSE.txt",
"bin/**/*.ts",
"lib/**/*.ts",
"dist/**/*.js",
"dist/**/*.js.map",
"dist/**/*.d.ts",
"dist/**/*.d.ts.map"
"dist/**/*.d.ts"
],
"bin": "./dist/bin/symbol-store.js",
"imports": {
Expand Down
22 changes: 19 additions & 3 deletions test/src/app/api/symbol-store/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";

Expand All @@ -20,16 +20,32 @@ import { NextResponse } from "next/server";
* const res = await fetch("https://cdn.example.com/symbolstore.svg");
* const svg = await res.text();
*
* The on-disk filename may carry a content hash (`symbolstore-<hash>.svg`, from
* `symbol-store --hash`), so we resolve it from /public rather than hardcoding a
* name — this route works whether or not `--hash` is used.
*
* Caching: this endpoint has a STABLE URL (the helper intentionally doesn't bake
* a hash into `/api/symbol-store`), so its contents are mutable and must NOT be
* served `immutable` — that would pin returning visitors to a stale sprite for up
* to a year after a redeploy with new icons. Instead we attach a content `ETag`
* and revalidate: an unchanged sprite comes back as a cheap `304 Not Modified`,
* and an icon update is picked up on the very next request.
*/
async function resolveSpriteFile(publicDir: string): Promise<string | null> {
const entries = await readdir(publicDir);
// Matches `symbolstore.svg` and a hashed `symbolstore-<hash>.svg`.
return entries.find((f) => /^symbolstore(-[0-9a-f]+)?\.svg$/.test(f)) ?? null;
}

export async function GET(request: Request) {
const spritePath = path.join(process.cwd(), "public", "symbolstore.svg");
const svg = await readFile(spritePath, "utf-8");
const publicDir = path.join(process.cwd(), "public");
const spriteFile = await resolveSpriteFile(publicDir);

if (!spriteFile) {
return new NextResponse("Sprite not found", { status: 404 });
}

const svg = await readFile(path.join(publicDir, spriteFile), "utf-8");

const etag = `"${createHash("sha256").update(svg).digest("hex").slice(0, 16)}"`;
const cacheControl = "public, max-age=0, must-revalidate";
Expand Down