Player Deploy Plan

Deploy PathMX sites to Cloudflare at *.path.app using:

  • SpaceDO per subdomain for mutable site state, deploy history, rollback, and live events
  • Worker for request routing and publish endpoints
  • R2 for immutable content-addressed artifacts

Architecture

HTTPS request WebSocket /_events resolve subdomain routes + manifest hashes content hash Browser Client Cloudflare Worker SpaceDO per subdomain Active site state Deploy history WebSocket clients PathMXServer / Router R2 content-addressed storage
POST /api/publish/begin check missing hashes missing hashes upload targets upload missing blobs POST /api/publish/commit commitPublish(roots, events) store deploy record update active state ChangeEvent broadcast active state live URL POST /api/publish/rollback rollbackDeploy(deployId, events) activate previous deploy ChangeEvent broadcast active state live URL CLI Worker R2 SpaceDO Client

Components

SpaceDO

Owns all mutable per-site concerns:

  • active site state
  • deploy history
  • publish commit serialization
  • rollback
  • /_events websocket clients
  • ChangeEvent broadcast

Worker

Owns stateless request handling:

  • parse host / subdomain
  • route /_events to the correct SpaceDO
  • load active state from SpaceDO
  • cache parsed routes and manifests by hash
  • fetch blobs from R2
  • expose publish and rollback APIs

R2

Stores immutable blobs by content hash:

  • HTML
  • JSON
  • CSS
  • JS
  • images
  • routes JSON
  • manifest JSON

CLI

Owns local build and remote publish operations:

  • pmx publish
  • pmx publish --rollback

State

type SpaceState = {
  deployId: string
  roots: BuildRootsJSON
  updatedAt: string
}

type DeployRecord = {
  deployId: string
  roots: BuildRootsJSON
  createdAt: string
  meta?: {
    commit?: string
    branch?: string
  }
}

Event Contract

Production should emit the same event shapes the player already expects:

type ChangeEvent =
  | { type: "sources-changed"; sources: string[] }
  | { type: "artifacts-changed"; paths: string[] }

Endpoint:

/_events

Events are scoped by subdomain through SpaceDO(subdomain).

Serving Flow

  1. Worker parses subdomain from host.
  2. Worker resolves SpaceDO(subdomain).
  3. Worker reads active SpaceState.
  4. Worker loads routes and manifest from R2 using hashes from roots.
  5. Worker reuses or creates a cached PathMXServer keyed by routesHash + manifestHash.
  6. PathMXServer resolves the request and fetches the target blob from R2.
  7. Worker returns the response with the correct headers.

Cache Rules

  • cache parsed routes and manifests in memory by hash
  • do not cache mutable site state by subdomain without checking deployId
  • stable route URLs like /lecture-1: ETag + Cache-Control: public, max-age=0, must-revalidate
  • hashed asset URLs: Cache-Control: public, max-age=31536000, immutable

Publish Flow

  1. pmx publish builds locally.
  2. CLI reads manifest and roots.
  3. CLI calls POST /api/publish/begin with subdomain and referenced hashes.
  4. Worker checks which hashes already exist in R2.
  5. CLI uploads only missing blobs.
  6. CLI calls POST /api/publish/commit with subdomain and new BuildRootsJSON.
  7. Worker validates auth and artifact existence.
  8. Worker forwards the commit to SpaceDO(subdomain).
  9. SpaceDO stores a new DeployRecord, updates active SpaceState, and broadcasts change events.
  10. Worker returns the live URL.

Publish Rules

  • upload content blobs before commit
  • upload routes/manifest blobs before commit
  • make the site live only in SpaceDO.commitPublish()
  • emit websocket events only after the active state changes

Rollback Flow

  1. pmx publish --rollback <deployId> calls POST /api/publish/rollback.
  2. Worker validates auth and resolves SpaceDO(subdomain).
  3. SpaceDO loads the target DeployRecord.
  4. SpaceDO makes that deploy active.
  5. SpaceDO broadcasts the same change events used for publish.
  6. Worker returns the live URL.

Rollback does not rebuild or re-upload artifacts.

API Surface

POST /api/publish/begin
POST /api/publish/commit
POST /api/publish/rollback
GET  /api/deploys?subdomain=...
GET  /_events

SpaceDO Interface

type SpaceDOApi = {
  getActiveState(): Promise<SpaceState | null>
  commitPublish(input: {
    roots: BuildRootsJSON
    meta?: DeployRecord["meta"]
    events: ChangeEvent[]
  }): Promise<SpaceState>
  listDeploys(): Promise<DeployRecord[]>
  rollbackDeploy(input: {
    deployId: string
    events: ChangeEvent[]
  }): Promise<SpaceState>
  connectEvents(request: Request): Promise<Response>
}

Package Layout

Worker-safe shared exports

Worker-safe subpaths that avoid Bun/Node dependencies:

@pathmx/core/shared   -> packages/core/src/shared/index.ts
@pathmx/server/shared  -> packages/server/src/shared/index.ts

@pathmx/core/shared exports:

  • Router, ResolvedRoute
  • route types (Routes, SourceRoute, Redirect, etc.)
  • ManifestData, ManifestFile
  • BuildRootsJSON, BuildRoot
  • ChangeEvent, SourceGraphData, GraphSourceEntry
  • base types (SourceLink, SourceMeta, etc.)

@pathmx/server/shared exports:

  • PathMXServer, Server, ServerConfig
  • PathMXStorage
  • resolveRequest, notFound, redirect

Worker project

apps/server/
  wrangler.toml          -- R2 + SpaceDO bindings
  worker-configuration.d.ts  -- generated Env types (wrangler types)
  tsconfig.json          -- extends root, includes worker-configuration.d.ts
  src/
    worker.ts            -- Worker fetch entry, subdomain parsing, routing
    space-do.ts          -- SpaceDO Durable Object
    server.ts            -- CloudflareServer (PathMXServer wrapper)
    storage.ts           -- R2Storage (PathMXStorage backed by R2)
    publish.ts           -- publish API handlers

Tasks

See pathmx-app-server.tasks.md for implementation checklist.

Later

  • preview deploys
  • custom domains
  • Cloudflare Images-backed image routes
  • richer deploy metadata