Skip to content

Commit b2b784e

Browse files
feat: Add .json()/.text()/.html()/.stream() context helpers
1 parent ffa845a commit b2b784e

File tree

4 files changed

+293
-6
lines changed

4 files changed

+293
-6
lines changed

packages/fresh/src/context.ts

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
renderRouteComponent,
2828
} from "./render.ts";
2929
import { renderToString } from "preact-render-to-string";
30+
import { isAsyncIterable, isIterable, isThenable } from "./utils.ts";
3031

3132
export interface Island {
3233
file: string;
@@ -258,11 +259,7 @@ export class Context<State> {
258259
appVNode = appChild ?? h(Fragment, null);
259260
}
260261

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

267264
headers.set("Content-Type", "text/html; charset=utf-8");
268265
const responseInit: ResponseInit = {
@@ -376,4 +373,165 @@ export class Context<State> {
376373
});
377374
return new Response(html, responseInit);
378375
}
376+
377+
/**
378+
* Respond with text. Sets `Content-Type: text/plain`.
379+
* ```tsx
380+
* ctx.text("Hello World!");
381+
* ```
382+
*/
383+
text(content: string, init?: ResponseInit) {
384+
const headers = getHeadersFromInit(init);
385+
headers.set("Content-Type", "text/plain; charset=utf-8");
386+
387+
return new Response(content, { ...init, headers });
388+
}
389+
390+
/**
391+
* Respond with html string. Sets `Content-Type: text/html`.
392+
* ```tsx
393+
* ctx.html("<h1>foo</h1>");
394+
* ```
395+
*/
396+
html(content: string, init?: ResponseInit) {
397+
const headers = getHeadersFromInit(init);
398+
headers.set("Content-Type", "text/html; charset=utf-8");
399+
400+
return new Response(content, { ...init, headers });
401+
}
402+
403+
/**
404+
* Respond with json string, same as `Response.json()`. Sets
405+
* `Content-Type: application/json`.
406+
* ```tsx
407+
* ctx.json({ foo: 123 });
408+
* ```
409+
*/
410+
// deno-lint-ignore no-explicit-any
411+
json(content: any, init?: ResponseInit) {
412+
return Response.json(content, init);
413+
}
414+
415+
/**
416+
* Helper to manually enqueue chunks. Encodes text automatically
417+
* ```tsx
418+
* ctx.stream((controller) => {
419+
* controller.enqueue("foo");
420+
* controller.enqueue("bar");
421+
* });
422+
* ```
423+
* Async works too:
424+
*
425+
* ```tsx
426+
* ctx.stream(async (controller, signal) => {
427+
* controller.enqueue("foo");
428+
* await new Promise(r => setTimeout(r, 1000));
429+
* if (signal.aborted) return;
430+
* controller.enqueue("bar");
431+
* });
432+
* ```
433+
*
434+
* Can also be used with sync and async generator
435+
* ```tsx
436+
* ctx.stream(function* gen() {
437+
* yield "foo";
438+
* yield "bar";
439+
* });
440+
* ```
441+
*/
442+
stream<U extends string | Uint8Array>(
443+
stream: StreamFn<U>,
444+
init?: ResponseInit,
445+
): Response {
446+
const body = runStreamFn(stream, this.req.signal);
447+
return new Response(body, init);
448+
}
449+
}
450+
451+
function getHeadersFromInit(init?: ResponseInit) {
452+
if (init === undefined) {
453+
return new Headers();
454+
}
455+
456+
return init.headers !== undefined
457+
? init.headers instanceof Headers ? init.headers : new Headers(init.headers)
458+
: new Headers();
459+
}
460+
461+
export type StreamFn<T> = (
462+
controller: ReadableStreamDefaultController<T>,
463+
signal: AbortSignal,
464+
) =>
465+
| void
466+
| Iterable<T, void, unknown>
467+
| Promise<void>
468+
| AsyncIterable<T, void, unknown>;
469+
470+
type ChunkEncodeFn<T> = (
471+
controller: ReadableStreamDefaultController<Uint8Array<ArrayBuffer>>,
472+
chunk: T | undefined,
473+
) => void;
474+
475+
function runStreamFn<T>(
476+
fn: StreamFn<T>,
477+
signal: AbortSignal,
478+
): ReadableStream<Uint8Array<ArrayBuffer>> {
479+
return new ReadableStream<Uint8Array<ArrayBuffer>>({
480+
async start(controller) {
481+
const wrapped = wrapStreamController(
482+
controller,
483+
enqueueEncodedChunk,
484+
);
485+
const result = fn(wrapped, signal);
486+
487+
if (isIterable(result)) {
488+
for (const chunk of result) {
489+
enqueueEncodedChunk(controller, chunk);
490+
}
491+
} else if (isAsyncIterable(result)) {
492+
for await (const chunk of result) {
493+
enqueueEncodedChunk(controller, chunk);
494+
}
495+
} else if (isThenable(result)) {
496+
await result;
497+
}
498+
499+
controller.close();
500+
},
501+
});
502+
}
503+
504+
function wrapStreamController<T>(
505+
controller: ReadableStreamDefaultController<Uint8Array<ArrayBuffer>>,
506+
encode: ChunkEncodeFn<T>,
507+
): ReadableStreamDefaultController<T> {
508+
return {
509+
get desiredSize() {
510+
return controller.desiredSize;
511+
},
512+
close: () => controller.close,
513+
enqueue(chunk) {
514+
encode(controller, chunk);
515+
},
516+
error: (err) => controller.error(err),
517+
};
518+
}
519+
520+
const ENCODER = new TextEncoder();
521+
522+
function enqueueEncodedChunk<T>(
523+
controller: ReadableStreamDefaultController<Uint8Array<ArrayBuffer>>,
524+
chunk: T | undefined,
525+
) {
526+
if (chunk === undefined) {
527+
return controller.enqueue(undefined);
528+
}
529+
530+
if (chunk instanceof Uint8Array) {
531+
// deno-lint-ignore no-explicit-any
532+
controller.enqueue(chunk as any);
533+
} else {
534+
const raw = ENCODER.encode(String(chunk));
535+
controller.enqueue(raw);
536+
}
379537
}

packages/fresh/src/context_test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,113 @@ 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() - empty callback", async () => {
147+
const app = new App()
148+
.get("/", (ctx) => ctx.stream(() => {}));
149+
150+
const server = new FakeServer(app.handler());
151+
const res = await server.get("/");
152+
const text = await res.text();
153+
expect(text).toEqual("");
154+
});
155+
156+
Deno.test("ctx.stream() - enqueue values", async () => {
157+
const app = new App()
158+
.get("/", (ctx) =>
159+
ctx.stream((controller) => {
160+
controller.enqueue("foo");
161+
controller.enqueue(new TextEncoder().encode("bar"));
162+
}));
163+
164+
const server = new FakeServer(app.handler());
165+
const res = await server.get("/");
166+
const text = await res.text();
167+
expect(text).toEqual("foobar");
168+
});
169+
170+
Deno.test("ctx.stream() - enqueue sync", async () => {
171+
const app = new App()
172+
.get("/", (ctx) =>
173+
ctx.stream((controller) => {
174+
controller.enqueue("foo");
175+
controller.enqueue("bar");
176+
}));
177+
178+
const server = new FakeServer(app.handler());
179+
const res = await server.get("/");
180+
const text = await res.text();
181+
expect(text).toEqual("foobar");
182+
});
183+
184+
Deno.test("ctx.stream() - enqueue async", async () => {
185+
const app = new App()
186+
.get("/", (ctx) =>
187+
ctx.stream(async (controller) => {
188+
controller.enqueue("foo");
189+
await new Promise((r) => setTimeout(r, 50));
190+
controller.enqueue("bar");
191+
}));
192+
193+
const server = new FakeServer(app.handler());
194+
const res = await server.get("/");
195+
const text = await res.text();
196+
expect(text).toEqual("foobar");
197+
});
198+
199+
Deno.test("ctx.stream() - support cancelling stream", async () => {
200+
const app = new App()
201+
.get("/", (ctx) =>
202+
ctx.stream(async (controller, signal) => {
203+
controller.enqueue("foo");
204+
await new Promise((r) => setTimeout(r, 300));
205+
if (signal.aborted) return;
206+
controller.enqueue("bar");
207+
}));
208+
209+
const server = new FakeServer(app.handler());
210+
const abort = new AbortController();
211+
const res = await server.get("/", { signal: abort.signal });
212+
213+
const p = res.text();
214+
await new Promise((r) => setTimeout(r, 100));
215+
abort.abort();
216+
217+
expect(await p).toEqual("foo");
218+
});

packages/fresh/src/mod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export { csrf, type CsrfOptions } from "./middlewares/csrf.ts";
1515
export { cors, type CORSOptions } from "./middlewares/cors.ts";
1616
export { csp, type CSPOptions } from "./middlewares/csp.ts";
1717
export type { FreshConfig, ResolvedFreshConfig } from "./config.ts";
18-
export type { Context, FreshContext, Island } from "./context.ts";
18+
export type { Context, FreshContext, Island, StreamFn } from "./context.ts";
1919
export { createDefine, type Define } from "./define.ts";
2020
export type { Method } from "./router.ts";
2121
export { HttpError } from "./error.ts";

packages/fresh/src/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,22 @@ function maybeDot(spec: string): string {
283283
export function isLazy<T>(value: MaybeLazy<T>): value is Lazy<T> {
284284
return typeof value === "function";
285285
}
286+
287+
export function isThenable(value: unknown): value is Promise<unknown> {
288+
return value !== null && typeof value === "object" && "then" in value &&
289+
typeof value.then === "function";
290+
}
291+
292+
export function isIterable<T>(value: unknown): value is Iterable<T> {
293+
return value !== null && typeof value === "object" &&
294+
Symbol.iterator in value &&
295+
typeof value[Symbol.iterator] === "function";
296+
}
297+
298+
export function isAsyncIterable<T>(
299+
value: unknown,
300+
): value is AsyncIterable<T> {
301+
return value !== null && typeof value === "object" &&
302+
Symbol.asyncIterator in value &&
303+
typeof value[Symbol.asyncIterator] === "function";
304+
}

0 commit comments

Comments
 (0)