refactor: reorganize src structure
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user