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.
| Type | What It Does | Use Case |
|---|---|---|
"static" | Serve HTML, CSS, JS, and images directly from the bucket | Marketing pages, docs, hosted assets |
"data" | Auto-generate pages from entity data using templates | Blog, portfolio, product catalog, docs site |
"admin" | Auto-generate a CRUD dashboard from schema models | Internal 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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable the bucket site |
type | string | "data" | "static", "data", or "admin" |
title | string | Bucket name | Site title (HTML <title>, admin header) |
description | string | "" | Site meta description |
basePath | string | "#site" | Bucket namespace for site assets (templates, static files) |
theme | string | "default" | Theme name |
admin | boolean | true | Mount admin dashboard at /_admin |
api | boolean | true | Mount REST API at /api |
pages | object | {} | Route-to-template mappings |
worm serve
Start a local HTTP server for development. Uses LocalFsTransport under the hood.
worm serve [options]
Options
| Flag | Default | Description |
|---|---|---|
--port <number> | 3000 | Port 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 |
--watch | true | Watch 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:
| Method | Path | Description |
|---|---|---|
GET | /api/posts | List all entities (supports query params) |
GET | /api/posts/:id | Get a single entity by ID |
POST | /api/posts | Create a new entity |
PUT | /api/posts/:id | Update an entity (full replace) |
PATCH | /api/posts/:id | Partial update |
DELETE | /api/posts/:id | Delete (soft or hard per model config) |
For singleton models like SiteSettings:
| Method | Path | Description |
|---|---|---|
GET | /api/site-settings | Get the singleton |
PUT | /api/site-settings | Update the singleton |
Query Parameters
GET /api/posts?status=published&_sort=publishedAt&_order=desc&_limit=10&_offset=0
| Parameter | Description |
|---|---|
<field>=<value> | Filter by equality |
<field>.$in=a,b,c | Filter: 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 |
_sort | Sort field name |
_order | Sort order: asc or desc |
_limit | Max results (default: 50, max: 500) |
_offset | Skip N results |
_populate | Comma-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:
| Method | Path | Description |
|---|---|---|
GET | /api/posts/trash | List soft-deleted entities |
POST | /api/posts/:id/restore | Restore from trash |
DELETE | /api/posts/:id/purge | Permanently delete |
Oplog Endpoints
For models with oplog: true:
| Method | Path | Description |
|---|---|---|
GET | /api/posts/:id/history | Get oplog entries |
POST | /api/posts/:id/rollback | Rollback 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 Name | API 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
| Route | Description |
|---|---|
/_admin | Dashboard home: model list, entity counts, recent changes |
/_admin/:model | List view with sortable columns, pagination, search |
/_admin/:model/new | Create form for a new entity |
/_admin/:model/:id | Detail and edit view |
/_admin/:model/trash | Soft-deleted entities (restore, purge) |
/_admin/_oplog | Global oplog viewer (all models, chronological) |
/_admin/_schema | Schema 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
_sortand_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 Type | Input Control |
|---|---|
string | Text input (textarea for body/content/description fields) |
number | Number input with step |
boolean | Toggle switch |
datetime | Date/time picker |
string with enum | Select dropdown |
string[] | Tag input (add/remove chips) |
object | JSON editor with syntax highlighting |
object[] | JSON array editor |
string with ref | Entity 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
| Pattern | Matches | Data Binding |
|---|---|---|
/ | Exact root | Static data or singleton model |
/posts | Exact path | findAll on model or view |
/posts/:id | Dynamic by ID | findById with URL param |
/posts/:slug | Dynamic by field | findAll with filter on field |
/tags/:tag | Dynamic with filter | findAll with computed filter |
Page Definition
{
"/posts/:slug": {
"template": "detail",
"data": {
"model": "Post",
"lookup": "slug",
"populate": ["authorId"]
},
"meta": {
"title": "Blog Post",
"description": "A blog post"
}
}
}
| Field | Type | Description |
|---|---|---|
template | string | Template name (resolved from theme) |
data.model | string | Model to query |
data.view | string | Named view (overrides model + filter) |
data.lookup | string | For detail pages: which field matches the URL param |
data.filter | object | Additional filter criteria |
data.sort | object | Sort config: { field, order } |
data.limit | number | Max results for list pages |
data.populate | string[] | Ref fields to populate |
meta.title | string | Page title override |
meta.description | string | Meta description override |
Template Expression Syntax
Templates use a minimal expression syntax for data binding:
| Expression | Meaning |
|---|---|
{{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
| Variable | Contents |
|---|---|
site | Site config (title, description, nav) |
page | Page definition (template, meta) |
data | Query result (entity, array, or singleton) |
params | URL parameters (:id, :slug, etc.) |
query | Query 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:
| Template | Purpose | Data Shape |
|---|---|---|
home | Landing page | Singleton or custom data |
list | Entity list with cards/rows | Array of entities |
detail | Single entity view | Single entity |
error | Error 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
- Load schema and site config
- 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
- Copy static assets from
#site - Generate
sitemap.xml - Generate
404.htmlfrom 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.
- File watcher detects change in
.worm/data/ - Server broadcasts
reloadevent via SSE - 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.