Skip to content

Commit 8de5b15

Browse files
feat: add .json()/.text()/.html()/.stream() context helpers (#3613)
Played around with Hono in the past days and the `.text()/.json()/.html()` helpers are kinda neat, even if you can always construct that yourself manually. Also added a `.stream()` helper to create streams without having to deal with `ReadableStream` and encodings yourself.
1 parent fd7ecec commit 8de5b15

File tree

2 files changed

+198
-5
lines changed

2 files changed

+198
-5
lines changed

packages/fresh/src/context.ts

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
} from "./render.ts";
2929
import { renderToString } from "preact-render-to-string";
3030

31+
const ENCODER = new TextEncoder();
32+
3133
export interface Island {
3234
file: string;
3335
name: string;
@@ -258,11 +260,7 @@ export class Context<State> {
258260
appVNode = appChild ?? h(Fragment, null);
259261
}
260262

261-
const headers = init.headers !== undefined
262-
? init.headers instanceof Headers
263-
? init.headers
264-
: new Headers(init.headers)
265-
: new Headers();
263+
const headers = getHeadersFromInit(init);
266264

267265
headers.set("Content-Type", "text/html; charset=utf-8");
268266
const responseInit: ResponseInit = {
@@ -376,4 +374,102 @@ export class Context<State> {
376374
});
377375
return new Response(html, responseInit);
378376
}
377+
378+
/**
379+
* Respond with text. Sets `Content-Type: text/plain`.
380+
* ```tsx
381+
* app.use(ctx => ctx.text("Hello World!"));
382+
* ```
383+
*/
384+
text(content: string, init?: ResponseInit): Response {
385+
return new Response(content, init);
386+
}
387+
388+
/**
389+
* Respond with html string. Sets `Content-Type: text/html`.
390+
* ```tsx
391+
* app.get("/", ctx => ctx.html("<h1>foo</h1>"));
392+
* ```
393+
*/
394+
html(content: string, init?: ResponseInit): Response {
395+
const headers = getHeadersFromInit(init);
396+
headers.set("Content-Type", "text/html; charset=utf-8");
397+
398+
return new Response(content, { ...init, headers });
399+
}
400+
401+
/**
402+
* Respond with json string, same as `Response.json()`. Sets
403+
* `Content-Type: application/json`.
404+
* ```tsx
405+
* app.get("/", ctx => ctx.json({ foo: 123 }));
406+
* ```
407+
*/
408+
// deno-lint-ignore no-explicit-any
409+
json(content: any, init?: ResponseInit): Response {
410+
return Response.json(content, init);
411+
}
412+
413+
/**
414+
* Helper to stream a sync or async iterable and encode text
415+
* automatically.
416+
*
417+
* ```tsx
418+
* function* gen() {
419+
* yield "foo";
420+
* yield "bar";
421+
* }
422+
*
423+
* app.use(ctx => ctx.stream(gen()))
424+
* ```
425+
*
426+
* Or pass in the function directly:
427+
*
428+
* ```tsx
429+
* app.use(ctx => {
430+
* return ctx.stream(function* gen() {
431+
* yield "foo";
432+
* yield "bar";
433+
* });
434+
* );
435+
* ```
436+
*/
437+
stream<U extends string | Uint8Array>(
438+
stream:
439+
| Iterable<U>
440+
| AsyncIterable<U>
441+
| (() => Iterable<U> | AsyncIterable<U>),
442+
init?: ResponseInit,
443+
): Response {
444+
const raw = typeof stream === "function" ? stream() : stream;
445+
446+
const body = ReadableStream.from(raw)
447+
.pipeThrough(
448+
new TransformStream({
449+
transform(chunk, controller) {
450+
if (chunk instanceof Uint8Array) {
451+
// deno-lint-ignore no-explicit-any
452+
controller.enqueue(chunk as any);
453+
} else if (chunk === undefined) {
454+
controller.enqueue(undefined);
455+
} else {
456+
const raw = ENCODER.encode(String(chunk));
457+
controller.enqueue(raw);
458+
}
459+
},
460+
}),
461+
);
462+
463+
return new Response(body, init);
464+
}
465+
}
466+
467+
function getHeadersFromInit(init?: ResponseInit) {
468+
if (init === undefined) {
469+
return new Headers();
470+
}
471+
472+
return init.headers !== undefined
473+
? init.headers instanceof Headers ? init.headers : new Headers(init.headers)
474+
: new Headers();
379475
}

packages/fresh/src/context_test.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,100 @@ Deno.test("ctx.route - should contain matched route", async () => {
106106
await server.get("/foo/123");
107107
expect(route).toEqual("/foo/:id");
108108
});
109+
110+
Deno.test("ctx.text()", async () => {
111+
const app = new App()
112+
.get("/", (ctx) => ctx.text("foobar"));
113+
114+
const server = new FakeServer(app.handler());
115+
const res = await server.get("/");
116+
117+
expect(res.headers.get("Content-Type")).toEqual("text/plain;charset=UTF-8");
118+
const text = await res.text();
119+
expect(text).toEqual("foobar");
120+
});
121+
122+
Deno.test("ctx.html()", async () => {
123+
const app = new App()
124+
.get("/", (ctx) => ctx.html("<h1>foo</h1>"));
125+
126+
const server = new FakeServer(app.handler());
127+
const res = await server.get("/");
128+
129+
expect(res.headers.get("Content-Type")).toEqual("text/html; charset=utf-8");
130+
const text = await res.text();
131+
expect(text).toEqual("<h1>foo</h1>");
132+
});
133+
134+
Deno.test("ctx.json()", async () => {
135+
const app = new App()
136+
.get("/", (ctx) => ctx.json({ foo: 123 }));
137+
138+
const server = new FakeServer(app.handler());
139+
const res = await server.get("/");
140+
141+
expect(res.headers.get("Content-Type")).toEqual("application/json");
142+
const text = await res.text();
143+
expect(text).toEqual('{"foo":123}');
144+
});
145+
146+
Deno.test("ctx.stream() - enqueue values", async () => {
147+
function* gen() {
148+
yield "foo";
149+
yield new TextEncoder().encode("bar");
150+
}
151+
152+
const app = new App()
153+
.get("/", (ctx) => ctx.stream(gen()));
154+
155+
const server = new FakeServer(app.handler());
156+
const res = await server.get("/");
157+
const text = await res.text();
158+
expect(text).toEqual("foobar");
159+
});
160+
161+
Deno.test("ctx.stream() - pass function", async () => {
162+
const app = new App()
163+
.get("/", (ctx) =>
164+
ctx.stream(function* () {
165+
yield "foo";
166+
yield "bar";
167+
}));
168+
169+
const server = new FakeServer(app.handler());
170+
const res = await server.get("/");
171+
const text = await res.text();
172+
expect(text).toEqual("foobar");
173+
});
174+
175+
Deno.test("ctx.stream() - support iterable", async () => {
176+
function* gen() {
177+
yield "foo";
178+
yield "bar";
179+
}
180+
181+
const app = new App()
182+
.get("/", (ctx) => ctx.stream(gen()));
183+
184+
const server = new FakeServer(app.handler());
185+
const res = await server.get("/");
186+
const text = await res.text();
187+
188+
expect(text).toEqual("foobar");
189+
});
190+
191+
Deno.test("ctx.stream() - support async iterable", async () => {
192+
async function* gen() {
193+
yield "foo";
194+
yield "bar";
195+
}
196+
197+
const app = new App()
198+
.get("/", (ctx) => ctx.stream(gen()));
199+
200+
const server = new FakeServer(app.handler());
201+
const res = await server.get("/");
202+
const text = await res.text();
203+
204+
expect(text).toEqual("foobar");
205+
});

0 commit comments

Comments
 (0)