Hyperclay Local
  1. Hyperclay Local
  2. Docs
  3. Serving documents
  4. Saving and stale-write protection

Saving and stale-write protection

How POST /_/save writes documents to disk, enforces conditional saves and save receipts, and runs the data-clobber guard.

Every malleable document served by Hyperclay Local saves itself back to disk through POST /_/save. The server serializes writes per folder through a shared/exclusive write gate, supports conditional saves with If-Match and save receipts with Save-ID, writes backups to .hyperclay/versions/, and runs the data-clobber guard.

For how ClayJS sends save requests from the browser, see Saving and Conditional saves and receipts.

POST /_/save#

POST /_/save writes a complete HTML document to disk. The older path POST /save is also accepted.

HTTP
POST /_/save
Content-Type: text/html; charset=utf-8
Document-URL: http://localhost:4321/blog/post.html
Save-Trigger: user
If-Match: "9f86d081884c7d65"
Save-ID: 01J9Z7X4V2Q8M6K1P3R5T7W9Y0

<!DOCTYPE html><html>...</html>

Request and response rules#

  • Request body: The body is the entire serialized document as text. It must contain a top-level <html> element; anything else returns 422:
    JSON
    {"msg":"Not a complete HTML document with a top-level <html> element.","msgType":"error","code":"invalid-document"}
    
  • Document-URL: The canonical header identifying which file to write. The pre-spec Page-URL spelling is accepted when Document-URL is absent, and Document-URL wins when both are sent. Document-URL must resolve inside the served folder.
  • Target file resolution: The target file is the pathname of Document-URL, percent-decoded once. A URL pointing at / resolves to index.html.
  • Content type: A JSON content type (or any non-document media type) is refused with 415:
    JSON
    {"msg":"/_/save takes the document as text, not JSON.","msgType":"error","code":"unsupported-type"}
    
  • Save-Trigger: Save-Trigger: user marks a human action, auto marks autosave or a script, and any unknown value is treated as auto. The legacy header X-Hyperclay-User-Driven: 1 is read only when Save-Trigger is absent. The trigger value feeds the data-clobber guard.
  • Success response: Returns 200:
    JSON
    {"msg":"Saved","msgType":"success","etag":"…"}
    
    When a Save-ID header was sent, the response also includes "saveId".
  • Maximum body size: 20971520 bytes (20 MB).
  • New files and folders: Saving to a path that does not exist yet creates the file and any missing parent directories.
  • Attribute stripping: Any save-token attributes on the opening <html> tag and the documentetag stamp added when serving are stripped before the file is written to disk and before the ETag is computed.

Conditional saves and save receipts#

Hyperclay Local announces "conditional" in GET /_/meta and stamps every served document with documentetag on its root <html> element.

Refusing stale writes with If-Match#

Send the ETag of the document you loaded in the If-Match header on POST /_/save. The server compares If-Match against the current document bytes inside the write lock:

  • If the stamp matches, the save proceeds and returns the new "etag".
  • If the document changed underneath the caller, the save is refused with 412 Precondition Failed and nothing is written to disk.
  • If the file on disk cannot be read, the save is refused rather than treating the file as empty.

Save receipts (Save-ID)#

A client can include Save-ID: <id>, an opaque identifier minted for a single save attempt. The server stores that identifier alongside the ETag of the bytes the save wrote and reports it as saveId in GET /_/meta, on 200 save responses, and on 412 responses for as long as the file on disk still matches that ETag.

  • If a save request times out over a slow connection, the client can inspect saveId to verify whether its own write landed instead of overwriting a newer edit or raising a false conflict.
  • If a text editor, a Git checkout, or the sync engine changes the file on disk, the ETag changes and the previous saveId is invalidated automatically.

Page documents and external disk replacements#

  • Page editor merge state: For page documents, editor merge state is stored in .hyperclay/page-state (one record per filename) so the file on disk and in .hyperclay/versions/ stays clean while served pages, If-Match, and every ETag use the composed document. Code-view and Data API saves based on an older version merge into the stored page so open windows merge them instead of stopping.
  • Unsaved live edits when replaced on disk: When another program replaces or removes a document that has unsaved live edits in an open room, the file on disk wins and the unsaved edits are saved as a recovered copy in .hyperclay/recovered/, listed in Version history routes as recovered.

What happens on every save#

Every save runs inside the folder’s shared/exclusive write gate (alongside the Data API, version restores, uploads, the Tailwind cache, and the sync engine). If a queued write’s file was moved, renamed, or closed while waiting, the write is refused instead of writing to the old path.

On an accepted save, the server:

  1. Reads the existing bytes on disk and, if the file has no version history yet, writes an initial backup of the previous bytes to .hyperclay/versions/.
  2. Rewrites any Tailwind CDN link to a folder-scoped local CSS URL.
  3. Formats the HTML only when the root <html> element carries the literal attribute formathtml="true"; otherwise all document bytes are kept verbatim.
  4. Writes a new backup of the saved content to .hyperclay/versions/.
  5. Writes the target file atomically using a temporary file plus rename.
  6. Broadcasts the stored HTML to view-mode tabs on the live-sync saved lane (see Live updates between tabs).
  7. Refreshes the /_/api JSON cache sidecar in .hyperclay/api/.
  8. Runs the data-clobber guard.
  9. Compiles Tailwind CSS if the document links it.

Sidecar refresh, data-clobber guard, and Tailwind compilation errors are logged and never cause the save itself to fail.

The data-clobber guard#

The data-clobber guard watches for saves that accidentally wipe out structured content, keyed off the same api rules tag that the Data API uses. It stores per-document state at .hyperclay/guard/<name>.json and keeps a whole-file recovery copy beside it at .hyperclay/guard/<name>.recover.html.

Check whether a document has an active data-loss incident or resolve one with /_/data-loss:

Shell
curl 'http://localhost:4321/_/data-loss?file=index.html'
# {"event":null}

curl -X POST http://localhost:4321/_/data-loss \
  -H 'Content-Type: application/json' \
  -d '{"file":"index.html","choice":"dismiss"}'
  • choice must be dismiss, revert, or restore; any other value returns 400.
  • revert and restore write through the same path as a normal save: version backup, optional formatting, atomic write, broadcast to view-mode tabs, sidecar refresh, and Tailwind recompile.
  • Dismissing an incident also sends a control message to hyperclay.com when cloud sync is running, clearing the same incident on your other synced computers.

Saving without a library#

No library is required to save in Hyperclay Local. Any page served from one of your folders that sends a POST request to /_/save with a Document-URL header saves itself to disk (see Saving and stale-write protection).

The following complete HTML file syncs form state, strips elements and attributes injected by browser extensions, runs onbeforesnapshot and onbeforesave hooks, removes [no-snapshot] and [no-save] elements, and posts the resulting HTML to /_/save:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Notes</title>
</head>
<body>
  <h1 contenteditable>Notes</h1>
  <p contenteditable>Start typing here. Everything saves to disk automatically.</p>
  <script>
    const injectedElements = [
      '[src^="chrome-extension://"]', '[href^="chrome-extension://"]',
      '[src^="moz-extension://"]', '[href^="moz-extension://"]',
      '[src^="safari-web-extension://"]', '[href^="safari-web-extension://"]',
      '[id="1p-menu-live-region"]', 'com-1password-button', 'com-1password-menu',
      'com-1password-notification', 'grammarly-extension',
      'grammarly-desktop-integration', 'lt-div', 'lt-card', 'lt-toolbar',
      'lt-highlighter', '[data-lastpass-root]', '[data-lastpass-icon-root]',
      '[data-dashlane-shadowhost]', '[data-klarna-extension]',
      '[class*="klarna-extension"]', '.skiptranslate', '#goog-gt-tt',
      '.goog-te-banner-frame', '.goog-te-balloon-frame', 'style.darkreader',
      'style[data-darkreader-mode]', 'style[data-darkreader-scheme]',
      '#loom-companion-mv3', 'loom-container', '[ext-id]'
    ].join(',')
    const injectedAttributes = /^(?:data-grammarly|data-lt|data-1p|data-com-onepassword|data-lastpass|data-lp|data-kwimpala|data-bitwarden|data-bw|data-dashlane|data-np|data-protonpass|data-proton-pass|data-keeper|data-rf|data-roboform|data-honey|data-rakuten|data-capital-one|data-wikibuy|data-klarna|data-darkreader|data-evernote|data-pocket|data-pin)(?:-|$)/

    const matching = (root, selector) => [
      ...(root.matches(selector) ? [root] : []),
      ...root.querySelectorAll(selector)
    ]

    function runHooks(root, name) {
      for (const element of matching(root, `[${name}]`)) {
        try {
          Function(element.getAttribute(name)).call(element)
        } catch (error) {
          console.error(`[${name}] failed`, error)
        }
      }
    }

    function syncFormState(clone) {
      const live = document.querySelectorAll('input, select, textarea')
      const saved = clone.querySelectorAll('input, select, textarea')
      if (live.length !== saved.length) throw new Error('Form controls changed during capture')
      live.forEach((control, index) => {
        const copy = saved[index]
        if (control.matches('input[type="password"], input[type="file"]')) {
          copy.removeAttribute('value')
        } else if (control instanceof HTMLInputElement) {
          if (control.type === 'checkbox' || control.type === 'radio') {
            copy.toggleAttribute('checked', control.checked)
          } else {
            copy.setAttribute('value', control.value)
          }
        } else if (control instanceof HTMLTextAreaElement) {
          copy.textContent = control.value
        } else {
          ;[...control.options].forEach((option, optionIndex) => {
            copy.options[optionIndex].toggleAttribute('selected', option.selected)
          })
        }
      })
    }

    function captureDocument() {
      const clone = document.documentElement.cloneNode(true)
      syncFormState(clone)
      runHooks(clone, 'onbeforesnapshot')
      matching(clone, '[no-snapshot]').forEach(element => element.remove())
      clone.querySelectorAll(injectedElements).forEach(element => element.remove())
      for (const element of [clone, ...clone.querySelectorAll('*')]) {
        for (const attribute of [...element.attributes]) {
          if (injectedAttributes.test(attribute.name.toLowerCase())) {
            element.removeAttribute(attribute.name)
          }
        }
      }
      runHooks(clone, 'onbeforesave')
      matching(clone, '[no-save]').forEach(element => element.remove())
      return '<!DOCTYPE html>' + clone.outerHTML
    }

    let saveInFlight = false
    let savePending = false

    async function save() {
      if (saveInFlight) {
        savePending = true
        return
      }
      saveInFlight = true
      try {
        do {
          savePending = false
          const response = await fetch('/_/save', {
            method: 'POST',
            credentials: 'same-origin',
            headers: {
              'Content-Type': 'text/html; charset=utf-8',
              'Document-URL': location.href,
              'Save-Trigger': 'auto'
            },
            body: captureDocument()
          })
          if (!response.ok) {
            let message = `HTTP ${response.status}`
            try { message = JSON.parse(await response.text()).msg || message } catch {}
            throw new Error(message)
          }
        } while (savePending)
      } catch (error) {
        console.error('Save failed', error)
      } finally {
        saveInFlight = false
        if (savePending) save()
      }
    }

    let saveTimer
    function queueSave() {
      clearTimeout(saveTimer)
      saveTimer = setTimeout(save, 250)
    }

    function startAutosave() {
      document.addEventListener('input', queueSave)
      new MutationObserver(queueSave).observe(document, {
        childList: true, subtree: true, attributes: true, characterData: true
      })
    }

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', startAutosave, { once: true })
    } else {
      startAutosave()
    }
  </script>
</body>
</html>