UNPKG

@tanstack/start-server-core

Version:

Modern and scalable routing for React applications

512 lines (505 loc) 21.4 kB
import { requestHandler } from "./request-response.js"; import { getStartManifest } from "./router-manifest.js"; import { handleServerAction } from "./server-functions-handler.js"; import { createEarlyHintsCollector } from "./early-hints.js"; import { createCachedBaseManifestLoader, createFinalManifestResolver } from "./finalManifest.js"; import { HEADERS } from "./constants.js"; import { ServerFunctionSerializationAdapter } from "./serializer/ServerFunctionSerializationAdapter.js"; import { createServerHistory } from "@tanstack/history"; import { createCsrfMiddleware, createNullProtoObject, csrfSymbol, flattenMiddlewares, mergeHeaders, safeObjectMerge } from "@tanstack/start-client-core"; import { _getRenderedMatches, executeRewriteInput, isDangerousProtocol, isPromise, isRedirect } from "@tanstack/router-core"; import { attachRouterServerSsrUtils, bindSsrResponseToRequest, disposeSsrResponse, getNormalizedURL, isSsrResponse, normalizeSsrResponse, replaceSsrResponse, stripSsrResponseBody, waitForRequest } from "@tanstack/router-core/ssr/server"; import { getStartContext, runWithStartContext } from "@tanstack/start-storage-context"; //#region src/createStartHandler.ts function getStartResponseHeaders(opts) { return mergeHeaders({ "Content-Type": "text/html; charset=utf-8" }, ..._getRenderedMatches(opts.router.stores.matches.get()).map((match) => { return match.headers; })); } var entriesPromise; var hasWarnedMissingCsrfMiddleware = false; var defaultCsrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }); var getCachedBaseManifest = createCachedBaseManifestLoader(() => getStartManifest()); var getProdBaseManifest = () => getCachedBaseManifest(); var getBaseManifest = process.env.TSS_DEV_SERVER === "true" ? getStartManifest : getProdBaseManifest; var createEarlyHintsForRequest = process.env.TSS_DEV_SERVER === "true" ? () => void 0 : createEarlyHintsCollector; async function loadEntries() { const [routerEntry, startEntry, pluginAdapters] = await Promise.all([ import("#tanstack-router-entry"), import("#tanstack-start-entry"), import("#tanstack-start-plugin-adapters") ]); return { routerEntry, startEntry, pluginAdapters }; } function getEntries() { if (!entriesPromise) entriesPromise = loadEntries(); return entriesPromise; } function hasCsrfMiddleware(middlewares) { return middlewares.some((middleware) => csrfSymbol in middleware); } function warnMissingCsrfMiddlewareOnce() { if (hasWarnedMissingCsrfMiddleware) return; hasWarnedMissingCsrfMiddleware = true; console.warn(`TanStack Start server functions are not protected by the CSRF middleware. Server functions are same-origin RPC endpoints and should be protected from cross-site requests. Add the CSRF middleware in src/start.ts: const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn', }) export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware], })) If you intentionally handle CSRF another way, disable this warning: tanstackStart({ serverFns: { disableCsrfMiddlewareWarning: true, }, })`); } var ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || "/"; var SERVER_FN_BASE = process.env.TSS_SERVER_FN_BASE; var IS_PRERENDERING = process.env.TSS_PRERENDERING === "true"; var IS_SHELL_ENV = process.env.TSS_SHELL === "true"; var IS_DEV = process.env.NODE_ENV === "development"; var ERR_NO_RESPONSE = IS_DEV ? `It looks like you forgot to return a response from your server route handler. If you want to defer to the app router, make sure to have a component set in this route.` : "Internal Server Error"; var ERR_NO_DEFER = IS_DEV ? `You cannot defer to the app router if there is no component defined on this route.` : "Internal Server Error"; function throwRouteHandlerError() { throw new Error(ERR_NO_RESPONSE); } function throwIfMayNotDefer() { throw new Error(ERR_NO_DEFER); } function getResponseFromResult(result) { return isSsrResponse(result) || result instanceof Response ? result : result?.response; } var responseBodySources = /* @__PURE__ */ new WeakMap(); function disposeResponseResult(result, reason) { const response = getResponseFromResult(result); if (isSsrResponse(response) || response instanceof Response) disposeSsrResponse(response, reason); } /** * Marks `response` as directly derived from the current `source`. * Middleware must consume or cancel any other `clone()` or `tee()` branches. */ function transferResponseBodyOwnership(source, response) { if (!source.body || !response.body) throw new Error("Response body ownership requires two response bodies"); responseBodySources.set(response, source); return response; } function hasResponseBody(value) { return value instanceof Response && value.body !== null; } function inheritsResponseOwnership(ownership, candidate) { return hasResponseBody(candidate) && (candidate.body === ownership.response.body || responseBodySources.get(candidate) === ownership.response); } function disposeResponseOwnership(ownership, reason) { const { response, sourceBody, streamResponse } = ownership; streamResponse?.dispose(reason); if (!streamResponse || response.body !== sourceBody) response.body.cancel(reason).catch(() => {}); } function getOwnedResponse(ownership) { const { response, sourceBody, streamResponse } = ownership; if (!streamResponse) return response; if (streamResponse.response === response && response.body === sourceBody) return streamResponse; if (response.body === sourceBody) return { ...streamResponse, response }; return { ...streamResponse, response, dispose(reason) { disposeResponseOwnership(ownership, reason); } }; } function createLateResponseDisposer(signal) { return (result) => disposeResponseResult(result, signal.reason); } /** * Compose middleware around a terminal response handler. With no middleware * the terminal runs directly. */ async function executeMiddleware(middlewares, terminal, ctx, signal, terminalNext) { let index = -1; let responseOwnership; let settled = false; const disposeAbandonedResult = createLateResponseDisposer(signal); const setResponse = (response) => { const ssrResponse = isSsrResponse(response) ? response : void 0; const streamResponse = ssrResponse?.serverSsrCleanup === "stream" ? ssrResponse : void 0; const exposed = ssrResponse ? ssrResponse.response : response; const current = responseOwnership; if (settled) { if (exposed !== ctx.response) disposeResponseResult(response, "late middleware response"); return; } if (current && current.response === exposed) current.streamResponse ??= streamResponse; else if (current && inheritsResponseOwnership(current, exposed)) { current.response = exposed; current.streamResponse ??= streamResponse; } else { if (current) disposeResponseOwnership(current, "middleware response replaced"); if (hasResponseBody(exposed)) responseOwnership = { response: exposed, sourceBody: exposed.body, streamResponse }; else responseOwnership = void 0; } ctx.response = exposed; }; const reconcileCtxResponse = () => { if (ctx.response !== responseOwnership?.response) setResponse(ctx.response); }; let nextPromise; function next(nextCtx) { const result = runNext(nextCtx); nextPromise = result; return result; } async function runNext(nextCtx) { signal.throwIfAborted(); if (nextCtx) { if (nextCtx.context) ctx.context = safeObjectMerge(ctx.context, nextCtx.context); for (const key of Object.keys(nextCtx)) if (key === "response") setResponse(nextCtx.response); else if (key !== "context") ctx[key] = nextCtx[key]; } index++; const isTerminal = index === middlewares.length; const middleware = index < middlewares.length ? middlewares[index] : isTerminal ? terminal : void 0; const middlewareNext = isTerminal && terminalNext ? terminalNext : next; if (!middleware) return ctx; let result; try { const pending = middleware({ ...ctx, next: middlewareNext }); if (nextPromise && pending === nextPromise) { nextPromise = void 0; await pending; if (signal.aborted) throw signal.reason; return ctx; } else if (!isPromise(pending)) { result = pending; signal.throwIfAborted(); } else result = await waitForRequest(pending, signal, disposeAbandonedResult, disposeAbandonedResult); } catch (err) { reconcileCtxResponse(); if (signal.aborted) { if (result !== void 0) disposeAbandonedResult(result); if (err !== signal.reason) disposeAbandonedResult(err); throw signal.reason; } if (err instanceof Response) { setResponse(err); return ctx; } throw err; } if (isTerminal && terminalNext && !result) throwRouteHandlerError(); reconcileCtxResponse(); if (result && result !== ctx) { const response = getResponseFromResult(result); if (response !== void 0 && response !== ctx.response) setResponse(response); if (response !== result && result.context && result.context !== ctx.context) ctx.context = safeObjectMerge(ctx.context, result.context); } return ctx; } try { await runNext(); const response = ctx.response; if (!response) throwRouteHandlerError(); reconcileCtxResponse(); if (signal.aborted) throw signal.reason; settled = true; return responseOwnership ? getOwnedResponse(responseOwnership) : response; } catch (err) { settled = true; if (responseOwnership) disposeResponseOwnership(responseOwnership, signal.aborted ? signal.reason : err); throw err; } } /** * Creates the TanStack Start request handler. * * @example Backwards-compatible usage (handler callback only): * ```ts * export default createStartHandler(defaultStreamHandler) * ``` * * @example With CDN URL rewriting: * ```ts * export default createStartHandler({ * handler: defaultStreamHandler, * transformAssets: 'https://cdn.example.com', * }) * ``` * * @example With per-request URL rewriting: * ```ts * export default createStartHandler({ * handler: defaultStreamHandler, * transformAssets: { * transform: ({ url }) => { * const cdnBase = getRequest().headers.get('x-cdn-base') || '' * return { href: `${cdnBase}${url}` } * }, * cache: false, * }, * }) * ``` */ function createStartHandler(cbOrOptions) { const handlerOptions = typeof cbOrOptions === "function" ? {} : cbOrOptions; const cb = typeof cbOrOptions === "function" ? cbOrOptions : cbOrOptions.handler; const finalManifestResolver = createFinalManifestResolver({ ...handlerOptions, cacheCreateTransform: process.env.TSS_DEV_SERVER !== "true" }); const resolveManifestForRequest = process.env.TSS_DEV_SERVER === "true" ? finalManifestResolver.resolveUncached : finalManifestResolver.resolveCached; if (process.env.TSS_DEV_SERVER !== "true") finalManifestResolver.warmup({ getBaseManifest: () => getBaseManifest(void 0) }); const startRequestResolver = async (request, requestOpts) => { const signal = request.signal; let router; let routerPromise; let responseOwnsCleanup = false; try { signal.throwIfAborted(); const { url, handledProtocolRelativeURL } = getNormalizedURL(request.url); const href = url.pathname + url.search + url.hash; const origin = url.origin; if (handledProtocolRelativeURL) return Response.redirect(url, 308); const entries = await waitForRequest(getEntries(), signal); const isServerFnRequest = !!SERVER_FN_BASE && url.pathname.startsWith(SERVER_FN_BASE); const startInstance = entries.startEntry.startInstance; let startOptions; if (startInstance) { const pendingStartOptions = startInstance.getOptions(); startOptions = isPromise(pendingStartOptions) ? await waitForRequest(pendingStartOptions, signal) : pendingStartOptions; signal.throwIfAborted(); } else startOptions = {}; const { hasPluginAdapters, pluginSerializationAdapters } = entries.pluginAdapters; const serializationAdapters = [ ...startOptions.serializationAdapters || [], ...hasPluginAdapters ? pluginSerializationAdapters : [], ServerFunctionSerializationAdapter ]; const requestStartOptions = { ...startOptions, requestMiddleware: startInstance ? startOptions.requestMiddleware : isServerFnRequest ? [defaultCsrfMiddleware] : void 0, serializationAdapters }; const flattenedRequestMiddlewares = requestStartOptions.requestMiddleware ? flattenMiddlewares(requestStartOptions.requestMiddleware) : []; const executedRequestMiddlewares = new Set(flattenedRequestMiddlewares); const getRouter = () => { routerPromise ??= (async () => { signal.throwIfAborted(); const requestRouter = await waitForRequest(entries.routerEntry.getRouter(), signal); let isShell = IS_SHELL_ENV; if (IS_PRERENDERING && !isShell) isShell = request.headers.get(HEADERS.TSS_SHELL) === "true"; const history = createServerHistory(href); requestRouter.update({ history, isShell, isPrerendering: IS_PRERENDERING, origin: requestRouter.options.origin ?? origin, defaultSsr: requestStartOptions.defaultSsr, serializationAdapters: [...requestStartOptions.serializationAdapters, ...requestRouter.options.serializationAdapters || []], basepath: ROUTER_BASEPATH }); router = requestRouter; return requestRouter; })(); return routerPromise; }; const handlerType = isServerFnRequest ? "serverFn" : "router"; const startContext = { getRouter, startOptions: requestStartOptions, request, executedRequestMiddlewares, handlerType }; let terminal; if (isServerFnRequest) { if (process.env.NODE_ENV !== "production" && process.env.TSS_DISABLE_CSRF_MIDDLEWARE_WARNING !== "true" && !hasCsrfMiddleware(flattenedRequestMiddlewares)) warnMissingCsrfMiddlewareOnce(); const serverFnId = url.pathname.slice(SERVER_FN_BASE.length).split("/")[0]; if (!serverFnId) throw new Error("Invalid server action param for serverFnId"); terminal = ({ context }) => runWithStartContext({ ...startContext, contextAfterGlobalMiddlewares: context }, () => handleServerAction({ request, context: requestOpts?.context, serverFnId })); } else { const executeRouter = async (serverContext, matchedRoutes) => { if (!/(^|,)\s*(\*\/\*|text\/html)/.test(request.headers.get("Accept") || "*/*")) return normalizeSsrResponse(Response.json({ error: "Only HTML requests are supported here" }, { status: 406 })); const manifest = await waitForRequest(resolveManifestForRequest({ request, requestInlineCss: requestOpts?.inlineCss, getBaseManifest: () => getBaseManifest(matchedRoutes) }), signal); const earlyHints = createEarlyHintsForRequest({ onEarlyHints: requestOpts?.onEarlyHints, responseLinkHeader: requestOpts?.responseLinkHeader }); earlyHints?.collectStatic({ manifest, matchedRoutes }); const routerInstance = await getRouter(); attachRouterServerSsrUtils({ router: routerInstance, manifest, getRequestAssets: () => getStartContext({ throwIfNotFound: false })?.requestAssets }); routerInstance.options.additionalContext = { serverContext }; await routerInstance.load({ _signal: signal }); signal.throwIfAborted(); if (routerInstance._serverResult?.type === "redirect") return normalizeSsrResponse(routerInstance._serverResult.redirect); earlyHints?.collectDynamic(_getRenderedMatches(routerInstance.stores.matches.get())); const ctx = getStartContext({ throwIfNotFound: false }); await routerInstance.serverSsr.dehydrate({ requestAssets: ctx?.requestAssets, signal }); signal.throwIfAborted(); const responseHeaders = getStartResponseHeaders({ router: routerInstance }); earlyHints?.appendResponseHeaders(responseHeaders); signal.throwIfAborted(); const disposeLate = createLateResponseDisposer(signal); return normalizeSsrResponse(await waitForRequest(cb({ request, router: routerInstance, responseHeaders }), signal, disposeLate, disposeLate)); }; terminal = ({ context }) => runWithStartContext({ ...startContext, contextAfterGlobalMiddlewares: context }, () => handleServerRoutes({ getRouter, request, url, executeRouter, context, executedRequestMiddlewares })); } const middlewareResponse = await executeMiddleware(flattenedRequestMiddlewares.map((d) => d.options.server), terminal, { request, pathname: url.pathname, handlerType, context: createNullProtoObject(requestOpts?.context) }, signal); let result; try { result = await handleRedirectResponse(middlewareResponse, getRouter, signal, isServerFnRequest && request.headers.get("x-tsr-serverFn") === "true"); if (request.method === "HEAD") result = stripSsrResponseBody(result, "HEAD body stripped"); } catch (error) { disposeResponseResult(middlewareResponse, signal.aborted ? signal.reason : error); throw error; } bindSsrResponseToRequest(router, result, signal); signal.throwIfAborted(); responseOwnsCleanup = result.serverSsrCleanup === "stream"; return result.response; } finally { if (router?.serverSsr && !responseOwnsCleanup) router.serverSsr.cleanup(); } }; return requestHandler(startRequestResolver); } var relativeRedirectProtocols = /* @__PURE__ */ new Set(); async function handleRedirectResponse(response, getRouter, signal, serializeRedirect) { signal.throwIfAborted(); const ssrResponse = normalizeSsrResponse(response); const redirect = ssrResponse.response; if (!isRedirect(redirect)) return ssrResponse; const opts = redirect.options; const href = redirect.headers.get("Location") || opts.href; if (!href && opts.to && typeof opts.to === "string" && !opts.to.startsWith("/")) throw new Error(`Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(opts)}`); if (!href && [ "params", "search", "hash" ].some((d) => typeof opts[d] === "function")) throw new Error(`Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys(opts).filter((d) => typeof opts[d] === "function").map((d) => `"${d}"`).join(", ")}`); signal.throwIfAborted(); if (href && !isDangerousProtocol(href, relativeRedirectProtocols)) { opts.href = href; redirect.headers.set("Location", href); } else { const router = await getRouter(); signal.throwIfAborted(); router.resolveRedirect(redirect); } if (serializeRedirect) { const redirectOptions = { ...opts }; delete redirectOptions.headers; const responseHeaders = new Headers(redirect.headers); responseHeaders.set("content-type", "application/json"); return replaceSsrResponse(ssrResponse, Response.json({ ...redirectOptions, isSerializedRedirect: true }, { headers: responseHeaders }), "redirect response replaced"); } return ssrResponse; } async function handleServerRoutes({ getRouter, request, url, executeRouter, context, executedRequestMiddlewares }) { const router = await getRouter(); const pathname = executeRewriteInput(router.rewrite, url).pathname; const [matchedRoutes, rawParams, foundRoute] = router.getMatchedRoutes(pathname); const isExactMatch = foundRoute && rawParams["**"] === void 0; const routeMiddlewares = []; let terminalHandler = (ctx) => executeRouter(ctx.context, matchedRoutes); let terminalNext; for (const route of matchedRoutes) { const serverMiddleware = route.options.server?.middleware; if (serverMiddleware) { const flattened = flattenMiddlewares(serverMiddleware); for (const m of flattened) if (!executedRequestMiddlewares.has(m)) routeMiddlewares.push(m.options.server); } } const server = foundRoute?.options.server; if (server?.handlers && isExactMatch) { const handlers = typeof server.handlers === "function" ? server.handlers({ createHandlers: (d) => d }) : server.handlers; const requestMethod = request.method.toUpperCase(); const handler = requestMethod === "HEAD" ? handlers["HEAD"] ?? handlers["GET"] ?? handlers["ANY"] : handlers[requestMethod] ?? handlers["ANY"]; if (handler) { const mayDefer = !!foundRoute.options.component; if (typeof handler === "function") if (!mayDefer) { terminalHandler = handler; terminalNext = throwIfMayNotDefer; } else routeMiddlewares.push(handler); else { if (handler.middleware?.length) { const handlerMiddlewares = flattenMiddlewares(handler.middleware); for (const m of handlerMiddlewares) routeMiddlewares.push(m.options.server); } if (handler.handler) if (!mayDefer) { terminalHandler = handler.handler; terminalNext = throwIfMayNotDefer; } else routeMiddlewares.push(handler.handler); } } } return normalizeSsrResponse(await executeMiddleware(routeMiddlewares, terminalHandler, { request, context, params: rawParams, pathname, handlerType: "router" }, request.signal, terminalNext)); } //#endregion export { createStartHandler, transferResponseBodyOwnership }; //# sourceMappingURL=createStartHandler.js.map