@hyperspan/framework 2.0.0-alpha.7 → 2.0.1
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/dist/actions.js +109 -0
- package/dist/callsites.js +18 -0
- package/dist/client/css.js +6 -0
- package/dist/client/js.js +314 -0
- package/dist/client/manifest.js +55 -0
- package/dist/cookies.js +142 -0
- package/dist/dev-bindings.js +15 -0
- package/dist/fetch-handler.js +108 -0
- package/dist/html.js +1 -0
- package/dist/index.js +50 -0
- package/dist/island.js +17 -0
- package/dist/layout.js +42 -0
- package/dist/middleware.js +89 -0
- package/dist/register-routes.js +41 -0
- package/dist/server.js +580 -0
- package/dist/ssr/install-server-dom-mock.js +2 -0
- package/dist/ssr/mock-dom.js +370 -0
- package/dist/utils.js +157 -0
- package/package.json +33 -18
- package/src/actions.test.ts +1 -1
- package/src/actions.ts +1 -1
- package/src/adapter.test.ts +1 -5
- package/src/client/js.test.ts +82 -17
- package/src/client/js.ts +163 -52
- package/src/client/manifest.ts +21 -12
- package/src/dev-bindings.ts +14 -0
- package/src/fetch-handler.test.ts +23 -0
- package/src/fetch-handler.ts +4 -3
- package/src/html.test.ts +13 -0
- package/src/html.ts +1 -0
- package/src/index.ts +3 -0
- package/src/island.ts +1 -1
- package/src/layout.test.ts +1 -1
- package/src/layout.ts +1 -1
- package/src/server.test.ts +49 -2
- package/src/server.ts +34 -18
- package/src/types.ts +28 -11
- package/.claude/settings.local.json +0 -9
- package/tsconfig.json +0 -25
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createContext } from "./server.js";
|
|
2
|
+
function compileRoutePath(path) {
|
|
3
|
+
const paramNames = [];
|
|
4
|
+
const isWildcard = path.endsWith("/*");
|
|
5
|
+
if (path === "/") {
|
|
6
|
+
return { pattern: /^\/$/, paramNames, isWildcard: false };
|
|
7
|
+
}
|
|
8
|
+
const segments = path.split("/").filter(Boolean);
|
|
9
|
+
const regexParts = segments.map((segment) => {
|
|
10
|
+
if (segment === "*") {
|
|
11
|
+
paramNames.push("...slug");
|
|
12
|
+
return "(.+)";
|
|
13
|
+
}
|
|
14
|
+
if (segment.startsWith(":")) {
|
|
15
|
+
paramNames.push(segment.slice(1));
|
|
16
|
+
return "([^/]+)";
|
|
17
|
+
}
|
|
18
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
});
|
|
20
|
+
const pattern = new RegExp(`^/${regexParts.join("/")}${isWildcard ? "" : "/?"}$`);
|
|
21
|
+
return { pattern, paramNames, isWildcard };
|
|
22
|
+
}
|
|
23
|
+
function mergeRouteMiddleware(server, route) {
|
|
24
|
+
route._serverConfig = server._config;
|
|
25
|
+
if (!server._middleware || !route._middleware) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const method of Object.keys(server._middleware)) {
|
|
29
|
+
if (Array.isArray(server._middleware[method]) && Array.isArray(route._middleware[method])) {
|
|
30
|
+
route._middleware[method] = server._middleware[method].concat(route._middleware[method]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function matchRoute(compiledRoutes, pathname, method) {
|
|
35
|
+
for (const compiled of compiledRoutes) {
|
|
36
|
+
const match = pathname.match(compiled.pattern);
|
|
37
|
+
if (!match) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const routeMethods = typeof compiled.route._methods === "function" ? compiled.route._methods() : ["*"];
|
|
41
|
+
const supportsMethod = routeMethods.includes(method) || routeMethods.includes("*") || method === "HEAD" && routeMethods.includes("GET");
|
|
42
|
+
if (!supportsMethod) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const params = {};
|
|
46
|
+
compiled.paramNames.forEach((name, index) => {
|
|
47
|
+
const value = match[index + 1];
|
|
48
|
+
if (name.startsWith("...")) {
|
|
49
|
+
params[name] = value;
|
|
50
|
+
} else {
|
|
51
|
+
params[name] = value;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return { route: compiled.route, params };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function trailingSlashRedirect(pathname, request) {
|
|
59
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
60
|
+
const url = new URL(request.url);
|
|
61
|
+
url.pathname = pathname.slice(0, -1);
|
|
62
|
+
return Response.redirect(url.toString(), 308);
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function createFetchHandler(server, options = {}) {
|
|
67
|
+
const compiledRoutes = server._routes.map((route) => {
|
|
68
|
+
const path = route._path();
|
|
69
|
+
mergeRouteMiddleware(server, route);
|
|
70
|
+
const { pattern, paramNames, isWildcard } = compileRoutePath(path);
|
|
71
|
+
return { route, pattern, paramNames, isWildcard };
|
|
72
|
+
});
|
|
73
|
+
compiledRoutes.sort((a, b) => {
|
|
74
|
+
const aSegments = a.route._path().split("/").filter(Boolean).length;
|
|
75
|
+
const bSegments = b.route._path().split("/").filter(Boolean).length;
|
|
76
|
+
return bSegments - aSegments;
|
|
77
|
+
});
|
|
78
|
+
return async (request) => {
|
|
79
|
+
const url = new URL(request.url);
|
|
80
|
+
const pathname = url.pathname;
|
|
81
|
+
const method = request.method.toUpperCase();
|
|
82
|
+
const slashRedirect = trailingSlashRedirect(pathname, request);
|
|
83
|
+
if (slashRedirect) {
|
|
84
|
+
return slashRedirect;
|
|
85
|
+
}
|
|
86
|
+
const matched = matchRoute(compiledRoutes, pathname, method);
|
|
87
|
+
if (matched) {
|
|
88
|
+
request.params = matched.params;
|
|
89
|
+
return matched.route.fetch(request);
|
|
90
|
+
}
|
|
91
|
+
if (options.onNotMatched) {
|
|
92
|
+
const staticResponse = await options.onNotMatched(request);
|
|
93
|
+
if (staticResponse) {
|
|
94
|
+
return staticResponse;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return createContext(request).res.notFound();
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function createApp(server, options = {}) {
|
|
101
|
+
const fetch = createFetchHandler(server, options);
|
|
102
|
+
return { fetch, server };
|
|
103
|
+
}
|
|
104
|
+
export {
|
|
105
|
+
compileRoutePath,
|
|
106
|
+
createApp,
|
|
107
|
+
createFetchHandler
|
|
108
|
+
};
|
package/dist/html.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@hyperspan/html";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createConfig,
|
|
3
|
+
createContext,
|
|
4
|
+
createRoute,
|
|
5
|
+
createServer,
|
|
6
|
+
initServerRoutes,
|
|
7
|
+
getRunnableRoute,
|
|
8
|
+
StreamResponse,
|
|
9
|
+
IS_PROD,
|
|
10
|
+
HTTPResponseException,
|
|
11
|
+
hyperspanDisableStreaming
|
|
12
|
+
} from "./server.js";
|
|
13
|
+
import { createFetchHandler, createApp, compileRoutePath } from "./fetch-handler.js";
|
|
14
|
+
import {
|
|
15
|
+
setAssetManifest,
|
|
16
|
+
getAssetManifest,
|
|
17
|
+
resolveImport,
|
|
18
|
+
getRouteCss,
|
|
19
|
+
getImportMap,
|
|
20
|
+
registerImport
|
|
21
|
+
} from "./client/manifest.js";
|
|
22
|
+
import { buildClientJS, registerPathAliases } from "./client/js.js";
|
|
23
|
+
import { registerRouteModule, registerRouteModules } from "./register-routes.js";
|
|
24
|
+
import { renderIsland } from "./island.js";
|
|
25
|
+
export {
|
|
26
|
+
HTTPResponseException,
|
|
27
|
+
IS_PROD,
|
|
28
|
+
StreamResponse,
|
|
29
|
+
buildClientJS,
|
|
30
|
+
compileRoutePath,
|
|
31
|
+
createApp,
|
|
32
|
+
createConfig,
|
|
33
|
+
createContext,
|
|
34
|
+
createFetchHandler,
|
|
35
|
+
createRoute,
|
|
36
|
+
createServer,
|
|
37
|
+
getAssetManifest,
|
|
38
|
+
getImportMap,
|
|
39
|
+
getRouteCss,
|
|
40
|
+
getRunnableRoute,
|
|
41
|
+
hyperspanDisableStreaming,
|
|
42
|
+
initServerRoutes,
|
|
43
|
+
registerImport,
|
|
44
|
+
registerPathAliases,
|
|
45
|
+
registerRouteModule,
|
|
46
|
+
registerRouteModules,
|
|
47
|
+
renderIsland,
|
|
48
|
+
resolveImport,
|
|
49
|
+
setAssetManifest
|
|
50
|
+
};
|
package/dist/island.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { html } from "./html.js";
|
|
2
|
+
function renderIsland(Component, props = {}, options = { ssr: true }) {
|
|
3
|
+
if (!Component?.__HS_ISLAND?.render) {
|
|
4
|
+
const label = Component?.name ?? "Component";
|
|
5
|
+
throw new Error(
|
|
6
|
+
`${label} is not a Hyperspan island. Import it with \`with { island: 'preact' | 'svelte' | 'vue' }\` and ensure the matching plugin is listed in hyperspan.config.ts plugins.`
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
const result = Component.__HS_ISLAND.render(props, options);
|
|
10
|
+
if (result != null && typeof result.then === "function") {
|
|
11
|
+
return result.then((s) => html.raw(s));
|
|
12
|
+
}
|
|
13
|
+
return html.raw(result);
|
|
14
|
+
}
|
|
15
|
+
export {
|
|
16
|
+
renderIsland
|
|
17
|
+
};
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
var __freeze = Object.freeze;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __template = (cooked, raw) => __freeze(__defProp(cooked, "raw", { value: __freeze(raw || cooked.slice()) }));
|
|
4
|
+
var _a, _b;
|
|
5
|
+
import { html } from "./html.js";
|
|
6
|
+
import { CSS_PUBLIC_PATH } from "./client/css.js";
|
|
7
|
+
import { getImportMap, getRouteCss } from "./client/manifest.js";
|
|
8
|
+
import { actionsClient, streamingClient } from "./client/js.js";
|
|
9
|
+
function hyperspanScriptTags() {
|
|
10
|
+
const imports = getImportMap();
|
|
11
|
+
const streamingPath = streamingClient.publicPath;
|
|
12
|
+
return html(_a || (_a = __template(['\n <script type="importmap">\n {"imports": ', `}
|
|
13
|
+
<\/script>
|
|
14
|
+
<script id="hyperspan-streaming-script">
|
|
15
|
+
// [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
|
|
16
|
+
window._hsc = window._hsc || [];
|
|
17
|
+
if (!window._hscInit) {
|
|
18
|
+
window._hscInit = true;
|
|
19
|
+
window._hsc.push = function (e) {
|
|
20
|
+
Array.prototype.push.call(window._hsc, e);
|
|
21
|
+
if (!window._hscLoading) {
|
|
22
|
+
window._hscLoading = true;
|
|
23
|
+
const script = document.createElement('script');
|
|
24
|
+
script.src = '`, "';\n document.body.appendChild(script);\n }\n };\n }\n <\/script>\n "])), imports, streamingPath);
|
|
25
|
+
}
|
|
26
|
+
function hyperspanStyleTags(context) {
|
|
27
|
+
const styleTags = [];
|
|
28
|
+
const cssImports = context.route.cssImports?.length ? context.route.cssImports : getRouteCss(context.route.path);
|
|
29
|
+
for (const cssFile of cssImports) {
|
|
30
|
+
const href = cssFile.startsWith("/") ? cssFile : `${CSS_PUBLIC_PATH}/${cssFile}`;
|
|
31
|
+
styleTags.push(html` <link rel="stylesheet" href="${href}" /> `);
|
|
32
|
+
}
|
|
33
|
+
return styleTags;
|
|
34
|
+
}
|
|
35
|
+
function hyperspanActionsScriptTag() {
|
|
36
|
+
return html(_b || (_b = __template(['<script type="module" src="', '"><\/script>'])), actionsClient.publicPath);
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
hyperspanActionsScriptTag,
|
|
40
|
+
hyperspanScriptTags,
|
|
41
|
+
hyperspanStyleTags
|
|
42
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { formDataToJSON } from "./utils.js";
|
|
2
|
+
import { flattenError, prettifyError } from "zod";
|
|
3
|
+
import { HTTPResponseException } from "./server.js";
|
|
4
|
+
function inferValidationType(headers) {
|
|
5
|
+
const contentType = headers.get("content-type")?.toLowerCase() || "";
|
|
6
|
+
if (contentType.includes("application/json")) {
|
|
7
|
+
return "json";
|
|
8
|
+
} else if (contentType.includes("multipart/form-data")) {
|
|
9
|
+
return "form";
|
|
10
|
+
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
11
|
+
return "urlencoded";
|
|
12
|
+
}
|
|
13
|
+
return "json";
|
|
14
|
+
}
|
|
15
|
+
class ZodValidationError extends Error {
|
|
16
|
+
fieldErrors;
|
|
17
|
+
formErrors;
|
|
18
|
+
constructor(error, schema) {
|
|
19
|
+
const message = prettifyError(error);
|
|
20
|
+
const flattened = flattenError(error);
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "ZodValidationError";
|
|
23
|
+
this.fieldErrors = flattened.fieldErrors;
|
|
24
|
+
this.formErrors = flattened.formErrors;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function validateQuery(schema) {
|
|
28
|
+
return async (context, next) => {
|
|
29
|
+
const query = formDataToJSON(context.req.query);
|
|
30
|
+
const validated = schema.safeParse(query);
|
|
31
|
+
context.vars.query = validated.data;
|
|
32
|
+
if (!validated.success) {
|
|
33
|
+
const err = new ZodValidationError(validated.error, schema);
|
|
34
|
+
throw new HTTPResponseException(err, { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
return next();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function validateBody(schema, type) {
|
|
40
|
+
return async (context, next) => {
|
|
41
|
+
const validationType = type || inferValidationType(context.req.headers);
|
|
42
|
+
let body = {};
|
|
43
|
+
if (validationType === "json") {
|
|
44
|
+
body = await context.req.raw.json();
|
|
45
|
+
} else if (validationType === "form") {
|
|
46
|
+
const formData = await context.req.formData();
|
|
47
|
+
body = formDataToJSON(formData);
|
|
48
|
+
} else if (validationType === "urlencoded") {
|
|
49
|
+
const urlencoded = await context.req.urlencoded();
|
|
50
|
+
body = formDataToJSON(urlencoded);
|
|
51
|
+
}
|
|
52
|
+
context.vars.body = body;
|
|
53
|
+
const validated = schema.safeParse(body);
|
|
54
|
+
if (!validated.success) {
|
|
55
|
+
const err = new ZodValidationError(validated.error, schema);
|
|
56
|
+
throw new HTTPResponseException(err, { status: 400 });
|
|
57
|
+
}
|
|
58
|
+
return next();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function isMiddlewareFunction(handler) {
|
|
62
|
+
return handler.length === 2;
|
|
63
|
+
}
|
|
64
|
+
async function executeMiddleware(context, handlers) {
|
|
65
|
+
if (handlers.length === 0) {
|
|
66
|
+
return context.res.notFound();
|
|
67
|
+
}
|
|
68
|
+
const createNext = (index) => {
|
|
69
|
+
return async () => {
|
|
70
|
+
if (index >= handlers.length) {
|
|
71
|
+
return context.res.notFound();
|
|
72
|
+
}
|
|
73
|
+
const handler = handlers[index];
|
|
74
|
+
if (isMiddlewareFunction(handler)) {
|
|
75
|
+
const next = createNext(index + 1);
|
|
76
|
+
const result = await handler(context, next);
|
|
77
|
+
return result instanceof Response ? result : context.res.html(String(result));
|
|
78
|
+
}
|
|
79
|
+
return await handler(context);
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
return await createNext(0)();
|
|
83
|
+
}
|
|
84
|
+
export {
|
|
85
|
+
ZodValidationError,
|
|
86
|
+
executeMiddleware,
|
|
87
|
+
validateBody,
|
|
88
|
+
validateQuery
|
|
89
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getRunnableRoute } from "./server.js";
|
|
2
|
+
import { isValidRoutePath, parsePath } from "./utils.js";
|
|
3
|
+
function registerRouteModule(server, relativeFilePath, mod, manifest) {
|
|
4
|
+
if (!isValidRoutePath(relativeFilePath)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
const route = getRunnableRoute(mod);
|
|
8
|
+
const parsedPath = parsePath(relativeFilePath);
|
|
9
|
+
let path = parsedPath.path;
|
|
10
|
+
if (typeof route._path === "function") {
|
|
11
|
+
const routePath = route._path();
|
|
12
|
+
if (routePath && routePath !== "/") path = routePath;
|
|
13
|
+
}
|
|
14
|
+
if (!route._config.path) {
|
|
15
|
+
route._config.path = path;
|
|
16
|
+
if (parsedPath.params.length > 0) {
|
|
17
|
+
const params = route._config.params ?? {};
|
|
18
|
+
parsedPath.params.forEach((param) => {
|
|
19
|
+
params[param] = void 0;
|
|
20
|
+
});
|
|
21
|
+
route._config.params = params;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (manifest) {
|
|
25
|
+
const cssFiles = manifest.css?.[path] ?? manifest.css?.["*"];
|
|
26
|
+
if (cssFiles?.length) {
|
|
27
|
+
route._config.cssImports = cssFiles;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
server._routes.push(route);
|
|
31
|
+
return route;
|
|
32
|
+
}
|
|
33
|
+
function registerRouteModules(server, modules, manifest) {
|
|
34
|
+
for (const { file, mod } of modules) {
|
|
35
|
+
registerRouteModule(server, file, mod, manifest);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
registerRouteModule,
|
|
40
|
+
registerRouteModules
|
|
41
|
+
};
|