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] 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

View file

@ -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<any> {
const app = createRouter();
app.get("/", async (ctx) => {
const { stream: html } = ctx;
setupDeferredRoutes(app);
app.get("/", async (ctx) => {
await (
<>
<h1>My Todos</h1>
<Defer>
<TodoList />
</Defer>
<Defer>
<TodoList />
</Defer>
</>
)(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());

View file

@ -12,7 +12,6 @@ export interface StreamOptions {
export function chunked() {
const chunks = new ChunkedStream<string>();
const encoder = new TextEncoder();
return {
chunks,
stream: new ReadableStream<Uint8Array>({
@ -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,

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
*/
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<string, string> = {
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<DeferProps>;
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 = {
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<string>, data: string) =>
void (chunks && !chunks.closed && chunks.write(data));
async function render(
node: any,
chunks: ChunkedStream<string>,
): Promise<void> {
export async function render(node: any, ctx: Context): Promise<void> {
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<string>) => {
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<P extends Props = Props>(
props ??= {} as P;
if (key !== undefined) props.key = key;
return async (chunks: ChunkedStream<string>) => {
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, `</${tag}>`);
await render(children, data);
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 { Router } from "cobweb/routing";
export interface Context<Params = Record<string, string>> {
readonly request: Request;
readonly info: Deno.ServeHandlerInfo<Deno.NetAddr>;
readonly url: URL;
readonly method: string;
readonly params: Params;
readonly pattern: URLPatternResult;
readonly stream: DataStream;
readonly signal: AbortSignal;
readonly router: Router;
state: Map<string | symbol, unknown>;
}
export async function createContext<P = Record<string, string>>(
router: Router,
request: Request,
info: Deno.ServeHandlerInfo<Deno.NetAddr>,
pattern: URLPatternResult,
stream: DataStream,
): Promise<Context<P>> {
return {
router,
request,
info,
url: new URL(request.url),
method: request.method,
params: (pattern.pathname.groups || {}) as P,

View file

@ -62,10 +62,13 @@ interface BaseRouter {
handler: Handler<HandlerParams<P>>,
): P;
fetch: (request: Request) => Promise<Response>;
fetch: (
request: Request,
info: Deno.ServeHandlerInfo<Deno.NetAddr>,
) => Promise<Response>;
}
type Router =
export type Router =
& BaseRouter
& {
[M in Method as Lowercase<M>]: <
@ -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<P extends string | TypedURLPattern<any> | URLPattern>(
@ -108,7 +111,10 @@ export function createRouter(namespace?: string): Router {
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;
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<Method>;
(router as any)[lower] = <
(r as any)[lower] = <
P extends string | TypedURLPattern<any> | URLPattern,
>(
path: 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";

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;
}