Docs/Advanced Features/Bucket Sites

    Bucket Sites

    Bucket Sites serve web content directly from S3WORM buckets. Define your data model in schema.json and get a REST API, admin dashboard, and data-driven pages for free.


    Site Types

    S3WORM supports three site flavors, all served by the same local server. A single bucket can combine all three.

    TypeWhat It DoesUse Case
    "static"Serve HTML, CSS, JS, and images directly from the bucketMarketing pages, docs, hosted assets
    "data"Auto-generate pages from entity data using templatesBlog, portfolio, product catalog, docs site
    "admin"Auto-generate a CRUD dashboard from schema modelsInternal tools, data management, content admin

    Site Configuration

    The site field in schema.json configures the bucket site.

    {
      "site": {
        "enabled": true,
        "type": "data",
        "title": "My Blog",
        "description": "A blog powered by S3WORM bucket sites",
        "basePath": "#site",
        "theme": "default",
        "admin": true,
        "api": true,
        "pages": {
          "/": {
            "template": "home",
            "data": { "model": "SiteSettings" }
          },
          "/posts": {
            "template": "list",
            "data": { "view": "PublishedPosts" }
          },
          "/posts/:slug": {
            "template": "detail",
            "data": { "model": "Post", "lookup": "slug" }
          },
          "/tags/:tag": {
            "template": "list",
            "data": {
              "model": "Post",
              "filter": { "tags": { "$contains": ":tag" } }
            }
          }
        }
      }
    }
    

    Configuration Fields

    FieldTypeDefaultDescription
    enabledbooleanfalseEnable the bucket site
    typestring"data""static", "data", or "admin"
    titlestringBucket nameSite title (HTML <title>, admin header)
    descriptionstring""Site meta description
    basePathstring"#site"Bucket namespace for site assets (templates, static files)
    themestring"default"Theme name
    adminbooleantrueMount admin dashboard at /_admin
    apibooleantrueMount REST API at /api
    pagesobject{}Route-to-template mappings

    worm serve

    Start a local HTTP server for development. Uses LocalFsTransport under the hood.

    worm serve [options]
    

    Options

    FlagDefaultDescription
    --port <number>3000Port to listen on
    --host <string>"localhost"Host to bind to
    --open--Open browser on start
    --no-admin--Disable admin dashboard
    --no-api--Disable REST API
    --watchtrueWatch for file changes and live-reload

    URL Structure

    http://localhost:3000/
      /                          Data-driven pages (from site.pages)
      /posts                     List page
      /posts/my-first-post       Detail page
      /_static/                  Static files from #site namespace
        css/style.css
        js/app.js
        images/logo.png
      /api/                      REST API (auto-generated from models)
        /api/posts
        /api/posts/:id
        /api/site-settings
      /_admin/                   Admin dashboard (auto-generated)
        /_admin/posts
        /_admin/posts/:id
        /_admin/site-settings
    

    Server Architecture

    The server uses Node's built-in http module with no framework dependencies. Routing dispatches to the appropriate handler based on path prefix.

    worm serve
      Load schema.json -> extract site config + model definitions
      Create LocalFsTransport -> point at .worm/data/
      Create S3Worm instance -> load schema
      Mount middleware:
        Static file handler -> serves #site assets
        API router -> auto-generated REST endpoints
        Page renderer -> template engine + entity data
        Admin handler -> auto-generated dashboard SPA
      Start HTTP server
      Watch .worm/data/ for changes -> live reload via SSE
    

    REST API

    Every model defined in the schema automatically gets REST API endpoints mounted at /api.

    CRUD Endpoints

    For a model named Post:

    MethodPathDescription
    GET/api/postsList all entities (supports query params)
    GET/api/posts/:idGet a single entity by ID
    POST/api/postsCreate a new entity
    PUT/api/posts/:idUpdate an entity (full replace)
    PATCH/api/posts/:idPartial update
    DELETE/api/posts/:idDelete (soft or hard per model config)

    For singleton models like SiteSettings:

    MethodPathDescription
    GET/api/site-settingsGet the singleton
    PUT/api/site-settingsUpdate the singleton

    Query Parameters

    GET /api/posts?status=published&_sort=publishedAt&_order=desc&_limit=10&_offset=0
    
    ParameterDescription
    <field>=<value>Filter by equality
    <field>.$in=a,b,cFilter: value in set
    <field>.$gt=<value>Filter: greater than
    <field>.$gte=<value>Filter: greater than or equal
    <field>.$lt=<value>Filter: less than
    <field>.$lte=<value>Filter: less than or equal
    <field>.$ne=<value>Filter: not equal
    _sortSort field name
    _orderSort order: asc or desc
    _limitMax results (default: 50, max: 500)
    _offsetSkip N results
    _populateComma-separated ref names to populate

    Related Entity Endpoints

    Auto-generated from ref field definitions:

    GET /api/invoices/:id/customer      Populate the customerId ref
    GET /api/customers/:id/invoices     Find invoices where customerId = :id
    

    Trash Endpoints

    For models with softDelete: true:

    MethodPathDescription
    GET/api/posts/trashList soft-deleted entities
    POST/api/posts/:id/restoreRestore from trash
    DELETE/api/posts/:id/purgePermanently delete

    Oplog Endpoints

    For models with oplog: true:

    MethodPathDescription
    GET/api/posts/:id/historyGet oplog entries
    POST/api/posts/:id/rollbackRollback to previous sequence

    View Endpoints

    Named views from the schema are exposed as read-only endpoints:

    GET /api/_views/PublishedPosts
    GET /api/_views/UnpaidInvoices?_limit=5
    

    Response Format

    All responses use a consistent envelope:

    // Success -- single entity
    {
      "data": { "id": "abc-123", "title": "Hello World", ... },
      "meta": { "model": "Post" }
    }
    
    // Success -- list
    {
      "data": [
        { "id": "abc-123", "title": "Hello World", ... },
        { "id": "def-456", "title": "Second Post", ... }
      ],
      "meta": {
        "model": "Post",
        "total": 42,
        "limit": 10,
        "offset": 0
      }
    }
    
    // Error
    {
      "error": {
        "code": "VALIDATION_FAILED",
        "message": "Validation failed for Post: title is required",
        "details": [
          { "field": "title", "message": "Required field is missing" }
        ]
      }
    }
    

    Model Name to API Path

    Model names are converted from PascalCase to kebab-case and pluralized:

    Model NameAPI Path
    Post/api/posts
    Customer/api/customers
    InvoiceLineItem/api/invoice-line-items
    OrgSettings (singleton)/api/org-settings

    Override with the apiPath field on the model:

    {
      "models": {
        "Person": {
          "apiPath": "people"
        }
      }
    }
    

    Admin Dashboard

    An auto-generated CRUD UI served as a self-contained SPA at /_admin. Single HTML page with inline JS and CSS. No external dependencies, no build step.

    Pages

    RouteDescription
    /_adminDashboard home: model list, entity counts, recent changes
    /_admin/:modelList view with sortable columns, pagination, search
    /_admin/:model/newCreate form for a new entity
    /_admin/:model/:idDetail and edit view
    /_admin/:model/trashSoft-deleted entities (restore, purge)
    /_admin/_oplogGlobal oplog viewer (all models, chronological)
    /_admin/_schemaSchema inspector (read-only view of loaded schema)

    List View

    Auto-generated from model field definitions:

    • Columns: One per field. String and number fields shown by default; arrays and objects collapsed.
    • Sort: Click column headers. Calls the API with _sort and _order.
    • Filter: Per-column filter bar (equality, contains, range).
    • Pagination: Page size selector (10 / 25 / 50 / 100) with prev/next.
    • Bulk actions: Select multiple rows for bulk delete or export.
    • Status badges: Enum fields rendered as colored badges.
    • Timestamps: Relative time display ("2 hours ago").

    Detail/Edit View

    Auto-generated form from field definitions:

    Field TypeInput Control
    stringText input (textarea for body/content/description fields)
    numberNumber input with step
    booleanToggle switch
    datetimeDate/time picker
    string with enumSelect dropdown
    string[]Tag input (add/remove chips)
    objectJSON editor with syntax highlighting
    object[]JSON array editor
    string with refEntity picker (dropdown with search)

    Oplog Viewer

    For entities with oplog: true:

    • Timeline view of all changes
    • Each entry shows sequence number, operation type, timestamp, actor, and diff
    • Diffs rendered as side-by-side before/after comparison
    • "Rollback to here" button per entry
    • Collapsible full snapshots on create/delete/restore entries

    Admin is API-Driven

    The admin dashboard is purely a client-side SPA that talks to the REST API. It works with any S3WORM REST API endpoint, not just worm serve. The admin is a single HTML file that can be cached and served statically.


    Data-Driven Pages

    For type: "data" sites, pages are generated from entity data and HTML templates.

    Route Matching

    PatternMatchesData Binding
    /Exact rootStatic data or singleton model
    /postsExact pathfindAll on model or view
    /posts/:idDynamic by IDfindById with URL param
    /posts/:slugDynamic by fieldfindAll with filter on field
    /tags/:tagDynamic with filterfindAll with computed filter

    Page Definition

    {
      "/posts/:slug": {
        "template": "detail",
        "data": {
          "model": "Post",
          "lookup": "slug",
          "populate": ["authorId"]
        },
        "meta": {
          "title": "Blog Post",
          "description": "A blog post"
        }
      }
    }
    
    FieldTypeDescription
    templatestringTemplate name (resolved from theme)
    data.modelstringModel to query
    data.viewstringNamed view (overrides model + filter)
    data.lookupstringFor detail pages: which field matches the URL param
    data.filterobjectAdditional filter criteria
    data.sortobjectSort config: { field, order }
    data.limitnumberMax results for list pages
    data.populatestring[]Ref fields to populate
    meta.titlestringPage title override
    meta.descriptionstringMeta description override

    Template Expression Syntax

    Templates use a minimal expression syntax for data binding:

    ExpressionMeaning
    {{field}}Output a value (HTML-escaped)
    {{{field}}}Output raw HTML (unescaped)
    {{#each items}}...{{/each}}Iterate over an array
    {{#if condition}}...{{/if}}Conditional rendering
    {{#if condition}}...{{else}}...{{/if}}Conditional with fallback
    {{field | truncate N}}Pipe filter: truncate to N characters
    {{field | date "YYYY-MM-DD"}}Pipe filter: format date
    {{field | markdown}}Pipe filter: render markdown to HTML
    {{> partial}}Include a partial template

    Template Context Variables

    VariableContents
    siteSite config (title, description, nav)
    pagePage definition (template, meta)
    dataQuery result (entity, array, or singleton)
    paramsURL parameters (:id, :slug, etc.)
    queryQuery string parameters

    Example Template

    <!-- .worm/data/site/templates/list.html -->
    <!DOCTYPE html>
    <html>
    <head>
      <title>{{site.title}} - {{page.meta.title}}</title>
      <link rel="stylesheet" href="/_static/css/theme.css">
    </head>
    <body>
      <header>
        <h1>{{site.title}}</h1>
        <nav>
          {{#each site.nav}}
            <a href="{{this.href}}">{{this.label}}</a>
          {{/each}}
        </nav>
      </header>
      <main>
        <h2>{{page.meta.title}}</h2>
        {{#each data}}
        <article>
          <h3><a href="/posts/{{this.slug}}">{{this.title}}</a></h3>
          <time>{{this.publishedAt}}</time>
          <p>{{this.body | truncate 200}}</p>
          <div class="tags">
            {{#each this.tags}}
              <span class="tag">{{this}}</span>
            {{/each}}
          </div>
        </article>
        {{/each}}
      </main>
    </body>
    </html>
    

    Built-In Templates

    The default theme includes:

    TemplatePurposeData Shape
    homeLanding pageSingleton or custom data
    listEntity list with cards/rowsArray of entities
    detailSingle entity viewSingle entity
    errorError page (404, 500)Error object

    Static Assets

    Static files live in the bucket under the basePath namespace (default: #site). In local dev, this maps to .worm/data/site/.

    Directory Structure

    .worm/data/site/
      templates/           HTML templates (for data-driven pages)
        home.html
        list.html
        detail.html
        error.html
      partials/            Shared template partials
        header.html
        footer.html
      css/                 Stylesheets
        theme.css
      js/                  Client-side JavaScript
        app.js
      images/              Images and media
        logo.png
      favicon.ico
    

    Static files are served at /_static/ during worm serve:

    .worm/data/site/css/theme.css    -->  /_static/css/theme.css
    .worm/data/site/images/logo.png  -->  /_static/images/logo.png
    

    Templates are NOT served as static files. They are only used by the page renderer.


    Build and Deploy

    worm site build

    Generate a static HTML site from entity data and templates.

    worm site build                     # build to .worm/out/
    worm site build --out ./dist        # custom output directory
    worm site build --base-url https://example.com
    

    Build Output

    .worm/out/
      index.html
      posts/
        index.html                  /posts list page
        my-first-post.html          /posts/:slug detail page
        another-post.html
      tags/
        javascript.html             /tags/:tag list page
        typescript.html
      css/
        theme.css
      images/
        logo.png
      sitemap.xml
      404.html
    

    Build Process

    1. Load schema and site config
    2. For each route in site.pages:
      • Resolve the data query (model/view + filter)
      • Execute the query against local transport
      • For dynamic routes (:id, :slug): generate one HTML file per entity
      • Render the template with the data
      • Write HTML to output directory
    3. Copy static assets from #site
    4. Generate sitemap.xml
    5. Generate 404.html from the error template

    worm site deploy

    Push the built site to S3.

    worm site deploy                        # deploy .worm/out/ to bucket
    worm site deploy --bucket my-cdn-bucket # deploy to a different bucket
    worm site deploy --prefix www/          # deploy under a key prefix
    worm site deploy --dry-run              # show what would be uploaded
    

    Uses the Transport interface, so it works with any configured backend (S3, R2, MinIO, etc.).

    worm site preview

    Build and serve the static output locally, simulating production.

    worm site preview                # build + serve .worm/out/ on port 3000
    worm site preview --port 4000    # custom port
    

    Unlike worm serve (which renders on-the-fly), worm site preview serves the pre-built static HTML.


    Programmatic API

    For embedding in other applications:

    import { S3Worm } from "@decoperations/s3worm";
    import { SiteServer, SiteBuilder } from "@decoperations/s3worm/server";
    
    const worm = new S3Worm({ bucket: "my-bucket", endpoint: "..." });
    await worm.loadSchema(".worm/schema.json");
    
    // Start server programmatically
    const server = new SiteServer(worm, {
      port: 3000,
      admin: true,
      api: true,
      watch: false,
    });
    
    await server.start();
    // server.url -> "http://localhost:3000"
    
    // Static site generation
    const builder = new SiteBuilder(worm, {
      outDir: "./dist",
      baseUrl: "https://example.com",
    });
    
    const result = await builder.build();
    // result.pages   -> number of pages generated
    // result.assets  -> number of static files copied
    // result.outDir  -> absolute path to output
    
    await server.stop();
    

    Live Reload

    During worm serve, rendered HTML pages include a small injected script that connects to an SSE (Server-Sent Events) endpoint at /_sse.

    1. File watcher detects change in .worm/data/
    2. Server broadcasts reload event via SSE
    3. Browser receives event and reloads the page

    For the admin dashboard SPA, the SSE event triggers a data refetch instead of a full page reload.