# Saving and stale-write protection

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

Source: https://hyperclaylocal.com/docs/saving/

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](https://clayjs.com/docs/guide/saving/) and [Conditional saves and receipts](https://clayjs.com/docs/conditional-saves/).

## `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](https://hyperclaylocal.com/docs/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](https://hyperclaylocal.com/docs/version-history/) 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](https://hyperclaylocal.com/docs/live-updates/)).
7. Refreshes the `/_/api` JSON cache sidecar in `.hyperclay/api/`.
8. Runs the data-clobber guard.
9. Compiles [Tailwind CSS](https://hyperclaylocal.com/docs/tailwind/) 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](https://hyperclaylocal.com/docs/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`:

```bash
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](https://hyperclaylocal.com/docs/saving/)).

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>
```
