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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,42 @@ as a second agent. Both ingest examples are published live:
Publishing more than one project into a single bucket needs a `prefix` on the
target; without one, each project's `latest.json` overwrites the others'.

## The link you can share

A release directory is named after its content digest, so the same inputs
republish to the same URL and different inputs get a new one. That makes a
release immutable, and it also means a link to one moves whenever the release
does — a new viewer bundle is enough, since the bundle is part of the digest.

So `publish` writes an `index.html` beside `latest.json` that reads the
pointer in the browser and forwards to whichever release it names. The page
holds no release of its own, so it is the same bytes every publish and cannot
fall behind. The publish root is the stable link:

```text
https://<host>/<prefix>/ → always the current release
https://<host>/<prefix>/latest.json → the pointer it reads
```

The page is written twice, at `<prefix>/index.html` and at `<prefix>/`, which
is what makes the bare URL portable. A host that resolves a directory looks
for `index.html`, the one name every static host agrees on; an object store
serves keys and nothing else, so it needs a key of that exact name. Writing
both means the same link works either way, and it travels with the bucket
rather than living in a rewrite rule at whichever CDN is in front of it.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The second write is the optional one. A target publishing to the bucket root
has no directory key to write, no filesystem allows a file named `<prefix>/`,
a store may refuse a key ending in a separator, and a foreign object already
sitting there is left alone. Whenever it does not happen, `index.html` is
still written and `entry_url` names it explicitly, so the link handed back
always resolves.

`publish` returns it as `entry_url`. If an `index.html` is already at that key
and chainplot did not write it, it is left alone and `entry_point_written`
comes back false — a bucket that serves a site of its own keeps its front
page.

## Look and feel

The viewer transcribes the Chainstack design tokens from `cp-ui-kit`
Expand Down
1 change: 1 addition & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Machine-readable source of truth: `chainplot capabilities --json`.
- Event sources: explicit address lists, one chain per project, rindexer adapter (pinned image, linux/amd64)
- End policies: `pinned` (finality-checked at plan time), `follow_finalized` (requires `finalized` chain policy)
- Publish targets: `directory` (atomic `latest.json`), `s3` (conditional write; verified on Cloudflare R2). Set `prefix` on a target when one bucket or directory holds more than one project — `latest.json` is otherwise a single key at the root and the projects overwrite each other's pointer.
- Publish root: beside `latest.json`, `publish` writes an `index.html` that forwards to the release the pointer names. A release directory is content-addressed, so its URL moves whenever the release does — a change to the viewer bundle is enough, since the bundle is part of the digest. The root is the URL that stays put, and `publish` returns it as `entry_url`. The page is written both at `<prefix>/index.html` and at `<prefix>/`: a host that resolves directories finds the former, an object store that serves keys needs the latter, and writing both keeps the bare URL working on either without a rewrite rule at the CDN. That second key is best effort: a prefix-less target has no directory key, a `directory` target cannot name a file that way, a store may refuse a key ending in a separator, and a foreign object already there is left alone. `entry_url` then names `index.html` explicitly, so the URL returned resolves either way. An `index.html` already at that key without chainplot's marker is left alone and `entry_point_written` comes back false, so publishing into a bucket that serves a site of its own does not replace that site's front page. On `s3` that refusal is enforced by a conditional write and holds against a concurrent writer; on `directory` it is a check followed by a rename, so a foreign file written into the same key mid-publish is overwritten. The page reads `latest.json` in the browser rather than naming a release, so its bytes depend only on the prefix: every publish writes the same page, and the entry point cannot fall behind the pointer whatever order concurrent publishers finish in. It needs script, as does the viewer it forwards to. Both the page and `latest.json` are written `no-cache, must-revalidate`, since a cache that serves either without asking would show an older release.
- Charts: `line`, `bar`, `area`, `kpi`, `table` (allowlisted encodings only)
- Dataset modes: `results_only` (default), `dataset_referenced`, `dataset_included`. The default publishes the page and its results only. `dataset_referenced` uploads the parquet beside the release and records a release-relative path and checksum, so `fork` fetches and verifies it on demand. `dataset_included` copies it into the release. Set per build (`--mode`) or per project (`policy.release_mode`).
- Panel presentation: `title`, `description`, `span` (`half`/`full`), `hide_columns`, `unit`
Expand Down
95 changes: 89 additions & 6 deletions src/publish/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,41 @@ import fs from "node:fs";
import path from "node:path";
import type { LatestPointer } from "./target.js";
import type { PublishTarget } from "./target.js";
import { ENTRY_POINT_MARKER } from "./entryPoint.js";

const LATEST = "latest.json";
const TEMP_PREFIX = ".latest-tmp-";

/**
* Stage a file next to where it is going, inside a directory of its own.
*
* `mkdtemp` is the platform's answer to the question this raises: it creates
* the directory itself, with a name nobody can guess and permissions nobody
* else can enter, so no symlink can be waiting at the path we are about to
* write. Building a name by hand and opening it carefully gets to the same
* place, but this is the version a reader does not have to check.
*
* Staging beside the destination rather than in the system temp directory
* keeps the final step a rename within one filesystem, which is what makes
* it atomic.
*/
function stage(destination: string, name: string, write: (tempPath: string) => void): {
path: string;
discard: () => void;
} {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const dir = fs.mkdtempSync(path.join(path.dirname(destination), TEMP_PREFIX));
const tempPath = path.join(dir, name);
try {
write(tempPath);
} catch (err) {
fs.rmSync(dir, { recursive: true, force: true });
throw err;
}
return { path: tempPath, discard: () => fs.rmSync(dir, { recursive: true, force: true }) };
}
const ENTRY_POINT = "index.html";

export class DirectoryTarget implements PublishTarget {
constructor(
private readonly rootDir: string,
Expand All @@ -17,6 +48,11 @@ export class DirectoryTarget implements PublishTarget {
return path.join(this.rootDir, this.keyPrefix, LATEST);
}

/** The forwarding page, beside the pointer. */
private entryPointPath(): string {
return path.join(this.rootDir, this.keyPrefix, ENTRY_POINT);
}

async uploadFiles(
releaseDir: string,
prefix: string,
Expand Down Expand Up @@ -68,15 +104,62 @@ export class DirectoryTarget implements PublishTarget {
return JSON.parse(fs.readFileSync(file, "utf8")) as LatestPointer;
}

async promoteEntryAlias(): Promise<boolean> {
// A file cannot be named `<prefix>/`. Anything serving a directory of
// files resolves the bare path to index.html by itself anyway.
return false;
}

async promoteEntryPoint(html: string): Promise<boolean> {
const entry = this.entryPointPath();
const existed = fs.existsSync(entry);
if (existed && !fs.readFileSync(entry, "utf8").includes(ENTRY_POINT_MARKER)) {
return false;
}
// Temp-then-publish, so a reader never sees a half-written page.
//
// Claiming a free key is atomic below; replacing our own page is not,
// because POSIX has no compare-and-replace. A foreign writer that
// replaces our page between the marker read above and the rename below
// loses its file. That window is a local directory being written by two
// processes at once, which the S3 target rules out with a conditional
// write and this one cannot; `docs/capabilities.md` says so rather than
// claiming a guarantee that is not here.
const staged = stage(entry, ENTRY_POINT, (temp) => fs.writeFileSync(temp, html));
try {
if (existed) {
// Replacing our own page: rename overwrites, atomically.
fs.renameSync(staged.path, entry);
} else {
// Claiming a free key: link fails if anything appeared since the
// check above, so a site's own index.html is never erased.
fs.linkSync(staged.path, entry);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
// Something appeared since the check. Another chainplot publisher wrote
// the same bytes, which is the outcome we wanted; anything else means
// there is no entry point to report.
return (
fs.existsSync(entry) &&
fs.readFileSync(entry, "utf8").includes(ENTRY_POINT_MARKER)
);
} finally {
staged.discard();
}
return true;
}

async promoteLatest(pointer: LatestPointer): Promise<void> {
// Atomic on the same filesystem: write temp, rename over latest.json.
const latest = this.latestPath();
const temp = path.join(
path.dirname(latest),
`${TEMP_PREFIX}${process.pid}-${Date.now()}`,
const staged = stage(latest, LATEST, (temp) =>
fs.writeFileSync(temp, `${JSON.stringify(pointer, null, 2)}\n`),
);
fs.mkdirSync(path.dirname(latest), { recursive: true });
fs.writeFileSync(temp, `${JSON.stringify(pointer, null, 2)}\n`);
fs.renameSync(temp, latest);
try {
fs.renameSync(staged.path, latest);
} finally {
staged.discard();
}
}
}
140 changes: 140 additions & 0 deletions src/publish/entryPoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// The stable entry point a reader can bookmark.
//
// A release directory is content-addressed, so its URL changes whenever the
// release does — including when only the viewer bundle changed, since the
// bundle is part of the digest. That is what makes a release immutable, and
// it is also why a link to one rots. `latest.json` never moves but serves
// JSON, so it cannot be the link you hand someone.
//
// This page sits beside `latest.json` at the publish root and forwards to
// whichever release that pointer names, so `https://host/<prefix>/index.html`
// renders the current dashboard and keeps doing so across republishes.
//
// It reads the pointer in the browser rather than carrying a release baked
// into it, and that is the whole design: the bytes depend only on the
// target's prefix, so every publish writes the same page and there is no
// state to keep in step with the pointer. A page that named a release would
// have to be rewritten on every publish and could fall behind — two
// publishers racing, or a publish that promoted and then failed before
// rewriting the page — and a dashboard silently showing an older release is
// the worst way for this to break. Nothing here can drift, because nothing
// here remembers anything.
//
// Reading the pointer needs script. So does the release it forwards to: the
// viewer renders nothing without it. That costs no reader anything, and the
// no-script path says so rather than linking somewhere equally unusable.

/**
* Marks the page as ours. `promoteEntryPoint` refuses to overwrite an
* index.html without it, so publishing into a bucket that already serves a
* site of its own does not clobber that site's front page.
*/
export const ENTRY_POINT_MARKER = "<!-- chainplot:entry-point -->";

/** How long the page stays blank before admitting something went wrong. */
const FALLBACK_DELAY_SECONDS = 4;

/**
* Embed a string in a script as a JSON literal.
*
* `</script>` inside a string ends the element early whatever JSON thinks, so
* the slash is escaped too.
*/
function scriptLiteral(value: string): string {
return JSON.stringify(value).replaceAll("/", "\\/");
}

/**
* The path from the publish root to a release, which is what the page
* forwards to. Relative on purpose: the same bytes work under a bucket root,
* a custom domain, or a host that serves the bucket under some path of its
* own.
*
* The result goes into a URL without percent-encoding, which is safe because
* neither half can carry a character that would change the URL's shape: a
* target prefix is `^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9._-]+)*$` in the
* project schema, and a release id is hex. No `#`, `?`, `%` or space can
* reach here. Widen that pattern and this needs encoding.
*/
export function relativeReleasePath(
releasePrefix: string,
keyPrefix: string | null,
): string {
if (!keyPrefix) return releasePrefix;
const base = `${keyPrefix}/`;
if (!releasePrefix.startsWith(base)) {
throw new Error(
`release prefix ${releasePrefix} is not under the target prefix ${keyPrefix}`,
);
}
return releasePrefix.slice(base.length);
}

/**
* The forwarding page for a target.
*
* Depends only on the prefix, so republishing a project writes identical
* bytes however many times it runs.
*/
export function entryPointHtml(keyPrefix: string | null): string {
const prefix = scriptLiteral(keyPrefix ?? "");
const delay = FALLBACK_DELAY_SECONDS;
// `replace` rather than `assign`: the forwarding page must not become a
// history entry, or Back from the dashboard lands here and bounces forward
// again. `no-store` because a cached pointer is the staleness this page
// exists to avoid.
return `<!doctype html>
${ENTRY_POINT_MARKER}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chainplot</title>
<meta name="robots" content="noindex">
<style>
body { margin: 0; font: 14px/1.5 system-ui, sans-serif; color: #33383d; }
/* Hidden until something has gone wrong, so a redirect that works shows
nothing at all rather than a message the reader cannot act on. */
#fallback { visibility: hidden; animation: reveal 0s ${delay}s forwards; padding: 2rem; }
@keyframes reveal { to { visibility: visible; } }
</style>
<script>
(function () {
var PREFIX = ${prefix};
function relative(releasePrefix) {
var base = PREFIX ? PREFIX + "/" : "";
if (base && releasePrefix.lastIndexOf(base, 0) !== 0) return null;
return releasePrefix.slice(base.length);
}
function fail(message) {
var note = document.getElementById("reason");
if (note) note.textContent = message;
}
if (typeof fetch !== "function") return;
fetch("latest.json", { cache: "no-store" })
.then(function (response) {
if (!response.ok) throw new Error("latest.json returned " + response.status);
return response.json();
})
.then(function (pointer) {
var path = pointer && pointer.release_prefix && relative(pointer.release_prefix);
if (!path) throw new Error("latest.json names no release under this prefix");
location.replace(path + "/index.html");
})
.catch(function (err) {
fail(String((err && err.message) || err));
});
})();
</script>
</head>
<body>
<div id="fallback">
<p><strong>This page forwards to the current release.</strong></p>
<p>It did not, which means <a href="latest.json">latest.json</a> could not be read.
<span id="reason"></span></p>
<noscript><p>It needs JavaScript, as does the dashboard it forwards to.</p></noscript>
</div>
</body>
</html>
`;
}
23 changes: 23 additions & 0 deletions src/publish/publishRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
export type { PublishResult, LatestPointer };

import { latestPointer } from "./latestPointer.js";
import { entryPointHtml } from "./entryPoint.js";
import { DirectoryTarget } from "./directory.js";
import { S3Target, s3EnvFromProcess } from "./s3.js";

Expand Down Expand Up @@ -170,8 +171,28 @@ export async function publishRelease(
const pointer: LatestPointer = latestPointer(prefix, body);
await target.promoteLatest(pointer);

// The release directory is content-addressed, so its URL moves whenever the
// release does — a viewer change is enough. The forwarding page at the
// publish root is what stays put, so there is a link worth sharing.
//
// The page reads the pointer in the browser, so its bytes depend only on the
// prefix. Every publish writes the same page, and nothing can drift out of
// step with the pointer because the page holds no release of its own.
const entryHtml = entryPointHtml(targetDoc.prefix ?? null);
const entryWritten = await target.promoteEntryPoint(entryHtml);
// The same page at `<prefix>/` as well, so the bare URL resolves on a store
// that serves keys. Where it is not written the `index.html` above still is,
// which is the name every static host agrees on.
const aliasWritten = entryWritten ? await target.promoteEntryAlias(entryHtml) : false;

const publicBase = targetDoc.public_base_url?.replace(/\/$/, "") ?? null;
const dashboardUrl = publicBase ? `${publicBase}/${prefix}/index.html` : null;
// The bare form once the directory key exists: a store that serves keys
// finds it there, and a host that resolves directories finds index.html by
// itself. Without that key only the explicit name is safe to hand out.
const entryUrl = publicBase
? `${publicBase}/${base}${aliasWritten ? "" : "index.html"}`
: null;
// verifyFiles proved the bytes are in the bucket. It says nothing about the
// URL handed back: a base URL naming a different bucket, a bucket with public
// access off, or an endpoint that folded the bucket into every key all pass
Expand All @@ -185,6 +206,8 @@ export async function publishRelease(
release_prefix: prefix,
latest_url: publicBase ? `${publicBase}/${base}latest.json` : null,
dashboard_url: dashboardUrl,
entry_url: entryWritten ? entryUrl : null,
entry_point_written: entryWritten,
files_uploaded: files.length + referenced.length,
datasets_referenced: referenced.map((r) => r.path),
promoted: true,
Expand Down
Loading
Loading