@hyperspan/framework 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -15,6 +15,28 @@ test('route fetch() returns a Response', async () => {
15
15
  expect(await response.text()).toBe('<h1>Hello World</h1>');
16
16
  });
17
17
 
18
+ test('route handler returning undefined or null yields 204 No Content', async () => {
19
+ const withHeader = createRoute().get((context: HS.Context) => {
20
+ context.res.headers.set('X-Empty', '1');
21
+ });
22
+
23
+ const req = new Request('http://localhost:3000/');
24
+ let response = await withHeader.fetch(req);
25
+ expect(response).toBeInstanceOf(Response);
26
+ expect(response.status).toBe(204);
27
+ expect(response.headers.get('X-Empty')).toBe('1');
28
+ expect(await response.text()).toBe('');
29
+
30
+ const explicitNull = createRoute().get(() => null);
31
+ response = await explicitNull.fetch(req);
32
+ expect(response.status).toBe(204);
33
+ expect(await response.text()).toBe('');
34
+
35
+ const asyncVoid = createRoute().get(async () => undefined);
36
+ response = await asyncVoid.fetch(req);
37
+ expect(response.status).toBe(204);
38
+ });
39
+
18
40
  test('server with two routes can return Response from one', async () => {
19
41
  const server = await createServer({
20
42
  appDir: './app',
@@ -214,6 +236,52 @@ test('createContext() merge() function preserves custom headers with json() meth
214
236
  expect(response.headers.get('Content-Type')).toBe('application/json');
215
237
  });
216
238
 
239
+ test('route returning ReadableStream produces a streaming response', async () => {
240
+ const route = createRoute().get((_context: HS.Context) => {
241
+ return new ReadableStream({
242
+ start(controller) {
243
+ const enc = new TextEncoder();
244
+ controller.enqueue(enc.encode('alpha'));
245
+ controller.enqueue(enc.encode('beta'));
246
+ controller.close();
247
+ },
248
+ });
249
+ });
250
+
251
+ const request = new Request('http://localhost:3000/');
252
+ const response = await route.fetch(request);
253
+
254
+ expect(response).toBeInstanceOf(Response);
255
+ expect(response.status).toBe(200);
256
+ expect(response.body).toBeInstanceOf(ReadableStream);
257
+ expect(response.headers.get('Transfer-Encoding')).toBe('chunked');
258
+ expect(response.headers.get('Content-Encoding')).toBe('Identity');
259
+ expect(response.headers.get('Content-Type')).toBe('application/octet-stream');
260
+ expect(await response.text()).toBe('alphabeta');
261
+ });
262
+
263
+ test('route returning ReadableStream respects Content-Type set on context.res.headers', async () => {
264
+ const route = createRoute().get((context: HS.Context) => {
265
+ context.res.headers.set('Content-Type', 'text/plain; charset=UTF-8');
266
+ return new ReadableStream({
267
+ start(controller) {
268
+ controller.enqueue(new TextEncoder().encode('ok'));
269
+ controller.close();
270
+ },
271
+ });
272
+ });
273
+
274
+ const request = new Request('http://localhost:3000/');
275
+ const response = await route.fetch(request);
276
+
277
+ expect(response).toBeInstanceOf(Response);
278
+ expect(response.status).toBe(200);
279
+ expect(response.headers.get('Transfer-Encoding')).toBe('chunked');
280
+ expect(response.headers.get('Content-Encoding')).toBe('Identity');
281
+ expect(response.headers.get('Content-Type')).toBe('text/plain; charset=UTF-8');
282
+ expect(await response.text()).toBe('ok');
283
+ });
284
+
217
285
  test('route returning AsyncGenerator produces a streaming response', async () => {
218
286
  async function* streamingHandler() {
219
287
  yield '<h1>Hello</h1>';
package/src/server.ts CHANGED
@@ -114,10 +114,20 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
114
114
  return context;
115
115
  }
116
116
 
117
+ /**
118
+ * 204 No Content when the route handler returns `undefined` or `null`.
119
+ */
120
+ function noContentResponse(context: HS.Context): Response {
121
+ return new Response(null, {
122
+ status: 204,
123
+ headers: context.res.headers,
124
+ });
125
+ }
117
126
 
118
127
  /**
119
128
  * Define a route that can handle a direct HTTP request.
120
- * Route handlers should return a HSHtml or Response object
129
+ * Route handlers should return HSHtml, a Response, a stream, a generator, etc.
130
+ * Returning `undefined` or `null` sends 204 No Content (with merged `context.res` headers).
121
131
  */
122
132
  export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
123
133
  const _handlers: Record<string, HS.RouteHandler> = {};
@@ -236,7 +246,7 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
236
246
  const globalMiddleware = api._middleware['*'] || [];
237
247
  const methodMiddleware = api._middleware[method] || [];
238
248
 
239
- const methodHandler = async (context: HS.Context) => {
249
+ const methodHandler: HS.RouteHandler = async (context) => {
240
250
  // Handle CORS preflight requests (if no OPTIONS handler is defined)
241
251
  if (method === 'OPTIONS' && !_handlers['OPTIONS']) {
242
252
  return context.res.html(
@@ -289,12 +299,25 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
289
299
  return returnHTMLResponse(context, () => routeContent, responseOptions);
290
300
  }
291
301
 
302
+ const streamOptions = {
303
+ status: context.res.status ?? 200,
304
+ headers: Object.fromEntries(context.res.headers.entries()),
305
+ };
306
+
292
307
  const contentType = _typeOf(routeContent);
293
308
  if (contentType === 'generator') {
294
- return new StreamResponse(routeContent as AsyncGenerator, { headers: Object.fromEntries(context.res.headers.entries()) });
309
+ return new StreamResponse(routeContent as AsyncGenerator, streamOptions);
295
310
  }
296
311
 
297
- return routeContent;
312
+ if (routeContent instanceof ReadableStream) {
313
+ return new StreamResponse(routeContent, streamOptions);
314
+ }
315
+
316
+ if (routeContent === undefined || routeContent === null) {
317
+ return noContentResponse(context);
318
+ }
319
+
320
+ return routeContent as HS.RouteHandlerReturn;
298
321
  };
299
322
 
300
323
  // Run the route handler and any middleware
@@ -537,17 +560,28 @@ async function showErrorReponse(
537
560
 
538
561
 
539
562
  /**
540
- * Streaming HTML Response
563
+ * Streaming response: chunked transfer with Transfer-Encoding and Content-Encoding.
564
+ * Pass an async iterator (HTML chunks) or a ReadableStream; default Content-Type is HTML for iterators
565
+ * and application/octet-stream for readable streams. Optional headers override defaults.
541
566
  */
542
567
  export class StreamResponse extends Response {
543
- constructor(iterator: AsyncIterator<unknown>, options: { status?: number; headers?: Record<string, string> } = {}) {
568
+ constructor(
569
+ body: AsyncIterator<unknown> | ReadableStream,
570
+ options: { status?: number; headers?: Record<string, string> } = {}
571
+ ) {
544
572
  super();
545
573
  const { status, headers, ...restOptions } = options;
546
- const stream = createReadableStreamFromAsyncGenerator(iterator as AsyncGenerator);
574
+ const stream =
575
+ body instanceof ReadableStream
576
+ ? body
577
+ : createReadableStreamFromAsyncGenerator(body as AsyncGenerator);
578
+
579
+ const defaultContentType =
580
+ body instanceof ReadableStream ? 'application/octet-stream' : 'text/html; charset=UTF-8';
547
581
 
548
582
  const mergedHeaders = new Headers({
549
583
  'Transfer-Encoding': 'chunked',
550
- 'Content-Type': 'text/html; charset=UTF-8',
584
+ 'Content-Type': defaultContentType,
551
585
  'Content-Encoding': 'Identity',
552
586
  });
553
587
  if (headers) {
package/src/types.ts CHANGED
@@ -100,7 +100,21 @@ export namespace Hyperspan {
100
100
  disableStreaming?: (context: Hyperspan.Context) => boolean;
101
101
  };
102
102
  };
103
- export type RouteHandler = (context: Hyperspan.Context) => unknown;
103
+
104
+ export type RouteHandlerReturn =
105
+ | Response
106
+ | HSHtml
107
+ | string
108
+ | ReadableStream
109
+ | AsyncIterable<unknown>
110
+ | Iterable<unknown>
111
+ | undefined
112
+ | null
113
+ | void;
114
+
115
+ export type RouteHandler = (
116
+ context: Hyperspan.Context
117
+ ) => RouteHandlerReturn | Promise<RouteHandlerReturn>;
104
118
  export type RouteHandlerOptions = {
105
119
  middleware?: Hyperspan.MiddlewareFunction[];
106
120
  }