From b2b784e9a40183b59d2b4cea6fbc9c5aff4c203b Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Tue, 11 Nov 2025 15:31:45 +0100 Subject: [PATCH 1/5] feat: Add `.json()/.text()/.html()/.stream()` context helpers --- packages/fresh/src/context.ts | 168 +++++++++++++++++++++++++++- packages/fresh/src/context_test.tsx | 110 ++++++++++++++++++ packages/fresh/src/mod.ts | 2 +- packages/fresh/src/utils.ts | 19 ++++ 4 files changed, 293 insertions(+), 6 deletions(-) diff --git a/packages/fresh/src/context.ts b/packages/fresh/src/context.ts index 362a515a3a9..3ad244f2416 100644 --- a/packages/fresh/src/context.ts +++ b/packages/fresh/src/context.ts @@ -27,6 +27,7 @@ import { renderRouteComponent, } from "./render.ts"; import { renderToString } from "preact-render-to-string"; +import { isAsyncIterable, isIterable, isThenable } from "./utils.ts"; export interface Island { file: string; @@ -258,11 +259,7 @@ export class Context { appVNode = appChild ?? h(Fragment, null); } - const headers = init.headers !== undefined - ? init.headers instanceof Headers - ? init.headers - : new Headers(init.headers) - : new Headers(); + const headers = getHeadersFromInit(init); headers.set("Content-Type", "text/html; charset=utf-8"); const responseInit: ResponseInit = { @@ -376,4 +373,165 @@ export class Context { }); return new Response(html, responseInit); } + + /** + * Respond with text. Sets `Content-Type: text/plain`. + * ```tsx + * ctx.text("Hello World!"); + * ``` + */ + text(content: string, init?: ResponseInit) { + const headers = getHeadersFromInit(init); + headers.set("Content-Type", "text/plain; charset=utf-8"); + + return new Response(content, { ...init, headers }); + } + + /** + * Respond with html string. Sets `Content-Type: text/html`. + * ```tsx + * ctx.html("

foo

"); + * ``` + */ + html(content: string, init?: ResponseInit) { + const headers = getHeadersFromInit(init); + headers.set("Content-Type", "text/html; charset=utf-8"); + + return new Response(content, { ...init, headers }); + } + + /** + * Respond with json string, same as `Response.json()`. Sets + * `Content-Type: application/json`. + * ```tsx + * ctx.json({ foo: 123 }); + * ``` + */ + // deno-lint-ignore no-explicit-any + json(content: any, init?: ResponseInit) { + return Response.json(content, init); + } + + /** + * Helper to manually enqueue chunks. Encodes text automatically + * ```tsx + * ctx.stream((controller) => { + * controller.enqueue("foo"); + * controller.enqueue("bar"); + * }); + * ``` + * Async works too: + * + * ```tsx + * ctx.stream(async (controller, signal) => { + * controller.enqueue("foo"); + * await new Promise(r => setTimeout(r, 1000)); + * if (signal.aborted) return; + * controller.enqueue("bar"); + * }); + * ``` + * + * Can also be used with sync and async generator + * ```tsx + * ctx.stream(function* gen() { + * yield "foo"; + * yield "bar"; + * }); + * ``` + */ + stream( + stream: StreamFn, + init?: ResponseInit, + ): Response { + const body = runStreamFn(stream, this.req.signal); + return new Response(body, init); + } +} + +function getHeadersFromInit(init?: ResponseInit) { + if (init === undefined) { + return new Headers(); + } + + return init.headers !== undefined + ? init.headers instanceof Headers ? init.headers : new Headers(init.headers) + : new Headers(); +} + +export type StreamFn = ( + controller: ReadableStreamDefaultController, + signal: AbortSignal, +) => + | void + | Iterable + | Promise + | AsyncIterable; + +type ChunkEncodeFn = ( + controller: ReadableStreamDefaultController>, + chunk: T | undefined, +) => void; + +function runStreamFn( + fn: StreamFn, + signal: AbortSignal, +): ReadableStream> { + return new ReadableStream>({ + async start(controller) { + const wrapped = wrapStreamController( + controller, + enqueueEncodedChunk, + ); + const result = fn(wrapped, signal); + + if (isIterable(result)) { + for (const chunk of result) { + enqueueEncodedChunk(controller, chunk); + } + } else if (isAsyncIterable(result)) { + for await (const chunk of result) { + enqueueEncodedChunk(controller, chunk); + } + } else if (isThenable(result)) { + await result; + } + + controller.close(); + }, + }); +} + +function wrapStreamController( + controller: ReadableStreamDefaultController>, + encode: ChunkEncodeFn, +): ReadableStreamDefaultController { + return { + get desiredSize() { + return controller.desiredSize; + }, + close: () => controller.close, + enqueue(chunk) { + encode(controller, chunk); + }, + error: (err) => controller.error(err), + }; +} + +const ENCODER = new TextEncoder(); + +function enqueueEncodedChunk( + controller: ReadableStreamDefaultController>, + chunk: T | undefined, +) { + if (chunk === undefined) { + return controller.enqueue(undefined); + } + + if (chunk instanceof Uint8Array) { + // deno-lint-ignore no-explicit-any + controller.enqueue(chunk as any); + } else { + const raw = ENCODER.encode(String(chunk)); + controller.enqueue(raw); + } } diff --git a/packages/fresh/src/context_test.tsx b/packages/fresh/src/context_test.tsx index de94d900e6a..cf3a87c2d40 100644 --- a/packages/fresh/src/context_test.tsx +++ b/packages/fresh/src/context_test.tsx @@ -106,3 +106,113 @@ Deno.test("ctx.route - should contain matched route", async () => { await server.get("/foo/123"); expect(route).toEqual("/foo/:id"); }); + +Deno.test("ctx.text()", async () => { + const app = new App() + .get("/", (ctx) => ctx.text("foobar")); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + + expect(res.headers.get("Content-Type")).toEqual("text/plain; charset=utf-8"); + const text = await res.text(); + expect(text).toEqual("foobar"); +}); + +Deno.test("ctx.html()", async () => { + const app = new App() + .get("/", (ctx) => ctx.html("

foo

")); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + + expect(res.headers.get("Content-Type")).toEqual("text/html; charset=utf-8"); + const text = await res.text(); + expect(text).toEqual("

foo

"); +}); + +Deno.test("ctx.json()", async () => { + const app = new App() + .get("/", (ctx) => ctx.json({ foo: 123 })); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + + expect(res.headers.get("Content-Type")).toEqual("application/json"); + const text = await res.text(); + expect(text).toEqual('{"foo":123}'); +}); + +Deno.test("ctx.stream() - empty callback", async () => { + const app = new App() + .get("/", (ctx) => ctx.stream(() => {})); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const text = await res.text(); + expect(text).toEqual(""); +}); + +Deno.test("ctx.stream() - enqueue values", async () => { + const app = new App() + .get("/", (ctx) => + ctx.stream((controller) => { + controller.enqueue("foo"); + controller.enqueue(new TextEncoder().encode("bar")); + })); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const text = await res.text(); + expect(text).toEqual("foobar"); +}); + +Deno.test("ctx.stream() - enqueue sync", async () => { + const app = new App() + .get("/", (ctx) => + ctx.stream((controller) => { + controller.enqueue("foo"); + controller.enqueue("bar"); + })); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const text = await res.text(); + expect(text).toEqual("foobar"); +}); + +Deno.test("ctx.stream() - enqueue async", async () => { + const app = new App() + .get("/", (ctx) => + ctx.stream(async (controller) => { + controller.enqueue("foo"); + await new Promise((r) => setTimeout(r, 50)); + controller.enqueue("bar"); + })); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const text = await res.text(); + expect(text).toEqual("foobar"); +}); + +Deno.test("ctx.stream() - support cancelling stream", async () => { + const app = new App() + .get("/", (ctx) => + ctx.stream(async (controller, signal) => { + controller.enqueue("foo"); + await new Promise((r) => setTimeout(r, 300)); + if (signal.aborted) return; + controller.enqueue("bar"); + })); + + const server = new FakeServer(app.handler()); + const abort = new AbortController(); + const res = await server.get("/", { signal: abort.signal }); + + const p = res.text(); + await new Promise((r) => setTimeout(r, 100)); + abort.abort(); + + expect(await p).toEqual("foo"); +}); diff --git a/packages/fresh/src/mod.ts b/packages/fresh/src/mod.ts index 283ed19ff92..865ad439a2b 100644 --- a/packages/fresh/src/mod.ts +++ b/packages/fresh/src/mod.ts @@ -15,7 +15,7 @@ export { csrf, type CsrfOptions } from "./middlewares/csrf.ts"; export { cors, type CORSOptions } from "./middlewares/cors.ts"; export { csp, type CSPOptions } from "./middlewares/csp.ts"; export type { FreshConfig, ResolvedFreshConfig } from "./config.ts"; -export type { Context, FreshContext, Island } from "./context.ts"; +export type { Context, FreshContext, Island, StreamFn } from "./context.ts"; export { createDefine, type Define } from "./define.ts"; export type { Method } from "./router.ts"; export { HttpError } from "./error.ts"; diff --git a/packages/fresh/src/utils.ts b/packages/fresh/src/utils.ts index 96952e34f9e..fbad7b6bd52 100644 --- a/packages/fresh/src/utils.ts +++ b/packages/fresh/src/utils.ts @@ -283,3 +283,22 @@ function maybeDot(spec: string): string { export function isLazy(value: MaybeLazy): value is Lazy { return typeof value === "function"; } + +export function isThenable(value: unknown): value is Promise { + return value !== null && typeof value === "object" && "then" in value && + typeof value.then === "function"; +} + +export function isIterable(value: unknown): value is Iterable { + return value !== null && typeof value === "object" && + Symbol.iterator in value && + typeof value[Symbol.iterator] === "function"; +} + +export function isAsyncIterable( + value: unknown, +): value is AsyncIterable { + return value !== null && typeof value === "object" && + Symbol.asyncIterator in value && + typeof value[Symbol.asyncIterator] === "function"; +} From 9721e9855f3939e46fba6651961d86c1654bf380 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Tue, 11 Nov 2025 16:00:24 +0100 Subject: [PATCH 2/5] lint --- packages/fresh/src/context.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/fresh/src/context.ts b/packages/fresh/src/context.ts index 3ad244f2416..aef0fe80649 100644 --- a/packages/fresh/src/context.ts +++ b/packages/fresh/src/context.ts @@ -380,7 +380,7 @@ export class Context { * ctx.text("Hello World!"); * ``` */ - text(content: string, init?: ResponseInit) { + text(content: string, init?: ResponseInit): Response { const headers = getHeadersFromInit(init); headers.set("Content-Type", "text/plain; charset=utf-8"); @@ -393,7 +393,7 @@ export class Context { * ctx.html("

foo

"); * ``` */ - html(content: string, init?: ResponseInit) { + html(content: string, init?: ResponseInit): Response { const headers = getHeadersFromInit(init); headers.set("Content-Type", "text/html; charset=utf-8"); @@ -408,7 +408,7 @@ export class Context { * ``` */ // deno-lint-ignore no-explicit-any - json(content: any, init?: ResponseInit) { + json(content: any, init?: ResponseInit): Response { return Response.json(content, init); } From 325f476cd7988b097b40b3b936a3bc1e249170b7 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Tue, 11 Nov 2025 16:19:11 +0100 Subject: [PATCH 3/5] feedback --- packages/fresh/src/context.ts | 11 ++++------- packages/fresh/src/context_test.tsx | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/fresh/src/context.ts b/packages/fresh/src/context.ts index aef0fe80649..a9176847f6f 100644 --- a/packages/fresh/src/context.ts +++ b/packages/fresh/src/context.ts @@ -377,20 +377,17 @@ export class Context { /** * Respond with text. Sets `Content-Type: text/plain`. * ```tsx - * ctx.text("Hello World!"); + * app.use(ctx => ctx.text("Hello World!")); * ``` */ text(content: string, init?: ResponseInit): Response { - const headers = getHeadersFromInit(init); - headers.set("Content-Type", "text/plain; charset=utf-8"); - - return new Response(content, { ...init, headers }); + return new Response(content, init); } /** * Respond with html string. Sets `Content-Type: text/html`. * ```tsx - * ctx.html("

foo

"); + * app.get("/", ctx => ctx.html("

foo

")); * ``` */ html(content: string, init?: ResponseInit): Response { @@ -404,7 +401,7 @@ export class Context { * Respond with json string, same as `Response.json()`. Sets * `Content-Type: application/json`. * ```tsx - * ctx.json({ foo: 123 }); + * app.get("/", ctx => ctx.json({ foo: 123 })); * ``` */ // deno-lint-ignore no-explicit-any diff --git a/packages/fresh/src/context_test.tsx b/packages/fresh/src/context_test.tsx index cf3a87c2d40..83b7cf64f99 100644 --- a/packages/fresh/src/context_test.tsx +++ b/packages/fresh/src/context_test.tsx @@ -114,7 +114,7 @@ Deno.test("ctx.text()", async () => { const server = new FakeServer(app.handler()); const res = await server.get("/"); - expect(res.headers.get("Content-Type")).toEqual("text/plain; charset=utf-8"); + expect(res.headers.get("Content-Type")).toEqual("text/plain;charset=UTF-8"); const text = await res.text(); expect(text).toEqual("foobar"); }); From cc043c7d3ff8ccd44c8848991c7e6dd23002856c Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Tue, 11 Nov 2025 16:42:48 +0100 Subject: [PATCH 4/5] feedback2 --- packages/fresh/src/context.ts | 75 +++++++++++++++-------------- packages/fresh/src/context_test.tsx | 69 +++++++++++--------------- 2 files changed, 68 insertions(+), 76 deletions(-) diff --git a/packages/fresh/src/context.ts b/packages/fresh/src/context.ts index a9176847f6f..0f57c8f6073 100644 --- a/packages/fresh/src/context.ts +++ b/packages/fresh/src/context.ts @@ -410,37 +410,55 @@ export class Context { } /** - * Helper to manually enqueue chunks. Encodes text automatically - * ```tsx - * ctx.stream((controller) => { - * controller.enqueue("foo"); - * controller.enqueue("bar"); - * }); - * ``` - * Async works too: + * Helper to stream a sync or async iterable and encode text + * automatically. * * ```tsx - * ctx.stream(async (controller, signal) => { - * controller.enqueue("foo"); - * await new Promise(r => setTimeout(r, 1000)); - * if (signal.aborted) return; - * controller.enqueue("bar"); - * }); + * function* gen() { + * yield "foo"; + * yield "bar"; + * } + * + * app.use(ctx => ctx.stream(gen())) * ``` * - * Can also be used with sync and async generator + * Or pass in the function directly: + * * ```tsx - * ctx.stream(function* gen() { - * yield "foo"; - * yield "bar"; - * }); + * app.use(ctx => { + * return ctx.stream(function* gen() { + * yield "foo"; + * yield "bar"; + * }); + * ); * ``` */ stream( - stream: StreamFn, + stream: + | Iterable + | AsyncIterable + | (() => Iterable | AsyncIterable), init?: ResponseInit, ): Response { - const body = runStreamFn(stream, this.req.signal); + const raw = typeof stream === "function" ? stream() : stream; + + const body = ReadableStream.from(raw) + .pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (chunk instanceof Uint8Array) { + // deno-lint-ignore no-explicit-any + controller.enqueue(chunk as any); + } else if (chunk === undefined) { + controller.enqueue(undefined); + } else { + const raw = ENCODER.encode(String(chunk)); + controller.enqueue(raw); + } + }, + }), + ); + return new Response(body, init); } } @@ -479,20 +497,7 @@ function runStreamFn( controller, enqueueEncodedChunk, ); - const result = fn(wrapped, signal); - - if (isIterable(result)) { - for (const chunk of result) { - enqueueEncodedChunk(controller, chunk); - } - } else if (isAsyncIterable(result)) { - for await (const chunk of result) { - enqueueEncodedChunk(controller, chunk); - } - } else if (isThenable(result)) { - await result; - } - + await fn(wrapped, signal); controller.close(); }, }); diff --git a/packages/fresh/src/context_test.tsx b/packages/fresh/src/context_test.tsx index 83b7cf64f99..b39dac1eb11 100644 --- a/packages/fresh/src/context_test.tsx +++ b/packages/fresh/src/context_test.tsx @@ -143,23 +143,14 @@ Deno.test("ctx.json()", async () => { expect(text).toEqual('{"foo":123}'); }); -Deno.test("ctx.stream() - empty callback", async () => { - const app = new App() - .get("/", (ctx) => ctx.stream(() => {})); - - const server = new FakeServer(app.handler()); - const res = await server.get("/"); - const text = await res.text(); - expect(text).toEqual(""); -}); - Deno.test("ctx.stream() - enqueue values", async () => { + function* gen() { + yield "foo"; + yield new TextEncoder().encode("bar"); + } + const app = new App() - .get("/", (ctx) => - ctx.stream((controller) => { - controller.enqueue("foo"); - controller.enqueue(new TextEncoder().encode("bar")); - })); + .get("/", (ctx) => ctx.stream(gen())); const server = new FakeServer(app.handler()); const res = await server.get("/"); @@ -167,12 +158,12 @@ Deno.test("ctx.stream() - enqueue values", async () => { expect(text).toEqual("foobar"); }); -Deno.test("ctx.stream() - enqueue sync", async () => { +Deno.test("ctx.stream() - pass function", async () => { const app = new App() .get("/", (ctx) => - ctx.stream((controller) => { - controller.enqueue("foo"); - controller.enqueue("bar"); + ctx.stream(function* () { + yield "foo"; + yield "bar"; })); const server = new FakeServer(app.handler()); @@ -181,38 +172,34 @@ Deno.test("ctx.stream() - enqueue sync", async () => { expect(text).toEqual("foobar"); }); -Deno.test("ctx.stream() - enqueue async", async () => { +Deno.test("ctx.stream() - support iterable", async () => { + function* gen() { + yield "foo"; + yield "bar"; + } + const app = new App() - .get("/", (ctx) => - ctx.stream(async (controller) => { - controller.enqueue("foo"); - await new Promise((r) => setTimeout(r, 50)); - controller.enqueue("bar"); - })); + .get("/", (ctx) => ctx.stream(gen())); const server = new FakeServer(app.handler()); const res = await server.get("/"); const text = await res.text(); + expect(text).toEqual("foobar"); }); -Deno.test("ctx.stream() - support cancelling stream", async () => { +Deno.test("ctx.stream() - support async iterable", async () => { + async function* gen() { + yield "foo"; + yield "bar"; + } + const app = new App() - .get("/", (ctx) => - ctx.stream(async (controller, signal) => { - controller.enqueue("foo"); - await new Promise((r) => setTimeout(r, 300)); - if (signal.aborted) return; - controller.enqueue("bar"); - })); + .get("/", (ctx) => ctx.stream(gen())); const server = new FakeServer(app.handler()); - const abort = new AbortController(); - const res = await server.get("/", { signal: abort.signal }); - - const p = res.text(); - await new Promise((r) => setTimeout(r, 100)); - abort.abort(); + const res = await server.get("/"); + const text = await res.text(); - expect(await p).toEqual("foo"); + expect(text).toEqual("foobar"); }); From 39fee320af71c726cc4214c4462f1c5b5d070991 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Tue, 11 Nov 2025 16:45:06 +0100 Subject: [PATCH 5/5] feedback --- packages/fresh/src/context.ts | 68 ++--------------------------------- packages/fresh/src/mod.ts | 2 +- packages/fresh/src/utils.ts | 19 ---------- 3 files changed, 3 insertions(+), 86 deletions(-) diff --git a/packages/fresh/src/context.ts b/packages/fresh/src/context.ts index 0f57c8f6073..3e1e59c164d 100644 --- a/packages/fresh/src/context.ts +++ b/packages/fresh/src/context.ts @@ -27,7 +27,8 @@ import { renderRouteComponent, } from "./render.ts"; import { renderToString } from "preact-render-to-string"; -import { isAsyncIterable, isIterable, isThenable } from "./utils.ts"; + +const ENCODER = new TextEncoder(); export interface Island { file: string; @@ -472,68 +473,3 @@ function getHeadersFromInit(init?: ResponseInit) { ? init.headers instanceof Headers ? init.headers : new Headers(init.headers) : new Headers(); } - -export type StreamFn = ( - controller: ReadableStreamDefaultController, - signal: AbortSignal, -) => - | void - | Iterable - | Promise - | AsyncIterable; - -type ChunkEncodeFn = ( - controller: ReadableStreamDefaultController>, - chunk: T | undefined, -) => void; - -function runStreamFn( - fn: StreamFn, - signal: AbortSignal, -): ReadableStream> { - return new ReadableStream>({ - async start(controller) { - const wrapped = wrapStreamController( - controller, - enqueueEncodedChunk, - ); - await fn(wrapped, signal); - controller.close(); - }, - }); -} - -function wrapStreamController( - controller: ReadableStreamDefaultController>, - encode: ChunkEncodeFn, -): ReadableStreamDefaultController { - return { - get desiredSize() { - return controller.desiredSize; - }, - close: () => controller.close, - enqueue(chunk) { - encode(controller, chunk); - }, - error: (err) => controller.error(err), - }; -} - -const ENCODER = new TextEncoder(); - -function enqueueEncodedChunk( - controller: ReadableStreamDefaultController>, - chunk: T | undefined, -) { - if (chunk === undefined) { - return controller.enqueue(undefined); - } - - if (chunk instanceof Uint8Array) { - // deno-lint-ignore no-explicit-any - controller.enqueue(chunk as any); - } else { - const raw = ENCODER.encode(String(chunk)); - controller.enqueue(raw); - } -} diff --git a/packages/fresh/src/mod.ts b/packages/fresh/src/mod.ts index 865ad439a2b..283ed19ff92 100644 --- a/packages/fresh/src/mod.ts +++ b/packages/fresh/src/mod.ts @@ -15,7 +15,7 @@ export { csrf, type CsrfOptions } from "./middlewares/csrf.ts"; export { cors, type CORSOptions } from "./middlewares/cors.ts"; export { csp, type CSPOptions } from "./middlewares/csp.ts"; export type { FreshConfig, ResolvedFreshConfig } from "./config.ts"; -export type { Context, FreshContext, Island, StreamFn } from "./context.ts"; +export type { Context, FreshContext, Island } from "./context.ts"; export { createDefine, type Define } from "./define.ts"; export type { Method } from "./router.ts"; export { HttpError } from "./error.ts"; diff --git a/packages/fresh/src/utils.ts b/packages/fresh/src/utils.ts index fbad7b6bd52..96952e34f9e 100644 --- a/packages/fresh/src/utils.ts +++ b/packages/fresh/src/utils.ts @@ -283,22 +283,3 @@ function maybeDot(spec: string): string { export function isLazy(value: MaybeLazy): value is Lazy { return typeof value === "function"; } - -export function isThenable(value: unknown): value is Promise { - return value !== null && typeof value === "object" && "then" in value && - typeof value.then === "function"; -} - -export function isIterable(value: unknown): value is Iterable { - return value !== null && typeof value === "object" && - Symbol.iterator in value && - typeof value[Symbol.iterator] === "function"; -} - -export function isAsyncIterable( - value: unknown, -): value is AsyncIterable { - return value !== null && typeof value === "object" && - Symbol.asyncIterator in value && - typeof value[Symbol.asyncIterator] === "function"; -}