feat(sync): replay durable projections from Rust

req: ms/001

req: ms/002

req: ms/003
This commit is contained in:
slhx agent
2026-07-14 00:17:20 +02:00
parent 2a74a28d3b
commit 3bad745da9
6 changed files with 217 additions and 178 deletions
+4 -4
View File
@@ -51,18 +51,18 @@ encryption, retention, backup, and deployment policy remain host concerns.
## Slice 5 — local-first multiplayer Kanban milestone
- [ ] **User value:** the complete north-star app demonstrates SSR-first startup, direct manipulation, offline durability, optimistic projection, reconciliation, and live presence as one comprehensible workflow.
- **State:** In progress — the single named milestone journey passes; closure still requires replacing the example-owned `offline.js`, `command-log.js`, and `sync.js` behavior because `v1_release/002` forbids app-authored JavaScript.
- **State:** In progress — the named milestone now performs durable projection/replay/upload with generated Rust/WASM plus the framework sync runtime and loads no example-authored JavaScript; the app binary still exposes the legacy app-authored `/sync.js` demo route, so `v1_release/002` cannot close yet.
- **Build:** connect the previous slices in the canonical Kanban example; keep native server-rendered fallback; add presence and server-canonical conflict presentation; exercise deploy fingerprint recovery and accessible online/offline/conflict state.
- **Refusals:** no demo-only runtime, hidden app JS, proprietary service, or requirement to load collaboration code for server-first apps.
- **Requirements:** `ms/001-003`, `v1_release/001-002`, `accessibility/001-007`, `operations/006`, `performance/006`.
- **Proof:** `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` composes the production Kanban page, generated client-local WASM, durable command queue, and real sync endpoints in one named browser journey. It proves native no-script movement, keyboard movement, local drag/drop, offline/reload durability, concurrent peer mutation and canonical convergence, presence projection, rejection and corrupt-queue recovery with deletion, mixed-version reload, and optional-asset isolation through the real public entry points. This does not yet close `v1_release/002`: the app still loads example-authored JavaScript for offline queue and sync orchestration.
- **Proof:** `cargo test -p hemx-wasm --test browser multiplayer_kanban_milestone_journey_recovers_and_converges -- --exact` composes the production Kanban page, generated client-local WASM, framework-owned durable sync runtime, and real sync endpoints in one named browser journey. It proves native no-script movement, keyboard movement, local drag/drop, offline/reload projection replay and upload, concurrent peer mutation and canonical convergence, presence projection, typed rejection recovery, mixed-version reload, and optional-asset isolation. The journey explicitly receives 404 for `/app.js` and `/offline.js` and asserts that its only scripts are generated/client runtime plus framework sync runtime. `cargo test -p hemx-wasm --test browser flat_patch_persists_offline_then_uploads_with_same_operation_identity -- --exact` additionally preserves the legacy flat durable-record upgrade path. This proves the durable milestone path but does not yet close `v1_release/002` while the separate legacy `/sync.js` demo route remains in the app binary.
Execution cursor: move the app-owned durable command/event/projection orchestration behind the generated Rust/WASM boundary and rerun the named milestone journey with no example-authored JavaScript loaded.
Execution cursor: delete or replace the legacy app-authored `/sync.js` demo route with the public framework sync mechanism, preserving its recovery/accessibility proof without bespoke example JavaScript.
## Slice 6 — production integration reference
- [ ] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy.
- **State:** Blocked by the remaining Slice 5 app-authored JavaScript gap.
- **State:** Blocked by the remaining Slice 5 legacy `/sync.js` route.
- **Build:** evolve one existing reference app using ordinary integration adapters; add durable app storage, authenticated/authorized allowed and denied mutations, CSRF/origin checks, transaction rollback, bounded input, structured failures, health/readiness, tracing/metrics hooks, and restart/deploy recovery.
- **Refusals:** no built-in database/auth provider, compliance claim, telemetry vendor, deployment system, or repository framework.
- **Requirements:** `security/001-009`, `operations/001-008`, `v1_release/003`, existing `adapter/*`, `integration/*`, and `diagnostics/*` contracts.
+10 -6
View File
@@ -87,11 +87,15 @@ pub fn reorder_card(
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
None => ui::client_board::client_cards.move_to_end(card.clone()),
};
(
move_effect,
ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)),
hemx_sync::SyncEffect::send_patch(patch),
)
let projection = hemx::IntoEffect::into_batch(
(
move_effect,
ui::client_board::client_notice
.text(format!("Moved {card} with {}", projected.input_kind)),
),
ui::BUILD_FINGERPRINT,
);
hemx_sync::SyncEffect::durable(patch, projection)
}
#[cfg(all(test, feature = "client"))]
@@ -115,7 +119,7 @@ mod client_tests {
.into_batch(ui::BUILD_FINGERPRINT);
assert_eq!(batch.ops.len(), 3);
assert!(
matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction"))
matches!(&batch.ops[2], hemx::advanced::Effect::Emit { name, payload } if name == hemx_sync::PATCH_EVENT && payload.contains("$hemx-interaction") && payload.contains("\"projection\":["))
);
}
}
+5 -2
View File
@@ -503,13 +503,16 @@
} else if (op.kind === "emit") {
let payload = op.payload;
if (currentOperationId && op.name === "hemx:sync-patch") {
const patch = JSON.parse(payload);
const event = JSON.parse(payload);
const patch = event && Object.getPrototypeOf(event) === Object.prototype && "patch" in event
? event.patch
: event;
if (patch.idempotencyKey !== "$hemx-interaction" || patch.operationId !== "$hemx-interaction") {
throw new Error("hemx sync patch is missing its interaction identity");
}
patch.idempotencyKey = currentOperationId;
patch.operationId = currentOperationId;
payload = JSON.stringify(patch);
payload = JSON.stringify(event);
}
handleRuntimeEvent(scope, op.name, payload);
emit(scope, op.name, payload);
+47 -7
View File
@@ -62,6 +62,28 @@ function validatePatch(patch) {
return patch;
}
function validateProjection(projection) {
if (!Array.isArray(projection)
|| projection.length > 1024 * 1024
|| projection.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
throw new Error("invalid durable projection");
}
return projection;
}
function normalizeEvent(payload) {
if (payload && Object.getPrototypeOf(payload) === Object.prototype && "patch" in payload) {
const keys = Object.keys(payload).sort();
if (keys.length !== 2 || keys[0] !== "patch" || keys[1] !== "projection") {
throw new Error("durable patch fields do not match schema");
}
const patch = validatePatch(payload.patch);
return { idempotencyKey: patch.idempotencyKey, patch, projection: validateProjection(payload.projection) };
}
const patch = validatePatch(payload);
return { idempotencyKey: patch.idempotencyKey, patch, projection: null };
}
async function allPatches() {
const transaction = database.transaction(STORE, "readonly");
const done = transactionDone(transaction);
@@ -70,10 +92,21 @@ async function allPatches() {
return patches.sort((left, right) => left.queuedAt - right.queuedAt || left.idempotencyKey.localeCompare(right.idempotencyKey));
}
async function persist(patch) {
function normalizeStoredRecord(stored) {
if (stored.patch) {
return {
patch: validatePatch(stored.patch),
projection: stored.projection === null ? null : validateProjection(stored.projection),
};
}
const { queuedAt: _queuedAt, ...legacyPatch } = stored;
return { patch: validatePatch(legacyPatch), projection: null };
}
async function persist(record) {
const transaction = database.transaction(STORE, "readwrite");
const done = transactionDone(transaction);
transaction.objectStore(STORE).add({ ...patch, queuedAt: Date.now() });
transaction.objectStore(STORE).add({ ...record, queuedAt: Date.now() });
await done;
root?.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
}
@@ -90,7 +123,7 @@ async function pump() {
pumping = true;
try {
for (const stored of await allPatches()) {
const { queuedAt: _queuedAt, ...patch } = stored;
const { patch } = normalizeStoredRecord(stored);
const endpoint = root?.getAttribute("data-sync-endpoint") || "/sync/patches";
const response = await fetch(endpoint, {
method: "POST",
@@ -122,18 +155,25 @@ async function pump() {
async function start() {
if (!root) return;
database = await openDatabase();
root.setAttribute("data-hemx-sync-ready", "");
root.setAttribute("data-hemx-sync-pending", String((await allPatches()).length));
const pending = await allPatches();
for (const stored of pending) {
const { projection } = normalizeStoredRecord(stored);
if (projection) {
window.hemx?.applyBatch(Uint8Array.from(projection).buffer, root);
}
}
document.addEventListener(EVENT, async (event) => {
try {
const patch = validatePatch(JSON.parse(event.detail));
await persist(patch);
const record = normalizeEvent(JSON.parse(event.detail));
await persist(record);
await pump();
} catch (error) {
root.setAttribute("data-hemx-sync-error", error instanceof Error ? error.message : String(error));
}
});
window.addEventListener("online", () => pump());
root.setAttribute("data-hemx-sync-pending", String(pending.length));
root.setAttribute("data-hemx-sync-ready", "");
await pump();
}
+41
View File
@@ -417,6 +417,29 @@ impl SyncEffect {
payload: patch.payload(),
}])
}
/// Apply an optimistic projection now and carry the same ordinary batch in
/// the durable patch event so the framework sync runtime can replay it
/// after reload before acknowledgement.
pub fn durable(patch: FlatPatch, projection: EffectBatch) -> Self {
patch.validate().expect("FlatPatch must remain valid");
let projection_wire = projection
.to_wire()
.iter()
.map(u8::to_string)
.collect::<Vec<_>>()
.join(",");
let payload = format!(
r#"{{"patch":{},"projection":[{projection_wire}]}}"#,
patch.payload()
);
let mut ops = projection.ops;
ops.push(Effect::Emit {
name: PATCH_EVENT.to_owned(),
payload,
});
Self(ops)
}
}
impl IntoEffect for SyncEffect {
@@ -429,6 +452,24 @@ impl IntoEffect for SyncEffect {
mod tests {
use super::*;
#[test]
fn durable_patch_carries_and_applies_ordinary_projection_batch() {
let patch =
FlatPatch::for_interaction("cardColumn", PatchValue::String("done".into())).unwrap();
let projection = Effect::Emit {
name: "projected".into(),
payload: "card:1".into(),
}
.into_batch(hemx_core::BuildFingerprint(7));
let batch =
SyncEffect::durable(patch, projection).into_batch(hemx_core::BuildFingerprint(7));
assert_eq!(batch.ops.len(), 2);
assert!(matches!(&batch.ops[0], Effect::Emit { name, .. } if name == "projected"));
assert!(
matches!(&batch.ops[1], Effect::Emit { name, payload } if name == PATCH_EVENT && payload.contains("\"projection\":[") && payload.contains("$hemx-interaction"))
);
}
#[test]
fn acknowledgement_updates_atom_and_emits_queue_signal() {
let atom = Atom::<String>::new(17);
+110 -159
View File
@@ -361,7 +361,7 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity(
#[tokio::test]
async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDriverResult<()> {
// test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001
// test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001 req: v1_release/002
// test req: accessibility/001 req: accessibility/002
// test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -399,7 +399,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
bootstrap,
rendered,
"kanban_client",
Some(kanban_app_assets(&workspace)),
Some(framework_sync_assets(&workspace)),
);
let webdriver_port = available_port();
@@ -498,22 +498,30 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
driver.goto(&server.url()).await?;
wait_until(
&driver,
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
"const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-hemx-sync-ready') === true",
)
.await?;
let framework_only = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
Promise.all(['/app.js', '/offline.js'].map((path) => fetch(path).then((response) => response.status)))
.then((statuses) => done({ statuses, scripts: [...document.scripts].map((script) => script.getAttribute('src')) }))
.catch((error) => done({ error: String(error) }));
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(framework_only["statuses"], serde_json::json!([404, 404]));
assert_eq!(
framework_only["scripts"],
serde_json::json!(["/hemx.js", "/hemx.client.js", "/hemx-sync.js"])
);
driver
.execute(
r#"
const root = document.querySelector('[data-hemx-root]');
window.__persistedCommand = null;
root.addEventListener('kanban:command-persisted', (event) => {
window.__persistedCommand = {
detail: event.detail,
order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'),
};
}, { once: true });
return true;
"#,
"window.__durablePatch = null; document.addEventListener('hemx:sync-patch', (event) => { window.__durablePatch = event.detail; }, { once: true }); document.querySelector('[data-hemx-root]').setAttribute('data-sync-endpoint', '/unavailable'); return true",
Vec::new(),
)
.await?;
@@ -525,84 +533,50 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
.await?;
wait_until(
&driver,
"return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'",
"const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && window.__durablePatch !== null && root.getAttribute('data-hemx-sync-pending') === '1' && root.getAttribute('data-hemx-sync-error') === 'upload-404'",
)
.await?;
let persisted = driver
let command_id = driver
.execute(
"return { persisted: window.__persistedCommand, count: document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') }",
"return JSON.parse(window.__durablePatch).patch.idempotencyKey",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(persisted["persisted"]["order"], "1|2");
assert_eq!(persisted["persisted"]["detail"]["schemaVersion"], 2);
assert_eq!(persisted["persisted"]["detail"]["targetColumn"], "done");
assert_eq!(persisted["persisted"]["detail"]["causal"], 1);
let actor = persisted["persisted"]["detail"]["actor"]
.as_str()
.expect("persisted actor");
let session = persisted["persisted"]["detail"]["session"]
.as_str()
.expect("persisted session");
assert!(!actor.is_empty());
assert!(!session.is_empty());
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1"));
assert_eq!(persisted["count"], "1");
let offline_shell = driver
.execute_async(
"const done = arguments[arguments.length - 1]; Promise.all([caches.keys(), caches.match('/')]).then(([keys, shell]) => done({ controlled: Boolean(navigator.serviceWorker.controller), keys, shell: Boolean(shell) })).catch((error) => done({ error: String(error) }))",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(offline_shell["controlled"], true, "{offline_shell}");
assert_eq!(offline_shell["shell"], true, "{offline_shell}");
assert_eq!(offline_shell["keys"][0], "hemx-kanban-shell-v1");
server.stop();
assert!(!server.is_reachable(), "fixture server must be unreachable");
driver
.execute("window.__reloadPending = true; location.reload()", Vec::new())
.await?;
.expect("durable interaction identity")
.to_owned();
driver.refresh().await?;
wait_until(
&driver,
"return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true",
"const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && root.getAttribute('data-hemx-sync-pending') === '0' && root.hasAttribute('data-hemx-sync-ack')",
)
.await?;
let restored = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), count: root.getAttribute('data-kanban-command-count'), error: root.getAttribute('data-kanban-command-error'), notice: root.querySelector('[data-sid]').textContent }",
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), notice: root.querySelector('[data-sid]').textContent, error: root.getAttribute('data-hemx-sync-error') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(restored["order"], "2|1");
assert_eq!(restored["count"], "1");
assert!(restored["error"].is_null());
assert_eq!(restored["notice"], "Moved 1 with drop");
assert!(restored["error"].is_null());
let app_addr = server.address.to_string();
server.stop();
assert!(!server.is_reachable(), "fixture server must be stopped before app restart");
let mut app_command = Command::new(&host_binary);
app_command.env("HEMX_KANBAN_ADDR", &app_addr);
let _app = ProcessGuard::start(app_command, &app_addr);
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
wait_until(
&driver,
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'",
)
.await?;
let command_id = persisted["persisted"]["detail"]["id"]
.as_str()
.expect("persisted command id");
let duplicate_script = format!(
r#"
const done = arguments[arguments.length - 1];
const commandId = {command_id:?};
(async () => {{
const initialResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }});
const initial = await initialResponse.json();
const duplicateResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }});
const duplicate = await duplicateResponse.json();
const conflictResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=2&column=done`, {{ method: 'POST' }});
@@ -613,23 +587,20 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
const peer = await peerResponse.json();
const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).json();
const history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text();
const open = indexedDB.open('hemx-kanban-v1');
open.onsuccess = () => {{
const count = open.result.transaction('commands', 'readonly').objectStore('commands').count();
count.onsuccess = () => done({{
duplicateStatus: duplicateResponse.status,
duplicate,
conflictStatus: conflictResponse.status,
conflict,
rejectionStatus: rejectionResponse.status,
rejection,
peerStatus: peerResponse.status,
peer,
snapshot,
history,
queueCount: count.result,
}});
}};
done({{
initialStatus: initialResponse.status,
initial,
duplicateStatus: duplicateResponse.status,
duplicate,
conflictStatus: conflictResponse.status,
conflict,
rejectionStatus: rejectionResponse.status,
rejection,
peerStatus: peerResponse.status,
peer,
snapshot,
history,
}});
}})().catch((error) => done({{ error: String(error), stack: error.stack }}));
"#
);
@@ -642,6 +613,9 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
convergence.get("error").is_none(),
"sync convergence failed: {convergence}"
);
assert_eq!(convergence["initialStatus"], 200);
assert_eq!(convergence["initial"]["commandId"], command_id);
assert_eq!(convergence["initial"]["serverSequence"], 1);
assert_eq!(convergence["duplicateStatus"], 200);
assert_eq!(convergence["duplicate"]["commandId"], command_id);
assert_eq!(convergence["duplicate"]["serverSequence"], 1);
@@ -657,7 +631,6 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
assert_eq!(convergence["peerStatus"], 200);
assert_eq!(convergence["peer"]["commandId"], "peer:1");
assert_eq!(convergence["peer"]["serverSequence"], 2);
assert_eq!(convergence["queueCount"], 0);
assert_eq!(convergence["snapshot"]["serverSequence"], 2);
assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1);
assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done");
@@ -667,70 +640,11 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
convergence["history"]
.as_str()
.expect("acknowledgement history")
.matches(command_id)
.matches(&command_id)
.count(),
1,
"duplicate replay emitted another acknowledgement: {convergence}"
);
driver.goto(&format!("http://{app_addr}/")).await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true",
)
.await?;
driver
.execute(
r#"
window.__futureCommandStored = false;
const request = indexedDB.open('hemx-kanban-v1');
request.onsuccess = () => {
const tx = request.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({
id: 'future:2', schemaVersion: 3, accountPartition: 'demo:demo', actor: 'future', session: 'future',
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
});
tx.oncomplete = () => { window.__futureCommandStored = true; };
};
return true;
"#,
Vec::new(),
)
.await?;
wait_until(&driver, "return window.__futureCommandStored === true").await?;
driver
.execute("window.__reloadPending = true; location.reload()", Vec::new())
.await?;
wait_until(
&driver,
"return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-error') === true",
)
.await?;
let rejected = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { error: root.getAttribute('data-kanban-command-error'), ready: root.hasAttribute('data-kanban-command-ready') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(
rejected["error"],
"restore: unsupported durable command future:2"
);
assert_eq!(rejected["ready"], false);
let delete_commands = driver
.find(By::Css("[data-kanban-command-action='delete']"))
.await?;
delete_commands.click().await?;
assert_eq!(delete_commands.text().await?, "Confirm delete commands");
delete_commands.click().await?;
wait_until(
&driver,
"const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-kanban-command-ready') === true && root.getAttribute('data-kanban-command-count') === '0' && !root.hasAttribute('data-kanban-command-error')",
)
.await?;
Ok(())
}
.await;
@@ -1744,15 +1658,23 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf {
}
struct AppAssets {
module: PathBuf,
service_worker: PathBuf,
module: Option<PathBuf>,
service_worker: Option<PathBuf>,
sync_runtime: PathBuf,
}
fn kanban_app_assets(workspace: &Path) -> AppAssets {
AppAssets {
module: workspace.join("examples/kanban/static/command-log.js"),
service_worker: workspace.join("examples/kanban/static/offline.js"),
module: Some(workspace.join("examples/kanban/static/command-log.js")),
service_worker: Some(workspace.join("examples/kanban/static/offline.js")),
sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"),
}
}
fn framework_sync_assets(workspace: &Path) -> AppAssets {
AppAssets {
module: None,
service_worker: None,
sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"),
}
}
@@ -1841,7 +1763,7 @@ fn serve(
let (content_type, body) = match path {
"/" => (
"text/html; charset=utf-8",
fixture_html(rendered, app_assets.is_some()).into_bytes(),
fixture_html(rendered, app_assets).into_bytes(),
),
"/hemx.js" => (
"text/javascript; charset=utf-8",
@@ -1859,15 +1781,36 @@ fn serve(
"text/javascript; charset=utf-8",
fs::read(bootstrap).expect("read generated client bootstrap"),
),
"/app.js" if app_assets.is_some() => (
"text/javascript; charset=utf-8",
fs::read(&app_assets.expect("checked app assets").module).expect("read app module"),
),
"/offline.js" if app_assets.is_some() => (
"text/javascript; charset=utf-8",
fs::read(&app_assets.expect("checked app assets").service_worker)
"/app.js"
if app_assets
.and_then(|assets| assets.module.as_ref())
.is_some() =>
{
(
"text/javascript; charset=utf-8",
fs::read(
app_assets
.and_then(|assets| assets.module.as_ref())
.expect("checked app module"),
)
.expect("read app module"),
)
}
"/offline.js"
if app_assets
.and_then(|assets| assets.service_worker.as_ref())
.is_some() =>
{
(
"text/javascript; charset=utf-8",
fs::read(
app_assets
.and_then(|assets| assets.service_worker.as_ref())
.expect("checked service worker"),
)
.expect("read service worker"),
),
)
}
"/hemx-sync.js" if app_assets.is_some() => (
"text/javascript; charset=utf-8",
fs::read(&app_assets.expect("checked app assets").sync_runtime)
@@ -1903,8 +1846,14 @@ fn serve(
let status = if path == "/"
|| path == "/hemx.js"
|| path == "/hemx.client.js"
|| ((path == "/app.js"
|| path == "/offline.js"
|| (((path == "/app.js"
&& app_assets
.and_then(|assets| assets.module.as_ref())
.is_some())
|| (path == "/offline.js"
&& app_assets
.and_then(|assets| assets.service_worker.as_ref())
.is_some())
|| path == "/hemx-sync.js"
|| path == "/sync/context"
|| path == "/sync/patches")
@@ -1921,11 +1870,13 @@ fn serve(
let _ = stream.write_all(&body);
}
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
let app_module = if has_app_module {
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
} else {
""
fn fixture_html(rendered: &str, app_assets: Option<&AppAssets>) -> String {
let app_module = match app_assets {
Some(assets) if assets.module.is_some() => {
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
}
Some(_) => "<script type=\"module\" src=\"/hemx-sync.js\"></script>",
None => "",
};
format!(
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script>{app_module}</body></html>"