UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

91 lines (87 loc) 3.92 kB
/** * Phase 5 room HTTP handlers (body classification + decode logic). The Hono * glue in `src/routes/rooms.ts` wraps these with DO dispatch and response * shaping; the pure functions here are 100% coverage-gated. * * Body content types accepted by PUT `/_/:room` and POST `/_` (per §6.1): * - `application/json` → JSON `{snapshot: string}` * - `text/x-socialcalc` → raw SocialCalc save text * - `text/plain` → treat like `text/x-socialcalc` * (legacy fallback when no * content-type matches — see * src/main.ls:329-330) * - `text/csv` → CSV converted via csvToSave * - `text/x-ethercalc-csv-double-encoded` → same, after UTF-8→Latin-1→UTF-8 * - `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` * - `application/vnd.oasis.opendocument.spreadsheet` * → deferred to Phase 8 * (501 `xlsx import lands * in Phase 8`) */ import { csvToSocialCalc, decodeDoubleEncoded } from '../lib/csv.ts'; /** The two types of payload this layer understands. */ export type DecodedBody = | { readonly kind: 'save'; readonly snapshot: string } // Retained for POST `/_/:room` — bulk xlsx on POST is deferred; PUT // handles it directly below via `xlsxToSave`. | { readonly kind: 'xlsx-deferred' } | { readonly kind: 'empty' }; const XLSX_MIMES: readonly string[] = [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.oasis.opendocument.spreadsheet', ]; /** * Classify a raw request body into a DecodedBody. `contentType` should be * the raw header value (or empty string); `bytes` is the read body. JSON * bodies are parsed here and asserted to contain a `snapshot` string — a * malformed payload produces `{kind: 'empty'}`, matching the legacy * `cb snapshot if snapshot` guard in src/main.ls:348-349. */ export function classifyRequestBody( contentType: string, bytes: Uint8Array, ): DecodedBody { const ct = contentType.split(';')[0]!.trim().toLowerCase(); if (XLSX_MIMES.includes(ct)) { if (bytes.byteLength === 0) return { kind: 'empty' }; return { kind: 'xlsx-deferred' }; } if (ct === 'application/json') { try { const text = new TextDecoder('utf-8').decode(bytes); const parsed = JSON.parse(text) as unknown; if ( parsed && typeof parsed === 'object' && 'snapshot' in (parsed as Record<string, unknown>) && typeof (parsed as Record<string, unknown>).snapshot === 'string' ) { return { kind: 'save', snapshot: (parsed as { snapshot: string }).snapshot, }; } } catch { /* fall through to empty */ } return { kind: 'empty' }; } if (ct === 'text/x-ethercalc-csv-double-encoded') { const csv = decodeDoubleEncoded(bytes); return { kind: 'save', snapshot: csvToSocialCalc(csv) }; } if (ct === 'text/csv') { const csv = new TextDecoder('utf-8').decode(bytes); return { kind: 'save', snapshot: csvToSocialCalc(csv) }; } // `text/x-socialcalc`, `text/plain`, and anything else — treat as raw // SocialCalc save text. The legacy server also falls back to // `ConvertOtherFormatToSave(csv, 'csv')` for unknown bodies via `J`'s // to_socialcalc path, but for Phase 5 we mirror only the well-known // content-types. Future phases can add more fallback paths. if (bytes.byteLength === 0) return { kind: 'empty' }; return { kind: 'save', snapshot: new TextDecoder('utf-8').decode(bytes), }; }