iframe-based deferred rendering with tokenized isolation layer
This commit is contained in:
parent
1c361de610
commit
3ec65b9a0f
8 changed files with 308 additions and 90 deletions
11
README.md
11
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
|
||||
|
|
|
|||
27
example.tsx
27
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<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());
|
||||
|
|
|
|||
|
|
@ -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
142
src/isolation.ts
Normal 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>`,
|
||||
);
|
||||
}
|
||||
84
src/jsx.ts
84
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<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>`);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
98
src/token.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue