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
20 changes: 14 additions & 6 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,14 @@ export function compileRouteTable(
lines.push('');
}

const trackAssetMisses = !options.nodeTarget && !managedDispatch;
const registerNotFound = (handler: string): void => {
const tracked = trackAssetMisses ? `__routingRuntime.trackNotFound(${handler})` : handler;
lines.push(`app.notFound(${tracked});`);
};
if (trackAssetMisses) {
lines.push('app.notFound(__routingRuntime.trackNotFound());');
}
if (
pages &&
(scan.pages.pages.length > 0 ||
Expand All @@ -484,8 +492,8 @@ export function compileRouteTable(
const pagePatterns = options.trailingSlash
? `[${scan.pages.pages.map((page) => patternToRegex(page.pattern)).join(', ')}]`
: 'undefined';
lines.push(
`app.notFound(__createPagesFallback({ ${createBoundaryManifest(scan.pages).root ? 'pageNotFound: __voidRootNotFound, ' : ''}routing: __routingRuntime, errors: __errorRenderer, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, trailingSlash: ${options.trailingSlash === true}, pagePatterns: ${pagePatterns} }));`,
registerNotFound(
`__createPagesFallback({ ${createBoundaryManifest(scan.pages).root ? 'pageNotFound: __voidRootNotFound, ' : ''}routing: __routingRuntime, errors: __errorRenderer, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, trailingSlash: ${options.trailingSlash === true}, pagePatterns: ${pagePatterns} })`,
);
}

Expand All @@ -495,8 +503,8 @@ export function compileRouteTable(
lines.push(
`const getClientAssetTags = __createClientAssetTags(${JSON.stringify({ entryId: ssr.clientEntryRelativePath, entryUrl: '/' + ssr.clientEntryRelativePath, kind: 'ssr', dev: options.dev === true, reactRefresh: options.reactRefresh })});`,
);
lines.push(
`app.notFound(__createSsrFallback({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, load: () => import(${JSON.stringify(ssr.serverEntryPath)}), assets: getClientAssetTags }));`,
registerNotFound(
`__createSsrFallback({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, load: () => import(${JSON.stringify(ssr.serverEntryPath)}), assets: getClientAssetTags })`,
);
} else if (
emitEdgeRules &&
Expand All @@ -511,8 +519,8 @@ export function compileRouteTable(
))
)
) {
lines.push(
`app.notFound(__createAssetNotFound({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson} }));`,
registerNotFound(
`__createAssetNotFound({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson} })`,
);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/void/src/runtime/routing-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,9 @@ export function createSsrFallback(options: {
if (typeof render !== 'function') {
throw new Error('ssr: Invalid entry. Export render(c, assetTags).');
}
return render(c, await options.assets(c.req.raw, c.env));
return options.routing.preserveNotFound(
await render(c, await options.assets(c.req.raw, c.env)),
);
};
}
export function createAssetNotFound(options: {
Expand Down
4 changes: 4 additions & 0 deletions packages/void/src/runtime/routing-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { redirectToHttps } from '@void/edge';
import type { ExecutionContext } from 'hono';
import { createManifestLoader, type ManifestLoader } from './routing-assets.ts';

// Native Void routing records intentional 404s before Hono wraps HEAD responses.
export const intentionalNotFoundRequests: WeakSet<Request> = new WeakSet();

export type AssetBinding = { fetch: (request: Request) => Promise<Response> };
export type RuntimeEnvironment = Record<string, unknown> & { ASSETS?: AssetBinding };
export type AssetFallbackOptions = {
Expand Down Expand Up @@ -106,6 +109,7 @@ export function createAssetFallback(
return async (request, env, response) => {
if (
response.status !== 404 ||
intentionalNotFoundRequests.has(request) ||
!env.ASSETS ||
(request.method !== 'GET' && request.method !== 'HEAD')
) {
Expand Down
149 changes: 148 additions & 1 deletion packages/void/src/runtime/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
mergeQueryIntoLocation,
substituteParams,
} from '@void/edge';
import type { Context, Hono, MiddlewareHandler } from 'hono';
import type { Context, Hono, MiddlewareHandler, NotFoundHandler } from 'hono';
import type { TypedResponse } from 'hono';
import type { RedirectStatusCode } from 'hono/utils/http-status';
import type { RouteDestination, RouteUrlInput } from 'void/routes';
Expand All @@ -25,6 +25,7 @@ import { convertReturnValue } from './response.ts';
import { toUrlString } from './route-url.ts';
import { ASSET_EXTENSIONS } from './routing-assets.ts';
import { markRouteHandler } from './routing-pages.ts';
import { intentionalNotFoundRequests } from './routing-worker.ts';
import { isUnchangedStaticAsset, markStaticAsset } from './static-asset-marker.ts';
export { createErrorRenderer } from './routing-errors.ts';

Expand All @@ -50,6 +51,8 @@ export type RoutingContext = Context & {
export type RoutingRuntime = {
requestContext: AsyncLocalStorage<RequestContextStore>;
contextMiddleware: MiddlewareHandler;
trackNotFound: (handler?: NotFoundHandler) => NotFoundHandler;
preserveNotFound: (response: Response) => Response;
requestMiddleware: MiddlewareHandler;
routingMiddleware: (rules: Array<RoutingRule>) => MiddlewareHandler;
headerMiddleware: (rules: Array<RoutingHeaderRule>, phase?: 'auth-error') => MiddlewareHandler;
Expand All @@ -75,6 +78,102 @@ export function createRoutingRuntime(
// Contexts whose inner header pass ran after next(). A handler that throws
// past it, such as notFound, leaves the context out.
const headerApplied = new WeakSet<Context>();
const explicitNotFound = new WeakSet<Context>();
let trackAssetMisses = false;
const intentionalResponses = new WeakSet<Response>();
const routeMisses = new WeakMap<Context, Array<Response>>();
const missBodies = new WeakMap<Context, WeakSet<ReadableStream>>();
const trackMissBody = (c: Context, body: ReadableStream | null): void => {
if (!body) {
return;
}
let bodies = missBodies.get(c);
if (!bodies) {
bodies = new WeakSet();
missBodies.set(c, bodies);
}
if (bodies.has(body)) {
return;
}
bodies.add(body);
// Middleware such as Hono's compress() replaces the body with a transform
// of this stream. That still carries a miss; an unrelated replacement does not.
if (Object.isExtensible(body)) {
const pipeThrough = body.pipeThrough;
Object.defineProperty(body, 'pipeThrough', {
configurable: true,
writable: true,
value(this: ReadableStream, ...args: Parameters<ReadableStream['pipeThrough']>) {
const transformed = pipeThrough.apply(this, args);
if (this === body) {
trackMissBody(c, transformed);
}
return transformed;
},
});
}
};
const recordMiss = (c: Context, response: Response): void => {
const misses = routeMisses.get(c) ?? [];
if (misses.includes(response)) {
return;
}
misses.push(response);
routeMisses.set(c, misses);
trackMissBody(c, response.body);
trackResponseCopies(c);
// Fetch's clone tees the body, so neither clone shares its former stream.
if (Object.isExtensible(response)) {
const clone = response.clone;
Object.defineProperty(response, 'clone', {
configurable: true,
writable: true,
value(this: Response) {
const copy = clone.call(this);
if (this === response) {
trackMissBody(c, this.body);
recordMiss(c, copy);
}
return copy;
},
});
}
};
const isRouteMiss = (c: Context, response: Response): boolean =>
routeMisses
.get(c)
?.some((miss) => miss === response || (miss.body !== null && miss.body === response.body)) ===
true ||
(response.body !== null && missBodies.get(c)?.has(response.body) === true);
const tracksResponseCopies = new WeakSet<Context>();
const trackResponseCopies = (c: Context): void => {
if (tracksResponseCopies.has(c)) {
return;
}
tracksResponseCopies.add(c);
// Carry provenance to the response Hono exposes after assignment or a header edit.
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(c), 'res');
if (descriptor?.get && descriptor.set) {
const set = descriptor.set;
Object.defineProperty(c, 'res', {
...descriptor,
set(response: Response | undefined) {
set.call(c, response);
if (response && isRouteMiss(c, response) && c.res !== response) {
recordMiss(c, c.res);
}
},
});
}
const header = c.header.bind(c);
c.header = (...args) => {
const before = c.finalized ? c.res : undefined;
header(...args);
if (before && c.res !== before && isRouteMiss(c, before)) {
recordMiss(c, c.res);
}
};
};
const warnedPatterns = new Set<string>();
const requestContext = new AsyncLocalStorage<RequestContextStore>();
const assetExtensions = options.blockedAssetExtensions ?? ASSET_EXTENSIONS;
Expand Down Expand Up @@ -104,6 +203,13 @@ export function createRoutingRuntime(
// The enclosing response may depend on auth even if middleware clones or replaces it.
authDependentRequests.add(c.req.raw);
}
if (trackAssetMisses && response.status === 404) {
if (intentionalNotFoundRequests.has(request)) {
intentionalResponses.add(response);
} else {
recordMiss(c, response);
}
}
return response;
};
const rememberOriginal = (request: Request, c: Context): void => {
Expand Down Expand Up @@ -173,6 +279,19 @@ export function createRoutingRuntime(
};
const contextMiddleware: MiddlewareHandler = async (context, next) => {
const c = context as RoutingContext;
if (trackAssetMisses) {
intentionalNotFoundRequests.delete(c.req.raw);
const notFound = c.notFound.bind(c);
// Hono calls its fallback directly when the router runs out of handlers.
c.notFound = () => {
explicitNotFound.add(c);
try {
return notFound();
} finally {
explicitNotFound.delete(c);
}
};
}
if (options.managedDispatch && !originalUrls.has(c.req.raw)) {
const original = c.req.raw.headers.get('X-Void-Original-URL');
if (original != null) {
Expand Down Expand Up @@ -222,10 +341,38 @@ export function createRoutingRuntime(
c.originalUrl = () => originalUrls.get(c.req.raw) ?? null;
c.isRewritten = () => originalUrls.has(c.req.raw);
await next();
if (!trackAssetMisses) {
return;
}
if (c.res.status === 404 && !isRouteMiss(c, c.res)) {
intentionalNotFoundRequests.add(c.req.raw);
}
};
return {
requestContext,
contextMiddleware,
trackNotFound: (handler = (c) => c.text('404 Not Found', 404)) => {
trackAssetMisses = true;
return (c) => {
const explicit = explicitNotFound.has(c);
const response = handler(c);
if (explicit) {
return response;
}
return Promise.resolve(response).then((miss) => {
if (miss.status === 404 && !intentionalResponses.has(miss)) {
recordMiss(c, miss);
}
return miss;
});
};
},
preserveNotFound: (response) => {
if (trackAssetMisses && response.status === 404) {
intentionalResponses.add(response);
}
return response;
},
requestMiddleware: async (c, next) => {
await requestContext.run(
options.nodeTarget
Expand Down
Loading