@hyperspan/framework 1.0.24 → 1.0.25
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/.claude/settings.local.json +1 -3
- package/package.json +1 -1
- package/src/actions.test.ts +268 -50
- package/src/actions.ts +62 -34
- package/src/index.ts +12 -2
- package/src/server.test.ts +191 -5
- package/src/server.ts +138 -47
- package/src/ssr/mock-dom.test.ts +1 -1
- package/src/ssr/mock-dom.ts +12 -9
- package/src/types.ts +130 -42
package/src/server.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { test, expect } from 'bun:test';
|
|
1
|
+
import { test, expect, describe } from 'bun:test';
|
|
2
2
|
import { createRoute, createServer, createContext } from './server';
|
|
3
|
+
import { createAction } from './actions';
|
|
4
|
+
import { html, placeholder } from '@hyperspan/html';
|
|
3
5
|
import type { Hyperspan as HS } from './types';
|
|
4
6
|
|
|
5
7
|
test('route fetch() returns a Response', async () => {
|
|
@@ -53,7 +55,6 @@ test('server with two routes can return Response from one', async () => {
|
|
|
53
55
|
return context.res.html('<h1>Posts Page</h1>');
|
|
54
56
|
});
|
|
55
57
|
|
|
56
|
-
|
|
57
58
|
// Test that we can get a Response from one of the routes
|
|
58
59
|
const request = new Request('http://localhost:3000/users');
|
|
59
60
|
const testRoute = server._routes.find((route: HS.Route) => route._path() === '/users');
|
|
@@ -80,7 +81,9 @@ test('server returns a route with a POST request', async () => {
|
|
|
80
81
|
return context.res.html('<h1>POST /users</h1>');
|
|
81
82
|
});
|
|
82
83
|
|
|
83
|
-
const route = server._routes.find(
|
|
84
|
+
const route = server._routes.find(
|
|
85
|
+
(route: HS.Route) => route._path() === '/users' && route._methods().includes('POST')
|
|
86
|
+
) as HS.Route;
|
|
84
87
|
const request = new Request('http://localhost:3000/users', { method: 'POST' });
|
|
85
88
|
const response = await route.fetch(request);
|
|
86
89
|
|
|
@@ -100,7 +103,9 @@ test('server returns a route with a ALL request', async () => {
|
|
|
100
103
|
return context.res.html('<h1>ALL /users</h1>');
|
|
101
104
|
});
|
|
102
105
|
|
|
103
|
-
const route = server._routes.find(
|
|
106
|
+
const route = server._routes.find(
|
|
107
|
+
(route: HS.Route) => route._path() === '/users' && route._methods().includes('*')
|
|
108
|
+
) as HS.Route;
|
|
104
109
|
|
|
105
110
|
// GET request
|
|
106
111
|
let request = new Request('http://localhost:3000/users', { method: 'GET' });
|
|
@@ -144,7 +149,7 @@ test('createContext() can get and set cookies', () => {
|
|
|
144
149
|
// Create a request with cookies in the Cookie header
|
|
145
150
|
const request = new Request('http://localhost:3000/', {
|
|
146
151
|
headers: {
|
|
147
|
-
|
|
152
|
+
Cookie: 'sessionId=abc123; theme=dark; userId=42',
|
|
148
153
|
},
|
|
149
154
|
});
|
|
150
155
|
|
|
@@ -369,3 +374,184 @@ test('createContext() merge() function allows response headers to override conte
|
|
|
369
374
|
expect(response.headers.get('X-Header')).toBe('response-value');
|
|
370
375
|
expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
|
|
371
376
|
});
|
|
377
|
+
|
|
378
|
+
describe('when streaming is disabled', () => {
|
|
379
|
+
test('placeholder resolves to real content in the initial response on the route', async () => {
|
|
380
|
+
const route = createRoute({
|
|
381
|
+
responseOptions: {
|
|
382
|
+
disableStreaming: () => true,
|
|
383
|
+
},
|
|
384
|
+
}).get(() => {
|
|
385
|
+
return html`<div>
|
|
386
|
+
${placeholder(html`<span>Loading...</span>`, Promise.resolve(html`<p>Real content</p>`))}
|
|
387
|
+
</div>`;
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const request = new Request('http://localhost:3000/');
|
|
391
|
+
const response = await route.fetch(request);
|
|
392
|
+
|
|
393
|
+
expect(response).toBeInstanceOf(Response);
|
|
394
|
+
expect(response.status).toBe(200);
|
|
395
|
+
expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
396
|
+
|
|
397
|
+
const text = await response.text();
|
|
398
|
+
expect(text).toContain('<p>Real content</p>');
|
|
399
|
+
expect(text).not.toContain('Loading...');
|
|
400
|
+
expect(text).not.toContain('hs:loading');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('placeholder resolves to real content in the initial response on the server', async () => {
|
|
404
|
+
const server = await createServer({
|
|
405
|
+
appDir: './app',
|
|
406
|
+
publicDir: './public',
|
|
407
|
+
plugins: [],
|
|
408
|
+
responseOptions: {
|
|
409
|
+
disableStreaming: () => true,
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
server.get('/', () => {
|
|
414
|
+
return html`<div>
|
|
415
|
+
${placeholder(html`<span>Loading...</span>`, Promise.resolve(html`<p>Real content</p>`))}
|
|
416
|
+
</div>`;
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const route = server._routes[0];
|
|
420
|
+
route._serverConfig = server._config;
|
|
421
|
+
|
|
422
|
+
const request = new Request('http://localhost:3000/');
|
|
423
|
+
const response = await route.fetch(request);
|
|
424
|
+
|
|
425
|
+
expect(response).toBeInstanceOf(Response);
|
|
426
|
+
expect(response.status).toBe(200);
|
|
427
|
+
expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
428
|
+
|
|
429
|
+
const text = await response.text();
|
|
430
|
+
expect(text).toContain('<p>Real content</p>');
|
|
431
|
+
expect(text).not.toContain('Loading...');
|
|
432
|
+
expect(text).not.toContain('hs:loading');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('disableStreaming receives hyperspanDisableStreaming for custom logic with framework fallback', async () => {
|
|
436
|
+
const route = createRoute({
|
|
437
|
+
responseOptions: {
|
|
438
|
+
disableStreaming(c, { hyperspanDisableStreaming }) {
|
|
439
|
+
if (c.req.query.get('no-stream') === '1') {
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
return hyperspanDisableStreaming(c);
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
}).get(() => {
|
|
446
|
+
return html`<div>
|
|
447
|
+
${placeholder(html`<span>Loading...</span>`, Promise.resolve(html`<p>Real content</p>`))}
|
|
448
|
+
</div>`;
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const normalRequest = new Request('http://localhost:3000/');
|
|
452
|
+
const normalResponse = await route.fetch(normalRequest);
|
|
453
|
+
expect(normalResponse.headers.get('Transfer-Encoding')).toBe('chunked');
|
|
454
|
+
|
|
455
|
+
const customRequest = new Request('http://localhost:3000/?no-stream=1');
|
|
456
|
+
const customResponse = await route.fetch(customRequest);
|
|
457
|
+
expect(customResponse.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
458
|
+
expect(await customResponse.text()).toContain('<p>Real content</p>');
|
|
459
|
+
|
|
460
|
+
const botRequest = new Request('http://localhost:3000/', {
|
|
461
|
+
headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' },
|
|
462
|
+
});
|
|
463
|
+
const botResponse = await route.fetch(botRequest);
|
|
464
|
+
expect(botResponse.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
465
|
+
expect(await botResponse.text()).toContain('<p>Real content</p>');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('nested promise and placeholder content fully resolves on initial load', async () => {
|
|
469
|
+
const route = createRoute({
|
|
470
|
+
responseOptions: {
|
|
471
|
+
disableStreaming: () => true,
|
|
472
|
+
},
|
|
473
|
+
}).get(async () => {
|
|
474
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
475
|
+
|
|
476
|
+
const sectionPromise = (async () => {
|
|
477
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
478
|
+
return placeholder(
|
|
479
|
+
html`<span>Outer placeholder</span>`,
|
|
480
|
+
(async () => {
|
|
481
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
482
|
+
return html`<p>Inner resolved content</p>`;
|
|
483
|
+
})()
|
|
484
|
+
);
|
|
485
|
+
})();
|
|
486
|
+
|
|
487
|
+
return html`<main>
|
|
488
|
+
<h1>Static heading</h1>
|
|
489
|
+
${sectionPromise}
|
|
490
|
+
</main>`;
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const request = new Request('http://localhost:3000/');
|
|
494
|
+
const response = await route.fetch(request);
|
|
495
|
+
|
|
496
|
+
expect(response).toBeInstanceOf(Response);
|
|
497
|
+
expect(response.status).toBe(200);
|
|
498
|
+
expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
499
|
+
|
|
500
|
+
const text = await response.text();
|
|
501
|
+
expect(text).toContain('<h1>Static heading</h1>');
|
|
502
|
+
expect(text).toContain('<p>Inner resolved content</p>');
|
|
503
|
+
expect(text).not.toContain('Outer placeholder');
|
|
504
|
+
expect(text).not.toContain('hs:loading');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('nested promise and async action form fully resolve on initial load', async () => {
|
|
508
|
+
const action = createAction({ name: 'nested-async-form-action' })
|
|
509
|
+
.form(async () => {
|
|
510
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
511
|
+
return html`<form>
|
|
512
|
+
<p>Action form content</p>
|
|
513
|
+
<button type="submit">Submit</button>
|
|
514
|
+
</form>`;
|
|
515
|
+
})
|
|
516
|
+
.post(async () => html`<p>Submitted</p>`);
|
|
517
|
+
|
|
518
|
+
const route = createRoute({
|
|
519
|
+
responseOptions: {
|
|
520
|
+
disableStreaming: () => true,
|
|
521
|
+
},
|
|
522
|
+
}).get(async (c: HS.Context) => {
|
|
523
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
524
|
+
|
|
525
|
+
const sectionPromise = (async () => {
|
|
526
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
527
|
+
return placeholder(
|
|
528
|
+
html`<span>Outer placeholder</span>`,
|
|
529
|
+
(async () => {
|
|
530
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
531
|
+
return action.render(c);
|
|
532
|
+
})()
|
|
533
|
+
);
|
|
534
|
+
})();
|
|
535
|
+
|
|
536
|
+
return html`<main>
|
|
537
|
+
<h1>Static heading</h1>
|
|
538
|
+
${sectionPromise}
|
|
539
|
+
</main>`;
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const request = new Request('http://localhost:3000/');
|
|
543
|
+
const response = await route.fetch(request);
|
|
544
|
+
|
|
545
|
+
expect(response).toBeInstanceOf(Response);
|
|
546
|
+
expect(response.status).toBe(200);
|
|
547
|
+
expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
|
|
548
|
+
|
|
549
|
+
const text = await response.text();
|
|
550
|
+
expect(text).toContain('<h1>Static heading</h1>');
|
|
551
|
+
expect(text).toContain('<hs-action');
|
|
552
|
+
expect(text).toContain('<p>Action form content</p>');
|
|
553
|
+
expect(text).toContain('type="submit"');
|
|
554
|
+
expect(text).not.toContain('Outer placeholder');
|
|
555
|
+
expect(text).not.toContain('hs:loading');
|
|
556
|
+
});
|
|
557
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import './ssr/install-server-dom-mock';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
HSHtml,
|
|
4
|
+
html,
|
|
5
|
+
isHSHtml,
|
|
6
|
+
renderStream,
|
|
7
|
+
renderAsync,
|
|
8
|
+
render,
|
|
9
|
+
_typeOf,
|
|
10
|
+
} from '@hyperspan/html';
|
|
3
11
|
import { isbot } from 'isbot';
|
|
4
12
|
import { executeMiddleware } from './middleware';
|
|
5
13
|
import { parsePath, removeUndefined } from './utils';
|
|
@@ -9,6 +17,22 @@ import type { Hyperspan as HS } from './types';
|
|
|
9
17
|
|
|
10
18
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
11
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Default streaming disable detection (bots receive fully rendered HTML).
|
|
22
|
+
*/
|
|
23
|
+
export function hyperspanDisableStreaming(context: HS.Context): boolean {
|
|
24
|
+
return isbot(context.req.raw.headers.get('user-agent') ?? '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const disableStreamingOptions = { hyperspanDisableStreaming };
|
|
28
|
+
|
|
29
|
+
function shouldDisableStreaming(
|
|
30
|
+
disableStreaming: HS.DisableStreamingFn | undefined,
|
|
31
|
+
context: HS.Context
|
|
32
|
+
): boolean {
|
|
33
|
+
return disableStreaming?.(context, disableStreamingOptions) ?? false;
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
export class HTTPResponseException extends Error {
|
|
13
37
|
public _error?: Error;
|
|
14
38
|
public _response?: Response;
|
|
@@ -28,8 +52,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
|
28
52
|
publicDir: './public',
|
|
29
53
|
plugins: [],
|
|
30
54
|
responseOptions: {
|
|
31
|
-
|
|
32
|
-
disableStreaming: (c) => isbot(c.req.raw.headers.get('user-agent') ?? ''),
|
|
55
|
+
disableStreaming: hyperspanDisableStreaming,
|
|
33
56
|
},
|
|
34
57
|
};
|
|
35
58
|
return {
|
|
@@ -51,11 +74,15 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
51
74
|
const method = req.method.toUpperCase();
|
|
52
75
|
const headers = new Headers(req.headers);
|
|
53
76
|
const path = route?._path() || '/';
|
|
54
|
-
|
|
55
|
-
const params:
|
|
77
|
+
const requestParams = (req as Request & { params?: Record<string, string | undefined> }).params;
|
|
78
|
+
const params: Record<string, string | undefined> = Object.assign(
|
|
79
|
+
{},
|
|
80
|
+
requestParams || {},
|
|
81
|
+
removeUndefined(route?._config.params || {})
|
|
82
|
+
);
|
|
56
83
|
|
|
57
84
|
// Replace catch-all param with the value from the URL path
|
|
58
|
-
const catchAllParam = Object.keys(params).find(key => key.startsWith('...'));
|
|
85
|
+
const catchAllParam = Object.keys(params).find((key) => key.startsWith('...'));
|
|
59
86
|
if (catchAllParam && path.includes('/*')) {
|
|
60
87
|
const catchAllValue = url.pathname.split(path.replace('/*', '/')).pop();
|
|
61
88
|
params[catchAllParam.replace('...', '')] = catchAllValue;
|
|
@@ -84,7 +111,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
84
111
|
name: route?._config.name || undefined,
|
|
85
112
|
path,
|
|
86
113
|
params: params,
|
|
87
|
-
cssImports: route ? route._config.cssImports ?? [] : [],
|
|
114
|
+
cssImports: route ? (route._config.cssImports ?? []) : [],
|
|
88
115
|
},
|
|
89
116
|
req: {
|
|
90
117
|
raw: req,
|
|
@@ -93,21 +120,50 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
93
120
|
headers,
|
|
94
121
|
query,
|
|
95
122
|
cookies: new Cookies(req),
|
|
96
|
-
async text() {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
async
|
|
123
|
+
async text() {
|
|
124
|
+
return req.clone().text();
|
|
125
|
+
},
|
|
126
|
+
async json<T = unknown>() {
|
|
127
|
+
return (await req.clone().json()) as T;
|
|
128
|
+
},
|
|
129
|
+
async formData<T = unknown>() {
|
|
130
|
+
return (await req.clone().formData()) as T;
|
|
131
|
+
},
|
|
132
|
+
async urlencoded() {
|
|
133
|
+
return new URLSearchParams(await req.clone().text());
|
|
134
|
+
},
|
|
100
135
|
},
|
|
101
136
|
res: {
|
|
102
137
|
cookies: new Cookies(req, headers),
|
|
103
138
|
headers,
|
|
104
139
|
status,
|
|
105
|
-
html: (html: string, options?: ResponseInit) =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
html: (html: string, options?: ResponseInit) =>
|
|
141
|
+
merge(
|
|
142
|
+
new Response(html, {
|
|
143
|
+
...options,
|
|
144
|
+
headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers },
|
|
145
|
+
})
|
|
146
|
+
),
|
|
147
|
+
json: (json: any, options?: ResponseInit) =>
|
|
148
|
+
merge(
|
|
149
|
+
new Response(JSON.stringify(json), {
|
|
150
|
+
...options,
|
|
151
|
+
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
|
152
|
+
})
|
|
153
|
+
),
|
|
154
|
+
text: (text: string, options?: ResponseInit) =>
|
|
155
|
+
merge(
|
|
156
|
+
new Response(text, {
|
|
157
|
+
...options,
|
|
158
|
+
headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers },
|
|
159
|
+
})
|
|
160
|
+
),
|
|
161
|
+
redirect: (url: string, options?: ResponseInit) =>
|
|
162
|
+
merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
|
|
163
|
+
error: (error: Error, options?: ResponseInit) =>
|
|
164
|
+
merge(new Response(error.message, { status: 500, ...options })),
|
|
165
|
+
notFound: (options?: ResponseInit) =>
|
|
166
|
+
merge(new Response('Not Found', { status: 404, ...options })),
|
|
111
167
|
merge,
|
|
112
168
|
},
|
|
113
169
|
};
|
|
@@ -137,7 +193,16 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
|
137
193
|
const api: HS.Route = {
|
|
138
194
|
_kind: 'hsRoute',
|
|
139
195
|
_config: config,
|
|
140
|
-
_middleware: {
|
|
196
|
+
_middleware: {
|
|
197
|
+
GET: [],
|
|
198
|
+
POST: [],
|
|
199
|
+
PUT: [],
|
|
200
|
+
PATCH: [],
|
|
201
|
+
DELETE: [],
|
|
202
|
+
HEAD: [],
|
|
203
|
+
OPTIONS: [],
|
|
204
|
+
'*': [],
|
|
205
|
+
},
|
|
141
206
|
_methods: () => Object.keys(_handlers),
|
|
142
207
|
_path() {
|
|
143
208
|
if (this._config.path) {
|
|
@@ -215,7 +280,7 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
|
215
280
|
*/
|
|
216
281
|
use(middleware: HS.MiddlewareFunction, opts: HS.MiddlewareMethodOptions = {}) {
|
|
217
282
|
if (opts?.methods) {
|
|
218
|
-
opts.methods.forEach(method => {
|
|
283
|
+
opts.methods.forEach((method) => {
|
|
219
284
|
api._middleware[method].push(middleware);
|
|
220
285
|
});
|
|
221
286
|
} else {
|
|
@@ -229,7 +294,7 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
|
229
294
|
*/
|
|
230
295
|
middleware(middleware: Array<HS.MiddlewareFunction>, opts: HS.MiddlewareMethodOptions = {}) {
|
|
231
296
|
if (opts?.methods) {
|
|
232
|
-
opts.methods.forEach(method => {
|
|
297
|
+
opts.methods.forEach((method) => {
|
|
233
298
|
api._middleware[method] = middleware;
|
|
234
299
|
});
|
|
235
300
|
} else {
|
|
@@ -252,25 +317,24 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
|
252
317
|
if (method === 'OPTIONS' && !_handlers['OPTIONS']) {
|
|
253
318
|
return context.res.html(
|
|
254
319
|
render(html`
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
320
|
+
<!DOCTYPE html>
|
|
321
|
+
<html lang="en"></html>
|
|
322
|
+
`),
|
|
258
323
|
{
|
|
259
324
|
status: 200,
|
|
260
325
|
headers: {
|
|
261
326
|
'Access-Control-Allow-Origin': '*',
|
|
262
|
-
'Access-Control-Allow-Methods': [
|
|
263
|
-
'
|
|
264
|
-
|
|
265
|
-
...Object.keys(_handlers),
|
|
266
|
-
].join(', '),
|
|
327
|
+
'Access-Control-Allow-Methods': ['HEAD', 'OPTIONS', ...Object.keys(_handlers)].join(
|
|
328
|
+
', '
|
|
329
|
+
),
|
|
267
330
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
268
331
|
},
|
|
269
332
|
}
|
|
270
333
|
);
|
|
271
334
|
}
|
|
272
335
|
|
|
273
|
-
const handler =
|
|
336
|
+
const handler =
|
|
337
|
+
(method === 'HEAD' ? _handlers['GET'] : _handlers[method]) ?? _handlers['*'];
|
|
274
338
|
|
|
275
339
|
if (!handler) {
|
|
276
340
|
return context.res.error(new Error('Method not allowed'), { status: 405 });
|
|
@@ -296,7 +360,10 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
|
296
360
|
|
|
297
361
|
if (isHTMLContent(routeContent)) {
|
|
298
362
|
// Merge server and route-specific response options
|
|
299
|
-
const responseOptions = {
|
|
363
|
+
const responseOptions = {
|
|
364
|
+
...(api._serverConfig?.responseOptions ?? {}),
|
|
365
|
+
...(api._config?.responseOptions ?? {}),
|
|
366
|
+
};
|
|
300
367
|
return returnHTMLResponse(context, () => routeContent, responseOptions);
|
|
301
368
|
}
|
|
302
369
|
|
|
@@ -324,11 +391,22 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
|
324
391
|
// Run the route handler and any middleware
|
|
325
392
|
// If an error occurs, run the error handler if it exists
|
|
326
393
|
try {
|
|
327
|
-
return await executeMiddleware(context, [
|
|
394
|
+
return await executeMiddleware(context, [
|
|
395
|
+
...globalMiddleware,
|
|
396
|
+
...methodMiddleware,
|
|
397
|
+
methodHandler,
|
|
398
|
+
]);
|
|
328
399
|
} catch (e) {
|
|
329
400
|
if (_errorHandler !== undefined) {
|
|
330
|
-
const responseOptions = {
|
|
331
|
-
|
|
401
|
+
const responseOptions = {
|
|
402
|
+
...(api._serverConfig?.responseOptions ?? {}),
|
|
403
|
+
...(api._config?.responseOptions ?? {}),
|
|
404
|
+
};
|
|
405
|
+
return returnHTMLResponse(
|
|
406
|
+
context,
|
|
407
|
+
() => (_errorHandler as HS.ErrorHandler)(context, e as Error),
|
|
408
|
+
responseOptions
|
|
409
|
+
);
|
|
332
410
|
}
|
|
333
411
|
throw e;
|
|
334
412
|
}
|
|
@@ -346,16 +424,25 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
|
|
|
346
424
|
|
|
347
425
|
// Load plugins, if any
|
|
348
426
|
if (config.plugins && config.plugins.length > 0) {
|
|
349
|
-
await Promise.all(config.plugins.map(plugin => plugin(config)));
|
|
427
|
+
await Promise.all(config.plugins.map((plugin) => plugin(config)));
|
|
350
428
|
}
|
|
351
429
|
|
|
352
430
|
const api: HS.Server = {
|
|
353
431
|
_config: config,
|
|
354
432
|
_routes: _routes,
|
|
355
|
-
_middleware: {
|
|
433
|
+
_middleware: {
|
|
434
|
+
GET: [],
|
|
435
|
+
POST: [],
|
|
436
|
+
PUT: [],
|
|
437
|
+
PATCH: [],
|
|
438
|
+
DELETE: [],
|
|
439
|
+
HEAD: [],
|
|
440
|
+
OPTIONS: [],
|
|
441
|
+
'*': [],
|
|
442
|
+
},
|
|
356
443
|
use(middleware: HS.MiddlewareFunction, opts?: HS.MiddlewareMethodOptions) {
|
|
357
444
|
if (opts?.methods) {
|
|
358
|
-
opts.methods.forEach(method => {
|
|
445
|
+
opts.methods.forEach((method) => {
|
|
359
446
|
api._middleware[method].push(middleware);
|
|
360
447
|
});
|
|
361
448
|
} else {
|
|
@@ -414,7 +501,8 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
|
|
|
414
501
|
* Checks if a response is HTML content
|
|
415
502
|
*/
|
|
416
503
|
function isHTMLContent(response: unknown): response is Response {
|
|
417
|
-
const hasHTMLContentType =
|
|
504
|
+
const hasHTMLContentType =
|
|
505
|
+
response instanceof Response && response.headers.get('Content-Type') === 'text/html';
|
|
418
506
|
const isHTMLTemplate = isHSHtml(response);
|
|
419
507
|
const isHTMLString = typeof response === 'string' && response.trim().startsWith('<');
|
|
420
508
|
|
|
@@ -427,7 +515,11 @@ function isHTMLContent(response: unknown): response is Response {
|
|
|
427
515
|
export async function returnHTMLResponse(
|
|
428
516
|
context: HS.Context,
|
|
429
517
|
handlerFn: () => unknown,
|
|
430
|
-
responseOptions?: {
|
|
518
|
+
responseOptions?: {
|
|
519
|
+
status?: number;
|
|
520
|
+
headers?: Record<string, string>;
|
|
521
|
+
disableStreaming?: HS.DisableStreamingFn;
|
|
522
|
+
}
|
|
431
523
|
): Promise<Response> {
|
|
432
524
|
try {
|
|
433
525
|
const routeContent = await handlerFn();
|
|
@@ -439,7 +531,7 @@ export async function returnHTMLResponse(
|
|
|
439
531
|
|
|
440
532
|
// Render HSHtml if returned from route handler
|
|
441
533
|
if (isHSHtml(routeContent)) {
|
|
442
|
-
const disableStreaming = responseOptions?.disableStreaming
|
|
534
|
+
const disableStreaming = shouldDisableStreaming(responseOptions?.disableStreaming, context);
|
|
443
535
|
|
|
444
536
|
// Stream only if enabled and there is async content to stream
|
|
445
537
|
if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
|
|
@@ -447,13 +539,13 @@ export async function returnHTMLResponse(
|
|
|
447
539
|
renderStream(routeContent as HSHtml, {
|
|
448
540
|
renderChunk: (chunk) => {
|
|
449
541
|
return html`
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
542
|
+
<template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
|
|
543
|
+
<script>
|
|
544
|
+
window._hsc = window._hsc || [];
|
|
545
|
+
window._hsc.push({ id: '${chunk.id}' });
|
|
546
|
+
</script>
|
|
547
|
+
`;
|
|
548
|
+
},
|
|
457
549
|
}),
|
|
458
550
|
responseOptions
|
|
459
551
|
) as Response;
|
|
@@ -559,7 +651,6 @@ async function showErrorReponse(
|
|
|
559
651
|
return context.res.html(output, Object.assign({ status }, responseOptions));
|
|
560
652
|
}
|
|
561
653
|
|
|
562
|
-
|
|
563
654
|
/**
|
|
564
655
|
* Streaming response: chunked transfer with Transfer-Encoding and Content-Encoding.
|
|
565
656
|
* Pass an async iterator (HTML chunks) or a ReadableStream; default Content-Type is HTML for iterators
|
package/src/ssr/mock-dom.test.ts
CHANGED
|
@@ -31,7 +31,7 @@ describe('installMockDom', () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
test('when a mock document is installed, it is annotated with MOCK_DOM_MARK', () => {
|
|
34
|
-
const doc = globalThis.document as Record<string, unknown> | undefined;
|
|
34
|
+
const doc = globalThis.document as unknown as Record<string, unknown> | undefined;
|
|
35
35
|
if (!doc?.[MOCK_DOM_MARK]) return;
|
|
36
36
|
expect(doc[MOCK_DOM_MARK]).toBe(true);
|
|
37
37
|
});
|
package/src/ssr/mock-dom.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
export const MOCK_DOM_MARK = '__hyperspan_mock_dom';
|
|
3
3
|
|
|
4
4
|
function markedMockDocument(d: unknown): boolean {
|
|
5
|
-
return Boolean(
|
|
5
|
+
return Boolean(
|
|
6
|
+
d && typeof d === 'object' && (d as Record<string, unknown>)[MOCK_DOM_MARK] === true
|
|
7
|
+
);
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
/** Minimal element-like node for the mock DOM; not a spec-compliant implementation */
|
|
@@ -35,7 +37,7 @@ export function stubElement(tag: string): any {
|
|
|
35
37
|
appendChild(child: any) {
|
|
36
38
|
children.push(child);
|
|
37
39
|
child.parentNode = node as unknown as ParentNode;
|
|
38
|
-
child.parentElement = node as unknown as ParentNode & Element | null;
|
|
40
|
+
child.parentElement = node as unknown as (ParentNode & Element) | null;
|
|
39
41
|
return child;
|
|
40
42
|
},
|
|
41
43
|
insertBefore(child: unknown, ref: unknown | null) {
|
|
@@ -43,8 +45,10 @@ export function stubElement(tag: string): any {
|
|
|
43
45
|
const refIx = ref == null ? -1 : ch.indexOf(ref);
|
|
44
46
|
if (refIx < 0) children.push(child);
|
|
45
47
|
else children.splice(refIx, 0, child);
|
|
46
|
-
(child as Record<string, unknown>).parentNode = node as
|
|
47
|
-
(child as Record<string, unknown>).parentElement = node as
|
|
48
|
+
(child as Record<string, unknown>).parentNode = node as unknown as ParentNode;
|
|
49
|
+
(child as Record<string, unknown>).parentElement = node as unknown as
|
|
50
|
+
| (ParentNode & Element)
|
|
51
|
+
| null;
|
|
48
52
|
return child as ChildNode as unknown as HTMLElement;
|
|
49
53
|
},
|
|
50
54
|
removeChild(child: unknown): unknown {
|
|
@@ -126,7 +130,8 @@ function memoryStorage(): Storage {
|
|
|
126
130
|
*/
|
|
127
131
|
export function installMockDom(): boolean {
|
|
128
132
|
const env = typeof process !== 'undefined' ? process.env : {};
|
|
129
|
-
const disabled =
|
|
133
|
+
const disabled =
|
|
134
|
+
env.HYPERSPAN_DISABLE_MOCK_DOM === '1' || env.HYPERSPAN_DISABLE_MOCK_DOM === 'true';
|
|
130
135
|
if (disabled) return false;
|
|
131
136
|
|
|
132
137
|
const g = globalThis as unknown as Record<string, unknown>;
|
|
@@ -218,9 +223,7 @@ export function installMockDom(): boolean {
|
|
|
218
223
|
setProperty() {},
|
|
219
224
|
}) as unknown as CSSStyleDeclaration,
|
|
220
225
|
requestIdleCallback(cb: IdleRequestCallback) {
|
|
221
|
-
queueMicrotask(() =>
|
|
222
|
-
cb({ didTimeout: false, timeRemaining: () => Number.MAX_SAFE_INTEGER }),
|
|
223
|
-
);
|
|
226
|
+
queueMicrotask(() => cb({ didTimeout: false, timeRemaining: () => Number.MAX_SAFE_INTEGER }));
|
|
224
227
|
return 1;
|
|
225
228
|
},
|
|
226
229
|
cancelIdleCallback() {},
|
|
@@ -348,7 +351,7 @@ export function installMockDom(): boolean {
|
|
|
348
351
|
g.document = documentStub as unknown as Document;
|
|
349
352
|
|
|
350
353
|
try {
|
|
351
|
-
(globalThis as any).navigator = navigatorStub as Navigator;
|
|
354
|
+
(globalThis as any).navigator = navigatorStub as unknown as Navigator;
|
|
352
355
|
} catch {
|
|
353
356
|
/* empty */
|
|
354
357
|
}
|