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] 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
|
||||||
|
|
|
||||||
27
example.tsx
27
example.tsx
|
|
@ -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());
|
|
||||||
|
|
|
||||||
|
|
@ -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
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
|
* 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>`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
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