@hyperspan/framework 1.0.16 → 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 +1 -1
- package/src/client/_hs/hyperspan-actions.client.ts +36 -12
- package/src/server.test.ts +68 -0
- package/src/server.ts +42 -8
- package/src/types.ts +15 -1
package/package.json
CHANGED
|
@@ -77,6 +77,27 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
77
77
|
submitBtn.setAttribute('disabled', 'disabled');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function applyResponseHtml(html: string) {
|
|
81
|
+
const isFullDocument = html.includes('<html');
|
|
82
|
+
if (isFullDocument) {
|
|
83
|
+
html = html.replace(/^[\s\uFEFF]*<!DOCTYPE[^>]*>/i, '');
|
|
84
|
+
}
|
|
85
|
+
const target = isFullDocument ? window.document : hsActionTag || form;
|
|
86
|
+
const options = isFullDocument ? undefined : { morphStyle: 'innerHTML' };
|
|
87
|
+
|
|
88
|
+
Idiomorph.morph(target, html, options);
|
|
89
|
+
|
|
90
|
+
if (!isFullDocument) {
|
|
91
|
+
const outerElement = target.querySelector('hs-action');
|
|
92
|
+
if (outerElement) {
|
|
93
|
+
outerElement.replaceWith(...outerElement.childNodes);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
opts.afterResponse && opts.afterResponse();
|
|
98
|
+
lazyLoadScripts();
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
fetch(formUrl, { body: formData, method, headers })
|
|
81
102
|
.then((res: Response) => {
|
|
82
103
|
// Look for special header that indicates a redirect.
|
|
@@ -84,6 +105,17 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
84
105
|
if (res.headers.has('X-Redirect-Location')) {
|
|
85
106
|
const newUrl = res.headers.get('X-Redirect-Location');
|
|
86
107
|
if (newUrl) {
|
|
108
|
+
const resolved = new URL(newUrl, window.location.href);
|
|
109
|
+
|
|
110
|
+
// If the new URL is the same as the current URL, we can just fetch the new HTML and apply it
|
|
111
|
+
if (resolved.pathname === window.location.pathname) {
|
|
112
|
+
return fetch(resolved.href, {
|
|
113
|
+
headers: { Accept: 'text/html' },
|
|
114
|
+
})
|
|
115
|
+
.then((r) => r.text());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// If the new URL is different, we need to redirect the user to the new URL
|
|
87
119
|
window.location.assign(newUrl);
|
|
88
120
|
}
|
|
89
121
|
return '';
|
|
@@ -97,17 +129,9 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
97
129
|
return;
|
|
98
130
|
}
|
|
99
131
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// Check for nested hs-action elements and remove them if present
|
|
105
|
-
const outerElement = target.querySelector('hs-action');
|
|
106
|
-
if (outerElement) {
|
|
107
|
-
outerElement.replaceWith(...outerElement.childNodes);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
opts.afterResponse && opts.afterResponse();
|
|
111
|
-
lazyLoadScripts();
|
|
132
|
+
applyResponseHtml(content);
|
|
133
|
+
})
|
|
134
|
+
.catch((error) => {
|
|
135
|
+
console.error('[Hyperspan] Error submitting form action:', error);
|
|
112
136
|
});
|
|
113
137
|
}
|
package/src/server.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
309
|
+
return new StreamResponse(routeContent as AsyncGenerator, streamOptions);
|
|
295
310
|
}
|
|
296
311
|
|
|
297
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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':
|
|
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
|
-
|
|
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
|
}
|