diff --git a/.gitignore b/.gitignore index 77fddec..e0f854a 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ web_modules/ .env .env.* !.env.example +!scripts/.env.publish-image.example # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/README.md b/README.md index b6b6b31..9d04fc4 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 ``` @@ -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 diff --git a/content/docs/images/architecture.svg b/content/docs/images/architecture.svg deleted file mode 100644 index 0458382..0000000 --- a/content/docs/images/architecture.svg +++ /dev/null @@ -1,968 +0,0 @@ - - - -
MANAGER
Scheduler
WASM
runtime
WASM
runtime
User
(CLI or API)
MAGISTRALA

(Service Mesh)
HTTP-to-MQTT
Proxy
MQTT
MQTT
HTTP
MQTT
MQTT
HTTP
MQTT
PROPLET

MCU 
(Zephyr RTOS)
PROPLET

Cloud Node 
(Linux)
DB
Wasm Module
Kubernetes Operator
MQTT
K8S Manifest
OCI Registry
diff --git a/content/docs/images/dag/architecture.svg b/content/docs/images/dag/architecture.svg deleted file mode 100644 index c911e80..0000000 --- a/content/docs/images/dag/architecture.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Manager
Orchestration
Jobs
Workflows
Standalone Tasks
Scheduling
Priority
Cron
Round Robin
Execution
Tasks
Proplets
Proplet A
Proplet B
Proplet C
\ No newline at end of file diff --git a/content/docs/images/dag/basic-dag.svg b/content/docs/images/dag/basic-dag.svg deleted file mode 100644 index 9a5032c..0000000 --- a/content/docs/images/dag/basic-dag.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
A
B
C
D
\ No newline at end of file diff --git a/content/docs/images/dag/conditional-branching.svg b/content/docs/images/dag/conditional-branching.svg deleted file mode 100644 index 6b41832..0000000 --- a/content/docs/images/dag/conditional-branching.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Parent Task
Child Task
(run_if: success)
Child Task
(run_if: failure)
Skipped
(Terminal State)
All parents succeeded
At least one parent failed
Condition not met → Marked Skipped
Condition not met → Marked Skipped
\ No newline at end of file diff --git a/content/docs/images/dag/data-passing-between-tasks.svg b/content/docs/images/dag/data-passing-between-tasks.svg deleted file mode 100644 index 299dbd1..0000000 --- a/content/docs/images/dag/data-passing-between-tasks.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Task A
Produces Results
Task B
Receives A's Results
Produces Results
Task C
Receives Results from A & B
results
results
results
\ No newline at end of file diff --git a/content/docs/images/dag/dependency.svg b/content/docs/images/dag/dependency.svg deleted file mode 100644 index ef204c1..0000000 --- a/content/docs/images/dag/dependency.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Task A
Task B
Task C
\ No newline at end of file diff --git a/content/docs/images/dag/example-dag.svg b/content/docs/images/dag/example-dag.svg deleted file mode 100644 index b0d6d97..0000000 --- a/content/docs/images/dag/example-dag.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Task 1
Task 2
Task 3
\ No newline at end of file diff --git a/content/docs/images/dag/fanout-fanin.svg b/content/docs/images/dag/fanout-fanin.svg deleted file mode 100644 index a82b481..0000000 --- a/content/docs/images/dag/fanout-fanin.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -IngestResizeWatermarkCompressMerge & Upload \ No newline at end of file diff --git a/content/docs/images/dag/priority-within-level.svg b/content/docs/images/dag/priority-within-level.svg deleted file mode 100644 index 60162a8..0000000 --- a/content/docs/images/dag/priority-within-level.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Scheduler
(Multiple Tasks Ready)
Task A
Priority: 90
Created: 10:00
Task B
Priority: 70
Created: 09:55
Task C
Priority: 70
Created: 10:05
1️⃣ Starts First
(Highest Priority)
2️⃣ Starts Next
(Equal Priority, Older First)
3️⃣ Starts After
(Equal Priority, Newer)
Ready
Ready
Ready
\ No newline at end of file diff --git a/content/docs/images/dag/run-if-success-failure-branching.svg b/content/docs/images/dag/run-if-success-failure-branching.svg deleted file mode 100644 index 9a989df..0000000 --- a/content/docs/images/dag/run-if-success-failure-branching.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Payment Task
Send Confirmation
(run_if: success)
Send Failure Alert
(run_if: failure)
on success
on failure
\ No newline at end of file diff --git a/content/docs/images/dag/state-transitions-in-workflows.svg b/content/docs/images/dag/state-transitions-in-workflows.svg deleted file mode 100644 index 7ecb66d..0000000 --- a/content/docs/images/dag/state-transitions-in-workflows.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Pending
(0)
Scheduled
(1)
Running
(2)
Completed
(3)
Failed
(4)
Skipped
(5)
Interrupted
(6)
assign to proplet
proplet starts
success
error
run_if = false
stop
restart
\ No newline at end of file diff --git a/content/docs/images/fml/federated-ml-architecture.svg b/content/docs/images/fml/federated-ml-architecture.svg deleted file mode 100644 index a0e55b6..0000000 --- a/content/docs/images/fml/federated-ml-architecture.svg +++ /dev/null @@ -1,517 +0,0 @@ - - - -
Manager Service
FL Coordinator
Aggregator Service
Proplet
Rust/Wasmtime
Magistrala MQTT
Proxy Service
Local Data Store
Model Registry
diff --git a/content/docs/images/fml/federated-ml-components.svg b/content/docs/images/fml/federated-ml-components.svg deleted file mode 100644 index 4670310..0000000 --- a/content/docs/images/fml/federated-ml-components.svg +++ /dev/null @@ -1,313 +0,0 @@ - - - -External Trigger / ClientManagerFL CoordinatorProplet (Wasm FL Client)ProxyArtifact Registry (Wasm Images)Model Registry (Global Models) diff --git a/content/docs/images/fml/federated-ml-lifecycle.svg b/content/docs/images/fml/federated-ml-lifecycle.svg deleted file mode 100644 index f133c42..0000000 --- a/content/docs/images/fml/federated-ml-lifecycle.svg +++ /dev/null @@ -1,537 +0,0 @@ - - - -
Model v0
Model v1
Model v2
Model vN
Round 1
Round 2
...
1. Distribution
2. Local Training
3. Update Submission
4. Aggregation
5. Storage
Next Round
diff --git a/content/docs/images/fml/federated-ml-message-workflow.svg b/content/docs/images/fml/federated-ml-message-workflow.svg deleted file mode 100644 index f013dda..0000000 --- a/content/docs/images/fml/federated-ml-message-workflow.svg +++ /dev/null @@ -1,731 +0,0 @@ - - - -
Orchestrator
/ Experiment Config

FL Coordinator

Model Registry
Client
(Wasm / Wasmtime)
Local Data Store
(Client Dataset)
Aggregator
(FedAvg)
1. Configure experiment
2. Load initial global model
3. GET /task
(model_ref, round_id, config)
4. Fetch model_v0
(if referenced)
5. Load local dataset
6. Local training
7. POST /update
(JSON/CBOR)
8. Validate & buffer updates
9. Aggregate buffered updates
(FedAvg)
10. New global model
11. Store model_v1
12. Next round task available
diff --git a/content/docs/images/fml/federated-ml-simple-privacy-story.svg b/content/docs/images/fml/federated-ml-simple-privacy-story.svg deleted file mode 100644 index 68b6eb7..0000000 --- a/content/docs/images/fml/federated-ml-simple-privacy-story.svg +++ /dev/null @@ -1,191 +0,0 @@ - - - -
Old way
Send raw data to cloud
• More network traffic
• Bigger privacy risk
Federated way
Keep raw data on each device
Send only small learning updates
• Less network traffic
• Better privacy
Move to this
diff --git a/content/docs/images/fml/fml-architecture.png b/content/docs/images/fml/fml-architecture.png deleted file mode 100644 index 7921c1c..0000000 Binary files a/content/docs/images/fml/fml-architecture.png and /dev/null differ diff --git a/content/docs/images/fml/fml-message-flow.png b/content/docs/images/fml/fml-message-flow.png deleted file mode 100644 index 411d6f0..0000000 Binary files a/content/docs/images/fml/fml-message-flow.png and /dev/null differ diff --git a/content/docs/images/fml/fml-model-lifecycle.png b/content/docs/images/fml/fml-model-lifecycle.png deleted file mode 100644 index 9aebed9..0000000 Binary files a/content/docs/images/fml/fml-model-lifecycle.png and /dev/null differ diff --git a/content/docs/images/kubernetes-operator/architecture.svg b/content/docs/images/kubernetes-operator/architecture.svg deleted file mode 100644 index 4a040da..0000000 --- a/content/docs/images/kubernetes-operator/architecture.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Kubernetes Cluster
Propeller
Kubernetes Operator
Controllers
Proplet
Reconciler
Task
Reconciler
PropellerJob
Reconciler
FederatedJob
Reconciler
Training
Round
Reconciler
Kubernetes
API Server
Custom Resources (CRDs)
Proplet
Task
PropellerJob
FederatedJob
Training
Round
K8s Proplet
Pod
SuperMQ
MQTT Broker
External Devices
Raspberry Pi
Proplet
ESP32
Proplet
Docker
Proplet
watch/update
MQTT
MQTT
manages
\ No newline at end of file diff --git a/content/docs/images/kubernetes-operator/federatedjob-lifecycle.svg b/content/docs/images/kubernetes-operator/federatedjob-lifecycle.svg deleted file mode 100644 index 734b2b6..0000000 --- a/content/docs/images/kubernetes-operator/federatedjob-lifecycle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Pending
Running
Completed
Failed
\ No newline at end of file diff --git a/content/docs/images/kubernetes-operator/mqtt-communication.svg b/content/docs/images/kubernetes-operator/mqtt-communication.svg deleted file mode 100644 index e963167..0000000 --- a/content/docs/images/kubernetes-operator/mqtt-communication.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Propeller
Kubernetes
Operator
SuperMQ
MQTT Broker
Proplet
m/{domain_id}/c/{channel_id}/
Topic Base:
control/proplet/alive
heartbeat every 10s
control/proplet/create
control/proplet/status
control/proplet/results
control/manager/start
task dispatch
control/manager/stop
Legend
proplet → operator
operator → proplet
Sub-topics
control/proplet/alive — heartbeat
control/proplet/create — discovery
control/proplet/status — task phase updates
control/proplet/results — task output
control/manager/start — dispatch task
control/manager/stop — stop task
\ No newline at end of file diff --git a/content/docs/images/kubernetes-operator/propellerjob-lifecycle.svg b/content/docs/images/kubernetes-operator/propellerjob-lifecycle.svg deleted file mode 100644 index dd9c46e..0000000 --- a/content/docs/images/kubernetes-operator/propellerjob-lifecycle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Pending
Running
Completed
Failed
\ No newline at end of file diff --git a/content/docs/images/kubernetes-operator/proplet-lifecycle.svg b/content/docs/images/kubernetes-operator/proplet-lifecycle.svg deleted file mode 100644 index bf30700..0000000 --- a/content/docs/images/kubernetes-operator/proplet-lifecycle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Initializing
Running
Offline
heartbeat
timeout
heartbeat
resumes
\ No newline at end of file diff --git a/content/docs/images/kubernetes-operator/scheduler-algorithm.svg b/content/docs/images/kubernetes-operator/scheduler-algorithm.svg deleted file mode 100644 index 7265a9e..0000000 --- a/content/docs/images/kubernetes-operator/scheduler-algorithm.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
1. FILTER
SelectCandidateProplets()
Phase = Running
propletId match
matchLabels
matchDeviceTypes
matchCapabilities
preferredPropletType
2. SCORE
Score()
Default: Round-Robin

• Last-selected proplet's
next neighbor → 0.1

• All others → 1.0

Lower score = preferred
3. PICK
Pick()
Select candidate with
lowest score

Ties broken
deterministically

Result: Selected proplet
for task execution
Scheduler Errors
ErrNoProplet
No proplets exist
ErrDeadProplets
All Offline/Initializing
ErrNoCandidates
No selector match
ErrInsufficientResources
No CPU/memory
Example: Round-Robin
Proplets: [A, B, C, D]
Last selected: B

Scores:
A → 1.0
B → 1.0
C → 0.1 ← next after B
D → 1.0

Selected: C
\ No newline at end of file diff --git a/content/docs/images/kubernetes-operator/task-execution-paths.svg b/content/docs/images/kubernetes-operator/task-execution-paths.svg deleted file mode 100644 index f7a5e4c..0000000 --- a/content/docs/images/kubernetes-operator/task-execution-paths.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
External Proplet Path
K8s Proplet Path
1. TaskReconciler
picks up Task
2. Resolve proplet
(type: external)
3. Publish MQTT
to start topic
4. Proplet receives
message
5. Load WASM,
execute function
6. Publish result
to results topic
7. Operator receives,
stores in status
8. phase=completed
1. TaskReconciler
picks up Task
2. Resolve proplet
(type: k8s)
3. Create ConfigMap
with task env
4. Create K8s Job
with imageUrl
5. Watch Job
every 5s
6. Job completes,
extract result
7. Multi-method
result extraction
8. phase=completed
Result Extraction Methods:
• Job annotations
• Container terminated msg
• Result ConfigMap
• Result Secret
• Pod annotations
Legend
Reconciler
MQTT
Proplet
\ No newline at end of file diff --git a/content/docs/images/kubernetes-operator/task-lifecycle.svg b/content/docs/images/kubernetes-operator/task-lifecycle.svg deleted file mode 100644 index fe4e0cd..0000000 --- a/content/docs/images/kubernetes-operator/task-lifecycle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
pending
scheduled
running
completed
failed
interrupted
skipped
recurring
\ No newline at end of file diff --git a/content/docs/images/kubernetes-operator/traininground-lifecycle.svg b/content/docs/images/kubernetes-operator/traininground-lifecycle.svg deleted file mode 100644 index ea93de9..0000000 --- a/content/docs/images/kubernetes-operator/traininground-lifecycle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Pending
Running
Aggregating
Completed
Failed
(timeout or
aggregation error)
\ No newline at end of file diff --git a/content/docs/images/manager/communication-flow.svg b/content/docs/images/manager/communication-flow.svg deleted file mode 100644 index 10d1648..0000000 --- a/content/docs/images/manager/communication-flow.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Client
HTTP
Manager
MQTT
Proplet
\ No newline at end of file diff --git a/content/docs/images/manager/proplet-registration-flow.svg b/content/docs/images/manager/proplet-registration-flow.svg deleted file mode 100644 index c59e1c7..0000000 --- a/content/docs/images/manager/proplet-registration-flow.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Proplet starts
Publishes discovery
control/proplet/create
Manager receives
Records proplet ID
and channel
Proplet sends
heartbeats
Every few seconds
control/proplet/alive
Manager tracks
liveness
Proplet eligible for scheduling
if heartbeat < 10s old
1
2
3
4
Legend
Proplet
Manager
MQTT Topic
State/Outcome
\ No newline at end of file diff --git a/content/docs/images/manager/task-execution-flow.svg b/content/docs/images/manager/task-execution-flow.svg deleted file mode 100644 index b3dfd1d..0000000 --- a/content/docs/images/manager/task-execution-flow.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Client
POST /tasks
Manager creates task
in Pending state
Client
POST /tasks/{id}/start
Manager selects proplet,
publishes MQTT command
Manager
MQTT "start"
Proplet receives task
Proplet
Executes Wasm
Produces results
Proplet
MQTT "results"
Manager receives results
Manager
Updates state
Task moves to
Completed/Failed
1
2
3
4
5
6
\ No newline at end of file diff --git a/content/docs/images/monitoring.svg b/content/docs/images/monitoring.svg deleted file mode 100644 index dc3332f..0000000 --- a/content/docs/images/monitoring.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
MQTT Broker
MQTT
Manager
- aggregate
- store
route metrics
API Clients
(query tools)
REST API
Task (WASM)
Monitoring Thread
collect metrics
m/{domain}/c/{channel}/metrics/proplet
publish metrics
\ No newline at end of file diff --git a/content/docs/images/proplet/binary-fetching.svg b/content/docs/images/proplet/binary-fetching.svg deleted file mode 100644 index c9861d7..0000000 --- a/content/docs/images/proplet/binary-fetching.svg +++ /dev/null @@ -1,877 +0,0 @@ - - - -
Proplet
PropletService
ChunkAssembler
Chunk Buffer
[0] [1] [2] [3] ...
Complete Binary
(WASM Module)
Runtime
MQTT
Broker
Proxy
RegistryService
OCI Client
Chunker
(512KB chunks)
ChunkPublisher
Container Registry
(GHCR, Docker Hub)
1. registry/proplet
{image_url: ...}
2. Receive
request
3. Pull OCI image
4. registry/server
{chunk[0], chunk[1], ...}
5. Receive
chunks
6. Assemble
7. Execute
⏱ 5-minute TTL
Incomplete assemblies
automatically expired
📦 512KB chunks
Optimized for MQTT
message size limits
diff --git a/content/docs/images/proplet/mqtt-topology.svg b/content/docs/images/proplet/mqtt-topology.svg deleted file mode 100644 index 67e35c4..0000000 --- a/content/docs/images/proplet/mqtt-topology.svg +++ /dev/null @@ -1,779 +0,0 @@ - - - -
Proplet
Manager
Proxy
MQTT Broker
(Magistrala)
Manager → Propletcontrol/manager/start → Start Taskcontrol/manager/stop → Stop TaskRegistry (Binary Transfer)registry/proplet → Fetch Request (Proplet→Proxy)registry/server → Chunk Delivery (Proxy→Proplet)
Publish
Subscribe
Deliver
Publish
Pub/Sub
Legend
Proplet publishes
Manager publishes
Proxy pub/sub
Topic reference
diff --git a/content/docs/images/proplet/task-execution-flow.svg b/content/docs/images/proplet/task-execution-flow.svg deleted file mode 100644 index 19231ce..0000000 --- a/content/docs/images/proplet/task-execution-flow.svg +++ /dev/null @@ -1,1317 +0,0 @@ - - - -
4. Runtime Execution
Manager
1. Command
Validation
2. Duplicate
Detection
Already
Running?
Ignore
3. Binary Acquisition
Source?
Decode Base64
from payload
Fetch chunks
from Proxy
Proxy
Embedded
Wasmtime
Host
Runtime
TEE
Runtime
5. Result Publishing
Result?
Publish
Results
Publish
Error
Manager
StartRequest
via MQTT
Yes
No
file
image_url
Success
Failure
Legend
External Service
Execution Step
Decision
Sub-operation
diff --git a/content/docs/images/proxy.png b/content/docs/images/proxy.png deleted file mode 100644 index 360a52b..0000000 Binary files a/content/docs/images/proxy.png and /dev/null differ diff --git a/content/docs/images/proxy/architecture.svg b/content/docs/images/proxy/architecture.svg deleted file mode 100644 index 437d8e5..0000000 --- a/content/docs/images/proxy/architecture.svg +++ /dev/null @@ -1,1142 +0,0 @@ - - - -
Proxy Service
HTTP Stream (Goroutine)
Request Handler
OCI Client
Auth Handler
Manifest Resolver
Layer Downloader
Chunker
(512KB default)
Max 50 concurrent downloads
Go Channel
MQTT Stream (Goroutine)
MQTT Subscriber
registry/proplet
Chunk Tracker
MQTT Publisher
registry/server
Completion Logger
QoS 2 (exactly-once)
Container Registry
(Docker Hub, GHCR, etc.)
MQTT Broker
(Magistrala)
HTTPS
chunks
requests
chunks
Data Flow: Requests ➜ HTTP Stream ➜ Go Channel ➜ MQTT Stream ➜ Broker
Legend
HTTP Stream
MQTT Stream
Go Channel
External
diff --git a/content/docs/images/proxy/chunk-transfer.svg b/content/docs/images/proxy/chunk-transfer.svg deleted file mode 100644 index c84433c..0000000 --- a/content/docs/images/proxy/chunk-transfer.svg +++ /dev/null @@ -1,1129 +0,0 @@ - - - -
WASM Module
(2.5 MB)
Proxy Chunker
512 KB Chunks
Chunk 0
512 KB
Chunk 1
512 KB
Chunk 2
512 KB
Chunk 3
512 KB
Chunk 4
452 KB
Chunk Payload Format (JSON)
{
"app_name": "docker.io/org/module.wasm",
"chunk_idx": 2,
"total_chunks": 5,
"data": "base64-encoded-512KB..."
}
Each chunk published as separate MQTT message
MQTT Broker (registry/server topic)
[0]
[1]
[2]
[3]
[4]
QoS 2
Exactly Once
Sequential delivery
Proplet - Chunk Assembler
✓ [0]
✓ [1]
✓ [2]
✓ [3]
✓ [4]
Concatenate
in order
Complete WASM Module
(2.5 MB)
Runtime
⏱ 5-minute TTL for incomplete assemblies
🔄 If chunk fails, only that chunk resent (not entire module)
💡 Why 512 KB? Balances MQTT broker limits (often 1MB max), transfer efficiency, and Proplet memory usage
diff --git a/content/docs/images/proxy/module-delivery-flow.svg b/content/docs/images/proxy/module-delivery-flow.svg deleted file mode 100644 index 9aab9eb..0000000 --- a/content/docs/images/proxy/module-delivery-flow.svg +++ /dev/null @@ -1,929 +0,0 @@ - - - -
👤
User/API
Manager
MQTT
Broker
Proplet
Proxy
Container
Registry
1. POST /tasks
{image_url: "docker.io/org/module.wasm"}
2. control/manager/start
{task_id, image_url}
3. registry/proplet
{image_url}
4. OCI Pull
(auth + manifest + layers)
WASM binary
5. Split into
512KB chunks
6. registry/server
{chunk[0], chunk[1], ...}
7. Reassemble
+ Execute
Legend
HTTP/API
MQTT relay
Proplet msg
Proxy msg
diff --git a/content/docs/images/tee/architecture.svg b/content/docs/images/tee/architecture.svg deleted file mode 100644 index 8ed1b39..0000000 --- a/content/docs/images/tee/architecture.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -Setup Phase
Developer
Generate RSA
key pair
Encrypt WASM
image (JWE)
Push private
key
Upload to KBS
(decryption key)
Container Registry
(encrypted WASM)
Key Broker Service (KBS)
• Store decryption keys
• Validate attestations
• Release keys to verified TEEs
Execution Phase
Manager
(submit task manifest)
MQTT Broker
Proplet (in TEE)
Detect TEE
hardware
Pull encrypted
image from registry
Request attestation
from TEE hardware
Attestation Agent
(TEE Hardware Interface)
Decrypt image
inside TEE using key
Execute WASM
workload
Return results
attestation proof
Key Broker Service (KBS)
• Validate attestation
• Release decryption key
decryption key
\ No newline at end of file diff --git a/content/docs/images/tee/attestation.svg b/content/docs/images/tee/attestation.svg deleted file mode 100644 index b4ff987..0000000 --- a/content/docs/images/tee/attestation.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
TEE Attestation Flow
Proplet

• Detects TEE environment
• Receives encrypted task request
• Needs decryption key from KBS
Attestation Agent (AA)

• Interacts with TEE hardware APIs
• Generates nonce for freshness
TEE Hardware

• Generates attestation report
• Report includes:
- TEE type (TDX/SEV-SNP/SGX)
- TEE configuration
- Runtime measurement
- Cryptographic signature
Attestation Agent (AA)

• Formats attestation report
• Sends to KBS for validation
Key Broker Service (KBS)

Validation Steps:
1. Verify hardware signature
2. Check TEE type is supported
3. Verify TEE configuration matches policy
4. Confirm runtime measurement is expected

If validation succeeds:
• Retrieve private key from secure storage
• Return decryption key to Proplet

If validation fails:
Deny key request
• Log security event
Proplet

• Receives decryption key
• Decrypts WASM image inside TEE
• Executes workload in protected environment
Request attestation via AA
Get TEE evidence
Attestation report
Validate & request key
Decryption key (if valid)
\ No newline at end of file diff --git a/content/docs/images/tee/encrypted-task-execution.svg b/content/docs/images/tee/encrypted-task-execution.svg deleted file mode 100644 index df2eade..0000000 --- a/content/docs/images/tee/encrypted-task-execution.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
CLI/User
Manager
POST /tasks
Proplet (in TEE)
3. Detect TEE
Environment
4. Pull Encrypted
Image
5. Request
Attestation
6. Attestation
Agent
publish start
7. Key Broker
Service (KBS)
attestation
Proplet (in TEE)
8. Decrypt Image
Inside TEE
9. Execute WASM
Workload
10. Return
Results
decryption key
\ No newline at end of file diff --git a/content/docs/images/tee/image-encryption.svg b/content/docs/images/tee/image-encryption.svg deleted file mode 100644 index 8e616e9..0000000 --- a/content/docs/images/tee/image-encryption.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Step 1: Push WASM to Local Registry
WASM File
my-app.wasm
Registry

(localhost:5000)
wasm-to-oci push
Step 2: Encrypt with Public Key
Encrypted
OCI Image

(JWE)
skopeo copy --encryption-key
jwe:/path/to/public_key.pem
Step 3: Push to Remote Registry
Remote
Registry

docker.io
skopeo copy
\ No newline at end of file diff --git a/content/docs/images/tee/kbs-setup.svg b/content/docs/images/tee/kbs-setup.svg deleted file mode 100644 index 49b49bf..0000000 --- a/content/docs/images/tee/kbs-setup.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
Developer

Generate
RSA Keys
Developer

Encrypt
WASM Image
with
Public Key
Developer

Upload
Private Key
to KBS
Encrypted
OCI Image

(JWE format)
Key Broker
Service

(KBS)
Output:
• Public key used to encrypt WASM images
• Private key stored securely in KBS
• Path 'default/key/my-app' references the key
\ No newline at end of file diff --git a/content/docs/images/wasi-nn/proplet.svg b/content/docs/images/wasi-nn/proplet.svg deleted file mode 100644 index 34b2192..0000000 --- a/content/docs/images/wasi-nn/proplet.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
propeller-cli
Manager
Proplet
HostRuntime
Filesystem
wasmtime (subprocess)
OpenVINO
POST /tasks/start
MQTT: StartTask (wasm_bytes, cli_args)
start_app(config)
Step 1: Write WASM to temp file
write /tmp/proplet_{id}.wasm
Step 2: Build & spawn subprocess
Command::new("wasmtime")
+ arg("run")
+ args(cli_args)
+ env(LD_LIBRARY_PATH)
+ env(INTEL_OPENVINO_DIR)
spawn process
Step 3: WASM execution
load wasi-nn-example.wasm
wasi_nn::load(model.xml, model.bin)
graph_handle
wasi_nn::compute(tensor)
results[1000]
println!("Found results: [865, ...]")
Step 4: Collect output
exit(0) + stdout + stderr
Step 5: Cleanup
delete /tmp/proplet_{id}.wasm
Ok(stdout)
MQTT: TaskComplete
task.state = completed
propeller-cli
Manager
Proplet
HostRuntime
Filesystem
wasmtime (subprocess)
OpenVINO
\ No newline at end of file diff --git a/package.json b/package.json index abb4280..a40af44 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/.env.publish-image.example b/scripts/.env.publish-image.example new file mode 100644 index 0000000..554a8c4 --- /dev/null +++ b/scripts/.env.publish-image.example @@ -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 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..2079142 --- /dev/null +++ b/scripts/README.md @@ -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/` 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 +`` (`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 +``` + +`` 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/` 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 -- `. 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 ` (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/ --remote --file=/tmp/check + ``` diff --git a/scripts/publish-image.mjs b/scripts/publish-image.mjs new file mode 100644 index 0000000..227987c --- /dev/null +++ b/scripts/publish-image.mjs @@ -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 +// +// 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 \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})`, +); diff --git a/source.config.ts b/source.config.ts index 6702f1d..0ef577f 100644 --- a/source.config.ts +++ b/source.config.ts @@ -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 @@ -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 . + remarkImageOptions: false, + remarkPlugins: [remarkDocImages], }, }); diff --git a/src/lib/remark-doc-images.ts b/src/lib/remark-doc-images.ts new file mode 100644 index 0000000..315563f --- /dev/null +++ b/src/lib/remark-doc-images.ts @@ -0,0 +1,50 @@ +import { dirname, join, normalize, relative } from "node:path"; + +// Doc content images live in the shared R2 bucket (see worker/index.ts) +// instead of content/docs/images. Authors keep writing plain markdown image +// syntax with the same paths they always used -- this plugin rewrites each +// image's `url` at compile time (pure path math, no image bytes needed) +// into the literal "/docs/propeller/img/..." URL the Worker serves, so +// nothing about the authoring experience changes. +// +// - Relative paths ("./images/x.svg", "../images/dag/x.svg", or bare +// "images/x.svg") resolve against the source .mdx file's own location, +// same as markdown always works. +// - Full external URLs (http://, https://) are left untouched. +const CONTENT_IMAGES_ROOT = join(process.cwd(), "content/docs/images"); +const IMG_ROUTE_PREFIX = "/docs/propeller/img"; + +// Minimal structural types for what this plugin touches -- avoids pulling in +// `@types/mdast`/`vfile` as direct dependencies for two fields. +interface MdastNode { + type?: string; + url?: string; + children?: MdastNode[]; +} +interface CompileFile { + path: string; +} + +function walk(node: MdastNode, visitor: (node: MdastNode) => void) { + if (node.type === "image") visitor(node); + if (Array.isArray(node.children)) { + for (const child of node.children) walk(child, visitor); + } +} + +export function remarkDocImages() { + return (tree: MdastNode, file: CompileFile) => { + walk(tree, (node) => { + if (typeof node.url !== "string" || node.url.length === 0) return; + if (/^https?:\/\//.test(node.url)) return; // external, leave alone + if (node.url.startsWith(IMG_ROUTE_PREFIX)) return; // already resolved + + const fileDir = dirname(file.path); + const absolute = normalize(join(fileDir, node.url)); + const relativeToImages = relative(CONTENT_IMAGES_ROOT, absolute) + .split("\\") + .join("/"); + node.url = `${IMG_ROUTE_PREFIX}/${relativeToImages}`; + }); + }; +} diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx index d6dede5..cd8e965 100644 --- a/src/mdx-components.tsx +++ b/src/mdx-components.tsx @@ -1,10 +1,8 @@ import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock"; -import { - ImageZoom, - type ImageZoomProps, -} from "fumadocs-ui/components/image-zoom"; +import { ImageZoom } from "fumadocs-ui/components/image-zoom"; import defaultMdxComponents from "fumadocs-ui/mdx"; import type { MDXComponents } from "mdx/types"; +import type { ComponentPropsWithoutRef } from "react"; import { APIPage } from "@/components/api-page"; import { CodeFromSource } from "@/components/code-from-source"; @@ -18,7 +16,26 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
{props.children}
), - img: (props) => , + // Content images are R2-backed (src/lib/remark-doc-images.ts resolves + // the path at compile time; remarkImageOptions is disabled in + // source.config.ts so this receives the literal, resolved `` MDX + // produced for every `![...](...)` in content, unmodified). Rendered as + // a plain, zoomable -- no next/image, no dimensions needed, same + // ImageZoom-wrapped UX this repo had before any of this migration. + img: (props: ComponentPropsWithoutRef<"img">) => { + if (typeof props.src !== "string") return null; + const { src, alt, ...rest } = props; + return ( + // src/alt passed here too, not just to the inner : ImageZoom's + // zoomed-in view reads its image from these props directly, not + // from `children` -- omitting them renders a blank zoomed-in image + // even though the inline thumbnail (via children) looks correct. + + {/* biome-ignore lint/performance/noImgElement: doc content images are served from R2, not Next's image pipeline -- see src/lib/remark-doc-images.ts */} + {alt + + ); + }, ...components, }; } diff --git a/tsconfig.json b/tsconfig.json index 057b290..3f2fc89 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,5 +31,11 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + // Runs directly on the Workers runtime via wrangler's own bundler/type + // scope, not part of the Next.js app — keep it out of `tsc --noEmit` + // here so it isn't checked against `lib: ["dom", ...]`. + "worker" + ] } diff --git a/worker/index.ts b/worker/index.ts new file mode 100644 index 0000000..ab2de30 --- /dev/null +++ b/worker/index.ts @@ -0,0 +1,133 @@ +// Cloudflare Worker entry point. +// +// This site is a fully static Next.js export (`output: "export"` in +// next.config.mjs) served by Cloudflare's native static assets (see +// `assets` in wrangler.jsonc) — there is no Next.js server runtime in +// production. That means neither @cloudflare/next-on-pages nor +// @opennextjs/cloudflare apply here: there's no running Next.js request +// handler on Cloudflare to reach a binding from. This file is the smallest +// possible layer on top of that: it serves everything through the ASSETS +// binding exactly as before, except requests for content images, which it +// answers directly from the shared R2 bucket. That's the only reason this +// repo has a `main` Worker script at all — see scripts/README.md. +// +// Docs content images used to be committed to git, co-located with their +// MDX files under content/docs/images/, and referenced with relative +// markdown paths (e.g. `./images/dag/architecture.svg`). Next's +// static-export bundler resolved those at build time into content-hashed +// files under `_next/static/media/`, which meant the image bytes had to be +// physically present in the repo just to run `next build`. That's +// incompatible with "stop committing images to git", so content images are +// now referenced by a stable absolute path instead (`/img/`, resolved +// to `/docs/propeller/img/` via NEXT_PUBLIC_BASE_PATH in +// src/components/doc-image.tsx) and served by this route. + +interface R2ObjectBody { + body: ReadableStream; + size: number; + httpEtag: string; + writeHttpMetadata(headers: Headers): void; +} + +interface R2Bucket { + get(key: string): Promise; +} + +interface Fetcher { + fetch(request: Request): Promise; +} + +interface Env { + ASSETS: Fetcher; + IMAGES_BUCKET: R2Bucket; +} + +// Minimal structural types for the Workers Cache API -- avoids depending on +// the gitignored, wrangler-generated worker-configuration.d.ts (pnpm run +// build never regenerates it, only the separate types:check script does). +interface CFCache { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; +} +interface CFCacheStorage { + readonly default: CFCache; +} +interface ExecutionContext { + waitUntil(promise: Promise): void; +} + +// Matches next.config.mjs's BASE_PATH — this Worker has no access to that +// module (it isn't part of the Next.js build), so it's repeated here. +const IMG_ROUTE_PREFIX = "/docs/propeller/img/"; + +// Shared bucket ("websites-images") holds assets for multiple properties; +// this prefix keeps this site's objects from colliding with theirs. +const R2_KEY_PREFIX = "propeller-docs"; + +function notFound(): Response { + return new Response("Not found", { + status: 404, + headers: { "cache-control": "no-store" }, + }); +} + +async function handleImageProxy( + request: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + const url = new URL(request.url); + const key = url.pathname.slice(IMG_ROUTE_PREFIX.length); + if (!key) return notFound(); + + // env.IMAGES_BUCKET.get() is an R2 binding call, not an HTTP subrequest -- + // it never touches Cloudflare's HTTP cache. Without explicitly writing the + // response into the Cache API, every request (from every visitor, at + // every edge location) would re-read from R2, no matter what + // Cache-Control header gets set on the returned Response. Using the + // request's own URL (unmodified) as the cache key keeps this purgeable by + // the existing purge-by-URL call in scripts/publish-image.mjs. + const cache = (caches as unknown as CFCacheStorage).default; + const cacheKey = new Request(request.url, request); + + const cached = await cache.match(cacheKey); + if (cached) return cached; + + const object = await env.IMAGES_BUCKET.get(`${R2_KEY_PREFIX}/${key}`); + if (!object) return notFound(); + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set("etag", object.httpEtag); + headers.set("content-length", String(object.size)); + // Browser TTL long enough to skip most repeat-visit requests, short + // enough to self-heal within the hour if a purge is ever missed. Edge TTL + // is effectively unbounded -- scripts/publish-image.mjs purges it + // explicitly and immediately on every upload, so there's no benefit to a + // shorter one, and every edge location that has ever served an image now + // actually caches it (see the Cache API use above). + headers.set("cache-control", "public, max-age=3600, s-maxage=31536000"); + + const response = new Response(object.body, { headers }); + ctx.waitUntil(cache.put(cacheKey, response.clone())); + return response; +} + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { + const url = new URL(request.url); + if (url.pathname.startsWith(IMG_ROUTE_PREFIX)) { + return handleImageProxy(request, env, ctx); + } + + // run_worker_first defaults to false, so in production this Worker only + // runs when no static asset matched the request path already — this + // fetch() is here for local `wrangler dev` parity and clarity, not to + // duplicate work the platform already does. + return env.ASSETS.fetch(request); + }, +}; diff --git a/wrangler.jsonc b/wrangler.jsonc index a710f03..a832991 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,12 +1,20 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "propeller-docs", + "main": "./worker/index.ts", "compatibility_date": "2026-05-29", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "observability": { "enabled": true }, "assets": { - "directory": "./out" - } + "directory": "./out", + "binding": "ASSETS" + }, + "r2_buckets": [ + { + "binding": "IMAGES_BUCKET", + "bucket_name": "websites-images" + } + ] }