diff --git a/deno.json b/deno.json index cfc1f35..be0ad91 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", + "license": "AGPLv3+", "tasks": { "dev:client": "deno run -A npm:vite", diff --git a/deno.lock b/deno.lock index 490d10c..995d720 100644 --- a/deno.lock +++ b/deno.lock @@ -2,6 +2,7 @@ "version": "5", "specifiers": { "jsr:@oak/commons@1": "1.0.1", + "jsr:@oak/oak@*": "17.1.4", "jsr:@oak/oak@^17.1.4": "17.1.4", "jsr:@panva/jose@^6.0.11": "6.0.11", "jsr:@sitnik/nanoid@^5.1.5": "5.1.5", @@ -15,15 +16,16 @@ "jsr:@std/media-types@1": "1.1.0", "jsr:@std/path@1": "1.1.0", "jsr:@std/regexp@*": "1.0.1", - "npm:@deno/vite-plugin@^1.0.4": "1.0.4_vite@6.3.5__picomatch@4.0.2", + "npm:@deno/vite-plugin@^1.0.4": "1.0.4_vite@6.3.5__picomatch@4.0.2_@types+node@22.15.15", + "npm:@types/node@*": "22.15.15", "npm:@types/react-dom@^19.1.6": "19.1.6_@types+react@19.1.6", "npm:@types/react@^19.1.6": "19.1.6", - "npm:@vitejs/plugin-react@^4.4.1": "4.5.1_vite@6.3.5__picomatch@4.0.2_@babel+core@7.27.4", + "npm:@vitejs/plugin-react@^4.4.1": "4.5.1_vite@6.3.5__picomatch@4.0.2_@babel+core@7.27.4_@types+node@22.15.15", "npm:path-to-regexp@^6.3.0": "6.3.0", "npm:react-dom@^19.1.0": "19.1.0_react@19.1.0", "npm:react-router-dom@^7.5.1": "7.6.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:react@^19.1.0": "19.1.0", - "npm:vite@^6.3.2": "6.3.5_picomatch@4.0.2", + "npm:vite@^6.3.2": "6.3.5_picomatch@4.0.2_@types+node@22.15.15", "npm:zod@3": "3.25.53" }, "jsr": { @@ -232,7 +234,13 @@ "@deno/vite-plugin@1.0.4_vite@6.3.5__picomatch@4.0.2": { "integrity": "sha512-xg8YT8Wn2sGXSnJgiGTpBGX1Dov0c6fd1rAp8VsfrCUtyBRRWzwVMAnd3fQ4yq8h7LSVvJUxEFN4U421k/DQLA==", "dependencies": [ - "vite" + "vite@6.3.5_picomatch@4.0.2" + ] + }, + "@deno/vite-plugin@1.0.4_vite@6.3.5__picomatch@4.0.2_@types+node@22.15.15": { + "integrity": "sha512-xg8YT8Wn2sGXSnJgiGTpBGX1Dov0c6fd1rAp8VsfrCUtyBRRWzwVMAnd3fQ4yq8h7LSVvJUxEFN4U421k/DQLA==", + "dependencies": [ + "vite@6.3.5_picomatch@4.0.2_@types+node@22.15.15" ] }, "@esbuild/aix-ppc64@0.25.5": { @@ -519,6 +527,12 @@ "@types/estree@1.0.7": { "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, "@types/react-dom@19.1.6_@types+react@19.1.6": { "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dependencies": [ @@ -540,7 +554,19 @@ "@rolldown/pluginutils", "@types/babel__core", "react-refresh", - "vite" + "vite@6.3.5_picomatch@4.0.2" + ] + }, + "@vitejs/plugin-react@4.5.1_vite@6.3.5__picomatch@4.0.2_@babel+core@7.27.4_@types+node@22.15.15": { + "integrity": "sha512-uPZBqSI0YD4lpkIru6M35sIfylLGTyhGHvDZbNLuMA73lMlwJKz5xweH7FajfcCAc2HnINciejA9qTz0dr0M7A==", + "dependencies": [ + "@babel/core", + "@babel/plugin-transform-react-jsx-self", + "@babel/plugin-transform-react-jsx-source", + "@rolldown/pluginutils", + "@types/babel__core", + "react-refresh", + "vite@6.3.5_picomatch@4.0.2_@types+node@22.15.15" ] }, "browserslist@4.25.0": { @@ -756,6 +782,9 @@ "picomatch" ] }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, "update-browserslist-db@1.1.3_browserslist@4.25.0": { "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dependencies": [ @@ -780,6 +809,25 @@ ], "bin": true }, + "vite@6.3.5_picomatch@4.0.2_@types+node@22.15.15": { + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dependencies": [ + "@types/node", + "esbuild", + "fdir", + "picomatch", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node" + ], + "bin": true + }, "yallist@3.1.1": { "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, diff --git a/docs/style-guide.org b/docs/style-guide.org new file mode 100644 index 0000000..6e23cd5 --- /dev/null +++ b/docs/style-guide.org @@ -0,0 +1,351 @@ +* Skypod Coding Guide + +This guide is mostly for guiding LLMs when I use them for brainstorming; +/vIbE-coDiNg/ this is not. + +** Architecture Overview + +Skypod is a WebSocket-based real-time messaging application using Deno with React +frontend. Designed as an offline-first RSS & Podcast PWA with peer-to-peer sync +capabilities. + +** Interaction + +The user is an expert developer, well versed in the web platform. + +- If they're asking for an opinion it's because they want to brainstorm about + possibilities, not because they don't know how it works. +- Only trust the author's expertise to a point, and be sure to offer and point out things + which they may have missed. + +Be friendly and generally concise. +Stop telling me I'm brilliant. + +** Core Principles + +*** Functional Over OOP + +- Prefer =camelCasedFunctions()= returning objects over =CapitalCaseClasses= +- Use classes only when beneficial: errors, wrappers, prototype/proxy functionality +- Factory functions with plain object returns for utilities + +*** Spare, Straightforward Code + +- No over-engineering or clever abstractions +- Direct approach preferred over defensive programming +- Question complexity - add only when needed + +*** Modern WebPlatform-First Design + +- All async operations support cancellation via AbortSignal +- Connection-level controllers pass signals down + +** Module Organization + +#+begin_example +src/ +├── common/ # Pure utilities, no side effects +├── schema/ # Zod schemas and branded types +├── server/ # Oak/WebSocket backend +├── client/ # React frontend +└── types/ # TypeScript utilities +#+end_example + +** Naming Conventions + +- *Functions*: =camelCase= → =timeoutSignal=, =makeGate= +- *Variables*: =camelCase= +- *Constants*: =UPPER_SNAKE_CASE= → =SIGNING_ALGO= +- *Types*: =PascalCase= → =RealmId=, =PreauthMessage= +- *Schemas*: =camelCase= + =Schema= → =realmIdSchema= +- *Files*: =kebab-case.ts= → =blocking-atom.ts= + +** Patterns +*** Factory Functions + +#+begin_src typescript +// Returns object with methods +export function makeGate void>() { + return { + once: (fn: F) => { /* ... */ }, + many: (fn: F) => { /* ... */ }, + }; +} + +// Returns object with cleanup +export function timeoutSignal(ms: number): { signal: AbortSignal, cleanup: () => void } { + // Implementation +} +#+end_src + +*** Async with Signal Support + +#+begin_src typescript +export async function takeSocket( + ws: WebSocket, + signal?: AbortSignal, +): Promise { + // Always check signal.aborted first + // Add abort listener that rejects with signal.reason + // Cleanup in finally block +} +#+end_src + +*** Configuration Objects + +#+begin_src typescript +const DEFAULTS = { + signal: undefined as AbortSignal | undefined, + timeout: 3000, + retries: 3, +}; + +export function someOperation(config_: Partial = {}) { + const config = { ...DEFAULTS, ...config_ }; + // Use config +} +#+end_src + +*** Error Hierarchy + +#+begin_src typescript +export class BaseError extends Error { + status: number; + constructor(message: string, status: number) { + const statusText = StatusCodes[status] || "Unknown"; + super(`${status} ${statusText}: ${message}`); + this.name = this.constructor.name; + this.status = status; + } +} + +export class TimeoutError extends BaseError { + constructor(reason: string, ms: number) { + super(`${reason} timed out after ${ms}ms`, 408); + } +} +#+end_src + +*** Error Handler Pattern + +#+begin_src typescript +function errorHandler(ws: WebSocket, e: any) { + if (e instanceof BaseError) { + ws.send(`${e.message}\n`); + console.warn(`${e.status} ${e.constructor.name}`); + } else if (e instanceof z.ZodError) { + const validation = new ValidationError(e); + ws.send(`${validation.message}\n`); + console.warn(`${validation.status} ValidationError`); + } else if (e instanceof DOMException && e.name === "TimeoutError") { + ws.send(`408 Request Timeout: operation timed out\n`); + console.warn(`408 TimeoutError`); + } + // ... handle other error types +} +#+end_src + +** Schema & Validation +*** Branded Types + +#+begin_src typescript +const realmIdBrand = makeBrandedId("realm", 16); +export type RealmId = inferBrandedId; +export const { + generator: generateRealmId, + validator: validateRealmId, + parser: parseRealmId, + schema: realmIdSchema, +} = realmIdBrand; +#+end_src + +*** Protocol Messages + +#+begin_src typescript +// Discriminated unions for type narrowing +export const preauthMessageSchema = z.discriminatedUnion("msg", [ + preauthAuthnMessageSchema, + // ... other message types +]); + +// Async transforms for complex validation +export const jwkSchema = z.unknown().transform(async (val, ctx) => { + try { + return await importJWK(val, SIGNING_ALGO, { extractable: true }); + } catch (e) { + ctx.addIssue({ + code: "custom", + message: `could not import JWK object: ${e}`, + }); + return z.NEVER; + } +}); +#+end_src + +** WebSocket Patterns +*** Streaming with Generators + +#+begin_src typescript +export async function* streamSocket( + ws: WebSocket, + config_: Partial = {}, +): AsyncGenerator { + const config = { ...STREAM_CONFIG_DEFAULT, ...config_ }; + const queue = new BlockingQueue(config.queueSize); + + try { + // Setup listeners + while (true) { + config.signal?.throwIfAborted(); + const [event, value] = await queue.dequeue(config.signal); + + switch (event) { + case "yield": yield value; continue; + case "error": throw value; + case "end": return; + } + } + } finally { + // Cleanup listeners + } +} +#+end_src + +*** Gate Pattern for Events + +#+begin_src typescript +const gate = makeGate(); +const onMessage = gate.once((m: MessageEvent) => atom.set(m.data)); +const onError = gate.once((e: Error) => { /* handle */ }); +#+end_src + +*** Signal Composition + +#+begin_src typescript +// Custom combineSignals preserves individual abort reasons +export function combineSignals(...signals: AbortSignal[]): AbortSignal { + const controller = new AbortController(); + + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener('abort', () => { + if (!controller.signal.aborted) { + controller.abort(signal.reason); // Preserve original reason + } + }); + } + + return controller.signal; +} +#+end_src + +*** Timeout Utilities + +#+begin_src typescript +export function timeoutSignal(ms: number): { signal: AbortSignal, cleanup: () => void } { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(new DOMException('Operation timed out', 'TimeoutError')); + }, ms); + + const cleanup = () => clearTimeout(timeout); + controller.signal.addEventListener("abort", cleanup); + + return { signal: controller.signal, cleanup }; +} +#+end_src + +*** Connection-Level Pattern + +#+begin_src typescript +export async function socketHandler(this: WebSocket) { + const controller = new AbortController(); + + // Auto-abort on socket close + this.addEventListener('close', () => + queueMicrotask(() => { + controller.abort(new CancellationError('socket closed')) + }) + ); + + try { + // Pass signal to operations + const result = await someOperation(this, controller.signal); + } catch (e) { + errorHandler(this, e); + } +} +#+end_src + +** Import/Export Conventions +:PROPERTIES: +:CUSTOM_ID: importexport-conventions +:END: +*** Path Mapping +:PROPERTIES: +:CUSTOM_ID: path-mapping +:END: +- Use =@repo/= prefix for all internal imports +- Configured in =deno.json= imports map +- Absolute imports preferred over relative + +#+begin_src typescript +import { takeSocket } from "@repo/common/socket.ts"; +import { timeoutSignal } from "@repo/common/aborts.ts"; +import { BaseError } from "@repo/common/errors.ts"; +#+end_src + +*** Export Patterns +:PROPERTIES: +:CUSTOM_ID: export-patterns +:END: +- Named exports preferred +- Default exports only for React components +- Group related exports from single module + +#+begin_src typescript +// Good - named exports +export { makeGate }; +export { timeoutSignal, combineSignals }; + +// React components only +export default function App() { /* ... */ } +#+end_src + +** TypeScript Guidelines +*** Strict Configuration +- Full strict mode enabled in =deno.json= +- No =any= types except for error handling +- Explicit return types for exported functions + +*** Generic Constraints +#+begin_src typescript +export function makeGate void>( + outercb?: () => void, +) { + // Implementation uses F constraint +} +#+end_src + +*** Utility Types +#+begin_src typescript +// Custom compatibility checking +export type Compatible = T extends U ? T : never; + +// Branded type inference +export type inferBrandedId = T extends { schema: z.ZodType } ? U : never; +#+end_src + +*** Dependencies +- JSR packages preferred over NPM when available +- Lock file committed (=deno.lock=) +- Version pinning in =deno.json= + +*** Testing Strategy +- Incremental testing during development +- Strategic logging for debugging +- Clean up logs once working +- Manual testing with =websocat= for WebSocket endpoints diff --git a/src/common/aborts.ts b/src/common/aborts.ts index 1beef94..497326d 100644 --- a/src/common/aborts.ts +++ b/src/common/aborts.ts @@ -1,77 +1,31 @@ -export class TimeoutError extends Error { - constructor(ms: number, operation?: string) { - super(`${operation ? operation+" " : ""}timed out after ${ms}ms`); - this.name = "TimeoutError"; - } -} - -export class CancellationError extends Error { - constructor(reason?: string) { - super(`operation was cancelled${reason ? `: ${reason}` : ""}`); - this.name = "CancellationError"; - } -} - -export type CancellableAbortController = AbortController & { - cancel: () => void; -}; - -export function makeAbort(): CancellableAbortController { - let cancelled = false; +export function timeoutSignal( + ms: number, +): { signal: AbortSignal; cleanup: () => void } { const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(new DOMException("Operation timed out", "TimeoutError")); + }, ms); + + const cleanup = () => clearTimeout(timeout); + controller.signal.addEventListener("abort", cleanup); - return { - cancel: () => { - cancelled = true; - }, - abort: (r) => { - !cancelled && controller.abort(r); - }, - signal: controller.signal, - }; + return { signal: controller.signal, cleanup }; } -export function makeTimeoutAbort(ms: number, operation?: string): CancellableAbortController { +export function combineSignals(...signals: AbortSignal[]): AbortSignal { const controller = new AbortController(); - const { promise, triggered, cancel } = makeTimeoutPromise(ms); - controller.signal.addEventListener("abort", () => { - if (!triggered()) { - cancel(); + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; } - }); - - promise.then(() => { - if (!controller.signal.aborted) { - controller.abort(new TimeoutError(ms, operation)); - } - }); - - return { - cancel, - abort: (reason?: any) => { - if (reason instanceof TimeoutError) { - controller.abort(reason); - } else { - controller.abort(new CancellationError(reason?.toString())); + signal.addEventListener("abort", () => { + if (!controller.signal.aborted) { + controller.abort(signal.reason); } - }, - signal: controller.signal, - }; -} - -export function makeTimeoutPromise(ms: number) { - const { promise, resolve } = Promise.withResolvers(); - - let triggered = false; - const timeout = setTimeout(() => { - triggered = true; - resolve(); - }, ms); + }); + } - return { - promise, - cancel: () => clearTimeout(timeout), - triggered: () => triggered, - }; + return controller.signal; } diff --git a/src/common/errors.ts b/src/common/errors.ts new file mode 100644 index 0000000..344309e --- /dev/null +++ b/src/common/errors.ts @@ -0,0 +1,60 @@ +import { prettifyError, ZodError } from "zod/v4"; + +export const StatusCodes: Record = { + 400: "Bad Request", + 403: "Forbidden", + 404: "Not Found", + 408: "Request Timeout", + 409: "Conflict", + 429: "Too Many Requests", + 499: "Client Closed Request", + 500: "Internal Server Error", +}; + +export class BaseError extends Error { + status: number; + + constructor(message: string, status: number) { + const statusText = StatusCodes[status] || "Unknown"; + super(`${status} ${statusText}: ${message}`); + + this.name = this.constructor.name; + this.status = status; + } +} + +export class CancellationError extends BaseError { + constructor(reason: string) { + super(`${reason} was cancelled`, 499); + } +} + +export class AuthError extends BaseError { + constructor(reason: string) { + super(`authentication failed: ${reason}`, 403); + } +} + +export class ValidationError extends BaseError { + constructor(zodError: ZodError) { + super(`validation failed: ${prettifyError(zodError)}`, 400); + } +} + +export class NotFoundError extends BaseError { + constructor(resource: string) { + super(`${resource} not found`, 404); + } +} + +export class ConflictError extends BaseError { + constructor(reason: string) { + super(`conflict: ${reason}`, 409); + } +} + +export class RateLimitError extends BaseError { + constructor(reason: string) { + super(`rate limit exceeded: ${reason}`, 429); + } +} diff --git a/src/common/socket.ts b/src/common/socket.ts index bae8561..1411766 100644 --- a/src/common/socket.ts +++ b/src/common/socket.ts @@ -1,9 +1,8 @@ -import { makeAbort, makeTimeoutAbort, TimeoutError, CancellationError } from "./aborts.ts"; import { BlockingAtom } from "./blocking-atom.ts"; import { BlockingQueue } from "./blocking-queue.ts"; import { makeGate } from "./gate.ts"; +import { CancellationError } from "./errors.ts"; -export type StreamConfig = typeof STREAM_CONFIG_DEFAULT; const STREAM_CONFIG_DEFAULT = { signal: undefined as AbortSignal | undefined, queueSize: 1000, @@ -20,9 +19,11 @@ type StreamEvent = export async function* streamSocket( ws: WebSocket, - config_: Partial = {}, + config_: Partial = {}, ): AsyncGenerator { - const config = { ...STREAM_CONFIG_DEFAULT, ...config_ }; + const { signal, ...config } = { ...STREAM_CONFIG_DEFAULT, ...config_ }; + signal?.throwIfAborted(); + const queue = new BlockingQueue(config.queueSize); const gate = makeGate(); @@ -35,8 +36,8 @@ export async function* streamSocket( }); const onError = gate.once((e: Error) => { - // todo: why are we getting this on client shutdown? if (e.message === "Unexpected EOF") { + // todo: why are we getting this on client shutdown instead of onClose? queue.enqueue(["end"]); } else { queue.enqueue(["error", e]); @@ -51,11 +52,12 @@ export async function* streamSocket( ws.addEventListener("close", onClose); while (true) { - config.signal?.throwIfAborted(); + signal?.throwIfAborted(); - const [event, value] = await queue.dequeue(config.signal); - if (queue.size < config.threshResume && config.onResume) + const [event, value] = await queue.dequeue(signal); + if (queue.size < config.threshResume && config.onResume) { queueMicrotask(config.onResume); + } switch (event) { case "yield": @@ -67,8 +69,6 @@ export async function* streamSocket( return; } } - } catch (e) { - if (!config.signal?.aborted) throw e; } finally { ws.removeEventListener("message", onMessage); ws.removeEventListener("error", onError); @@ -76,54 +76,25 @@ export async function* streamSocket( } } -export async function takeSocket(ws: WebSocket): Promise; export async function takeSocket( ws: WebSocket, - ms: number, -): Promise; -export async function takeSocket( - ws: WebSocket, - ms?: number, -): Promise { + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const atom = new BlockingAtom(); - const abort = ms ? makeTimeoutAbort(ms, "socket read") : makeAbort(); const gate = makeGate(); - // callback functions - consts so we can `off` them on clean up - const onMessage = gate.once((m: MessageEvent) => atom.set(m.data)); - const onClose = gate.once(() => abort.abort("socket closed")); - const onError = gate.once((e: Error) => { - if (e.message == "Unexpected EOF") { - // todo: why are we getting this on client shutdown? - abort.abort("socket closed"); - } else { - abort.abort(e); - } - }); - try { ws.addEventListener("message", onMessage); - ws.addEventListener("close", onClose); - ws.addEventListener("error", onError); - - return await atom.get(abort.signal); - } catch (e) { - // only throw if we're not awaiting a timeout - // otherwise we know to expect to get undefined - if (ms && e instanceof DOMException && e.name === "AbortError" && abort.signal.reason instanceof TimeoutError) { - return undefined; + const data = await atom.get(signal); + if (!data) { + throw new CancellationError("socket read aborted"); } - // other errors bubble up (cancellations, socket errors, ...) - throw e; + return data; } finally { - if (!abort.signal.aborted) { - abort.cancel(); - } - ws.removeEventListener("message", onMessage); - ws.removeEventListener("close", onClose); - ws.removeEventListener("error", onError); } } diff --git a/src/schema/crypto.ts b/src/schema/crypto.ts index a4b5768..1871d50 100644 --- a/src/schema/crypto.ts +++ b/src/schema/crypto.ts @@ -2,7 +2,9 @@ import { z } from "zod/v4"; import { decodeJwt, importJWK, jwtVerify } from "@panva/jose"; import { JWTPayload, JWTVerifyResult } from "@panva/jose"; -export const SIGNING_ALGO = "ES256"; +// application wide! + +const SIGNING_ALGO = "ES256"; // tokens diff --git a/src/server/app.ts b/src/server/app.ts index 0e2acf1..2cb894a 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,6 +1,6 @@ import { Application } from "@oak/oak/application"; -import { apiMiddleware } from "./api/middleware.ts"; -import { socketMiddleware } from "./socket/middleware.ts"; +import { apiMiddleware } from "./routes-api/middleware.ts"; +import { socketMiddleware } from "./routes-socket/middleware.ts"; import { makeSpaRoute, makeStaticRoute } from "./routes.static.ts"; import { notFound } from "./routes.error.ts"; diff --git a/src/server/api/middleware.ts b/src/server/routes-api/middleware.ts similarity index 100% rename from src/server/api/middleware.ts rename to src/server/routes-api/middleware.ts diff --git a/src/server/routes-socket/handler.ts b/src/server/routes-socket/handler.ts new file mode 100644 index 0000000..b620e0f --- /dev/null +++ b/src/server/routes-socket/handler.ts @@ -0,0 +1,86 @@ +import { takeSocket } from "@repo/common/socket.ts"; +import { combineSignals, timeoutSignal } from "@repo/common/aborts.ts"; +import { + AuthError, + BaseError, + CancellationError, + ValidationError, +} from "@repo/common/errors.ts"; +import { z } from "zod/v4"; + +import { StrictMap } from "@repo/common/strict-map.ts"; +import { IdentId, RealmId } from "@repo/schema/state.ts"; +import { jwtDecodedSchema } from "@repo/schema/crypto.ts"; +import { preauthMessageSchema } from "@repo/schema/proto.ts"; + +type Realm = { + id: RealmId; + sockets: StrictMap; + identities: StrictMap; +}; + +/* +const realmMap = new StrictMap(); + +function attachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { + realm.sockets.update(ident, (ss) => ss ? [...ss, socket] : [socket]); +} + +function detachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { + realm.sockets.update(ident, (ss) => ss ? ss.filter((s) => s !== socket) : []) +} +*/ + +export async function socketHandler(this: WebSocket) { + const controller = new AbortController(); + this.addEventListener("close", () => + queueMicrotask(() => { + controller.abort(new CancellationError("socket closed")); + })); + + try { + const jwt = await preauthHandler(this, controller.signal); + console.log(jwt); + } catch (e) { + errorHandler(this, e); + } finally { + if (this.readyState !== this.CLOSED) { + this.send(`kthkbye\n`); + this.close(); + } + } +} + +async function preauthHandler(ws: WebSocket, signal: AbortSignal) { + const timeout = timeoutSignal(3000); + const combinedSignal = combineSignals(signal, timeout.signal); + + try { + const data = await takeSocket(ws, combinedSignal); + const schema = jwtDecodedSchema.pipe(preauthMessageSchema) + + return await schema.parseAsync(data); + } finally { + timeout.cleanup(); + } +} + +function errorHandler(ws: WebSocket, e: unknown) { + if (e instanceof BaseError) { + ws.send(`${e.message}\n`); + console.warn(`${e.status} ${e.constructor.name}`); + } else if (e instanceof z.ZodError) { + const validation = new ValidationError(e); + ws.send(`${validation.message}\n`); + console.warn(`${validation.status} ValidationError`); + } else if (e instanceof DOMException && e.name === "TimeoutError") { + ws.send(`408 Request Timeout: operation timed out\n`); + console.warn(`408 TimeoutError`); + } else if (e instanceof DOMException && e.name === "AbortError") { + ws.send(`499 Client Closed Request: operation was aborted\n`); + console.warn(`499 AbortError`); + } else { + ws.send(`500 Internal Server Error: ${e}\n`); + console.error(e); + } +} diff --git a/src/server/socket/middleware.ts b/src/server/routes-socket/middleware.ts similarity index 100% rename from src/server/socket/middleware.ts rename to src/server/routes-socket/middleware.ts diff --git a/src/server/socket/handler.ts b/src/server/socket/handler.ts deleted file mode 100644 index 9cb024d..0000000 --- a/src/server/socket/handler.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { takeSocket } from "@repo/common/socket.ts"; -import { TimeoutError, CancellationError } from "@repo/common/aborts.ts"; -import { z } from "zod/v4"; - -import { StrictMap } from "@repo/common/strict-map.ts"; -import { IdentId, RealmId } from "@repo/schema/state.ts"; -import { jwtDecodedSchema } from "@repo/schema/crypto.ts"; -import { preauthMessageSchema } from "@repo/schema/proto.ts"; - -type Realm = { - id: RealmId; - sockets: StrictMap; - identities: StrictMap; -}; - -export class AuthError extends Error {} - -/* -const realmMap = new StrictMap(); - -function attachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { - realm.sockets.update(ident, (ss) => ss ? [...ss, socket] : [socket]); -} - -function detachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { - realm.sockets.update(ident, (ss) => ss ? ss.filter((s) => s !== socket) : []) -} -*/ - -export async function socketHandler(this: WebSocket) { - try { - const jwt = await preauthHandler(this); - console.log(jwt) - } catch (e) { - errorHandler(this, e) - } finally { - if (this.readyState !== this.CLOSED) { - this.send(`kthkbye\n`); - this.close(); - } - } -} - -async function preauthHandler(ws: WebSocket) { - const data = await takeSocket(ws, 3000); - if (!data) { - throw new AuthError("authentication timeout"); - } - - const jwt = await jwtDecodedSchema.pipe(preauthMessageSchema).parseAsync(data); - console.log(jwt); - - return jwt; -} - -function errorHandler(ws: WebSocket, e: any) { - if (e instanceof z.ZodError) { - ws.send(`400 bad params: ${z.prettifyError(e)}\n`); - console.warn('400 bad params') - } else if (e instanceof AuthError) { - ws.send(`403 forbidden: ${e.message}\n`); - console.warn('404 forbidden') - } else if (e instanceof TimeoutError) { - ws.send(`408 request timeout: ${e.message}\n`); - console.warn('408 request timeout') - } else if (e instanceof CancellationError) { - ws.send(`499 client closed connection: ${e.message}\n`); - console.warn('499 client closed connection') - } else { - ws.send(`500 server error: ${e}\n`); - console.error(e); - } -} \ No newline at end of file diff --git a/src/server/util/routeStaticFilesFrom.ts b/src/server/util/routeStaticFilesFrom.ts deleted file mode 100644 index 8c2667c..0000000 --- a/src/server/util/routeStaticFilesFrom.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Next } from "jsr:@oak/oak/middleware"; -import { Context } from "jsr:@oak/oak/context"; - -// Configure static site routes so that we can serve -// the Vite build output and the public folder -export default function routeStaticFilesFrom(staticPaths: string[]) { - return async (context: Context>, next: Next) => { - for (const path of staticPaths) { - try { - await context.send({ root: path, index: "index.html" }); - return; - } catch { - continue; - } - } - - await next(); - }; -} diff --git a/src/types/assertions.ts b/src/types/assertions.ts deleted file mode 100644 index 9b019fc..0000000 --- a/src/types/assertions.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type Compatible = T extends U ? T : never; - -export function assertCompatible(): Compatible { - return undefined as Compatible; -}