iframe-based deferred rendering with tokenized isolation layer

This commit is contained in:
laura 2025-11-08 18:34:17 -03:00
parent 1c361de610
commit 3ec65b9a0f
Signed by: w
GPG key ID: BCD2117C99E69817
8 changed files with 308 additions and 90 deletions

View file

@ -8,11 +8,14 @@ applications
- [x] type-safe routing - [x] type-safe routing
- [x] html streaming support - [x] html streaming support
- [x] jsx runtime - [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 - [ ] intutive high-level apis
- [ ] safely defer html streams - [ ] scoped css-in-js library
- [ ] isolated deferred rendering through iframes
- [ ] scoped css through shadow dom
- [ ] css-in-js library
- [ ] interactives structures and dynamic data visibility toggling via modern - [ ] interactives structures and dynamic data visibility toggling via modern
css features css features
- [ ] only use html streaming shenanigans for noscript environments - [ ] only use html streaming shenanigans for noscript environments

View file

@ -5,7 +5,7 @@
import { createRouter } from "cobweb/routing"; import { createRouter } from "cobweb/routing";
import { Defer } from "cobweb/jsx-runtime"; import { Defer } from "cobweb/jsx-runtime";
import { computed, effect, signal } from "cobweb/signals.ts"; import { setupDeferredRoutes } from "cobweb/isolation.ts";
interface Todo { interface Todo {
id: string; id: string;
@ -42,19 +42,23 @@ const TodoList = async function* (): AsyncGenerator<any> {
const app = createRouter(); const app = createRouter();
app.get("/", async (ctx) => { setupDeferredRoutes(app);
const { stream: html } = ctx;
app.get("/", async (ctx) => {
await ( await (
<> <>
<h1>My Todos</h1> <h1>My Todos</h1>
<Defer> <Defer>
<TodoList /> <TodoList />
</Defer> </Defer>
<Defer>
<TodoList />
</Defer>
</> </>
)(html.chunks); )(ctx);
return html.response; ctx.stream.close();
return ctx.stream.response;
}); });
app.get("/meow/:test?", async (ctx) => { app.get("/meow/:test?", async (ctx) => {
@ -62,16 +66,3 @@ app.get("/meow/:test?", async (ctx) => {
}); });
Deno.serve({ port: 8000 }, app.fetch); 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());

View file

@ -12,7 +12,6 @@ export interface StreamOptions {
export function chunked() { export function chunked() {
const chunks = new ChunkedStream<string>(); const chunks = new ChunkedStream<string>();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
return { return {
chunks, chunks,
stream: new ReadableStream<Uint8Array>({ stream: new ReadableStream<Uint8Array>({
@ -53,8 +52,8 @@ export async function createDataStream(
return { return {
blob: stream, blob: stream,
chunks, chunks,
close: chunks.close, close: () => chunks.close(),
error: chunks.error, error: (e) => chunks.error(e),
response: new Response(stream, { response: new Response(stream, {
headers: { headers: {
"Content-Type": options.contentType, "Content-Type": options.contentType,

142
src/isolation.ts Normal file
View file

@ -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<string> => {
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<void>;
}
class DeferredRegistry {
private readonly components = new Map<string, DeferredComponent>();
register(
resource: string,
render: (ctx: Context) => Promise<void>,
): 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(
"<div>⚠️ something went wrong</div>",
{
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(`<div>⚠️ something went wrong</div>`);
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(
`<iframe src="${path}" loading="lazy" frameborder="0" sandbox="allow-same-origin allow-scripts" referrerpolicy="no-referrer">
<noscript><a href="${path}">Load deferred content</a></noscript></iframe>`,
);
}

View file

@ -3,7 +3,9 @@
* SPDX-License-Identifier: BSD-3-Clause * SPDX-License-Identifier: BSD-3-Clause
*/ */
import { Context } from "cobweb/routing";
import { ChunkedStream } from "./stream.ts"; import { ChunkedStream } from "./stream.ts";
import { $defer } from "./isolation.ts";
// deno-fmt-ignore // deno-fmt-ignore
export const voidTags = new Set([ export const voidTags = new Set([
@ -18,11 +20,11 @@ const ESC_LUT: Record<string, string> = {
const ESC_RE = /[&<>"']/g; const ESC_RE = /[&<>"']/g;
export const Fragment = Symbol("jsx.fragment") as any as JsxElement; 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<DeferProps>;
export type Component<P = Props> = (props: P) => JsxElement; export type Component<P = Props> = (props: P) => JsxElement;
export type JsxElement = (chunks: ChunkedStream<string>) => Promise<void>; export type JsxElement = (ctx: Context) => Promise<void>;
type Props = { type Props = {
children?: JsxElement; children?: JsxElement;
@ -30,9 +32,10 @@ type Props = {
[key: string]: unknown; [key: string]: unknown;
}; };
interface DeferProps { export interface DeferProps {
fallback?: JsxElement;
children: JsxElement; children: JsxElement;
authority?: string;
ttl?: number;
} }
export const jsxEscape = (input: string): string => export const jsxEscape = (input: string): string =>
@ -48,36 +51,35 @@ export const jsxAttr = (k: string, v: unknown) =>
const emit = (chunks: ChunkedStream<string>, data: string) => const emit = (chunks: ChunkedStream<string>, data: string) =>
void (chunks && !chunks.closed && chunks.write(data)); void (chunks && !chunks.closed && chunks.write(data));
async function render( export async function render(node: any, ctx: Context): Promise<void> {
node: any,
chunks: ChunkedStream<string>,
): Promise<void> {
if (node == null || typeof node === "boolean") return; if (node == null || typeof node === "boolean") return;
if (typeof node === "string") return emit(chunks, node); if (typeof node === "string") return emit(ctx.stream.chunks, node);
if (typeof node === "function") return node(chunks); if (typeof node === "function") {
if (node instanceof Promise) return render(await node, chunks); return node(ctx);
}
if (node instanceof Promise) return render(await node, ctx);
if (Array.isArray(node)) { if (Array.isArray(node)) {
for (const item of node) await render(item, chunks); for (const item of node) await render(item, ctx);
return; return;
} }
if (typeof node === "object" && Symbol.asyncIterator in node) { 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; return;
} }
emit(chunks, escape(String(node))); emit(ctx.stream.chunks, jsxEscape(String(node)));
} }
export function jsxTemplate( export function jsxTemplate(
template: string[], template: string[],
...values: unknown[] ...values: unknown[]
): JsxElement { ): JsxElement {
return async (chunks: ChunkedStream<string>) => { return async (ctx: Context) => {
for (let i = 0; i < template.length; i++) { for (let i = 0; i < template.length; i++) {
emit(chunks, template[i]); emit(ctx.stream.chunks, template[i]);
i < values.length && await render(values[i], chunks); i < values.length && await render(values[i], ctx);
} }
}; };
} }
@ -90,67 +92,37 @@ export function jsx<P extends Props = Props>(
props ??= {} as P; props ??= {} as P;
if (key !== undefined) props.key = key; if (key !== undefined) props.key = key;
return async (chunks: ChunkedStream<string>) => { return async (data: Context) => {
const { children, key: _, ...attrs } = props; const { children, key: _, ...attrs } = props;
if (tag === Fragment) { if (tag === Fragment) {
for (const child of Array.isArray(children) ? children : [children]) { for (const child of Array.isArray(children) ? children : [children]) {
await render(child, chunks); await render(child, data);
return; return;
} }
} }
if (tag === Defer) { if (tag === Defer) {
return defer(chunks, props as DeferProps); return $defer(data, props as DeferProps);
} }
if (typeof tag === "function") { if (typeof tag === "function") {
const result = await (tag as any)(props); const result = await (tag as any)(props);
return render(result, chunks); return render(result, data);
} }
const isVoid = voidTags.has(tag); const isVoid = voidTags.has(tag);
emit(chunks, `<${tag}`); emit(data.stream.chunks, `<${tag}`);
for (const name in attrs) { for (const name in attrs) {
const value = (attrs as any)[name]; 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) { if (!isVoid) {
await render(children, chunks); await render(children, data);
emit(chunks, `</${tag}>`); emit(data.stream.chunks, `</${tag}>`);
} }
}; };
} }
async function defer(
chunks: ChunkedStream<string>,
{ fallback, children }: DeferProps,
) {
const id = `deferred-${Math.random().toString(36).slice(2, 10)}`;
emit(chunks, `<div id="${id}">`);
await render(fallback, chunks);
emit(chunks, `</div>`);
Promise.resolve(children).then(async (resolved) => {
const buffer = new ChunkedStream<string>();
await render(resolved, buffer);
buffer.close();
const content: string[] = [];
for await (const chunk of buffer) content.push(chunk);
emit(
chunks,
`<div id="${id}"><template shadowrootmode="open">${
content.join("")
}</template></div>`,
);
}).catch((err) => {
console.error("defer error:", err);
emit(chunks, `<div>⚠️ something went wrong</div>`);
});
}

View file

@ -4,25 +4,32 @@
*/ */
import { DataStream } from "./http.ts"; import { DataStream } from "./http.ts";
import { Router } from "cobweb/routing";
export interface Context<Params = Record<string, string>> { export interface Context<Params = Record<string, string>> {
readonly request: Request; readonly request: Request;
readonly info: Deno.ServeHandlerInfo<Deno.NetAddr>;
readonly url: URL; readonly url: URL;
readonly method: string; readonly method: string;
readonly params: Params; readonly params: Params;
readonly pattern: URLPatternResult; readonly pattern: URLPatternResult;
readonly stream: DataStream; readonly stream: DataStream;
readonly signal: AbortSignal; readonly signal: AbortSignal;
readonly router: Router;
state: Map<string | symbol, unknown>; state: Map<string | symbol, unknown>;
} }
export async function createContext<P = Record<string, string>>( export async function createContext<P = Record<string, string>>(
router: Router,
request: Request, request: Request,
info: Deno.ServeHandlerInfo<Deno.NetAddr>,
pattern: URLPatternResult, pattern: URLPatternResult,
stream: DataStream, stream: DataStream,
): Promise<Context<P>> { ): Promise<Context<P>> {
return { return {
router,
request, request,
info,
url: new URL(request.url), url: new URL(request.url),
method: request.method, method: request.method,
params: (pattern.pathname.groups || {}) as P, params: (pattern.pathname.groups || {}) as P,

View file

@ -62,10 +62,13 @@ interface BaseRouter {
handler: Handler<HandlerParams<P>>, handler: Handler<HandlerParams<P>>,
): P; ): P;
fetch: (request: Request) => Promise<Response>; fetch: (
request: Request,
info: Deno.ServeHandlerInfo<Deno.NetAddr>,
) => Promise<Response>;
} }
type Router = export type Router =
& BaseRouter & BaseRouter
& { & {
[M in Method as Lowercase<M>]: < [M in Method as Lowercase<M>]: <
@ -80,14 +83,14 @@ export function createRouter(namespace?: string): Router {
const routes: Route[] = []; const routes: Route[] = [];
const middlewares: Middleware[] = []; const middlewares: Middleware[] = [];
const router: BaseRouter = { const r: BaseRouter = {
routes, routes,
middlewares, middlewares,
namespace, namespace,
use(...mw: Middleware[]) { use(...mw: Middleware[]) {
middlewares.push(...mw); middlewares.push(...mw);
return router; return r;
}, },
on<P extends string | TypedURLPattern<any> | URLPattern>( on<P extends string | TypedURLPattern<any> | URLPattern>(
@ -108,7 +111,10 @@ export function createRouter(namespace?: string): Router {
return path; return path;
}, },
async fetch(request: Request): Promise<Response> { async fetch(
request: Request,
info: Deno.ServeHandlerInfo<Deno.NetAddr>,
): Promise<Response> {
const method = request.method.toUpperCase() as Method; const method = request.method.toUpperCase() as Method;
for (const route of routes) { for (const route of routes) {
@ -118,7 +124,7 @@ export function createRouter(namespace?: string): Router {
if (!match) continue; if (!match) continue;
const stream = await createDataStream(); const stream = await createDataStream();
const ctx = await createContext(request, match, stream); const ctx = await createContext(r as any, request, info, match, stream);
return ( return (
(await compose(middlewares, route.handler)(ctx)) || (await compose(middlewares, route.handler)(ctx)) ||
@ -136,16 +142,16 @@ export function createRouter(namespace?: string): Router {
["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].forEach( ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].forEach(
(method) => { (method) => {
const lower = method.toLowerCase() as Lowercase<Method>; const lower = method.toLowerCase() as Lowercase<Method>;
(router as any)[lower] = < (r as any)[lower] = <
P extends string | TypedURLPattern<any> | URLPattern, P extends string | TypedURLPattern<any> | URLPattern,
>( >(
path: P, path: P,
handler: Handler<HandlerParams<P>>, handler: Handler<HandlerParams<P>>,
) => 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"; export * from "./middleware.ts";

98
src/token.ts Normal file
View file

@ -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<string>();
constructor(secretKey: string = getOrCreateSecretKey()) {
this.secretKey = secretKey;
}
async generate(
authority: string,
resource: string,
ttlMs: number,
): Promise<string> {
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<TokenPayload | undefined> {
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<string> {
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;
}