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
11 changes: 10 additions & 1 deletion STANDARD-SITE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ and angular-buch.com); each parent repo supplies its own config.
| `STANDARD_SITE_NAME` | no | Publication name. Default: the handle. |
| `STANDARD_SITE_DESCRIPTION` | no | Publication description. |
| `STANDARD_SITE_EXPECTED_DID` | no | Abort if the logged-in DID differs (guards against a wrong account). |
| `STANDARD_SITE_ICON` | no | Publication profile image (square ≥256×256, png/jpg/webp). An http(s) URL or a local file path; uploaded as a blob and set as the publication `icon`. |
| `STANDARD_SITE_ICON` | no | Publication profile image (square ≥256×256, png/jpg/webp). An http(s) URL or a local file path; uploaded as a blob and set as the publication `icon`. A missing/broken icon is non-fatal (warns and continues). |

**Icon convention:** the icon is a website asset, not a shared-build asset. Put a
square `standard-site-icon.png` in the consuming **website** repo's `public/` and
point `STANDARD_SITE_ICON` at its deployed URL
(e.g. `https://example.com/standard-site-icon.png`). Deploy the website before the
article-data build runs so the URL resolves.
| `STANDARD_SITE_SHOW_IN_DISCOVER` | no | `false` to opt out of the discovery feed. Default `true`. |
| `STANDARD_SITE_DRY_RUN` | no | `true` logs the records that would be written/pruned without touching the PDS. |

Expand Down Expand Up @@ -68,6 +74,9 @@ and angular-buch.com); each parent repo supplies its own config.
deleted.
- `validate: false` is used on `putRecord` because a PDS does not natively know
third-party lexicons like `site.standard.*`.
- Resilient: transient PDS responses (429, 5xx) are retried with backoff, and a
publish failure is caught and logged rather than thrown — it never fails the
article-data build/deploy. Records catch up on the next successful build.

## Adding another website (e.g. angular-buch.com)

Expand Down
19 changes: 14 additions & 5 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,20 @@ async function build(): Promise<void> {
console.log('\nValidating anchor links...');
printValidationResults();

// Publish standard.site records (no-op unless configured via env)
await publishStandardSite([
{ contentType: 'blog', entries: blogEntries },
{ contentType: 'material', entries: materialEntries },
]);
// Publish standard.site records (no-op unless configured via env). This is a
// secondary side-effect: a publish failure must never block the article-data
// build/deploy, so it is caught and logged rather than thrown.
try {
await publishStandardSite([
{ contentType: 'blog', entries: blogEntries },
{ contentType: 'material', entries: materialEntries },
]);
} catch (error) {
console.error(
'standard.site: publishing failed (continuing build):',
error instanceof Error ? error.message : error,
);
}

console.log('\nBuild complete!');
}
Expand Down
38 changes: 36 additions & 2 deletions standard-site/atproto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,45 @@ export interface AtpSession {
accessJwt: string;
}

/** How many attempts (1 initial + retries) for a transient failure. */
const MAX_ATTEMPTS = 4;

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** 429 (rate limit) and 5xx (server) responses are worth retrying. */
function isTransientStatus(status: number): boolean {
return status === 429 || status >= 500;
}

/**
* fetch with retries + exponential backoff for transient conditions (network
* errors, 429, 5xx). A PDS blip like a 502 should not fail the whole build.
*/
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 1; ; attempt++) {
let response: Response;
try {
response = await fetch(url, init);
} catch (error) {
if (attempt >= MAX_ATTEMPTS) {
throw error;
}
await sleep(250 * 2 ** (attempt - 1));
continue;
}
if (isTransientStatus(response.status) && attempt < MAX_ATTEMPTS) {
await sleep(250 * 2 ** (attempt - 1));
continue;
}
return response;
}
}

async function xrpc<T>(
url: string,
init: RequestInit,
): Promise<T> {
const response = await fetch(url, init);
const response = await fetchWithRetry(url, init);
const text = await response.text();
if (!response.ok) {
throw new Error(`XRPC ${init.method} ${url} failed: ${response.status} ${text}`);
Expand Down Expand Up @@ -79,7 +113,7 @@ export async function uploadBlob(
bytes: Uint8Array,
mimeType: string,
): Promise<BlobRef> {
const response = await fetch(`${pds}/xrpc/com.atproto.repo.uploadBlob`, {
const response = await fetchWithRetry(`${pds}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: {
'content-type': mimeType,
Expand Down
Loading