From 3ec65b9a0f206624f1caced7e5be4c36a09c95fd Mon Sep 17 00:00:00 2001 From: laura Date: Sat, 8 Nov 2025 18:34:17 -0300 Subject: [PATCH] iframe-based deferred rendering with tokenized isolation layer --- README.md | 11 ++-- example.tsx | 27 +++------ src/http.ts | 5 +- src/isolation.ts | 142 ++++++++++++++++++++++++++++++++++++++++++++++ src/jsx.ts | 84 +++++++++------------------ src/middleware.ts | 7 +++ src/router.ts | 24 +++++--- src/token.ts | 98 ++++++++++++++++++++++++++++++++ 8 files changed, 308 insertions(+), 90 deletions(-) create mode 100644 src/isolation.ts create mode 100644 src/token.ts diff --git a/README.md b/README.md index ad6f2b2..ad00fa1 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,14 @@ applications - [x] type-safe routing - [x] html streaming support - [x] jsx runtime +- [x] isolated deferred rendering through iframes +- [x] safely defer html streams +- [x] server-sided token-based sessions +- [ ] global styles w/ iframe passthrough +- [ ] iframe route lifecycle management +- [ ] proper iframe state management - [ ] intutive high-level apis -- [ ] safely defer html streams -- [ ] isolated deferred rendering through iframes -- [ ] scoped css through shadow dom -- [ ] css-in-js library +- [ ] scoped css-in-js library - [ ] interactives structures and dynamic data visibility toggling via modern css features - [ ] only use html streaming shenanigans for noscript environments diff --git a/example.tsx b/example.tsx index cf545b4..ac8c7dd 100644 --- a/example.tsx +++ b/example.tsx @@ -5,7 +5,7 @@ import { createRouter } from "cobweb/routing"; import { Defer } from "cobweb/jsx-runtime"; -import { computed, effect, signal } from "cobweb/signals.ts"; +import { setupDeferredRoutes } from "cobweb/isolation.ts"; interface Todo { id: string; @@ -42,19 +42,23 @@ const TodoList = async function* (): AsyncGenerator { const app = createRouter(); -app.get("/", async (ctx) => { - const { stream: html } = ctx; +setupDeferredRoutes(app); +app.get("/", async (ctx) => { await ( <>

My Todos

+ + + - )(html.chunks); + )(ctx); - return html.response; + ctx.stream.close(); + return ctx.stream.response; }); app.get("/meow/:test?", async (ctx) => { @@ -62,16 +66,3 @@ app.get("/meow/:test?", async (ctx) => { }); Deno.serve({ port: 8000 }, app.fetch); - -const count = signal(1); -const doubleCount = computed(() => count() * 2); - -effect(() => { - console.log(`Count is: ${count()}`); -}); - -console.log(doubleCount()); - -count(2); - -console.log("meow", doubleCount()); diff --git a/src/http.ts b/src/http.ts index 8cb1c50..39ff980 100644 --- a/src/http.ts +++ b/src/http.ts @@ -12,7 +12,6 @@ export interface StreamOptions { export function chunked() { const chunks = new ChunkedStream(); const encoder = new TextEncoder(); - return { chunks, stream: new ReadableStream({ @@ -53,8 +52,8 @@ export async function createDataStream( return { blob: stream, chunks, - close: chunks.close, - error: chunks.error, + close: () => chunks.close(), + error: (e) => chunks.error(e), response: new Response(stream, { headers: { "Content-Type": options.contentType, diff --git a/src/isolation.ts b/src/isolation.ts new file mode 100644 index 0000000..4eeab77 --- /dev/null +++ b/src/isolation.ts @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { Context, Router } from "cobweb/routing"; +import { DeferProps, render } from "cobweb/jsx-runtime"; +import { getOrCreateSecretKey, SignedTokenManager } from "./token.ts"; + +export const authority = async (hostname: string): Promise => { + if (!hostname) { + throw new Error("Hostname required for authority calculation"); + } + + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(hostname), + ); + + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +}; + +interface DeferConfig { + tokenManager: SignedTokenManager; + registry: DeferredRegistry; +} + +interface DeferredComponent { + resource: string; + render: (ctx: Context) => Promise; +} + +class DeferredRegistry { + private readonly components = new Map(); + + register( + resource: string, + render: (ctx: Context) => Promise, + ): void { + if (this.components.has(resource)) { + throw new Error(`Component ${resource} already registered`); + } + this.components.set(resource, { resource: resource, render }); + } + + consume(resource: string): DeferredComponent | undefined { + const component = this.components.get(resource); + if (component) this.components.delete(resource); + return component; + } +} + +const deferConfig: DeferConfig = { + tokenManager: new SignedTokenManager(getOrCreateSecretKey()), + registry: new DeferredRegistry(), +}; + +export function setupDeferredRoutes(router: Router): void { + router.get("/_defer/:token", async (ctx) => { + const token = ctx.params.token; + + const payload = await deferConfig.tokenManager.validate(token); + if (!payload) { + return new Response("Invalid or expired token", { + status: 403, + headers: { "Content-Type": "text/plain" }, + }); + } + + const author = ctx.info.remoteAddr.hostname; + if (payload.authority !== await authority(author)) { + console.warn( + `Authority mismatch: expected ${payload.authority}, got ${await authority( + author, + )}`, + ); + return new Response("Unauthorized", { + status: 401, + headers: { "Content-Type": "text/html" }, + }); + } + + const component = deferConfig.registry.consume(payload.resource); + if (!component) { + return new Response("Not Found", { + status: 404, + headers: { "Content-Type": "text/html" }, + }); + } + + try { + await component.render(ctx); + return ctx.stream.response; + } catch (error) { + console.error("defer:setupDeferredRoutes error:", error); + return new Response( + "
⚠️ something went wrong
", + { + status: 500, + headers: { "Content-Type": "text/html" }, + }, + ); + } + }); +} + +const renderIsolated = ({ children }: DeferProps) => (ctx: Context) => + Promise.resolve(children).then(async (resolved) => { + render(resolved, ctx).then(ctx.stream.close); + }).catch((err) => { + console.error("defer:renderIsolated error:", err); + if (!ctx.stream.chunks.closed) { + ctx.stream.chunks.write(`
⚠️ something went wrong
`); + ctx.stream.chunks.close(); + } + }); + +export async function $defer( + { info, stream }: Context, + props: DeferProps, +) { + props.authority ??= await authority( + info.remoteAddr.hostname, + ); + props.ttl ??= 300000; + + const resource = crypto.randomUUID(); + const token = await deferConfig.tokenManager.generate( + props.authority, + resource, + props.ttl, + ); + const path = `/_defer/${token}`; + deferConfig.registry.register(resource, renderIsolated(props)); + + !stream.chunks.closed && stream.chunks.write( + ``, + ); +} diff --git a/src/jsx.ts b/src/jsx.ts index 85da75f..960561a 100644 --- a/src/jsx.ts +++ b/src/jsx.ts @@ -3,7 +3,9 @@ * SPDX-License-Identifier: BSD-3-Clause */ +import { Context } from "cobweb/routing"; import { ChunkedStream } from "./stream.ts"; +import { $defer } from "./isolation.ts"; // deno-fmt-ignore export const voidTags = new Set([ @@ -18,11 +20,11 @@ const ESC_LUT: Record = { const ESC_RE = /[&<>"']/g; export const Fragment = Symbol("jsx.fragment") as any as JsxElement; -export const Defer = Symbol("jsx.defer") as any as Component; +export const Defer = Symbol("jsx.defer") as any as Component; export type Component

= (props: P) => JsxElement; -export type JsxElement = (chunks: ChunkedStream) => Promise; +export type JsxElement = (ctx: Context) => Promise; type Props = { children?: JsxElement; @@ -30,9 +32,10 @@ type Props = { [key: string]: unknown; }; -interface DeferProps { - fallback?: JsxElement; +export interface DeferProps { children: JsxElement; + authority?: string; + ttl?: number; } export const jsxEscape = (input: string): string => @@ -48,36 +51,35 @@ export const jsxAttr = (k: string, v: unknown) => const emit = (chunks: ChunkedStream, data: string) => void (chunks && !chunks.closed && chunks.write(data)); -async function render( - node: any, - chunks: ChunkedStream, -): Promise { +export async function render(node: any, ctx: Context): Promise { if (node == null || typeof node === "boolean") return; - if (typeof node === "string") return emit(chunks, node); - if (typeof node === "function") return node(chunks); - if (node instanceof Promise) return render(await node, chunks); + if (typeof node === "string") return emit(ctx.stream.chunks, node); + if (typeof node === "function") { + return node(ctx); + } + if (node instanceof Promise) return render(await node, ctx); if (Array.isArray(node)) { - for (const item of node) await render(item, chunks); + for (const item of node) await render(item, ctx); return; } if (typeof node === "object" && Symbol.asyncIterator in node) { - for await (const item of node) await render(item, chunks); + for await (const item of node) await render(item, ctx); return; } - emit(chunks, escape(String(node))); + emit(ctx.stream.chunks, jsxEscape(String(node))); } export function jsxTemplate( template: string[], ...values: unknown[] ): JsxElement { - return async (chunks: ChunkedStream) => { + return async (ctx: Context) => { for (let i = 0; i < template.length; i++) { - emit(chunks, template[i]); - i < values.length && await render(values[i], chunks); + emit(ctx.stream.chunks, template[i]); + i < values.length && await render(values[i], ctx); } }; } @@ -90,67 +92,37 @@ export function jsx

( props ??= {} as P; if (key !== undefined) props.key = key; - return async (chunks: ChunkedStream) => { + return async (data: Context) => { const { children, key: _, ...attrs } = props; if (tag === Fragment) { for (const child of Array.isArray(children) ? children : [children]) { - await render(child, chunks); + await render(child, data); return; } } if (tag === Defer) { - return defer(chunks, props as DeferProps); + return $defer(data, props as DeferProps); } if (typeof tag === "function") { const result = await (tag as any)(props); - return render(result, chunks); + return render(result, data); } const isVoid = voidTags.has(tag); - emit(chunks, `<${tag}`); + emit(data.stream.chunks, `<${tag}`); for (const name in attrs) { const value = (attrs as any)[name]; - emit(chunks, jsxAttr(name, value)); + emit(data.stream.chunks, jsxAttr(name, value)); } - emit(chunks, isVoid ? "/>" : ">"); + emit(data.stream.chunks, isVoid ? "/>" : ">"); if (!isVoid) { - await render(children, chunks); - emit(chunks, ``); + await render(children, data); + emit(data.stream.chunks, ``); } }; } - -async function defer( - chunks: ChunkedStream, - { fallback, children }: DeferProps, -) { - const id = `deferred-${Math.random().toString(36).slice(2, 10)}`; - - emit(chunks, `

`); - await render(fallback, chunks); - emit(chunks, `
`); - - Promise.resolve(children).then(async (resolved) => { - const buffer = new ChunkedStream(); - await render(resolved, buffer); - buffer.close(); - - const content: string[] = []; - for await (const chunk of buffer) content.push(chunk); - - emit( - chunks, - `
`, - ); - }).catch((err) => { - console.error("defer error:", err); - emit(chunks, `
⚠️ something went wrong
`); - }); -} diff --git a/src/middleware.ts b/src/middleware.ts index 363b326..e9cf86f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,25 +4,32 @@ */ import { DataStream } from "./http.ts"; +import { Router } from "cobweb/routing"; export interface Context> { readonly request: Request; + readonly info: Deno.ServeHandlerInfo; readonly url: URL; readonly method: string; readonly params: Params; readonly pattern: URLPatternResult; readonly stream: DataStream; readonly signal: AbortSignal; + readonly router: Router; state: Map; } export async function createContext

>( + router: Router, request: Request, + info: Deno.ServeHandlerInfo, pattern: URLPatternResult, stream: DataStream, ): Promise> { return { + router, request, + info, url: new URL(request.url), method: request.method, params: (pattern.pathname.groups || {}) as P, diff --git a/src/router.ts b/src/router.ts index f95c99f..64a22c4 100644 --- a/src/router.ts +++ b/src/router.ts @@ -62,10 +62,13 @@ interface BaseRouter { handler: Handler>, ): P; - fetch: (request: Request) => Promise; + fetch: ( + request: Request, + info: Deno.ServeHandlerInfo, + ) => Promise; } -type Router = +export type Router = & BaseRouter & { [M in Method as Lowercase]: < @@ -80,14 +83,14 @@ export function createRouter(namespace?: string): Router { const routes: Route[] = []; const middlewares: Middleware[] = []; - const router: BaseRouter = { + const r: BaseRouter = { routes, middlewares, namespace, use(...mw: Middleware[]) { middlewares.push(...mw); - return router; + return r; }, on

| URLPattern>( @@ -108,7 +111,10 @@ export function createRouter(namespace?: string): Router { return path; }, - async fetch(request: Request): Promise { + async fetch( + request: Request, + info: Deno.ServeHandlerInfo, + ): Promise { const method = request.method.toUpperCase() as Method; for (const route of routes) { @@ -118,7 +124,7 @@ export function createRouter(namespace?: string): Router { if (!match) continue; const stream = await createDataStream(); - const ctx = await createContext(request, match, stream); + const ctx = await createContext(r as any, request, info, match, stream); return ( (await compose(middlewares, route.handler)(ctx)) || @@ -136,16 +142,16 @@ export function createRouter(namespace?: string): Router { ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].forEach( (method) => { const lower = method.toLowerCase() as Lowercase; - (router as any)[lower] = < + (r as any)[lower] = < P extends string | TypedURLPattern | URLPattern, >( path: P, handler: Handler>, - ) => router.on(method as Method, path, handler); + ) => r.on(method as Method, path, handler); }, ); - return router as Router; + return r as Router; } export * from "./middleware.ts"; diff --git a/src/token.ts b/src/token.ts new file mode 100644 index 0000000..3481c96 --- /dev/null +++ b/src/token.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +export interface TokenPayload { + authority: string; + resource: string; + exp: number; +} + +export class SignedTokenManager { + private readonly secretKey: string; + private readonly consumed = new Set(); + + constructor(secretKey: string = getOrCreateSecretKey()) { + this.secretKey = secretKey; + } + + async generate( + authority: string, + resource: string, + ttlMs: number, + ): Promise { + const payload: TokenPayload = { + authority, + resource, + exp: Date.now() + ttlMs, + }; + + const data = JSON.stringify(payload); + const sig = await this.sign(data); + const b64 = btoa(data); + + return `${b64}.${sig}`; + } + + private add(token: string, payload: TokenPayload) { + const expiry = Math.max(0, payload.exp - Date.now()); + expiry && setTimeout(() => this.consumed.delete(token), expiry); + this.consumed.add(token); + return payload; + } + + async validate(token: string): Promise { + if (this.consumed.has(token)) return; + + try { + const [b64, signature] = token.split("."); + + const data = atob(b64); + if (signature !== await this.sign(data)) { + return; + } + + const payload: TokenPayload = JSON.parse(data); + return Date.now() > payload.exp ? undefined : this.add(token, payload); + } catch { + // no-op + } + } + + private async sign(data: string): Promise { + const encoder = new TextEncoder(); + const keyData = encoder.encode(this.secretKey); + const messageData = encoder.encode(data); + + const key = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + const signature = await crypto.subtle.sign("HMAC", key, messageData); + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } +} + +export function getOrCreateSecretKey(): string { + if (Deno.env.get("SECRET_KEY")) return Deno.env.get("SECRET_KEY")!; + + const generatedKey = Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + console.warn( + "⚠️ No ,,SECRET_KEY`` found in environment. A temporary key has been generatd.\n" + + ` This key will change upon restart. Generated key: ${ + generatedKey.slice(0, 16) + }...`, + ); + + return generatedKey; +}