refactor: reorganize src structure

This commit is contained in:
2026-05-31 12:46:21 +00:00
parent f349617427
commit 25bf171f6b
21 changed files with 26 additions and 26 deletions
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
describe("scene helpers", () => {
test("creates empty scenes with dark theme by default", () => {
const scene = emptyScene();
expect(scene.appState.theme).toBe("dark");
});
test("normalizes volatile app state fields", () => {
const scene = normalizeScene({
elements: [],
appState: {
selectedElementIds: { one: true },
viewModeEnabled: false,
},
files: {},
});
expect(scene.appState.selectedElementIds).toBeUndefined();
expect(scene.appState.viewModeEnabled).toBe(false);
});
test("rejects oversized payloads", () => {
const text = "x".repeat(MAX_SCENE_BYTES + 1);
expect(() => parseJsonBody(text)).toThrow(HttpError);
expect(() => parseJsonBody(text)).toThrow("Payload too large");
});
});
+89
View File
@@ -0,0 +1,89 @@
import { DEFAULT_TITLE, type ScenePayload } from "./shared";
export const MAX_SCENE_BYTES = 25 * 1024 * 1024;
export class HttpError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
const VOLATILE_APP_STATE_KEYS = new Set([
"collaborators",
"selectedElementIds",
"selectedGroupIds",
"editingElement",
"editingLinearElement",
"openMenu",
"openSidebar",
"contextMenu",
"toast",
]);
export function emptyScene(): ScenePayload {
return {
elements: [],
appState: { theme: "dark" },
files: {},
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function normalizeTitle(input: unknown): string {
if (typeof input !== "string") {
return DEFAULT_TITLE;
}
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed : DEFAULT_TITLE;
}
export function normalizeScene(input: unknown): ScenePayload {
if (!isRecord(input)) {
throw new HttpError(400, "Body must be an object");
}
if (!Array.isArray(input.elements)) {
throw new HttpError(400, "elements must be an array");
}
if (!isRecord(input.appState)) {
throw new HttpError(400, "appState must be an object");
}
if (!isRecord(input.files)) {
throw new HttpError(400, "files must be an object");
}
const appState = Object.fromEntries(
Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
);
return {
elements: input.elements,
appState,
files: input.files,
};
}
export function parseJsonBody<T = unknown>(text: string): T {
if (new TextEncoder().encode(text).byteLength > MAX_SCENE_BYTES) {
throw new HttpError(413, "Payload too large");
}
try {
return JSON.parse(text) as T;
} catch {
throw new HttpError(400, "Invalid JSON");
}
}
export function parseSceneText(text: string): ScenePayload {
return normalizeScene(parseJsonBody(text));
}
+19
View File
@@ -0,0 +1,19 @@
export type ScenePayload = {
elements: unknown[];
appState: Record<string, unknown>;
files: Record<string, unknown>;
};
export type DrawingMeta = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
revision: number;
};
export type DrawingRecord = DrawingMeta & {
scene: ScenePayload;
};
export const DEFAULT_TITLE = "Untitled";