@hyperspan/framework 1.0.29 → 2.0.0-alpha.13
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 +30 -7
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +3 -6
- package/src/adapter.test.ts +23 -0
- package/src/client/_hs/hyperspan-actions.client.ts +37 -45
- package/src/client/js.test.ts +157 -4
- package/src/client/js.ts +287 -64
- package/src/client/manifest.ts +83 -0
- package/src/dev-bindings.ts +14 -0
- package/src/fetch-handler.test.ts +113 -0
- package/src/fetch-handler.ts +168 -0
- package/src/index.ts +27 -1
- package/src/island.test.ts +30 -0
- package/src/island.ts +37 -0
- package/src/layout.test.ts +12 -0
- package/src/layout.ts +28 -11
- package/src/register-routes.ts +64 -0
- package/src/server.test.ts +60 -2
- package/src/server.ts +42 -11
- package/src/ssr/mock-dom.test.ts +1 -1
- package/src/types.ts +72 -3
- package/src/utils.test.ts +72 -2
- package/src/utils.ts +66 -10
- package/tsconfig.json +4 -8
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createContext } from './server';
|
|
2
|
+
import type { Hyperspan as HS } from './types';
|
|
3
|
+
|
|
4
|
+
export type FetchHandlerOptions = {
|
|
5
|
+
/** Called when no route matches (before 404). Return a Response to handle static assets etc. */
|
|
6
|
+
onNotMatched?: (request: Request) => Promise<Response | undefined>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type CompiledRoute = {
|
|
10
|
+
route: HS.Route;
|
|
11
|
+
pattern: RegExp;
|
|
12
|
+
paramNames: string[];
|
|
13
|
+
isWildcard: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert a Hyperspan route path (`/users/:id`, `/blog/*`) into a RegExp + param names.
|
|
18
|
+
*/
|
|
19
|
+
export function compileRoutePath(path: string): {
|
|
20
|
+
pattern: RegExp;
|
|
21
|
+
paramNames: string[];
|
|
22
|
+
isWildcard: boolean;
|
|
23
|
+
} {
|
|
24
|
+
const paramNames: string[] = [];
|
|
25
|
+
const isWildcard = path.endsWith('/*');
|
|
26
|
+
|
|
27
|
+
if (path === '/') {
|
|
28
|
+
return { pattern: /^\/$/, paramNames, isWildcard: false };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const segments = path.split('/').filter(Boolean);
|
|
32
|
+
const regexParts = segments.map((segment) => {
|
|
33
|
+
if (segment === '*') {
|
|
34
|
+
paramNames.push('...slug');
|
|
35
|
+
return '(.+)';
|
|
36
|
+
}
|
|
37
|
+
if (segment.startsWith(':')) {
|
|
38
|
+
paramNames.push(segment.slice(1));
|
|
39
|
+
return '([^/]+)';
|
|
40
|
+
}
|
|
41
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const pattern = new RegExp(`^/${regexParts.join('/')}${isWildcard ? '' : '/?'}$`);
|
|
45
|
+
return { pattern, paramNames, isWildcard };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mergeRouteMiddleware(server: HS.Server, route: HS.Route): void {
|
|
49
|
+
route._serverConfig = server._config;
|
|
50
|
+
|
|
51
|
+
if (!server._middleware || !route._middleware) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const method of Object.keys(server._middleware) as HS.MiddlewareMethod[]) {
|
|
56
|
+
if (Array.isArray(server._middleware[method]) && Array.isArray(route._middleware[method])) {
|
|
57
|
+
route._middleware[method] = server._middleware[method].concat(route._middleware[method]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function matchRoute(
|
|
63
|
+
compiledRoutes: CompiledRoute[],
|
|
64
|
+
pathname: string,
|
|
65
|
+
method: string
|
|
66
|
+
): { route: HS.Route; params: Record<string, string | undefined> } | null {
|
|
67
|
+
for (const compiled of compiledRoutes) {
|
|
68
|
+
const match = pathname.match(compiled.pattern);
|
|
69
|
+
if (!match) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Actions and some custom handlers may not expose _methods(); treat as all methods.
|
|
74
|
+
const routeMethods =
|
|
75
|
+
typeof compiled.route._methods === 'function' ? compiled.route._methods() : ['*'];
|
|
76
|
+
const supportsMethod =
|
|
77
|
+
routeMethods.includes(method) ||
|
|
78
|
+
routeMethods.includes('*') ||
|
|
79
|
+
(method === 'HEAD' && routeMethods.includes('GET'));
|
|
80
|
+
|
|
81
|
+
if (!supportsMethod) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const params: Record<string, string | undefined> = {};
|
|
86
|
+
compiled.paramNames.forEach((name, index) => {
|
|
87
|
+
const value = match[index + 1];
|
|
88
|
+
if (name.startsWith('...')) {
|
|
89
|
+
params[name] = value;
|
|
90
|
+
} else {
|
|
91
|
+
params[name] = value;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return { route: compiled.route, params };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function trailingSlashRedirect(pathname: string, request: Request): Response | null {
|
|
102
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
103
|
+
const url = new URL(request.url);
|
|
104
|
+
url.pathname = pathname.slice(0, -1);
|
|
105
|
+
return Response.redirect(url.toString(), 308);
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create a portable fetch handler for a Hyperspan server.
|
|
112
|
+
* Works on Node, Bun, Cloudflare Workers, and any runtime with Web Fetch API.
|
|
113
|
+
*/
|
|
114
|
+
export function createFetchHandler(
|
|
115
|
+
server: HS.Server,
|
|
116
|
+
options: FetchHandlerOptions = {}
|
|
117
|
+
): (request: Request) => Promise<Response> {
|
|
118
|
+
const compiledRoutes: CompiledRoute[] = server._routes.map((route) => {
|
|
119
|
+
const path = route._path();
|
|
120
|
+
mergeRouteMiddleware(server, route);
|
|
121
|
+
const { pattern, paramNames, isWildcard } = compileRoutePath(path);
|
|
122
|
+
return { route, pattern, paramNames, isWildcard };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Longer / more specific paths first (more segments wins)
|
|
126
|
+
compiledRoutes.sort((a, b) => {
|
|
127
|
+
const aSegments = a.route._path().split('/').filter(Boolean).length;
|
|
128
|
+
const bSegments = b.route._path().split('/').filter(Boolean).length;
|
|
129
|
+
return bSegments - aSegments;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return async (request: Request): Promise<Response> => {
|
|
133
|
+
const url = new URL(request.url);
|
|
134
|
+
const pathname = url.pathname;
|
|
135
|
+
const method = request.method.toUpperCase();
|
|
136
|
+
|
|
137
|
+
const slashRedirect = trailingSlashRedirect(pathname, request);
|
|
138
|
+
if (slashRedirect) {
|
|
139
|
+
return slashRedirect;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const matched = matchRoute(compiledRoutes, pathname, method);
|
|
143
|
+
if (matched) {
|
|
144
|
+
// Attach params on the original request. `new Request(request)` clones
|
|
145
|
+
// through Node's Request constructor, which strips Cookie/Origin/Host.
|
|
146
|
+
(request as Request & { params?: Record<string, string | undefined> }).params =
|
|
147
|
+
matched.params;
|
|
148
|
+
return matched.route.fetch(request);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (options.onNotMatched) {
|
|
152
|
+
const staticResponse = await options.onNotMatched(request);
|
|
153
|
+
if (staticResponse) {
|
|
154
|
+
return staticResponse;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return createContext(request).res.notFound();
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create an app object with a fetch method (adapter contract).
|
|
164
|
+
*/
|
|
165
|
+
export function createApp(server: HS.Server, options: FetchHandlerOptions = {}) {
|
|
166
|
+
const fetch = createFetchHandler(server, options);
|
|
167
|
+
return { fetch, server };
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,10 +3,36 @@ export {
|
|
|
3
3
|
createContext,
|
|
4
4
|
createRoute,
|
|
5
5
|
createServer,
|
|
6
|
+
initServerRoutes,
|
|
6
7
|
getRunnableRoute,
|
|
7
8
|
StreamResponse,
|
|
8
9
|
IS_PROD,
|
|
9
10
|
HTTPResponseException,
|
|
10
11
|
hyperspanDisableStreaming,
|
|
11
12
|
} from './server';
|
|
12
|
-
export
|
|
13
|
+
export { createFetchHandler, createApp, compileRoutePath } from './fetch-handler';
|
|
14
|
+
export type { FetchHandlerOptions } from './fetch-handler';
|
|
15
|
+
export {
|
|
16
|
+
setAssetManifest,
|
|
17
|
+
getAssetManifest,
|
|
18
|
+
resolveImport,
|
|
19
|
+
getRouteCss,
|
|
20
|
+
getImportMap,
|
|
21
|
+
registerImport,
|
|
22
|
+
} from './client/manifest';
|
|
23
|
+
export { buildClientJS, registerPathAliases } from './client/js';
|
|
24
|
+
export type { BuildClientJSOptions, ClientJSType } from './client/js';
|
|
25
|
+
export { registerRouteModule, registerRouteModules } from './register-routes';
|
|
26
|
+
export type { RouteModuleEntry } from './register-routes';
|
|
27
|
+
export type { AssetManifest } from './client/manifest';
|
|
28
|
+
export { renderIsland } from './island';
|
|
29
|
+
export type { IslandRenderOptions } from './island';
|
|
30
|
+
export type {
|
|
31
|
+
Hyperspan,
|
|
32
|
+
ServerCreateContext,
|
|
33
|
+
DeployAdapter,
|
|
34
|
+
Adapter,
|
|
35
|
+
AdapterAfterBuildContext,
|
|
36
|
+
DeployEntry,
|
|
37
|
+
DeployEntryContext,
|
|
38
|
+
} from './types';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest';
|
|
2
|
+
import { renderIsland } from './island';
|
|
3
|
+
|
|
4
|
+
describe('renderIsland', () => {
|
|
5
|
+
test('throws when component has no __HS_ISLAND', () => {
|
|
6
|
+
expect(() => renderIsland({ name: 'Bare' } as never, {})).toThrow(/not a Hyperspan island/i);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('returns html.raw for sync island render', () => {
|
|
10
|
+
const component = {
|
|
11
|
+
name: 'Counter',
|
|
12
|
+
__HS_ISLAND: {
|
|
13
|
+
render: () => '<div id="x">1</div>',
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const result = renderIsland(component, { count: 1 });
|
|
17
|
+
expect(result).toHaveProperty('_kind', 'html_safe');
|
|
18
|
+
expect((result as { content: string }).content).toContain('<div id="x">1</div>');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('awaits async island render', async () => {
|
|
22
|
+
const component = {
|
|
23
|
+
__HS_ISLAND: {
|
|
24
|
+
render: async () => '<div>async</div>',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const result = await renderIsland(component, {});
|
|
28
|
+
expect(result.content).toContain('<div>async</div>');
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/island.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { html } from '@hyperspan/html';
|
|
2
|
+
|
|
3
|
+
type IslandComponent = {
|
|
4
|
+
__HS_ISLAND?: {
|
|
5
|
+
render: (props: unknown, options: unknown) => string | Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
name?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type IslandRenderOptions = {
|
|
11
|
+
ssr?: boolean;
|
|
12
|
+
loading?: 'lazy' | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render any Hyperspan island component (Preact, Svelte, Vue).
|
|
17
|
+
* Proxies to `Component.__HS_ISLAND.render` injected by the island Vite plugin.
|
|
18
|
+
*/
|
|
19
|
+
export function renderIsland(
|
|
20
|
+
Component: IslandComponent,
|
|
21
|
+
props: Record<string, unknown> = {},
|
|
22
|
+
options: IslandRenderOptions = { ssr: true }
|
|
23
|
+
) {
|
|
24
|
+
if (!Component?.__HS_ISLAND?.render) {
|
|
25
|
+
const label = Component?.name ?? 'Component';
|
|
26
|
+
throw new Error(
|
|
27
|
+
`${label} is not a Hyperspan island. Import it with \`with { island: 'preact' | 'svelte' | 'vue' }\` ` +
|
|
28
|
+
`and ensure the matching plugin is listed in hyperspan.config.ts plugins.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = Component.__HS_ISLAND.render(props, options);
|
|
33
|
+
if (result != null && typeof (result as Promise<string>).then === 'function') {
|
|
34
|
+
return (result as Promise<string>).then((s) => html.raw(s));
|
|
35
|
+
}
|
|
36
|
+
return html.raw(result as string);
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test, expect } from 'vitest';
|
|
2
|
+
import { render } from '@hyperspan/html';
|
|
3
|
+
import { hyperspanScriptTags } from './layout';
|
|
4
|
+
import { streamingClient } from './client/js';
|
|
5
|
+
|
|
6
|
+
test('streaming bootstrap injects a classic script so chunks swap in during parse', () => {
|
|
7
|
+
const html = render(hyperspanScriptTags());
|
|
8
|
+
expect(html).toContain("document.createElement('script')");
|
|
9
|
+
expect(html).toContain(`script.src = '${streamingClient.publicPath}'`);
|
|
10
|
+
expect(html).not.toContain("type = 'module'");
|
|
11
|
+
expect(html).not.toContain(`import('${streamingClient.publicPath}')`);
|
|
12
|
+
});
|
package/src/layout.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { html } from '@hyperspan/html';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { CSS_PUBLIC_PATH } from './client/css';
|
|
3
|
+
import { getImportMap, getRouteCss } from './client/manifest';
|
|
4
|
+
import { actionsClient, streamingClient } from './client/js';
|
|
4
5
|
import type { Hyperspan as HS } from './types';
|
|
5
6
|
|
|
6
|
-
const clientStreamingJS = await buildClientJS(
|
|
7
|
-
import.meta.resolve('./client/_hs/hyperspan-streaming.client')
|
|
8
|
-
);
|
|
9
|
-
|
|
10
7
|
/**
|
|
11
|
-
* Output the importmap for the client so we can use ESModules on the client to load JS files on demand
|
|
8
|
+
* Output the importmap for the client so we can use ESModules on the client to load JS files on demand.
|
|
9
|
+
*
|
|
10
|
+
* The streaming client MUST be loaded as a classic <script>, not type=module.
|
|
11
|
+
* Browsers defer all ESM until the document finishes parsing, which would hold
|
|
12
|
+
* every streaming chunk until the whole page is done. A dynamically inserted
|
|
13
|
+
* classic script runs as soon as it downloads.
|
|
12
14
|
*/
|
|
13
15
|
export function hyperspanScriptTags() {
|
|
16
|
+
const imports = getImportMap();
|
|
17
|
+
const streamingPath = streamingClient.publicPath;
|
|
18
|
+
|
|
14
19
|
return html`
|
|
15
20
|
<script type="importmap">
|
|
16
|
-
{"imports": ${
|
|
21
|
+
{"imports": ${imports}}
|
|
17
22
|
</script>
|
|
18
23
|
<script id="hyperspan-streaming-script">
|
|
19
24
|
// [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
|
|
@@ -25,7 +30,7 @@ export function hyperspanScriptTags() {
|
|
|
25
30
|
if (!window._hscLoading) {
|
|
26
31
|
window._hscLoading = true;
|
|
27
32
|
const script = document.createElement('script');
|
|
28
|
-
script.src = '${
|
|
33
|
+
script.src = '${streamingPath}';
|
|
29
34
|
document.body.appendChild(script);
|
|
30
35
|
}
|
|
31
36
|
};
|
|
@@ -39,11 +44,23 @@ export function hyperspanScriptTags() {
|
|
|
39
44
|
*/
|
|
40
45
|
export function hyperspanStyleTags(context: HS.Context) {
|
|
41
46
|
const styleTags = [];
|
|
42
|
-
const cssImports = context.route.cssImports
|
|
47
|
+
const cssImports = context.route.cssImports?.length
|
|
48
|
+
? context.route.cssImports
|
|
49
|
+
: getRouteCss(context.route.path);
|
|
43
50
|
|
|
44
51
|
for (const cssFile of cssImports) {
|
|
45
|
-
|
|
52
|
+
// Absolute Vite/dev URLs (e.g. /app/styles/globals.css) are used as-is;
|
|
53
|
+
// hashed build artifacts are served under /_hs/css/.
|
|
54
|
+
const href = cssFile.startsWith('/') ? cssFile : `${CSS_PUBLIC_PATH}/${cssFile}`;
|
|
55
|
+
styleTags.push(html` <link rel="stylesheet" href="${href}" /> `);
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
return styleTags;
|
|
49
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render the actions client script tag (used by createAction).
|
|
63
|
+
*/
|
|
64
|
+
export function hyperspanActionsScriptTag() {
|
|
65
|
+
return html`<script type="module" src="${actionsClient.publicPath}"></script>`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getRunnableRoute } from './server';
|
|
2
|
+
import { isValidRoutePath, parsePath } from './utils';
|
|
3
|
+
import type { AssetManifest } from './client/manifest';
|
|
4
|
+
import type { Hyperspan as HS } from './types';
|
|
5
|
+
|
|
6
|
+
export type RouteModuleEntry = {
|
|
7
|
+
file: string;
|
|
8
|
+
mod: unknown;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register a route or action module on a Hyperspan server.
|
|
13
|
+
* Shared by the CLI, Vite plugin, and the generated production server entry.
|
|
14
|
+
*/
|
|
15
|
+
export function registerRouteModule(
|
|
16
|
+
server: HS.Server,
|
|
17
|
+
relativeFilePath: string,
|
|
18
|
+
mod: unknown,
|
|
19
|
+
manifest?: AssetManifest
|
|
20
|
+
): HS.Route | null {
|
|
21
|
+
if (!isValidRoutePath(relativeFilePath)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const route = getRunnableRoute(mod);
|
|
26
|
+
const parsedPath = parsePath(relativeFilePath);
|
|
27
|
+
|
|
28
|
+
let path = parsedPath.path;
|
|
29
|
+
if (typeof route._path === 'function') {
|
|
30
|
+
const routePath = route._path();
|
|
31
|
+
if (routePath && routePath !== '/') path = routePath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!route._config.path) {
|
|
35
|
+
route._config.path = path;
|
|
36
|
+
if (parsedPath.params.length > 0) {
|
|
37
|
+
const params = route._config.params ?? {};
|
|
38
|
+
parsedPath.params.forEach((param) => {
|
|
39
|
+
params[param] = undefined;
|
|
40
|
+
});
|
|
41
|
+
route._config.params = params;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (manifest) {
|
|
46
|
+
const cssFiles = manifest.css?.[path] ?? manifest.css?.['*'];
|
|
47
|
+
if (cssFiles?.length) {
|
|
48
|
+
route._config.cssImports = cssFiles;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
server._routes.push(route);
|
|
53
|
+
return route;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function registerRouteModules(
|
|
57
|
+
server: HS.Server,
|
|
58
|
+
modules: RouteModuleEntry[],
|
|
59
|
+
manifest?: AssetManifest
|
|
60
|
+
): void {
|
|
61
|
+
for (const { file, mod } of modules) {
|
|
62
|
+
registerRouteModule(server, file, mod, manifest);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/server.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { test, expect, describe } from '
|
|
2
|
-
import { createRoute, createServer, createContext } from './server';
|
|
1
|
+
import { test, expect, describe } from 'vitest';
|
|
2
|
+
import { createRoute, createServer, createContext, initServerRoutes } from './server';
|
|
3
3
|
import { createAction } from './actions';
|
|
4
4
|
import { html, placeholder } from '@hyperspan/html';
|
|
5
5
|
import type { Hyperspan as HS } from './types';
|
|
@@ -92,6 +92,25 @@ test('server returns a route with a POST request', async () => {
|
|
|
92
92
|
expect(await response.text()).toBe('<h1>POST /users</h1>');
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
test('POST HTML response does not inherit request Content-Length', async () => {
|
|
96
|
+
const route = createRoute().post((context: HS.Context) => {
|
|
97
|
+
return context.res.html('<h1>hello world this is longer than the form body</h1>');
|
|
98
|
+
});
|
|
99
|
+
const body = 'email=a@b.com&password=secret';
|
|
100
|
+
const request = new Request('http://localhost:3000/', {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: {
|
|
103
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
104
|
+
'content-length': String(body.length),
|
|
105
|
+
},
|
|
106
|
+
body,
|
|
107
|
+
});
|
|
108
|
+
const response = await route.fetch(request);
|
|
109
|
+
const text = await response.text();
|
|
110
|
+
expect(text).toBe('<h1>hello world this is longer than the form body</h1>');
|
|
111
|
+
expect(Number(response.headers.get('content-length') ?? text.length)).toBe(text.length);
|
|
112
|
+
});
|
|
113
|
+
|
|
95
114
|
test('server returns a route with a ALL request', async () => {
|
|
96
115
|
const server = await createServer({
|
|
97
116
|
appDir: './app',
|
|
@@ -199,6 +218,17 @@ test('createContext() can get and set cookies', () => {
|
|
|
199
218
|
}
|
|
200
219
|
});
|
|
201
220
|
|
|
221
|
+
test('createContext() url() returns a relative URL string', () => {
|
|
222
|
+
const request = new Request('http://localhost:3000/posts?sort=asc#top');
|
|
223
|
+
const context = createContext(request);
|
|
224
|
+
|
|
225
|
+
expect(typeof context.url()).toBe('string');
|
|
226
|
+
expect(context.url()).toBe('/posts?sort=asc#top');
|
|
227
|
+
expect(context.url({ searchParams: { page: 2 } })).toBe('/posts?sort=asc&page=2#top');
|
|
228
|
+
expect(context.url({ searchParams: { page: 2 } }, { clean: true })).toBe('/posts?page=2');
|
|
229
|
+
expect(context.req.url.search).toBe('?sort=asc');
|
|
230
|
+
});
|
|
231
|
+
|
|
202
232
|
test('createContext() merge() function preserves custom headers when using response methods', async () => {
|
|
203
233
|
// Create a request
|
|
204
234
|
const request = new Request('http://localhost:3000/');
|
|
@@ -555,3 +585,31 @@ describe('when streaming is disabled', () => {
|
|
|
555
585
|
expect(text).not.toContain('hs:loading');
|
|
556
586
|
});
|
|
557
587
|
});
|
|
588
|
+
|
|
589
|
+
test('initServerRoutes runs beforeRoutesAdded, then addRoutes, then afterRoutesAdded', async () => {
|
|
590
|
+
const order: string[] = [];
|
|
591
|
+
const server = await createServer({
|
|
592
|
+
appDir: './app',
|
|
593
|
+
publicDir: './public',
|
|
594
|
+
plugins: [],
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
await initServerRoutes(
|
|
598
|
+
server,
|
|
599
|
+
{
|
|
600
|
+
beforeRoutesAdded(s) {
|
|
601
|
+
order.push('before');
|
|
602
|
+
s.use(async (_c, next) => next());
|
|
603
|
+
},
|
|
604
|
+
afterRoutesAdded() {
|
|
605
|
+
order.push('after');
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
() => {
|
|
609
|
+
order.push('routes');
|
|
610
|
+
}
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
expect(order).toEqual(['before', 'routes', 'after']);
|
|
614
|
+
expect(server._middleware['*']).toHaveLength(1);
|
|
615
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
} from '@hyperspan/html';
|
|
11
11
|
import { isbot } from 'isbot';
|
|
12
12
|
import { executeMiddleware } from './middleware';
|
|
13
|
-
import { parsePath, removeUndefined } from './utils';
|
|
13
|
+
import { buildUrl, parsePath, removeUndefined } from './utils';
|
|
14
14
|
import { Cookies } from './cookies';
|
|
15
|
+
import { nodeAdapter } from '@hyperspan/adapter-node';
|
|
15
16
|
|
|
16
17
|
import type { Hyperspan as HS } from './types';
|
|
17
18
|
|
|
@@ -51,6 +52,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
|
51
52
|
appDir: './app',
|
|
52
53
|
publicDir: './public',
|
|
53
54
|
plugins: [],
|
|
55
|
+
deployAdapter: config.deployAdapter ?? nodeAdapter(),
|
|
54
56
|
responseOptions: {
|
|
55
57
|
disableStreaming: hyperspanDisableStreaming,
|
|
56
58
|
},
|
|
@@ -58,6 +60,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
|
58
60
|
return {
|
|
59
61
|
...defaultConfig,
|
|
60
62
|
...config,
|
|
63
|
+
deployAdapter: config.deployAdapter ?? defaultConfig.deployAdapter,
|
|
61
64
|
responseOptions: {
|
|
62
65
|
...defaultConfig.responseOptions,
|
|
63
66
|
...config.responseOptions,
|
|
@@ -72,7 +75,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
72
75
|
const url = new URL(req.url);
|
|
73
76
|
const query = new URLSearchParams(url.search);
|
|
74
77
|
const method = req.method.toUpperCase();
|
|
75
|
-
const
|
|
78
|
+
const requestHeaders = new Headers(req.headers);
|
|
79
|
+
const responseHeaders = new Headers();
|
|
76
80
|
const path = route?._path() || '/';
|
|
77
81
|
const requestParams = (req as Request & { params?: Record<string, string | undefined> }).params;
|
|
78
82
|
const params: Record<string, string | undefined> = Object.assign(
|
|
@@ -92,12 +96,21 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
92
96
|
// Status override for the response. Will use if set. (e.g. c.res.status = 400)
|
|
93
97
|
let status: number | undefined = undefined;
|
|
94
98
|
|
|
99
|
+
const copyHeaders = (source: Headers, target: Headers) => {
|
|
100
|
+
source.forEach((value, key) => {
|
|
101
|
+
if (key.toLowerCase() === 'set-cookie') return;
|
|
102
|
+
target.set(key, value);
|
|
103
|
+
});
|
|
104
|
+
const cookies = typeof source.getSetCookie === 'function' ? source.getSetCookie() : [];
|
|
105
|
+
for (const cookie of cookies) {
|
|
106
|
+
target.append('Set-Cookie', cookie);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
95
110
|
const merge = async (response: Response) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
...Object.fromEntries(response.headers.entries()),
|
|
100
|
-
};
|
|
111
|
+
const mergedHeaders = new Headers();
|
|
112
|
+
copyHeaders(responseHeaders, mergedHeaders);
|
|
113
|
+
copyHeaders(response.headers, mergedHeaders);
|
|
101
114
|
|
|
102
115
|
return new Response(await response.text(), {
|
|
103
116
|
status: context.res.status ?? response.status,
|
|
@@ -107,6 +120,10 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
107
120
|
|
|
108
121
|
const context: HS.Context = {
|
|
109
122
|
vars: {},
|
|
123
|
+
url: (diff, options) => {
|
|
124
|
+
const next = buildUrl(url, diff, options);
|
|
125
|
+
return `${next.pathname}${next.search}${next.hash}`;
|
|
126
|
+
},
|
|
110
127
|
route: {
|
|
111
128
|
name: route?._config.name || undefined,
|
|
112
129
|
path,
|
|
@@ -117,7 +134,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
117
134
|
raw: req,
|
|
118
135
|
url,
|
|
119
136
|
method,
|
|
120
|
-
headers,
|
|
137
|
+
headers: requestHeaders,
|
|
121
138
|
query,
|
|
122
139
|
cookies: new Cookies(req),
|
|
123
140
|
async text() {
|
|
@@ -134,8 +151,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
134
151
|
},
|
|
135
152
|
},
|
|
136
153
|
res: {
|
|
137
|
-
cookies: new Cookies(req,
|
|
138
|
-
headers,
|
|
154
|
+
cookies: new Cookies(req, responseHeaders),
|
|
155
|
+
headers: responseHeaders,
|
|
139
156
|
status,
|
|
140
157
|
html: (html: string, options?: ResponseInit) =>
|
|
141
158
|
merge(
|
|
@@ -497,6 +514,20 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
|
|
|
497
514
|
return api;
|
|
498
515
|
}
|
|
499
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Shared route lifecycle for Vite dev, production entries, and `hyperspan start`.
|
|
519
|
+
* Always: `beforeRoutesAdded` → add routes → `afterRoutesAdded`.
|
|
520
|
+
*/
|
|
521
|
+
export async function initServerRoutes(
|
|
522
|
+
server: HS.Server,
|
|
523
|
+
config: Pick<HS.Config, 'beforeRoutesAdded' | 'afterRoutesAdded'>,
|
|
524
|
+
addRoutes: (server: HS.Server) => void | Promise<void>
|
|
525
|
+
): Promise<void> {
|
|
526
|
+
await config.beforeRoutesAdded?.(server);
|
|
527
|
+
await addRoutes(server);
|
|
528
|
+
await config.afterRoutesAdded?.(server);
|
|
529
|
+
}
|
|
530
|
+
|
|
500
531
|
/**
|
|
501
532
|
* Checks if a response is HTML content
|
|
502
533
|
*/
|
|
@@ -537,7 +568,7 @@ export async function returnHTMLResponse(
|
|
|
537
568
|
if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
|
|
538
569
|
return new StreamResponse(
|
|
539
570
|
renderStream(routeContent as HSHtml, {
|
|
540
|
-
renderChunk: (chunk) => {
|
|
571
|
+
renderChunk: (chunk: { id: string; content: string }) => {
|
|
541
572
|
// Trailing <!--/hs:chunk--> marks the end of a streaming chunk boundary.
|
|
542
573
|
return html`
|
|
543
574
|
<template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
|
package/src/ssr/mock-dom.test.ts
CHANGED