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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ web_modules/
.env
.env.*
!.env.example
!scripts/.env.publish-image.example

# parcel-bundler cache (https://parceljs.org/)
.cache
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ This site uses:

- **Next.js static export** — `next build` outputs static files to `out/`
- **Next.js `basePath`** — generates links and assets under `/docs/propeller`
- **Post-build nesting** — `scripts/nest-static-export.mjs` moves the export under `out/docs/propeller/` so Cloudflare static assets can serve it from the route prefix without custom Worker code
- **Post-build nesting** — `scripts/nest-static-export.mjs` moves the export under `out/docs/propeller/` so Cloudflare static assets can serve it from the route prefix
- **Worker-proxied doc images** — `worker/index.ts` (the `main` entry in `wrangler.jsonc`) sits in front of the static assets and serves `/docs/propeller/img/*` requests directly from the shared `websites-images` R2 bucket; every other request falls through to the static asset binding unchanged. See [`scripts/README.md`](./scripts/README.md) for how images get published there.

### Cloudflare build settings (Dashboard)

Expand All @@ -45,9 +46,11 @@ flowchart LR
end

subgraph Runtime_Request_Flow
U[Browser request] --> H[Cloudflare static asset route]
H --> J[Static asset lookup]
J --> U
U[Browser request] --> W[worker/index.ts]
W -->|"/docs/propeller/img/*"| R[(R2: websites-images)]
W -->|everything else| H[ASSETS binding]
R --> U
H --> U
end
```

Expand All @@ -74,6 +77,9 @@ Set this as a Cloudflare build variable so it is embedded into the static output
| `src/lib/layout.shared.tsx` | Shared layout options |
| `content/openapi.yaml` | OpenAPI spec (generates API docs) |
| `scripts/nest-static-export.mjs` | Moves static export under `/docs/propeller` |
| `worker/index.ts` | Cloudflare Worker: proxies `/docs/propeller/img/*` from R2, falls through to static assets otherwise |
| `src/lib/remark-doc-images.ts` | Remark plugin resolving markdown image paths (relative to their source file) to their R2-proxy URL at build time |
| `scripts/publish-image.mjs` | Maintainer-only: uploads a content image to R2 and purges its cache entry |

## Learn More

Expand Down
968 changes: 0 additions & 968 deletions content/docs/images/architecture.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/architecture.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/basic-dag.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/conditional-branching.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/data-passing-between-tasks.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/dependency.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/example-dag.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/fanout-fanin.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/priority-within-level.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/run-if-success-failure-branching.svg

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/dag/state-transitions-in-workflows.svg

This file was deleted.

517 changes: 0 additions & 517 deletions content/docs/images/fml/federated-ml-architecture.svg

This file was deleted.

313 changes: 0 additions & 313 deletions content/docs/images/fml/federated-ml-components.svg

This file was deleted.

537 changes: 0 additions & 537 deletions content/docs/images/fml/federated-ml-lifecycle.svg

This file was deleted.

731 changes: 0 additions & 731 deletions content/docs/images/fml/federated-ml-message-workflow.svg

This file was deleted.

191 changes: 0 additions & 191 deletions content/docs/images/fml/federated-ml-simple-privacy-story.svg

This file was deleted.

Binary file removed content/docs/images/fml/fml-architecture.png
Binary file not shown.
Binary file removed content/docs/images/fml/fml-message-flow.png
Binary file not shown.
Binary file removed content/docs/images/fml/fml-model-lifecycle.png
Binary file not shown.
4 changes: 0 additions & 4 deletions content/docs/images/kubernetes-operator/architecture.svg

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

4 changes: 0 additions & 4 deletions content/docs/images/kubernetes-operator/proplet-lifecycle.svg

This file was deleted.

This file was deleted.

Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/kubernetes-operator/task-lifecycle.svg
Diff not rendered.
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/manager/communication-flow.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/manager/proplet-registration-flow.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/manager/task-execution-flow.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/monitoring.svg
Diff not rendered.
877 changes: 0 additions & 877 deletions content/docs/images/proplet/binary-fetching.svg
Diff not rendered.
779 changes: 0 additions & 779 deletions content/docs/images/proplet/mqtt-topology.svg
Diff not rendered.
1,317 changes: 0 additions & 1,317 deletions content/docs/images/proplet/task-execution-flow.svg
Diff not rendered.
Binary file removed content/docs/images/proxy.png
Diff not rendered.
1,142 changes: 0 additions & 1,142 deletions content/docs/images/proxy/architecture.svg
Diff not rendered.
1,129 changes: 0 additions & 1,129 deletions content/docs/images/proxy/chunk-transfer.svg
Diff not rendered.
929 changes: 0 additions & 929 deletions content/docs/images/proxy/module-delivery-flow.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/tee/architecture.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/tee/attestation.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/tee/encrypted-task-execution.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/tee/image-encryption.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/tee/kbs-setup.svg
Diff not rendered.
4 changes: 0 additions & 4 deletions content/docs/images/wasi-nn/proplet.svg
Diff not rendered.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"lint": "biome check .",
"lint:fix": "biome check src --write",
"check:assets": "bash scripts/check-assets.sh",
"publish-image": "node scripts/publish-image.mjs",
"optimize:images": "f() { webp=\"${1%.*}.webp\"; magick \"$1\" -resize 1200x1200\\> -quality 82 \"$webp\" && rm \"$1\" && echo \"Converted $1 → $webp (original removed)\"; }; f"
},
"dependencies": {
Expand Down
14 changes: 14 additions & 0 deletions scripts/.env.publish-image.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copy this file to scripts/.env.publish-image and fill in the values below.
# The copy is gitignored (.env.* pattern in .gitignore) — never commit it or
# paste the token value into a PR, issue, or chat.

# Cloudflare API token with both permissions on the same token:
# - Workers R2 Storage: Edit (scoped to the "websites-images" bucket)
# - Zone -> Cache Purge -> Purge (scoped to the zone fronting this site)
CLOUDFLARE_API_TOKEN=

# Not secret - the www.absmach.eu zone ID. This site (docs/propeller) is
# served under that domain (see README.md's NEXT_PUBLIC_BASE_URL); reused
# from the same zone already confirmed for the absmach-website reference
# repo and the other absmach.eu-domain docs sites.
CLOUDFLARE_ZONE_ID=9cb2232dc0e21fbfabf9ce52b1834f56
147 changes: 147 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Publishing docs images (maintainers only)

Content images are no longer committed to this repo. They're stored in a shared
Cloudflare R2 bucket (`websites-images`, under the `propeller-docs` key prefix so they
don't collide with other properties in the same bucket) and served at
`/docs/propeller/img/<path>` by a small Cloudflare Worker script,
[`worker/index.ts`](../worker/index.ts), that reads the object from R2 and streams it
back.

**Authoring is unchanged** — write plain markdown image syntax exactly as before, with
whatever relative path you'd naturally use from the `.mdx` file you're editing:

```md
![Propeller DAG Architecture](./images/dag/architecture.svg)
```

[`src/lib/remark-doc-images.ts`](../src/lib/remark-doc-images.ts) resolves that path
(relative to the source file's own location — pure path math, no image bytes needed) into
the literal `/docs/propeller/img/dag/architecture.svg` URL the Worker serves, at compile
time. `src/mdx-components.tsx`'s `img:` override then renders it as a plain, zoomable
`<img>` (`fumadocs-ui`'s `ImageZoom` wrapping a plain element, not `next/image`) — no
width/height needed, so there's no manifest to keep in sync when images change.

## Why a Worker script, not a Next.js route

This site is a fully static Next.js export (`output: "export"` in `next.config.mjs`),
deployed to Cloudflare as static assets with no Next.js server at all. That's also why
neither `@cloudflare/next-on-pages` nor `@opennextjs/cloudflare` apply here: there's no
running Next.js request handler on Cloudflare to reach an R2 binding from.

Before this change, fumadocs-mdx's `remarkImage` plugin resolved markdown image syntax
into a webpack `import` at build time, content-hashed under `_next/static/media/` — which
meant the image bytes had to be physically present in the repo just to run `next build`,
incompatible with getting them out of git. `source.config.ts` disables that plugin, and
`wrangler.jsonc` has a `main` Worker script (`worker/index.ts`) purely to answer the
`/docs/propeller/img/*` route: it falls back from the `ASSETS` binding (Cloudflare serves
any matching static file directly and only invokes this Worker when nothing matches,
since `run_worker_first` defaults to `false`) to reading the request straight out of
`IMAGES_BUCKET`.

Only maintainers publish images, using [`publish-image.mjs`](./publish-image.mjs). The
script is safe to have in a public repo because it's inert without a token — nobody can
upload to the bucket just by reading this file. See "Why maintainer-only" below.

## One-time setup

1. Create `scripts/.env.publish-image` from the template:

```bash
cp scripts/.env.publish-image.example scripts/.env.publish-image
```

2. Create a Cloudflare API token: dashboard -> **My Profile -> API Tokens -> Create Token
-> Custom Token**, with both permissions on the same token:
- `Workers R2 Storage: Edit`
- `Zone -> Cache Purge -> Purge`, **Zone Resources** scoped to the zone fronting this
site (`www.absmach.eu` — see `scripts/.env.publish-image.example`)

3. Paste the token into `CLOUDFLARE_API_TOKEN` in `scripts/.env.publish-image`
(`CLOUDFLARE_ZONE_ID` is pre-filled — it isn't secret).

4. Sanity-check the token before first use:

```bash
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
```

Should return `"status":"active"`. If it doesn't, the token value itself is wrong
(bad copy/paste, expired, revoked) — fix that before troubleshooting anything else.

## Publishing an image

```bash
pnpm run publish-image <local-file> <public-path>
```

`<public-path>` must start with `img/` and include the rest of the path MDX content will
reference. Example:

```bash
pnpm run publish-image ./architecture.svg img/dag/architecture.svg
# -> uploaded to r2://websites-images/propeller-docs/dag/architecture.svg
# -> live at https://www.absmach.eu/docs/propeller/img/dag/architecture.svg
# -> reference from MDX with any relative path resolving to
# content/docs/images/dag/architecture.svg, e.g. ![Alt](./images/dag/architecture.svg)
```

The script does two things, in order:

1. `wrangler r2 object put ... --remote` — uploads to the **real** bucket. `--remote` is
required; without it, `wrangler` silently writes to a local simulated bucket and
prints a normal-looking "Upload complete" with no error, and the object is never
actually live.
2. Purges that exact URL from Cloudflare's edge cache (`POST /zones/{id}/purge_cache`),
so the update is visible within seconds instead of waiting out the cache TTL.

If you re-run the same command for an existing path, it overwrites the object in place and
purges again — that's the intended way to update an image without changing its URL or the
MDX that references it.

If you're adding a brand-new diagram: drop it wherever makes sense under
`content/docs/images/` conceptually (the directory itself no longer exists in git, but the
path still determines the R2 key — see above), run `publish-image` on it, then reference
it from your MDX with the matching relative path — no separate registration step.

## Migrating the existing diagrams (one-time, already done)

This repo's 40 actually-referenced diagrams (5 more existed under `content/docs/images/`
but weren't linked from any `.mdx` file, so were dropped rather than migrated) have
already been uploaded to the real R2 bucket and spot-checked byte-for-byte against the
originals. Nothing further to do here unless a diagram needs updating — use
`publish-image` for that, same as any other image.

## Why maintainer-only

This repo is public. The risk isn't the script being visible — it's inert without a
credential. The risk is _credential distribution_: whoever holds `CLOUDFLARE_API_TOKEN`
can write to the shared bucket. So nobody, internal or external, gets a personal R2
token. Only a maintainer, holding this one scoped token, runs `publish-image`.

Practical flow for a PR that adds a diagram: the contributor attaches the image to the PR
the normal GitHub way (drag-and-drop into the description or a comment) and references
`/img/<path>` from their MDX changes. A maintainer reviewing the PR runs
`pnpm run publish-image` locally before merging, then approves.

## Troubleshooting

- **`Local file not found: --`** — you ran `pnpm run publish-image -- <file>`. pnpm
forwards a leading `--` to the script literally instead of stripping it like npm does.
The script strips it defensively, but plain `pnpm run publish-image <file> <path>` (no
`--`) is the form to use.
- **`Resource location: local` in the upload output** — means `--remote` didn't get
applied for some reason (e.g. running the underlying `wrangler` command by hand without
copying the full flag list from the script). The object was never written to the real
bucket even though the CLI reports success. Always use `pnpm run publish-image`, or add
`--remote` yourself if invoking wrangler directly.
- **`Cache purge failed` / `Authentication error` (code 10000)** — Cloudflare reuses this
code for both "bad token" and "token valid but missing this permission." Run the token
verify curl command above first to rule out a bad token. If that succeeds, the token is
missing `Zone -> Cache Purge -> Purge` for the correct zone, or that permission's Zone
Resources selector doesn't include it — edit the token in the dashboard and add it.
- To confirm an object actually made it into the bucket after a `--remote` upload:

```bash
wrangler r2 object get websites-images/propeller-docs/<path> --remote --file=/tmp/check
```
134 changes: 134 additions & 0 deletions scripts/publish-image.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env node
// Maintainer-only. Uploads a doc image to the shared R2 bucket and purges
// it from Cloudflare's edge cache, so it's live right after this finishes.
// Requires CLOUDFLARE_API_TOKEN (scoped: R2 Edit on websites-images + Zone
// Cache Purge on absmach.eu) and CLOUDFLARE_ZONE_ID.
//
// Usage:
// pnpm run publish-image <local-file> <public-path>
//
// <public-path> is the path used in MDX content, starting with "img/" to
// match the route worker/index.ts serves it back on:
// pnpm run publish-image ./architecture.svg img/dag/architecture.svg
// -> referenced in MDX as ![Alt](./images/dag/architecture.svg) (or any
// relative path that resolves to content/docs/images/dag/architecture.svg)
// -> live at https://www.absmach.eu/docs/propeller/img/dag/architecture.svg

import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { extname } from "node:path";
import process from "node:process";

const BUCKET_NAME = "websites-images";
// This docs site is served under https://www.absmach.eu/docs/propeller --
// same zone as the main absmach-website repo, which is why
// CLOUDFLARE_ZONE_ID below matches that repo's.
const SITE_ORIGIN = "https://www.absmach.eu";
const BASE_PATH = "docs/propeller";

// Shared bucket ("websites-images") holds assets for multiple properties;
// this prefix keeps this site's objects from colliding with theirs. Keep
// in sync with R2_KEY_PREFIX in worker/index.ts.
const R2_KEY_PREFIX = "propeller-docs";

const MIME_TYPES = {
".webp": "image/webp",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".gif": "image/gif",
".avif": "image/avif",
};

try {
process.loadEnvFile(new URL("./.env.publish-image", import.meta.url));
} catch {
// No local env file -- assume CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID
// are already exported (e.g. in CI).
}

// pnpm forwards a leading "--" to the underlying command instead of
// stripping it (unlike npm), so tolerate it either way.
const cliArgs = process.argv.slice(2).filter((arg) => arg !== "--");
const [localFile, publicPath] = cliArgs;

if (!localFile || !publicPath) {
console.error(
"Usage: pnpm run publish-image <local-file> <public-path>\n" +
"Example: pnpm run publish-image ./architecture.svg img/dag/architecture.svg",
);
process.exit(1);
}

if (!existsSync(localFile)) {
console.error(`Local file not found: ${localFile}`);
process.exit(1);
}

const destKey = publicPath.replace(/^\/+/, "");
if (!destKey.startsWith("img/") || destKey === "img/") {
console.error(
`Destination must start with "img/" and include a path, got: ${destKey}`,
);
process.exit(1);
}
const restPath = destKey.slice("img/".length);

const contentType = MIME_TYPES[extname(restPath).toLowerCase()];
if (!contentType) {
console.error(`Unrecognized file extension for: ${destKey}`);
process.exit(1);
}

const { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID } = process.env;
if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ZONE_ID) {
console.error(
"Missing CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_ZONE_ID.\n" +
"Copy scripts/.env.publish-image.example to scripts/.env.publish-image and fill in both.",
);
process.exit(1);
}

const objectPath = `${BUCKET_NAME}/${R2_KEY_PREFIX}/${restPath}`;

console.log(`Uploading ${localFile} -> r2://${objectPath}`);
execFileSync(
"wrangler",
[
"r2",
"object",
"put",
objectPath,
`--file=${localFile}`,
`--content-type=${contentType}`,
"--remote",
],
{ stdio: "inherit", env: process.env },
);

const publicUrl = `${SITE_ORIGIN}/${BASE_PATH}/${destKey}`;

console.log(`Purging edge cache for ${publicUrl}`);
const purgeResponse = await fetch(
`https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache`,
{
method: "POST",
headers: {
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ files: [publicUrl] }),
},
);

const purgeResult = await purgeResponse.json();
if (!purgeResponse.ok || !purgeResult.success) {
console.error("Cache purge failed:", JSON.stringify(purgeResult, null, 2));
process.exit(1);
}

console.log(`Done. Live at ${publicUrl}`);
console.log(
`Reference it from MDX with any relative path resolving to content/docs/images/${restPath}, e.g.: ![Alt text](./images/${restPath})`,
);
13 changes: 12 additions & 1 deletion source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
metaSchema,
} from "fumadocs-mdx/config";
import { z } from "zod";
import { remarkDocImages } from "./src/lib/remark-doc-images";

// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.dev/docs/mdx/collections
Expand All @@ -25,6 +26,16 @@ export const docs = defineDocs({

export default defineConfig({
mdxOptions: {
// MDX options
// Content images are served at runtime from R2 via worker/index.ts
// (see scripts/README.md), not committed to this repo. fumadocs-mdx's
// remarkImage plugin needs the file on local disk at build time --
// exactly what we're avoiding -- so it's disabled here. Authors keep
// writing plain markdown image syntax with whatever relative path they
// always used (`./images/x.svg`, `../images/dag/x.svg`); remarkDocImages
// below resolves that to the R2-proxy URL at compile time instead (pure
// path math, no manifest), and mdx-components.tsx's `img:` override
// renders it as a plain, zoomable <img>.
remarkImageOptions: false,
remarkPlugins: [remarkDocImages],
},
});
Loading