Skip to content

Web Standards First ​

@loewen-digital/fullstack is written against the Web Platform APIs that browsers, Node, Bun, Deno and Cloudflare Workers share. That is what makes the core framework-agnostic: a module that takes a Request runs behind any framework that hands one over, and every meta-framework does.

The APIs in use ​

Web StandardWhere
RequestvalidateForm and the adapters read forms and JSON from it; incoming webhooks are verified from it
ResponseerrorToResponse turns any error into one; the adapters return them
HeaderscorsHeaders returns them, ready to merge into a response
URLOAuth authorization URLs, cookie and redirect handling
FormDataForm parsing in validateForm
ReadableStreamStreamed uploads in storage.put and mail attachments
Uint8ArrayEvery byte buffer: storage contents, attachments, hashes
crypto.subtleHMAC-SHA256 for CSRF tokens, signed session cookies and webhook signatures
crypto.randomUUID, crypto.getRandomValuesIds and nonces
fetchEvery HTTP driver: Resend, Postmark, S3, R2, Meilisearch, Typesense, external log transport

Why not the Node APIs ​

Node's own HTTP types (IncomingMessage, ServerResponse, Buffer, node:http) exist only in Node. A module written against them needs a shim on every other runtime, and a framework adapter has to translate on the way in and out.

Every meta-framework (SvelteKit, Nuxt via Nitro, Remix, Astro) hands its server code a Web Standard Request and expects a Response. So the adapters are thin: they pass the request through and read a few framework-specific things such as locals and cookies.

Reading a request ​

Node style, one runtime:

ts
import type { IncomingMessage } from 'node:http'

function readBodyFromNode(req: IncomingMessage): Promise<string> {
  return new Promise((resolve) => {
    let body = ''
    req.on('data', (chunk: string) => (body += chunk))
    req.on('end', () => resolve(body))
  })
}

Web Standard, every runtime:

ts
function readBody(request: Request): Promise<string> {
  return request.text()
}

Uint8Array instead of Buffer ​

Buffer is a Node subclass of Uint8Array. The package accepts the base class, so a FormData file, crypto.subtle output or a Buffer goes into storage.put without conversion, and it returns bytes on a plain ArrayBuffer of their own (Uint8Array<ArrayBuffer>), so what storage.get hands back goes straight into new Response(bytes) and new Blob([bytes]).

ts
async function sha256(data: Uint8Array<ArrayBuffer>): Promise<Uint8Array> {
  return new Uint8Array(await crypto.subtle.digest('SHA-256', data))
}

async function digestOf(text: string) {
  return sha256(new TextEncoder().encode(text))
}

crypto.subtle for signatures ​

Everything that signs or verifies uses the Web Crypto API: CSRF tokens are an HMAC over the session id, the cookie session driver signs its payload, incoming webhooks are checked against their signature header. The standalone security functions show the shape:

ts
import { generateCsrfToken, verifyCsrfToken } from '@loewen-digital/fullstack/security'

async function roundTrip(sessionId: string, secret: string) {
  const token = await generateCsrfToken(sessionId, secret) // HMAC-SHA256 through crypto.subtle
  return verifyCsrfToken(sessionId, token, secret) // true
}

Where Node is still needed ​

A few things have no Web Standard yet, and there the package uses Node built-ins. On Cloudflare Workers they run with the nodejs_compat flag; on Bun and Deno they work as they are.

ModuleNode APIWhy
authnode:crypto scrypt and randomBytesPassword hashing; Web Crypto has no memory-hard KDF
dbbetter-sqlite3The sqlite driver is a native binding, an optional peer you install
storage local driver, logging file transportnode:fsFiles on disk
mail SMTP drivernodemailerSMTP is a TCP protocol

A handler on any runtime ​

Because the modules take and return the standard types, a handler written once runs behind every adapter, or with none:

ts
import { errorToResponse, NotFoundError } from '@loewen-digital/fullstack/errors'
import { corsHeaders } from '@loewen-digital/fullstack/security'
import type { StorageInstance } from '@loewen-digital/fullstack/storage'

export function createFileHandler(storage: StorageInstance) {
  return async (request: Request): Promise<Response> => {
    const cors = corsHeaders(request.headers.get('origin'), { origins: ['https://app.example.com'] })
    try {
      const key = new URL(request.url).pathname.slice('/files/'.length)
      const bytes = await storage.get(key)
      if (bytes === null) throw new NotFoundError(`No file ${key}`)
      return new Response(bytes, { headers: cors })
    } catch (err) {
      return errorToResponse(err, cors)
    }
  }
}