- Hyperclay Local
- Docs
- Serving documents
- 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.
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 returns422:{"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-specPage-URLspelling is accepted whenDocument-URLis absent, andDocument-URLwins when both are sent.Document-URLmust 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 toindex.html. - Content type: A JSON content type (or any non-document media type) is refused with
415:{"msg":"/_/save takes the document as text, not JSON.","msgType":"error","code":"unsupported-type"} Save-Trigger:Save-Trigger: usermarks a human action,automarks autosave or a script, and any unknown value is treated asauto. The legacy headerX-Hyperclay-User-Driven: 1is read only whenSave-Triggeris absent. The trigger value feeds the data-clobber guard.- Success response: Returns
200:When a{"msg":"Saved","msgType":"success","etag":"…"}Save-IDheader was sent, the response also includes"saveId". - Maximum body size:
20971520bytes (20MB). - 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 thedocumentetagstamp 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 Failedand 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
saveIdto 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
saveIdis 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 asrecovered.
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:
- 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/. - Rewrites any Tailwind CDN link to a folder-scoped local CSS URL.
- Formats the HTML only when the root
<html>element carries the literal attributeformathtml="true"; otherwise all document bytes are kept verbatim. - Writes a new backup of the saved content to
.hyperclay/versions/. - Writes the target file atomically using a temporary file plus rename.
- Broadcasts the stored HTML to view-mode tabs on the live-sync
savedlane (see Live updates between tabs). - Refreshes the
/_/apiJSON cache sidecar in.hyperclay/api/. - Runs the data-clobber guard.
- 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:
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"}'
choicemust bedismiss,revert, orrestore; any other value returns400.revertandrestorewrite 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:
<!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>