Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/pages/tools/dev-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,29 @@ Test Files 1 passed (1)
Liveblocks dev server shut down
```

You can also pass extra arguments after the command. They will be appended to
the end of the command string:

```bash
npx liveblocks dev --cmd 'vitest run' my-test.test.ts
# Runs: vitest run my-test.test.ts
```

If you need precise control over where the extra arguments are inserted, use
`{}` as a placeholder:

```bash
npx liveblocks dev --cmd 'vitest run {} --reporter=verbose' my-test.test.ts
# Runs: vitest run my-test.test.ts --reporter=verbose
```

Use `--` before any extra arguments that start with `-`:

```bash
npx liveblocks dev --cmd 'vitest run' -- --coverage my-test.test.ts
# Runs: vitest run --coverage my-test.test.ts
```

We recommend:

1. `npx liveblocks dev` for manual testing and development, which retains data
Expand Down
2 changes: 1 addition & 1 deletion packages/liveblocks-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/server",
"version": "1.2.0",
"version": "1.3.0",
"description": "Liveblocks backend server foundation.",
"type": "module",
"main": "./dist/index.js",
Expand Down
8 changes: 8 additions & 0 deletions tools/liveblocks-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## vNEXT (not yet released)

## v1.3.0

- Add feeds support (`feeds:write` permission)
- Add verbose logging toggle
- Fix permission validation to accept all valid permission combinations
- Support passing extra arguments to `--cmd` (`-c`), appended to the command or
replacing `{}` if present

## v1.2.0

- Add live socket inspector view
Expand Down
2 changes: 1 addition & 1 deletion tools/liveblocks-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "liveblocks",
"version": "1.2.0",
"version": "1.3.0",
"description": "Liveblocks command line interface",
"type": "module",
"bin": {
Expand Down
7 changes: 4 additions & 3 deletions tools/liveblocks-cli/src/dev-server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { nanoid, Permission } from "@liveblocks/core";
import { nanoid } from "@liveblocks/core";
import type { CreateTicketOptions } from "@liveblocks/server";
import { ProtocolVersion } from "@liveblocks/server";

import * as Rooms from "./db/rooms";
import type { LiteAccessToken, LiteIdToken, LiteToken } from "./lib/jwt-lite";
import { verifyJwtLite } from "./lib/jwt-lite";
import { Permission } from "./lib/permissions";

function resolvePermissions_acc(
token: LiteAccessToken,
Expand Down Expand Up @@ -150,7 +151,7 @@ export function authorizeWebSocket(

// Auto-create the room if it doesn't exist yet
Rooms.getOrCreateRoom(roomId, {
defaultAccesses: [Permission.Write],
defaultAccesses: [Permission.RoomWrite],
});

// Public key auth always grants write access (matches production behavior)
Expand All @@ -160,7 +161,7 @@ export function authorizeWebSocket(
ticketData: {
version,
anonymousId: nanoid(),
scopes: [Permission.Write],
scopes: [Permission.RoomWrite],
},
};
}
Expand Down
51 changes: 26 additions & 25 deletions tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -934,9 +934,10 @@ export class BunSQLiteDriver implements IStorageDriver {

get_feed(feedId: string): Feed | undefined {
const row = this.db
.query<FeedRow, [string]>(
"SELECT feed_id, jmetadata, created_at, updated_at FROM feeds WHERE feed_id = ?"
)
.query<
FeedRow,
[string]
>("SELECT feed_id, jmetadata, created_at, updated_at FROM feeds WHERE feed_id = ?")
.get(feedId);
if (row === undefined || row === null) return undefined;
return {
Expand All @@ -949,9 +950,10 @@ export class BunSQLiteDriver implements IStorageDriver {

create_feed(feed: Feed): void {
const existing = this.db
.query<Pick<FeedRow, "feed_id">, [string]>(
"SELECT feed_id FROM feeds WHERE feed_id = ?"
)
.query<
Pick<FeedRow, "feed_id">,
[string]
>("SELECT feed_id FROM feeds WHERE feed_id = ?")
.get(feed.feedId);
if (existing !== undefined && existing !== null) {
throw new Error(`Feed ${feed.feedId} already exists`);
Expand All @@ -970,19 +972,18 @@ export class BunSQLiteDriver implements IStorageDriver {

update_feed_metadata(feedId: string, metadata: Feed["metadata"]): void {
const result = this.db
.query<FeedRow, [string, string]>(
"UPDATE feeds SET jmetadata = ? WHERE feed_id = ? RETURNING feed_id, jmetadata, created_at, updated_at"
)
.query<
FeedRow,
[string, string]
>("UPDATE feeds SET jmetadata = ? WHERE feed_id = ? RETURNING feed_id, jmetadata, created_at, updated_at")
.get(JSON.stringify(metadata), feedId);
if (result === undefined || result === null) {
throw new Error(`Feed ${feedId} not found`);
}
}

delete_feed(feedId: string): void {
this.db
.query("DELETE FROM feeds WHERE feed_id = ?")
.run(feedId);
this.db.query("DELETE FROM feeds WHERE feed_id = ?").run(feedId);
}

list_feed_messages(
Expand Down Expand Up @@ -1074,9 +1075,10 @@ export class BunSQLiteDriver implements IStorageDriver {
timestamp?: number
): FeedMessage {
const existing = this.db
.query<FeedMessageRow, [string, string]>(
"SELECT feed_id, message_id, jdata, created_at, updated_at FROM feed_messages WHERE feed_id = ? AND message_id = ?"
)
.query<
FeedMessageRow,
[string, string]
>("SELECT feed_id, message_id, jdata, created_at, updated_at FROM feed_messages WHERE feed_id = ? AND message_id = ?")
.get(feedId, messageId);
if (existing === undefined || existing === null) {
throw new Error(`Feed message ${messageId} not found in feed ${feedId}`);
Expand All @@ -1096,9 +1098,7 @@ export class BunSQLiteDriver implements IStorageDriver {
.query<
FeedMessageRow,
[string, number, string, string, number]
>(
"UPDATE feed_messages SET jdata = ?, updated_at = ? WHERE feed_id = ? AND message_id = ? AND updated_at <= ? RETURNING feed_id, message_id, jdata, created_at, updated_at"
)
>("UPDATE feed_messages SET jdata = ?, updated_at = ? WHERE feed_id = ? AND message_id = ? AND updated_at <= ? RETURNING feed_id, message_id, jdata, created_at, updated_at")
.get(
JSON.stringify(data),
effectiveTimestamp,
Expand All @@ -1108,12 +1108,15 @@ export class BunSQLiteDriver implements IStorageDriver {
);
if (result === undefined || result === null) {
const latest = this.db
.query<FeedMessageRow, [string, string]>(
"SELECT feed_id, message_id, jdata, created_at, updated_at FROM feed_messages WHERE feed_id = ? AND message_id = ?"
)
.query<
FeedMessageRow,
[string, string]
>("SELECT feed_id, message_id, jdata, created_at, updated_at FROM feed_messages WHERE feed_id = ? AND message_id = ?")
.get(feedId, messageId);
if (latest === undefined || latest === null) {
throw new Error(`Feed message ${messageId} not found in feed ${feedId}`);
throw new Error(
`Feed message ${messageId} not found in feed ${feedId}`
);
}
return {
id: latest.message_id,
Expand All @@ -1132,9 +1135,7 @@ export class BunSQLiteDriver implements IStorageDriver {

delete_feed_message(feedId: string, messageId: string): void {
this.db
.query(
"DELETE FROM feed_messages WHERE feed_id = ? AND message_id = ?"
)
.query("DELETE FROM feed_messages WHERE feed_id = ? AND message_id = ?")
.run(feedId, messageId);
}

Expand Down
6 changes: 4 additions & 2 deletions tools/liveblocks-cli/src/dev-server/db/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import type { JsonObject, Permission } from "@liveblocks/core";
import type { JsonObject } from "@liveblocks/core";
import { nanoid, WebsocketCloseCodes } from "@liveblocks/core";
import type { Millis } from "@liveblocks/server";
import { DefaultMap, Room } from "@liveblocks/server";
Expand All @@ -24,6 +24,8 @@ import { mkdirSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { dirname, join, resolve } from "path";

import type { Permission } from "~/dev-server/lib/permissions";

import { BunSQLiteDriver } from "./BunSQLiteDriver";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -229,7 +231,7 @@ function createDbRoom(
id: roomId,
internalId,
organizationId,
defaultAccesses: defaultAccesses as Permission[],
defaultAccesses,
usersAccesses: opts?.usersAccesses ?? {},
groupsAccesses: opts?.groupsAccesses ?? {},
metadata,
Expand Down
87 changes: 77 additions & 10 deletions tools/liveblocks-cli/src/dev-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import {
Promise_withResolvers,
tryParseJson,
WebsocketCloseCodes as CloseCode,
} from "@liveblocks/core";
import type { Millis, Room, SessionKey, Ticket } from "@liveblocks/server";
Expand Down Expand Up @@ -111,6 +112,14 @@ function shellCmd(cmd: string): string[] {
: ["sh", "-c", cmd];
}

/**
* Escapes a string for safe inclusion in a shell command.
* Wraps in single quotes, escaping any existing single quotes.
*/
function shellEscape(arg: string): string {
return "'" + arg.replace(/'/g, "'\\''") + "'";
}

type Options = {
port: string;
host?: string;
Expand All @@ -125,15 +134,33 @@ const dev: SubCommand = {
description: "Start the local Liveblocks dev server",

async run(argv) {
const { options } = parseArgs<Options>(argv, {
port: { type: "string", short: "p", default: DEFAULT_PORT.toString() },
host: { type: "string" },
cmd: { type: "string", short: "c" },
help: { type: "boolean", short: "h", default: false },
"no-check": { type: "boolean", default: false },
ci: { type: "boolean", default: false },
verbose: { type: "boolean", short: "v", default: false },
});
const { options, args } = parseArgs<Options>(
argv,
{
port: { type: "string", short: "p", default: DEFAULT_PORT.toString() },
host: { type: "string" },
cmd: { type: "string", short: "c" },
help: { type: "boolean", short: "h", default: false },
"no-check": { type: "boolean", default: false },
ci: { type: "boolean", default: false },
verbose: { type: "boolean", short: "v", default: false },
},
{ allowPositionals: true }
);

if (args.length > 0 && !options.cmd) {
console.error(red("Extra arguments are only supported with --cmd (-c)"));
process.exit(1);
}

if (args.length > 0 && options.cmd) {
const escaped = args.map(shellEscape).join(" ");
if (options.cmd.includes("{}")) {
options.cmd = options.cmd.replaceAll("{}", escaped);
} else {
options.cmd += " " + escaped;
}
}

if (options.help) {
console.log("Usage: liveblocks dev [options]");
Expand All @@ -145,6 +172,8 @@ const dev: SubCommand = {
console.log(" --host Host to bind to (default: localhost)");
console.log(" --cmd, -c Run a one-off command against a fresh server instance, then"); // prettier-ignore
console.log(" shut down. Does not affect your local data in .liveblocks/."); // prettier-ignore
console.log(" Extra args are appended to the command, or replace {} if"); // prettier-ignore
console.log(" present. Use -- before args starting with -."); // prettier-ignore
console.log(" --ci Start a fresh server instance on every boot, ideal for CI"); // prettier-ignore
console.log(" --no-check Skip project setup check on start");
console.log(" --verbose, -v Show verbose output");
Expand Down Expand Up @@ -186,6 +215,7 @@ const dev: SubCommand = {
}

let server: Bun.Server<SocketData>;
let verbose = false;

function createServer() {
return Bun.serve<SocketData>({
Expand Down Expand Up @@ -253,6 +283,17 @@ const dev: SubCommand = {
// Defer all other routing to ZenRouter
// TODO: Maybe port this logging to ZenRouter natively
const route = `${req.method} ${url.pathname}`;

// In verbose mode, clone the request so we can read its body
let reqBody: string | undefined;
if (verbose) {
try {
reqBody = await req.clone().text();
} catch {
// Ignore - body may not be readable
}
}

const resp = await zen.fetch(req);
const status = resp.status;
const colorStatus =
Expand All @@ -264,6 +305,21 @@ const dev: SubCommand = {
console.log(`${colorStatus} ${route}`);
const warnMsg = resp.headers.get("X-LB-Warn") ?? undefined;
warn(warnMsg, !resp.ok);

if (verbose) {
if (reqBody) {
const parsed = tryParseJson(reqBody);
if (parsed !== undefined) {
console.log(dim(` → ${JSON.stringify(parsed)}`));
}
}
const respBody = await resp.clone().text();
const parsedResp = tryParseJson(respBody);
if (parsedResp !== undefined) {
console.log(dim(` ← ${JSON.stringify(parsedResp)}`));
}
}

return resp;
},

Expand Down Expand Up @@ -435,7 +491,9 @@ const dev: SubCommand = {
bold("!") +
dim(" crash, ") +
bold("c") +
dim(" clear");
dim(" clear, ") +
bold("v") +
dim(verbose ? " verbose (on)" : " verbose");

const switchToLogs = (): void => {
if (repaintTimer) {
Expand Down Expand Up @@ -684,6 +742,15 @@ const dev: SubCommand = {
originalLog(renderTabBar());
originalLog(logsLegend());
originalLog();
} else if (ch === "v") {
verbose = !verbose;
// Repaint tab bar + legend in-place to reflect new state
process.stdout.write("\x1B[s\x1B[H\x1B[2K");
originalLog(renderTabBar());
process.stdout.write("\x1B[2K");
originalLog(logsLegend());
process.stdout.write("\x1B[u");
console.log(dim(verbose ? "Verbose mode on" : "Verbose mode off"));
} else if (ch === "p") {
if (configIssues.length > 0) {
const prompt = buildFixPrompt(configIssues, baseUrl);
Expand Down
3 changes: 2 additions & 1 deletion tools/liveblocks-cli/src/dev-server/lib/jwt-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/

import type { DistributiveOmit } from "@liveblocks/core";
import { nanoid, Permission, tryParseJson } from "@liveblocks/core";
import { nanoid, tryParseJson } from "@liveblocks/core";
import type { DecoderType } from "decoders";
import {
array,
Expand All @@ -31,6 +31,7 @@ import {
} from "decoders";

import { userInfo } from "./decoders";
import { Permission } from "./permissions";

const unsignedJwtHeader = object({
alg: constant("none"),
Expand Down
Loading
Loading