Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion .github/tests/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ async function main() {
const app = createApp();
assert.equal(typeof app.error, "function");

assert.throws(
() => app.static("/users/:id", "<html>invalid</html>"),
/app\.static\(\) only supports exact GET paths without params/,
);

const middlewareBlockedApp = createApp();
middlewareBlockedApp.use(async (_req, _res, next) => {
await next();
});
middlewareBlockedApp.static("/blocked", "<html><body>blocked</body></html>");

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"
Expand Down Expand Up @@ -163,9 +179,36 @@ async function main() {
engine: "rust",
});
});
app.static(
"/ssr",
"<html><body><main>ssr</main></body></html>",
{
objects: {
page: "ssr",
safe: "</script><b>nope</b>",
},
},
);
app.static(
"/ssr-tail",
"<html><main>tail</main></html>",
{
objects: {
page: "tail",
},
},
);
app.get("/stable", (req, res) => {
res.json(stablePayload);
});
app.get("/html-helper", (_req, res) => {
res.html("<html><body><main>helper</main></body></html>", {
status: 201,
objects: {
helper: true,
},
});
});
app.get("/native/:id", (req, res) => {
res.json({
id: req.params.id,
Expand Down Expand Up @@ -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,
/<main>ssr<\/main><script>window\.hnSSR=window\.hnSSR\|\|\{\};window\.hnSSR\.objects=/,
);
assert.match(ssrMarkup, /"page":"ssr"/);
assert.doesNotMatch(ssrMarkup, /<\/script><b>nope<\/b>/);
assert.match(ssrMarkup, /<\/script><\/body><\/html>$/);

const ssrTailResponse = await fetch(new URL("/ssr-tail", server.url));
assert.equal(ssrTailResponse.status, 200);
const ssrTailMarkup = await ssrTailResponse.text();
assert.match(ssrTailMarkup, /<\/html><script>/);

const htmlHelperResponse = await fetch(new URL("/html-helper", server.url));
assert.equal(htmlHelperResponse.status, 201);
assert.equal(htmlHelperResponse.headers.get("content-type"), "text/html; charset=utf-8");
const htmlHelperMarkup = await htmlHelperResponse.text();
assert.match(htmlHelperMarkup, /window\.hnSSR\.objects=\{"helper":true\}/);

const userResponse = await fetch(new URL("/users/42", server.url));
assert.equal(userResponse.status, 200);
assert.equal(userResponse.headers.get("x-powered-by"), "http-native");
Expand Down Expand Up @@ -403,6 +469,9 @@ async function main() {
const stableRoute = snapshot.routes.find(
(route) => route.method === "GET" && route.path === "/stable",
);
const ssrRoute = snapshot.routes.find(
(route) => route.method === "GET" && route.path === "/ssr",
);
const userRoute = snapshot.routes.find(
(route) => route.method === "GET" && route.path === "/users/:id",
);
Expand All @@ -418,6 +487,7 @@ async function main() {

assert.ok(rootRoute);
assert.ok(stableRoute);
assert.ok(ssrRoute);
assert.ok(userRoute);
assert.ok(nativeRoute);
assert.ok(chainRoute);
Expand All @@ -434,6 +504,10 @@ async function main() {
assert.equal(stableRoute.hits, 32);
assert.equal(stableRoute.recommendation, null);

assert.equal(ssrRoute.staticFastPath, true);
assert.equal(ssrRoute.binaryBridge, true);
assert.equal(ssrRoute.bridgeObserved, false);

assert.equal(userRoute.staticFastPath, false);
assert.equal(userRoute.binaryBridge, true);
assert.equal(userRoute.bridgeObserved, true);
Expand All @@ -455,6 +529,7 @@ async function main() {

const summary = server.optimizations.summary();
assert.match(summary, /GET \/ \[static-fast-path, binary-bridge\]/);
assert.match(summary, /GET \/ssr \[static-fast-path, binary-bridge\]/);
assert.match(summary, /GET \/stable \[bridge-dispatch, binary-bridge, bridge-observed, cache-candidate\]/);
assert.match(summary, /GET \/users\/:id \[bridge-dispatch, binary-bridge, bridge-observed\]/);

Expand All @@ -469,4 +544,4 @@ async function main() {
console.log("[http-native] test suite passed");
}

await main();
await main();
13 changes: 13 additions & 0 deletions rsrc/src/manifest.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::collections::HashMap;

use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -101,6 +103,8 @@ pub struct RouteInput {
pub needs_session: bool,
#[serde(default)]
pub cache: Option<CacheConfigInput>,
#[serde(default)]
pub static_response: Option<StaticResponseInput>,
}

#[derive(Debug, Clone, Deserialize)]
Expand All @@ -118,6 +122,15 @@ pub struct CacheVaryInput {
pub name: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StaticResponseInput {
pub status: u16,
#[serde(default)]
pub headers: HashMap<String, String>,
pub body: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WsRouteInput {
Expand Down
37 changes: 36 additions & 1 deletion rsrc/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::analyzer::{
analyze_dynamic_fast_path, analyze_route, normalize_path, parse_segments, AnalysisResult,
DynamicFastPathSpec, RouteSegment,
};
use crate::manifest::{ManifestInput, MiddlewareInput, RouteInput};
use crate::manifest::{ManifestInput, MiddlewareInput, RouteInput, StaticResponseInput};

const ROUTE_KIND_EXACT: u8 = 1;
const ROUTE_KIND_PARAM: u8 = 2;
Expand Down Expand Up @@ -216,6 +216,25 @@ impl Router {
for route in &manifest.routes {
let method = route.method.to_uppercase();
let path = normalize_path(route.path.as_str());
if let Some(static_response) = route.static_response.as_ref() {
let Some(method_key) = MethodKey::from_method_str(method.as_str()) else {
continue;
};

let exact_route = build_exact_static_route(static_response);

if method_key == MethodKey::Get && path == "/" {
exact_get_root = Some(exact_route);
continue;
}

exact_static_routes
.entry(method_key)
.or_insert_with(HashMap::new)
.insert(Box::<[u8]>::from(path.as_bytes()), exact_route);
continue;
}

if let AnalysisResult::ExactStaticFastPath(spec) =
analyze_route(route, &manifest.middlewares)
{
Expand Down Expand Up @@ -469,6 +488,22 @@ fn compile_dynamic_route_spec(route: &RouteInput, middlewares: &[MiddlewareInput
}
}

fn build_exact_static_route(static_response: &StaticResponseInput) -> ExactStaticRoute {
let body = static_response.body.as_bytes();
ExactStaticRoute {
close_response: Bytes::from(build_close_response(
static_response.status,
&static_response.headers,
body,
)),
keep_alive_response: Bytes::from(build_keep_alive_response(
static_response.status,
&static_response.headers,
body,
)),
}
}

fn hash_cache_namespace(value: &str) -> u64 {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
Expand Down
15 changes: 15 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export interface Response {
/** Send a JSON response with proper Content-Type */
json(data: unknown): Response;

/** Send an HTML response, optionally injecting window.hnSSR.objects */
html(html: string, options?: HtmlResponseOptions): Response;

/** Send a response body (string, Buffer, or object) */
send(data?: string | Buffer | Uint8Array | null): Response;

Expand Down Expand Up @@ -209,6 +212,15 @@ export interface ReloadOptions {
clear?: boolean;
}

export interface HtmlResponseOptions {
/** HTTP status code (default: 200) */
status?: number;
/** Extra response headers */
headers?: Record<string, string>;
/** Static SSR payload injected as window.hnSSR.objects */
objects?: Record<string, unknown> | null;
}

export interface HotReloadOptions {
/** Files or directories to watch for runtime process respawn */
paths?: string[];
Expand Down Expand Up @@ -363,6 +375,9 @@ export interface Application {
/** Register a handler for all HTTP methods */
all(path: string, handler: RouteHandler): Application;

/** Register an exact GET HTML route served from the native static fast path */
static(path: string, html: string, options?: HtmlResponseOptions): Application;

/** Configure first-class app reload behavior for dev runtimes */
reload(options?: ReloadOptions): Application;

Expand Down
Loading
Loading