diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json
index a04ee119451..ed335e3df27 100644
--- a/packages/liveblocks-server/package.json
+++ b/packages/liveblocks-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/server",
- "version": "1.3.0",
+ "version": "1.4.0",
"description": "Liveblocks backend server foundation.",
"type": "module",
"main": "./dist/index.js",
diff --git a/tools/liveblocks-cli/CHANGELOG.md b/tools/liveblocks-cli/CHANGELOG.md
index 25ee9a06a3f..87e3c0bb09f 100644
--- a/tools/liveblocks-cli/CHANGELOG.md
+++ b/tools/liveblocks-cli/CHANGELOG.md
@@ -1,5 +1,11 @@
## vNEXT (not yet released)
+- ...
+
+## v1.4.0
+
+- Add support for `client.mutateStorage()` (from `@liveblocks/node`)
+
## v1.3.0
- Add feeds support (`feeds:write` permission)
diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json
index b967fc6b014..73ed5f51176 100644
--- a/tools/liveblocks-cli/package.json
+++ b/tools/liveblocks-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "liveblocks",
- "version": "1.3.0",
+ "version": "1.4.0",
"description": "Liveblocks command line interface",
"type": "module",
"bin": {
diff --git a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts
index 3688601cc5d..53efd562dbf 100644
--- a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts
+++ b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts
@@ -15,16 +15,18 @@
* along with this program. If not, see .
*/
-import type { JsonObject, PlainLsonObject } from "@liveblocks/core";
+import type { Json, JsonObject, PlainLsonObject } from "@liveblocks/core";
import { QueryParser } from "@liveblocks/query-parser";
import type { Guid, Logger, YDocId } from "@liveblocks/server";
import {
jsonObjectYolo,
ROOT_YDOC_ID,
snapshotToLossyJson_eager,
+ snapshotToNodeStream,
snapshotToPlainLson_eager,
+ transientClientMsgDecoder,
} from "@liveblocks/server";
-import { json, ZenRouter } from "@liveblocks/zenrouter";
+import { json, ndjsonStream, ZenRouter } from "@liveblocks/zenrouter";
import {
array,
constant,
@@ -36,6 +38,7 @@ import {
record,
string,
} from "decoders";
+import { chain } from "itertools";
import { Base64 } from "js-base64";
import * as Y from "yjs";
@@ -601,6 +604,50 @@ zen.route("POST /v2/rooms//feeds//messages", ({ p }) => {
});
});
+zen.route(
+ "POST /v2/rooms//request-storage-mutation",
+
+ async ({ p }) => {
+ if (!Rooms.getRoom(p.roomId)) {
+ throw ROOM_NOT_FOUND(p.roomId);
+ }
+
+ const room = Rooms.getRoomInstance(p.roomId);
+ await room.load();
+
+ const actor = await room.driver.next_actor();
+ const snapshot = room.storage.loadedDriver.get_snapshot(false);
+ return ndjsonStream(
+ chain([{ actor }], snapshotToNodeStream(snapshot))
+ );
+ }
+);
+
+zen.route(
+ "POST /v2/rooms//send-message",
+
+ object({ messages: array(transientClientMsgDecoder) }),
+
+ async ({ p, body }) => {
+ if (!Rooms.getRoom(p.roomId)) {
+ throw ROOM_NOT_FOUND(p.roomId);
+ }
+
+ const room = Rooms.getRoomInstance(p.roomId);
+ await room.load();
+
+ const [session, capturedServerMsgs] =
+ await room.createBackendSession_experimental();
+
+ await room.processClientMsgFromBackendSession(session, body.messages);
+
+ return new Response(`{"messages":[${capturedServerMsgs.join(",")}]}`, {
+ status: 200,
+ headers: { "Content-Type": "application/json; charset=utf-8" },
+ });
+ }
+);
+
/**
* ------------------------------------------------------------
* NOT IMPLEMENTED ROUTES
diff --git a/tools/liveblocks-cli/test/devserver/rest-api/storage-mutation.test.ts b/tools/liveblocks-cli/test/devserver/rest-api/storage-mutation.test.ts
new file mode 100644
index 00000000000..6b5a3a844c9
--- /dev/null
+++ b/tools/liveblocks-cli/test/devserver/rest-api/storage-mutation.test.ts
@@ -0,0 +1,229 @@
+/**
+ * Copyright (c) Liveblocks Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { ClientMsgCode, CrdtType, OpCode } from "@liveblocks/core";
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+
+import * as Rooms from "~/dev-server/db/rooms";
+import { zen } from "~/dev-server/routes/rest-api";
+
+const BASE = "http://localhost";
+const AUTH = { Authorization: "Bearer sk_localdev" };
+
+async function api(
+ method: string,
+ path: string,
+ body?: unknown
+): Promise {
+ const headers: Record = { ...AUTH };
+ const init: RequestInit = { method, headers };
+ if (body !== undefined) {
+ headers["Content-Type"] = "application/json";
+ init.body = JSON.stringify(body);
+ }
+ return zen.fetch(new Request(`${BASE}${path}`, init));
+}
+
+function parseNdjson(text: string): unknown[] {
+ return text
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line) as unknown);
+}
+
+describe("REST API - storage mutation", () => {
+ beforeAll(() => {
+ Rooms.useEphemeralStorage();
+ });
+
+ afterAll(() => {
+ Rooms.cleanup();
+ });
+
+ describe("POST /v2/rooms//request-storage-mutation", () => {
+ test("returns 404 for non-existent room", async () => {
+ const resp = await api(
+ "POST",
+ "/v2/rooms/nonexistent/request-storage-mutation"
+ );
+ expect(resp.status).toBe(404);
+ });
+
+ test("returns actor and empty root for new room", async () => {
+ await api("POST", "/v2/rooms", { id: "mut-empty" });
+
+ const resp = await api(
+ "POST",
+ "/v2/rooms/mut-empty/request-storage-mutation"
+ );
+ expect(resp.status).toBe(200);
+ expect(resp.headers.get("content-type")).toContain(
+ "application/x-ndjson"
+ );
+
+ const lines = parseNdjson(await resp.text());
+
+ // First line: frontmatter with actor
+ expect(lines[0]).toEqual({ actor: expect.any(Number) as unknown });
+
+ // Second line: root node with empty data
+ expect(lines[1]).toEqual(["root", { type: CrdtType.OBJECT, data: {} }]);
+ });
+
+ test("returns storage nodes for room with data", async () => {
+ await api("POST", "/v2/rooms", { id: "mut-with-data" });
+ await api("POST", "/v2/rooms/mut-with-data/storage", {
+ liveblocksType: "LiveObject",
+ data: {
+ name: "Alice",
+ scores: { liveblocksType: "LiveList", data: [10, 20] },
+ },
+ });
+
+ const resp = await api(
+ "POST",
+ "/v2/rooms/mut-with-data/request-storage-mutation"
+ );
+ expect(resp.status).toBe(200);
+
+ const lines = parseNdjson(await resp.text());
+ expect(lines[0]).toEqual({ actor: expect.any(Number) as unknown });
+
+ // Should have root + at least one child node
+ expect(lines.length).toBeGreaterThan(2);
+
+ // Root should contain "name" in its data
+ const root = lines[1] as [string, { type: number; data: object }];
+ expect(root[0]).toBe("root");
+ expect(root[1].type).toBe(CrdtType.OBJECT);
+ expect(root[1].data).toMatchObject({ name: "Alice" });
+ });
+
+ test("returns incrementing actor IDs", async () => {
+ await api("POST", "/v2/rooms", { id: "mut-actors" });
+
+ const resp1 = await api(
+ "POST",
+ "/v2/rooms/mut-actors/request-storage-mutation"
+ );
+ const lines1 = parseNdjson(await resp1.text());
+ const actor1 = (lines1[0] as { actor: number }).actor;
+
+ const resp2 = await api(
+ "POST",
+ "/v2/rooms/mut-actors/request-storage-mutation"
+ );
+ const lines2 = parseNdjson(await resp2.text());
+ const actor2 = (lines2[0] as { actor: number }).actor;
+
+ expect(actor2).toBeGreaterThan(actor1);
+ });
+ });
+
+ describe("POST /v2/rooms//send-message", () => {
+ test("returns 404 for non-existent room", async () => {
+ const resp = await api("POST", "/v2/rooms/nonexistent/send-message", {
+ messages: [],
+ });
+ expect(resp.status).toBe(404);
+ });
+
+ test("applies UPDATE_STORAGE ops to room storage", async () => {
+ await api("POST", "/v2/rooms", { id: "mut-send" });
+
+ // First, get an actor ID via request-storage-mutation
+ const mutResp = await api(
+ "POST",
+ "/v2/rooms/mut-send/request-storage-mutation"
+ );
+ const lines = parseNdjson(await mutResp.text());
+ const { actor } = lines[0] as { actor: number };
+
+ // Send an UPDATE_STORAGE message that sets a key on root
+ const resp = await api("POST", "/v2/rooms/mut-send/send-message", {
+ messages: [
+ {
+ type: ClientMsgCode.UPDATE_STORAGE,
+ ops: [
+ {
+ type: OpCode.UPDATE_OBJECT,
+ id: "root",
+ data: { greeting: "hello" },
+ opId: `${actor}:0`,
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(resp.status).toBe(200);
+ const body = (await resp.json()) as { messages: unknown[] };
+ expect(body).toHaveProperty("messages");
+
+ // Verify the storage was actually updated
+ const storageResp = await api("GET", "/v2/rooms/mut-send/storage");
+ const storage = (await storageResp.json()) as {
+ liveblocksType: string;
+ data: Record;
+ };
+ expect(storage.data).toMatchObject({ greeting: "hello" });
+ });
+
+ test("applies CreateObject ops", async () => {
+ await api("POST", "/v2/rooms", { id: "mut-create" });
+
+ const mutResp = await api(
+ "POST",
+ "/v2/rooms/mut-create/request-storage-mutation"
+ );
+ const lines = parseNdjson(await mutResp.text());
+ const { actor } = lines[0] as { actor: number };
+
+ // Create a child object under root
+ const resp = await api("POST", "/v2/rooms/mut-create/send-message", {
+ messages: [
+ {
+ type: ClientMsgCode.UPDATE_STORAGE,
+ ops: [
+ {
+ type: OpCode.CREATE_OBJECT,
+ id: `${actor}:0`,
+ parentId: "root",
+ parentKey: "child",
+ data: { value: 42 },
+ opId: `${actor}:1`,
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(resp.status).toBe(200);
+
+ // Verify via GET storage
+ const storageResp = await api("GET", "/v2/rooms/mut-create/storage");
+ const storage = (await storageResp.json()) as {
+ liveblocksType: string;
+ data: Record;
+ };
+ expect(storage.data.child).toMatchObject({
+ liveblocksType: "LiveObject",
+ data: { value: 42 },
+ });
+ });
+ });
+});