Static sites

Deploy a directory as a release and serve it with clean URLs, a real 404 page, and cache headers.

Docs menu

An app's website is its public directory, deployed as a release with the CLI.

Deploying

substrio site deploy ./dist          # hash, upload what the server lacks, activate
substrio site deploy ./dist --dry-run
substrio site releases               # list releases, most recently used first; * marks the active one
substrio site activate <id>          # switch to any kept release (rollback)
  1. The CLI sends a manifest of {path, hash, size} (site.release.create). The server returns the release id and hashes it does not already have.
  2. Each missing file is uploaded once (site.blob.upload). A blob must hash to its name.
  3. site.release.activate switches the site to the release in one step: every request is served either the old release or the new one, never a mix.

Files use GxHash-128 content names with 32 lowercase hexadecimal digits. The CLI computes the same hash as the server. Each upload is hashed once, and stored bytes are compared before an existing name is shared. The release id is derived from the manifest, so deploying identical content produces the same release.

A release can contain up to 20,000 files, with paths up to 512 bytes each. Ten inactive releases are kept for rollback, ranked by when each was last created, redeployed, or replaced. The rest are pruned, so the release just replaced is always kept.

Shipping with no hash (an agent)

An agent can ship a site over MCP without computing a hash. Use version.commit to write files as site/<path> units in the app's history, with text in text or bytes in base64. Then use version.live.set to ship the commit. See versioning.md, "Developing over MCP". Upload a file too large for one call first:

  • Uploads with no hash. site.blob.upload without hash (or HTTP PUT /v1/_site/blobs with raw bytes) names the file using GxHash-128 and returns {hash, size, stored}. A supplied hash must have 32 lowercase hexadecimal digits and match the bytes.
  • In parts with no hash. A file larger than one MCP call (640 KiB) goes in order with offset and total. With no hash, the first part answers {upload, received, total, complete: false}; every later part carries that upload id; the last answers {hash, size, stored}. A part in parts with neither a hash nor an upload (past offset 0), or an upload no longer in progress (finished, or idle for 10 minutes), is refused naming upload.
  • Committed by id. version.commit accepts the upload's 32-digit hash as {path: "site/<path>", id: "<that hash>"}. Returned ObjectIds have 64 digits. Keep the full returned ID when storing or sending it.

Worked example: a large file in parts

{"name": "substrio_site_blob_upload", "arguments": {"app": "shop", "offset": 0, "total": 1500000, "content_base64": "<the first part>"}}
{"name": "substrio_site_blob_upload", "arguments": {"app": "shop", "upload": "<the id it answered>", "offset": 655360, "total": 1500000, "content_base64": "<the second part>"}}
{"name": "substrio_site_blob_upload", "arguments": {"app": "shop", "upload": "<the id>", "offset": 1310720, "total": 1500000, "content_base64": "<the last part>"}}
{"name": "substrio_version_commit", "arguments": {"app": "shop", "branch": "main", "units": [{"path": "site/video.mp4", "id": "<the hash the last part answered>"}]}}

The commit returns its number. substrio_live_set {version: <number>} serves the file.

Refusals

Code Status When Field
INVALID_ARGUMENT 400 a later part with no upload, an upload not in progress, an offset or total that does not follow upload, offset or total
STORAGE_QUOTA_EXCEEDED 507 the app's storage limit does not hold the file
CONTENT_HASH_COLLISION 409 Different bytes are already stored under this content name. The response has retryable: false.

Retrying the same bytes under the same name returns the same refusal. Change the file contents and upload their new content name.

For example, the content name of hello is b72d5f54eba088e98c3218566d5a0719.

printf hello | curl -X PUT \
  "https://<app>.<domain>/v1/_site/blobs/b72d5f54eba088e98c3218566d5a0719" \
  -H "Authorization: Bearer $SUBSTRIO_KEY" \
  -H "Content-Type: application/octet-stream" --data-binary @-

Serving

Every GET that no API route or function route claims is resolved inside public/. The first match wins:

  1. the file itself
  2. <path>/index.html
  3. <path>.html

For navigations only (the client accepts text/html, sends */*, or sends no Accept):

  1. the nearest ancestor <dir>/index.html below the root, so an app at public/app/index.html owns every path under /app/
  2. 404.html, answered with status 404
  3. the root index.html, so a single-page app keeps working at every path

A request for a missing script or image is a 404, never an HTML page. A path that escapes public/ is refused.

Steps 4 and 6 answer page paths only. A missing file of a known type that is not a page, such as robots.txt, sitemap.xml or logo.png, gets the site's 404.html with status 404, or a 404 with no page. This holds even when the request accepts text/html, as browsers and search engine crawlers do. A single-page app's own routes keep working, because a path with no extension, .html, or an extension of no known type is still a page. Without this rule, a search engine reads every missing file as a copy of the home page.

GET /_/<path> serves public/_/<path> when that file exists; otherwise it serves public/<path>, as /_/ always has.

One address for your site

Search engines index a site that answers at two names as two copies of itself, and its ranking is split between them. When an app holds both names of a www pair, its custom domain is the site's one address. A page fetched on the other name is answered with a permanent redirect to the custom domain, with the same path and query.

Custom domain Also attached A GET on Answer
example.com www.example.com https://www.example.com/pricing?ref=a 301, Location: https://example.com/pricing?ref=a
www.example.com example.com https://example.com/ 301, Location: https://www.example.com/
example.com docs.example.com https://docs.example.com/ the site, as before

The redirect covers every GET and HEAD that reaches the site, function routes included. API routes and every other method answer on both names as before. There is nothing to configure. The other name is attached with tenant.domain.attach (POST /v1/_tenant/domains), and the custom domain decides which of the two is the address.

A host that names no app

A host under a root domain whose name matches no app, such as noexist.substrio.com, gets an answer chosen by Accept:

Request Answer
GET / or any page path, Accept names text/html (a browser or a search engine) 404, Content-Type: text/html; charset=utf-8, the "App not found" page from Substrio
the same paths, any other Accept (application/json, */*, none) 404, the typed error TENANT_NOT_FOUND as JSON
/_/<path>, /_files/<path> and every /v1/ route unchanged: their own typed JSON errors

The page is compiled into the server. It runs no script and fetches nothing. It is always dark, and it follows prefers-reduced-motion. Its status is 404, so search engines index nothing at the name. It is sent with Cache-Control: no-store, so an app created at that name later shows at once, and with the same security headers as the starter page below. An app that is paused or suspended keeps its own answer.

An app with no site

A new app has an empty public/. Until a site is deployed, a navigation that nothing above answers, in a public/ that is missing or holds no entry at all, gets an answer chosen by Accept:

Request Answer
GET / or any page path, Accept names text/html (a browser) 404, Content-Type: text/html; charset=utf-8, a starter page from Substrio: "This app is live, and empty", one line on deploying, a link to the docs
the same paths, any other Accept (application/json, */*, none) 404, the typed error FILE_NOT_FOUND as JSON
/_/<path> and every /v1/ route, whatever the Accept unchanged: their own typed JSON errors

The starter page is compiled into the server: no script, no external fetch, no font download, dark and light by prefers-color-scheme. It is sent with Cache-Control: no-store (so the first deploy shows at once), Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none', X-Frame-Options: DENY and Referrer-Policy: no-referrer. Its status is 404, as the JSON error it stands in for: the path holds nothing, so crawlers index nothing and monitors see no site.

An app with any file in public/ never gets the starter page: its own 404.html answers a missing page, and with no 404.html and no root index.html a missing page is the JSON FILE_NOT_FOUND.

To replace it, deploy a site, for example:

substrio site deploy ./dist          # ./dist/index.html becomes the app's root

or have an agent ship site/<path> units over MCP (see "Shipping with no hash" above). The next request serves the site.

Caching

Caching uses revalidation or content-specific URLs so a new version can serve updated files:

  • Every page, and every file whose name carries no content hash, is sent with no-cache and an ETag. The browser asks again each time, and a matching If-None-Match gets 304 with no body.
  • When a release goes live, each reference its pages and stylesheets make to a /_/ file is written as /_/<path>?v=<tag>. The tag is the first 8 hex digits of that file's ETag. A request whose v is that tag names exactly those bytes, so it is sent with Cache-Control: public, max-age=31536000, immutable. The next version that changes the file changes its tag, so its pages name a new URL.
  • A file whose name carries a content hash, as a bundler writes one, is sent as immutable on its own. That means a dot-separated piece of 8 or more hex digits before the extension, such as app.3f2a9b1c.js. A name like app-3f2a9b1c.js carries no hash by this rule and revalidates, and so does a base64url hash such as Vite's default index-DCEBzBzW.js: a word like settings would pass for one. Tell the bundler to write hex hashes after a dot (Vite with rolldown: hashCharacters: 'hex' and entryFileNames: 'assets/[name].[hash].js', the same for chunks and assetFileNames: 'assets/[name].[hash][extname]').
Request Cache-Control
A page, /_/app.js, /robots.txt no-cache (304 when unchanged)
/_/app.js?v=3f2a9b1c, the tag of the bytes served public, max-age=31536000, immutable
/_/app.js?v=<an older tag> no-cache
/_/app.3f2a9b1c.js public, max-age=31536000, immutable

Some references are left as written:

  • a link to a page (<a href>);
  • a URL that already carries a query;
  • a stylesheet's @import of a stylesheet later in the release;
  • a reference to a file past --static-cache-file-bytes (its ETag is its time and size);
  • a reference to a file whose name carries a content hash (/_/assets/index.3f2a9b1c.js), which is immutable by its name already. A page or stylesheet with such a name is still tagged, because the release changes its bytes under the same name.

JavaScript module imports are never rewritten: give a module a hashed name for it to be immutable. A served page is the version's page with these tags in it, so its bytes (and its ETag) differ from the unit committed.

A page or stylesheet shipped with precompressed siblings is tagged the same way, and no sibling is served with older bytes than its page. When the tags change the page:

  • its .gz is written again from the tagged bytes (gzip, level 9), so a client that accepts gzip gets the tagged page;
  • its .br is left out of the release, because the server has no brotli encoder. A client that accepts brotli and gzip gets the .gz; one that accepts neither gets the plain page.

A page the tags leave unchanged (it names no /_/ file) keeps both siblings as shipped. To keep brotli for a page that names /_/ files, give those files hashed names: the page then needs no tag, and the bundler's .br is served.

<!-- site/index.html as committed -->
<link rel="stylesheet" href="/_/style.css"><script src="/_/app.js"></script>
<!-- as served -->
<link rel="stylesheet" href="/_/style.css?v=9c41e07a"><script src="/_/app.js?v=3f2a9b1c"></script>
  • HEAD gets exactly the headers GET would (status, Content-Type, Content-Length, ETag) and no body. Crawlers such as Googlebot check a sitemap or page this way before fetching it.
  • Precompressed siblings <file>.br and <file>.gz are chosen by Accept-Encoding (a q=0 refusal is honoured), with Vary: Accept-Encoding.
  • A new release is served from the next request on.

Tools

An app's command line tools use SHA-256 pins for their image bytes.

PUT /v1/tools/{name}/{sha256}        # tool.install, body is the binary
GET /v1/tools                        # tool.list
DELETE /v1/tools/{name}/{version}    # tool.remove, version is the 16 lowercase hex digits tool.install returned
POST /v1/tools/{name}/run            # tool.run, body names args/env/stdin/limits

A name is lowercase letters, digits, - and _, starting with a letter or digit. tool.install answers 201 {name, version, bytes, format, already_installed} on the first install of a given name and version, 200 with already_installed: true on a repeat of the same bytes; a wrong sha256 or a body that isn't a binary in the expected tool format is refused before anything is written. tool.list answers {tools: [{name, version, bytes, format, current, installed_at}, ...]}; the most recently installed version of a name is current. tool.remove deletes one name and version; removing a version never installed is refused. Each install and remove is one audit row; installing and removing is irreversible (no undo).

tool.run runs one installed version (the current one, or the version the body names) in a sandbox, under the app's own tool limits. The body: args (an array of strings, exactly argv[1..], no shell and no splitting), env (an object of strings; the server's own environment is never inherited), stdin (a string, or {"base64": ...} for bytes that aren't UTF-8), and limits (optional per-run overrides, each clamped to min(requested, the app's effective limit); 0 or absent takes the effective value). The reply: status (the exit code; a nonzero status is data, not an error), signal, limit (the name of whichever limit ended the run, or null), wall_us, cpu_user_us, cpu_system_us, max_rss_kb, and stdout/stderr as {"encoding":"utf8","text":...} or {"encoding":"base64","data":...}, each with bytes and truncated. Each run is one audit row (name, version, exit status or limit hit, wall and CPU microseconds; never arguments, environment or output); running is irreversible (no undo).