diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx
index 74e4c638..f13d1f2b 100644
--- a/src/context/ProjectContext.tsx
+++ b/src/context/ProjectContext.tsx
@@ -370,14 +370,29 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) =
// revalidation of the membership endpoint.
useEffect(() => {
if (!provider) return;
- const handler = (newRole: string) => {
+ const handler = async (newRole: string) => {
setProject((prev) => (prev ? { ...prev, role: newRole as ProjectRole } : prev));
+ if (project?.project.id) {
+ try {
+ const res = await fetch(`/api/projects/${project.project.id}/cloud-token`);
+ if (res.ok) {
+ const { token } = (await res.json()) as { token: string };
+ if (token) {
+ // Update token silently so future reconnects use the new role
+ // We don't force reconnect because the DO already updated our active session
+ await provider.updateToken(token, false);
+ }
+ }
+ } catch (e) {
+ console.warn("Failed to fetch new token on role change", e);
+ }
+ }
};
provider.on("role-changed", handler);
return () => {
provider.off("role-changed", handler);
};
- }, [provider]);
+ }, [provider, project?.project.id]);
const updateScreenplay = useCallback((newScreenplay: Screenplay) => {
setScreenplay(newScreenplay);
diff --git a/src/lib/cloud/protocol.ts b/src/lib/cloud/protocol.ts
index 261ba4b2..07ae86d9 100644
--- a/src/lib/cloud/protocol.ts
+++ b/src/lib/cloud/protocol.ts
@@ -42,9 +42,10 @@ export function handleProtocolMessage(room: ProjectRoom, fullMessage: Uint8Array
// apply them, so the rest of the packet stays parseable.
if (isReadOnly) {
decoding.readVarUint8Array(decoder);
- console.warn(
- `[Room] Dropped doc write from viewer ${session?.userId ?? "?"}`,
- );
+ console.warn(JSON.stringify({
+ event: "dropped_write_from_viewer",
+ userId: session?.userId ?? "?"
+ }));
} else if (subType === SYNC_STEP_2) {
syncProtocol.readSyncStep2(decoder, room.doc, sender);
} else {
@@ -52,12 +53,15 @@ export function handleProtocolMessage(room: ProjectRoom, fullMessage: Uint8Array
}
} else {
// Unknown sub-type — bail to avoid mis-aligning the decoder.
- console.warn(`[Room] Unknown sync sub-message type: ${subType}`);
+ console.warn(JSON.stringify({
+ event: "unknown_sync_sub_message_type",
+ subType
+ }));
break;
}
}
} catch (e) {
- console.error("[Room] Error reading sync message:", e);
+ console.error(JSON.stringify({ event: "error_reading_sync_message", error: String(e) }));
}
// If there's a response to send (e.g., SyncStep2 in response to SyncStep1),
@@ -96,11 +100,15 @@ export function handleProtocolMessage(room: ProjectRoom, fullMessage: Uint8Array
break;
default:
- console.warn(`[Room] Unknown message type: ${messageType}`);
+ console.warn(JSON.stringify({ event: "unknown_message_type", messageType }));
break;
}
} catch (e) {
- console.error(`[Room] Protocol error for message type ${messageType}:`, e);
+ console.error(JSON.stringify({
+ event: "protocol_error",
+ messageType,
+ error: String(e)
+ }));
// For non-awareness messages, still try to broadcast (might be important)
if (messageType !== 1) {
room.broadcast(fullMessage, sender);
diff --git a/src/lib/cloud/room.ts b/src/lib/cloud/room.ts
index 8d359e1b..ac9156e7 100644
--- a/src/lib/cloud/room.ts
+++ b/src/lib/cloud/room.ts
@@ -179,16 +179,14 @@ export class ProjectRoom extends DurableObject {
this.userConnections.set(attachment.userId, ws);
}
if (hibernatedSockets.length > 0) {
- console.log(
- `[Room] Restored ${this.sessions.size} session(s) from ${hibernatedSockets.length} hibernated WebSocket(s)`,
- );
+ console.log(JSON.stringify({ event: "room_restore", hibernatedSockets: hibernatedSockets.length, restoredSessions: this.sessions.size }));
// Awareness state was lost when the DO hibernated. Ask all
// restored clients to re-broadcast their awareness so we can
// rebuild room.awareness from scratch.
this.broadcastAwarenessRequest();
}
- console.log("[Room] Initialized");
+ console.log(JSON.stringify({ event: "room_initialized" }));
}
/**
@@ -219,10 +217,7 @@ export class ProjectRoom extends DurableObject {
break;
case "migrated":
this.docVersion = outcome.to;
- console.log(
- `[Room] Migrated doc from v${outcome.from} to v${outcome.to} ` +
- `(${outcome.appliedSteps.length} step${outcome.appliedSteps.length === 1 ? "" : "s"})`,
- );
+ console.log(JSON.stringify({ event: "document_migrated", fromVersion: outcome.from, toVersion: outcome.to, steps: outcome.appliedSteps.length }));
// Persist the migrated state immediately so a restart doesn't replay.
await this.saveToDisk();
break;
@@ -231,18 +226,12 @@ export class ProjectRoom extends DurableObject {
// Refuse new connections until the worker is upgraded.
this.docMigrationFailed = true;
this.docVersion = outcome.storedVersion;
- console.error(
- `[Room] Doc at v${outcome.storedVersion} but worker only supports v${outcome.expected}. ` +
- `Worker is out of date — refusing connections.`,
- );
+ console.error(JSON.stringify({ event: "document_migration_future_version", storedVersion: outcome.storedVersion, expectedVersion: outcome.expected, message: "Worker is out of date — refusing connections." }));
break;
case "failed":
this.docMigrationFailed = true;
this.docVersion = outcome.from;
- console.error(
- `[Room] Doc migration failed at step v${outcome.failedAt} ` + `(stored v${outcome.from}):`,
- outcome.error,
- );
+ console.error(JSON.stringify({ event: "document_migration_failed", failedAtStep: outcome.failedAt, storedVersion: outcome.from, error: String(outcome.error) }));
break;
}
}
@@ -305,12 +294,12 @@ export class ProjectRoom extends DurableObject {
await (this.env as Env).SNAPSHOTS.put(key, state, {
customMetadata: { type: "auto" },
});
- console.log(`[Room] Snapshot saved to R2: ${key}`);
+ console.log(JSON.stringify({ event: "snapshot_saved", key }));
// Run retention cleanup
await this.cleanupAutoSaves();
} catch (e) {
- console.error("[Room] Failed to snapshot to R2:", e);
+ console.error(JSON.stringify({ event: "snapshot_failed", error: String(e) }));
// Re-mark dirty so next alarm retries
this.isDirty = true;
this.scheduleSnapshotAlarm();
@@ -381,7 +370,7 @@ export class ProjectRoom extends DurableObject {
// Batch delete (R2 supports up to 1000 keys per delete)
if (toDelete.length > 0) {
await (this.env as Env).SNAPSHOTS.delete(toDelete);
- console.log(`[Room] Retention cleanup: deleted ${toDelete.length} auto-saves`);
+ console.log(JSON.stringify({ event: "retention_cleanup", deletedCount: toDelete.length }));
}
}
@@ -419,9 +408,7 @@ export class ProjectRoom extends DurableObject {
const timeSinceActivity = now - session.lastActivity;
if (timeSinceActivity > STALE_AWARENESS_TIMEOUT_MS) {
- console.log(
- `[Room] Session for user ${session.userId} is stale (${timeSinceActivity}ms since activity)`,
- );
+ console.log(JSON.stringify({ event: "stale_session", userId: session.userId, timeSinceActivity }));
staleClientIds.push(...session.clientIds);
staleSockets.push(socket);
}
@@ -470,7 +457,7 @@ export class ProjectRoom extends DurableObject {
}
}
- console.log(`[Room] Cleaned up ${staleClientIds.length} stale awareness states`);
+ console.log(JSON.stringify({ event: "cleaned_stale_awareness", count: staleClientIds.length }));
}
}
@@ -567,7 +554,7 @@ export class ProjectRoom extends DurableObject {
this.ctx.storage.sql.exec("INSERT OR IGNORE INTO blacklist (user_id) VALUES (?);", userId);
- console.log(`[Room] Blacklisted user ${userId}`);
+ console.log(JSON.stringify({ event: "user_blacklisted", userId }));
return new Response(`User ${userId} blacklisted.`, { status: 200 });
}
@@ -583,7 +570,7 @@ export class ProjectRoom extends DurableObject {
this.ctx.storage.sql.exec("DELETE FROM blacklist WHERE user_id = ?;", userId);
}
- console.log(`[Room] Allowed user ${userId}`);
+ console.log(JSON.stringify({ event: "user_allowed", userId }));
return new Response(`User ${userId} allowed.`, { status: 200 });
}
@@ -612,11 +599,11 @@ export class ProjectRoom extends DurableObject {
encoding.writeVarString(encoder, role);
socket.send(encoding.toUint8Array(encoder));
} catch (e) {
- console.warn(`[Room] Failed to push role-update to ${userId}:`, e);
+ console.error(JSON.stringify({ event: "role_update_push_failed", userId, error: String(e) }));
}
}
- console.log(`[Room] Role updated for user ${userId} -> ${role}`);
+ console.log(JSON.stringify({ event: "role_updated", userId, role }));
return new Response(`User ${userId} role updated.`, { status: 200 });
}
@@ -670,7 +657,7 @@ export class ProjectRoom extends DurableObject {
const clientVersionParam = url.searchParams.get("clientVersion");
const clientVersion = clientVersionParam !== null ? Number(clientVersionParam) : NaN;
if (Number.isFinite(clientVersion) && clientVersion < this.docVersion) {
- console.log(`[Room] Rejecting stale client v${clientVersion} (doc at v${this.docVersion})`);
+ console.warn(JSON.stringify({ event: "client_rejected_stale", clientVersion, docVersion: this.docVersion }));
try {
server.close(4006, `Stale client: update to access v${this.docVersion}`);
} catch {}
@@ -709,7 +696,7 @@ export class ProjectRoom extends DurableObject {
server.send(encoding.toUint8Array(awarenessEncoder));
}
- console.log(`[Room] User ${userId} connected. Total sessions: ${this.sessions.size}`);
+ console.log(JSON.stringify({ event: "user_connected", userId, totalSessions: this.sessions.size }));
// Opportunistic cleanup on connect — a new client arriving is the
// best moment to drop awareness for clients that quietly went away.
@@ -793,7 +780,7 @@ export class ProjectRoom extends DurableObject {
customMetadata: { type: "manual", name },
});
- console.log(`[Room] Manual save created: ${name}`);
+ console.log(JSON.stringify({ event: "manual_save_created", name }));
const entry: SaveEntry = { key, type: "manual", name, date: timestamp, size: state.byteLength };
return Response.json(entry, { status: 201 });
@@ -856,7 +843,7 @@ export class ProjectRoom extends DurableObject {
this.sessions.clear();
this.userConnections.clear();
- console.log(`[Room] Restored from: ${key}`);
+ console.log(JSON.stringify({ event: "restored_from_save", key }));
return new Response("Restored", { status: 200 });
}
@@ -882,7 +869,7 @@ export class ProjectRoom extends DurableObject {
});
await (this.env as Env).SNAPSHOTS.delete(key);
- console.log(`[Room] Renamed save: ${key} -> ${name}`);
+ console.log(JSON.stringify({ event: "save_renamed", key, name }));
return new Response("Renamed", { status: 200 });
}
@@ -893,7 +880,7 @@ export class ProjectRoom extends DurableObject {
}
await (this.env as Env).SNAPSHOTS.delete(key);
- console.log(`[Room] Deleted save: ${key}`);
+ console.log(JSON.stringify({ event: "save_deleted", key }));
return new Response("Deleted", { status: 200 });
}
@@ -921,17 +908,17 @@ export class ProjectRoom extends DurableObject {
const fullDocState = Y.encodeStateAsUpdate(this.doc);
this.ctx.storage.sql.exec("INSERT OR REPLACE INTO project (id, data) VALUES (1, ?);", fullDocState);
this.saveTimeout = null;
- console.log("[Room] Document saved to disk");
+ console.log(JSON.stringify({ event: "document_saved" }));
} catch (e) {
- console.error("[Room] Failed to save document:", e);
+ console.error(JSON.stringify({ event: "document_save_failed", error: String(e) }));
}
}
- async webSocketClose(ws: WebSocket): Promise
{
+ async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise {
const session = this.sessions.get(ws);
if (session) {
- console.log(`[Room] User ${session.userId} disconnected`);
+ console.log(JSON.stringify({ event: "websocket_close", userId: session.userId }));
if (session.clientIds.size > 0) {
const clientIds = Array.from(session.clientIds);
@@ -942,7 +929,7 @@ export class ProjectRoom extends DurableObject {
// Broadcast removal to remaining clients
this.broadcastAwarenessRemoval(clientIds, ws);
- console.log(`[Room] Removed awareness for clients: ${clientIds.join(", ")}`);
+ console.log(JSON.stringify({ event: "awareness_removed", clientIds }));
}
// Only delete from userConnections if this is the active entry
@@ -952,11 +939,24 @@ export class ProjectRoom extends DurableObject {
}
this.sessions.delete(ws);
- console.log(`[Room] Remaining sessions: ${this.sessions.size}`);
+ console.log(JSON.stringify({ event: "session_count_update", count: this.sessions.size }));
}
async webSocketError(ws: WebSocket, error: unknown): Promise {
- console.error("[Room] WebSocket error:", error);
+ const errStr = String(error);
+ if (errStr.includes("Network connection lost") || errStr.includes("WebSocket disconnected") || errStr.includes("1006") || errStr.includes("1005")) {
+ console.log(JSON.stringify({
+ event: "websocket_disconnect",
+ level: "info",
+ reason: "idle or network connection lost",
+ error: errStr
+ }));
+ } else {
+ console.error(JSON.stringify({
+ event: "websocket_error",
+ error: errStr
+ }));
+ }
// The close handler will clean up
}
@@ -979,7 +979,7 @@ export class ProjectRoom extends DurableObject {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, 3); // Message type 3: messageQueryAwareness
this.broadcast(encoding.toUint8Array(encoder), excludeSocket);
- console.log("[Room] Sent awareness request to existing clients");
+ console.log(JSON.stringify({ event: "awareness_request_sent" }));
}
/**
@@ -991,7 +991,7 @@ export class ProjectRoom extends DurableObject {
try {
client.send(message);
} catch (e) {
- console.error(`[Room] Failed to send to client ${session.userId}:`, e);
+ console.error(JSON.stringify({ event: "send_to_client_failed", userId: session.userId, error: String(e) }));
}
}
}
diff --git a/src/lib/cloud/utils.ts b/src/lib/cloud/utils.ts
index 2edded27..ea742242 100644
--- a/src/lib/cloud/utils.ts
+++ b/src/lib/cloud/utils.ts
@@ -421,10 +421,11 @@ export class ThrottledWebsocketProvider extends WebsocketProvider {
}
/**
- * Update the authentication token and reconnect.
- * This allows refreshing expired tokens without destroying the provider.
+ * Update the authentication token.
+ * @param newToken The new JWT token
+ * @param forceReconnect Whether to immediately disconnect and reconnect using the new token (default: true)
*/
- public async updateToken(newToken: string): Promise {
+ public async updateToken(newToken: string, forceReconnect: boolean = true): Promise {
if (this.isDestroyed) {
console.warn("[WS] Cannot update token on destroyed provider");
return;
@@ -435,26 +436,26 @@ export class ThrottledWebsocketProvider extends WebsocketProvider {
return;
}
- console.log("[WS] Updating token and reconnecting...");
-
// Snapshot current params so we can roll back if reconnect fails.
- // Without this, a failed reconnect would leave the provider holding
- // a new (possibly invalid) token with no active connection.
const previousParams = { ...this.params };
- try {
- // Update params with new token
- this.params = {
- ...this.params,
- token: newToken,
- };
- await this.reconnect();
- } catch (e) {
- // Restore original params — the old token stays in effect so that
- // a future updateToken() call or reconnect attempt can succeed.
- this.params = previousParams;
- console.warn("[WS] Failed to update token, params restored to previous state", e);
- throw e;
+ // Update params with new token
+ this.params = {
+ ...this.params,
+ token: newToken,
+ };
+
+ if (forceReconnect) {
+ console.log("[WS] Updating token and reconnecting...");
+ try {
+ await this.reconnect();
+ } catch (e) {
+ // Restore original params — the old token stays in effect so that
+ // a future updateToken() call or reconnect attempt can succeed.
+ this.params = previousParams;
+ console.warn("[WS] Failed to update token, params restored to previous state", e);
+ throw e;
+ }
}
}
diff --git a/src/lib/cloud/wrangler.toml b/src/lib/cloud/wrangler.toml
index 37e7f2db..400c38d3 100644
--- a/src/lib/cloud/wrangler.toml
+++ b/src/lib/cloud/wrangler.toml
@@ -4,6 +4,10 @@ main = "index.ts"
compatibility_date = "2025-12-08"
compatibility_flags = ["nodejs_compat"]
+[observability.logs]
+enabled = true
+invocation_logs = false
+
# Migrations are global to the script class
[[migrations]]
tag = "v1"
diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts
index ae244132..81a9c31b 100644
--- a/src/lib/editor/use-document-editor.ts
+++ b/src/lib/editor/use-document-editor.ts
@@ -305,15 +305,18 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum
provider,
user: userInfo,
render: (user: { color: string; name: string }) => {
+ // Render with no DOM children. The username label is rendered
+ // via a ::before pseudo-element (see styles/scriptio.css),
+ // so there is no text node Firefox can place the local HTML
+ // caret into when the user clicks an empty node containing
+ // this remote caret.
const caret = document.createElement("span");
caret.classList.add("collab-caret");
caret.style.borderLeft = `2px solid ${user.color}`;
- const label = document.createElement("div");
- label.classList.add("collab-caret-label");
- label.style.backgroundColor = user.color;
- label.innerText = user.name;
- label.contentEditable = "false";
- caret.appendChild(label);
+ caret.style.setProperty("--collab-caret-color", user.color);
+ // JSON.stringify yields a CSS-safe quoted string (escapes \ and ").
+ caret.style.setProperty("--collab-caret-name", JSON.stringify(user.name));
+ caret.contentEditable = "false";
return caret;
},
}),
diff --git a/src/lib/screenplay/extensions/placeholder-extension.ts b/src/lib/screenplay/extensions/placeholder-extension.ts
index 08fc9b6d..bac529a5 100644
--- a/src/lib/screenplay/extensions/placeholder-extension.ts
+++ b/src/lib/screenplay/extensions/placeholder-extension.ts
@@ -135,33 +135,13 @@ export const Placeholder = Extension.create({
const oldAnchor = oldState.selection.anchor
const newAnchor = newState.selection.anchor
- // Check if we need to recompute:
- // 1. Anchor moved to a different node
- // 2. A node became empty or non-empty
- const anchorNodeChanged = oldState.doc.resolve(oldAnchor).parent !== newState.doc.resolve(newAnchor).parent
-
if (tr.docChanged) {
- // Check if the node at the cursor changed emptiness
- try {
- const newNode = newState.doc.resolve(newAnchor).parent
- const wasEmpty = !oldState.doc.resolve(oldAnchor).parent.content.size
- const isEmpty = !newNode.content.size
-
- // If emptiness changed or anchor moved to different node, recompute
- if (wasEmpty !== isEmpty || anchorNodeChanged) {
- return computePlaceholderDecorations(newState.doc, newAnchor)
- }
- } catch {
- // Position resolution failed, recompute to be safe
- return computePlaceholderDecorations(newState.doc, newAnchor)
- }
-
- // Simple text edit in non-empty node — just remap positions (O(log n))
- return oldDecorations.map(tr.mapping, newState.doc)
+ return computePlaceholderDecorations(newState.doc, newAnchor)
}
// Selection-only change: recompute if anchor moved to different node
// (showOnlyCurrent mode needs this, but also hasAnchor attribute changes)
+ const anchorNodeChanged = oldState.doc.resolve(oldAnchor).parent !== newState.doc.resolve(newAnchor).parent
if (anchorNodeChanged) {
return computePlaceholderDecorations(newState.doc, newAnchor)
}
diff --git a/styles/scriptio.css b/styles/scriptio.css
index ab047c03..a6f359ab 100644
--- a/styles/scriptio.css
+++ b/styles/scriptio.css
@@ -103,6 +103,34 @@
height: var(--line-height);
}
+ /* Stabilize the absolute caret's containing block. Without this, the caret
+ resolves up to .ProseMirror — and during a backspace that destroys the
+ paragraph it lives in, y-prosemirror briefly remaps the caret's head
+ position to 0 before awareness syncs, painting it for one frame at the
+ editor's leftmost edge (the very start of the document). Pinning the
+ containing block to whichever paragraph the caret currently sits in
+ keeps the absolute position bounded to that paragraph's box. */
+ p:has(.collab-caret),
+ .dual_dialogue_column p:has(.collab-caret) {
+ position: relative;
+ }
+
+ /* Empty non-parenthetical paragraphs additionally need overflow:visible so
+ the caret's pseudo-element username label (anchored above the line via
+ top: -1.8em) isn't clipped. Parenthetical empties keep overflow:hidden
+ to clip the trailing line box that would push their `)` to a second
+ line; the label is sacrificed there as an acceptable trade. */
+ .is-empty:has(.collab-caret):not(.parenthetical) {
+ overflow: visible;
+ }
+
+ /* Defensive: the widget should always live inside a paragraph. If a
+ transaction ever briefly attaches it directly to .ProseMirror (e.g.
+ during a structural restructure), don't paint it. */
+ > .collab-caret {
+ display: none;
+ }
+
.action {
text-align: var(--action-align, left) !important;
font-weight: var(--action-weight, normal);
@@ -308,10 +336,19 @@
pointer-events: none;
user-select: none;
-webkit-user-select: none !important;
- transition: transform 0.1s ease-out;
+ -moz-user-select: none !important;
}
- .collab-caret-label {
+ /* Username label rendered as a pseudo-element rather than a child .
+ Pseudo-elements aren't in the DOM, so they cannot host a text cursor;
+ this prevents Firefox from placing the local HTML caret into the
+ label's text node when the local user clicks an empty node hosting a
+ remote caret. The color and name come from custom properties set
+ inline by the cursor render function. */
+ .collab-caret::before {
+ content: var(--collab-caret-name, "");
+ background-color: var(--collab-caret-color, currentColor);
+
position: absolute;
top: -1.8em;
left: -0.5em;
@@ -322,7 +359,7 @@
border-radius: 1em;
line-height: normal;
- /* Prevent the collaboration caret from inheriting parent styles, such as with scene headings (uppercase & bold) */
+ /* Prevent inheritance of parent styles (scene headings are uppercase & bold). */
text-transform: none !important;
font-weight: 400 !important;
font-style: normal !important;