diff --git a/.github/tests/test.js b/.github/tests/test.js
index 145a91a..7ff26b6 100644
--- a/.github/tests/test.js
+++ b/.github/tests/test.js
@@ -124,6 +124,22 @@ async function main() {
const app = createApp();
assert.equal(typeof app.error, "function");
+ assert.throws(
+ () => app.static("/users/:id", "invalid"),
+ /app\.static\(\) only supports exact GET paths without params/,
+ );
+
+ const middlewareBlockedApp = createApp();
+ middlewareBlockedApp.use(async (_req, _res, next) => {
+ await next();
+ });
+ middlewareBlockedApp.static("/blocked", "
blocked");
+
+ await assert.rejects(
+ () => middlewareBlockedApp.listen({ port: 0 }),
+ /app\.static\(\"\/blocked\"\) cannot be used with applicable middleware/,
+ );
+
httpServerConfig.tls = {
cert: "/tst/cert.pem",
key: "/tst/key.pem"
@@ -163,9 +179,36 @@ async function main() {
engine: "rust",
});
});
+ app.static(
+ "/ssr",
+ "ssr",
+ {
+ objects: {
+ page: "ssr",
+ safe: "nope",
+ },
+ },
+ );
+ app.static(
+ "/ssr-tail",
+ "tail",
+ {
+ objects: {
+ page: "tail",
+ },
+ },
+ );
app.get("/stable", (req, res) => {
res.json(stablePayload);
});
+ app.get("/html-helper", (_req, res) => {
+ res.html("helper", {
+ status: 201,
+ objects: {
+ helper: true,
+ },
+ });
+ });
app.get("/native/:id", (req, res) => {
res.json({
id: req.params.id,
@@ -271,6 +314,29 @@ async function main() {
engine: "rust",
});
+ const ssrResponse = await fetch(new URL("/ssr", server.url));
+ assert.equal(ssrResponse.status, 200);
+ assert.equal(ssrResponse.headers.get("content-type"), "text/html; charset=utf-8");
+ const ssrMarkup = await ssrResponse.text();
+ assert.match(
+ ssrMarkup,
+ /ssr<\/main>`;
+ const bodyCloseMatch = /<\/body\s*>/i.exec(markup);
+
+ if (!bodyCloseMatch || bodyCloseMatch.index === undefined) {
+ return `${markup}${script}`;
+ }
+
+ return `${markup.slice(0, bodyCloseMatch.index)}${script}${markup.slice(bodyCloseMatch.index)}`;
+}
+
+function buildHtmlResponsePayload(html, options = {}) {
+ const normalized = normalizeHtmlResponseOptions(options);
+ const headers = {
+ ...normalized.headers,
+ };
+
+ if (!headers["content-type"]) {
+ headers["content-type"] = "text/html; charset=utf-8";
+ }
+
+ return {
+ status: normalized.status,
+ headers,
+ body: injectHtmlObjectsScript(html, normalized.objects),
+ };
+}
+
+function createStaticHtmlFallbackHandler(staticResponse) {
+ return (_req, res) => {
+ res.status(staticResponse.status);
+ for (const [name, value] of Object.entries(staticResponse.headers)) {
+ res.header(name, value);
+ }
+ res.send(staticResponse.body);
+ };
+}
+
const RESPONSE_POOL_MAX = 512;
@@ -251,6 +351,22 @@ const RESPONSE_PROTO = {
return this;
},
+ html(html, options = {}) {
+ const state = this._state;
+ if (state.finished) {
+ return this;
+ }
+
+ const payload = buildHtmlResponsePayload(html, options);
+ state.status = payload.status;
+ for (const [name, value] of Object.entries(payload.headers)) {
+ state.headers[name] = value;
+ }
+ state.body = Buffer.from(payload.body, "utf8");
+ state.finished = true;
+ return this;
+ },
+
send(data) {
const state = this._state;
if (state.finished) {
@@ -791,6 +907,28 @@ function normalizeRouteRegistration(method, path, handler, options = {}) {
};
}
+function normalizeStaticRouteRegistration(path, html, options = {}) {
+ if (typeof html !== "string") {
+ throw new TypeError("app.static(path, html, options) expects html to be a string");
+ }
+
+ const normalizedPath = normalizeRoutePath("GET", path);
+ if (normalizedPath.includes(":")) {
+ throw new Error("app.static() only supports exact GET paths without params");
+ }
+
+ const staticResponse = buildHtmlResponsePayload(html, options);
+ return {
+ method: "GET",
+ path: normalizedPath,
+ handler: createStaticHtmlFallbackHandler(staticResponse),
+ cache: null,
+ staticResponse,
+ sourceLocation: options.sourceLocation ?? null,
+ syntheticHandlerSource: "(req, res) => res.html(\"\")",
+ };
+}
+
function captureRouteRegistrationLocation() {
const stack = new Error().stack;
if (!stack) {
@@ -852,6 +990,10 @@ function compileMiddlewareRegistration(middleware) {
}
function createRouteResponseCache(route, applicableMiddlewares, requestPlan, optConfig) {
+ if (route.staticResponse) {
+ return null;
+ }
+
if (optConfig?.cache !== true) {
return null;
}
@@ -1405,10 +1547,15 @@ function buildCompiledApplication(app, normalizedOptions) {
);
const routes = app._routes.map((route) => {
- let handlerSource = Function.prototype.toString.call(route.handler);
- const probedSource = probeHandlerForFastPath(route, handlerSource);
- if (probedSource) {
- handlerSource = probedSource;
+ let handlerSource =
+ route.syntheticHandlerSource ??
+ Function.prototype.toString.call(route.handler);
+
+ if (!route.staticResponse) {
+ const probedSource = probeHandlerForFastPath(route, handlerSource);
+ if (probedSource) {
+ handlerSource = probedSource;
+ }
}
return {
@@ -1418,6 +1565,22 @@ function buildCompiledApplication(app, normalizedOptions) {
...compileRouteShape(route.method, route.path),
};
});
+
+ for (const route of routes) {
+ if (!route.staticResponse) {
+ continue;
+ }
+
+ const hasMiddleware = compiledMiddlewares.some((middleware) =>
+ pathPrefixMatches(middleware.pathPrefix, route.path),
+ );
+ if (hasMiddleware) {
+ throw new Error(
+ `app.static(${JSON.stringify(route.path)}) cannot be used with applicable middleware`,
+ );
+ }
+ }
+
const compiledRoutes = routes.map((route) =>
compileRouteDispatch(
route,
@@ -1455,6 +1618,13 @@ function buildCompiledApplication(app, normalizedOptions) {
route.requestPlan.queryKeys.size > 0,
cache: normalizeManifestCache(route.cache),
needsSession: /\breq\.session\b|\breq\.sessionId\b/.test(route.handlerSource),
+ staticResponse: route.staticResponse
+ ? {
+ status: route.staticResponse.status,
+ headers: route.staticResponse.headers,
+ body: route.staticResponse.body,
+ }
+ : null,
})),
wsRoutes: app._wsRoutes.map((ws) => ({
path: ws.path,
@@ -1748,6 +1918,7 @@ export function createApp(config = {}) {
patch: undefined,
options: undefined,
all: undefined,
+ static: undefined,
reload(options = {}) {
this._reloadConfig = normalizeApplicationReloadConfig(options);
@@ -1934,6 +2105,23 @@ export function createApp(config = {}) {
app.patch = createMethodRegistrar(app, "PATCH");
app.options = createMethodRegistrar(app, "OPTIONS");
app.all = createMethodRegistrar(app, "ALL");
+ app.static = (routePath, html, options = {}) => {
+ const groupPrefix = app._groupPrefix ?? "/";
+ const scopedPath =
+ typeof routePath === "string"
+ ? applyGroupPrefixToRoutePath(routePath, groupPrefix)
+ : routePath;
+ const sourceLocation = captureRouteRegistrationLocation();
+ const routeOptions = sourceLocation
+ ? { ...options, sourceLocation }
+ : options;
+
+ app._routes.push({
+ ...normalizeStaticRouteRegistration(scopedPath, html, routeOptions),
+ handlerId: app._allocateHandlerId(),
+ });
+ return app;
+ };
return app;
}
diff --git a/src/opt/entry.js b/src/opt/entry.js
index b72e315..ffb19a1 100644
--- a/src/opt/entry.js
+++ b/src/opt/entry.js
@@ -5,7 +5,9 @@ export function buildRouteEntry(route, middlewares) {
);
const source = route.handlerSource ?? "";
const nativeCache = source.includes("res.ncache(");
- const staticFastPath = !nativeCache && isStaticFastPathCandidate(route, hasMiddleware, source);
+ const staticFastPath =
+ route.staticResponse != null ||
+ (!nativeCache && isStaticFastPathCandidate(route, hasMiddleware, source));
const cacheCandidate =
!staticFastPath &&
route.method === "GET" &&