@timber-js/app 0.2.0-alpha.131 → 0.2.0-alpha.132
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/server/internal.js +60 -14
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/cli.ts +0 -0
- package/src/server/pipeline-interception.ts +14 -2
- package/src/server/pipeline-phases.ts +46 -17
- package/src/server/pipeline.ts +43 -3
- package/src/server/rsc-entry/error-renderer.ts +2 -2
- package/src/server/rsc-entry/ssr-renderer.ts +3 -2
- package/LICENSE +0 -8
package/dist/server/internal.js
CHANGED
|
@@ -1615,6 +1615,16 @@ function escapeXml(str) {
|
|
|
1615
1615
|
* See design/07-routing.md §"Intercepting Routes"
|
|
1616
1616
|
*/
|
|
1617
1617
|
/**
|
|
1618
|
+
* Check if a pathname starts with a prefix on a segment boundary.
|
|
1619
|
+
*
|
|
1620
|
+
* Prevents /feed from matching /feed-private — the prefix must be
|
|
1621
|
+
* followed by '/' or be an exact match.
|
|
1622
|
+
*/
|
|
1623
|
+
function hasSegmentPrefix(pathname, prefix) {
|
|
1624
|
+
if (!pathname.startsWith(prefix)) return false;
|
|
1625
|
+
return pathname.length === prefix.length || pathname[prefix.length] === "/";
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1618
1628
|
* Check if an intercepting route applies for this soft navigation.
|
|
1619
1629
|
*
|
|
1620
1630
|
* Matches the target pathname against interception rewrites, constrained
|
|
@@ -1624,7 +1634,7 @@ function escapeXml(str) {
|
|
|
1624
1634
|
*/
|
|
1625
1635
|
function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
|
|
1626
1636
|
for (const rewrite of rewrites) {
|
|
1627
|
-
if (!sourceUrl
|
|
1637
|
+
if (!hasSegmentPrefix(sourceUrl, rewrite.interceptingPrefix)) continue;
|
|
1628
1638
|
if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
|
|
1629
1639
|
}
|
|
1630
1640
|
return null;
|
|
@@ -1792,6 +1802,25 @@ async function outcomeToResponse(config, outcome, ctx) {
|
|
|
1792
1802
|
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow".
|
|
1793
1803
|
*/
|
|
1794
1804
|
/**
|
|
1805
|
+
* Validate and canonicalize the X-Timber-URL header value.
|
|
1806
|
+
*
|
|
1807
|
+
* Returns the canonical pathname if valid, or null if rejected:
|
|
1808
|
+
* - Must start with '/' (relative pathname, no scheme/authority)
|
|
1809
|
+
* - Must not contain control characters
|
|
1810
|
+
* - Must pass canonicalization (no encoded separators, null bytes, etc.)
|
|
1811
|
+
*/
|
|
1812
|
+
function validateInterceptionHeader(raw, stripTrailingSlash) {
|
|
1813
|
+
if (!raw.startsWith("/")) return null;
|
|
1814
|
+
if (raw.startsWith("//")) return null;
|
|
1815
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1816
|
+
const code = raw.charCodeAt(i);
|
|
1817
|
+
if (code <= 31 || code === 127) return null;
|
|
1818
|
+
}
|
|
1819
|
+
const result = canonicalize(raw, stripTrailingSlash);
|
|
1820
|
+
if (!result.ok) return null;
|
|
1821
|
+
return result.pathname;
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1795
1824
|
* Run the proxy.ts phase. Calls user proxy code and uses `handleRequest` as
|
|
1796
1825
|
* the inner `next()` continuation. The proxy resolver was picked at pipeline
|
|
1797
1826
|
* construction time so the hot path sees no per-request branching on the
|
|
@@ -1976,7 +2005,8 @@ async function handleRequest(config, req, method, path) {
|
|
|
1976
2005
|
if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "auto-sitemap");
|
|
1977
2006
|
return new Response(null, { status: 500 });
|
|
1978
2007
|
}
|
|
1979
|
-
|
|
2008
|
+
const isRscRequest = (req.headers.get("Accept") ?? "").includes("text/x-component");
|
|
2009
|
+
if (isRscRequest) {
|
|
1980
2010
|
if (!checkVersionSkew(req).ok) {
|
|
1981
2011
|
const reloadHeaders = new Headers();
|
|
1982
2012
|
applyReloadHeaders(reloadHeaders);
|
|
@@ -1988,14 +2018,17 @@ async function handleRequest(config, req, method, path) {
|
|
|
1988
2018
|
}
|
|
1989
2019
|
let match = config.matchRoute(canonicalPathname);
|
|
1990
2020
|
let interception;
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
const
|
|
1994
|
-
if (
|
|
1995
|
-
const
|
|
1996
|
-
if (
|
|
1997
|
-
|
|
1998
|
-
|
|
2021
|
+
if (isRscRequest && config.interceptionRewrites?.length) {
|
|
2022
|
+
const rawSourceUrl = req.headers.get("X-Timber-URL");
|
|
2023
|
+
const validatedSourceUrl = rawSourceUrl ? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash) : null;
|
|
2024
|
+
if (validatedSourceUrl) {
|
|
2025
|
+
const intercepted = findInterceptionMatch(canonicalPathname, validatedSourceUrl, config.interceptionRewrites);
|
|
2026
|
+
if (intercepted) {
|
|
2027
|
+
const sourceMatch = config.matchRoute(intercepted.sourcePathname);
|
|
2028
|
+
if (sourceMatch) {
|
|
2029
|
+
match = sourceMatch;
|
|
2030
|
+
interception = { targetPathname: canonicalPathname };
|
|
2031
|
+
}
|
|
1999
2032
|
}
|
|
2000
2033
|
}
|
|
2001
2034
|
}
|
|
@@ -2084,13 +2117,26 @@ function createPipeline(config) {
|
|
|
2084
2117
|
}, async () => {
|
|
2085
2118
|
const otelIds = await getOtelTraceId();
|
|
2086
2119
|
if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
2120
|
+
const stripTrailingSlash = config.stripTrailingSlash ?? true;
|
|
2121
|
+
const canonResult = canonicalize(url.pathname, stripTrailingSlash);
|
|
2122
|
+
if (!canonResult.ok) {
|
|
2123
|
+
if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${canonResult.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
|
|
2124
|
+
return new Response(null, { status: canonResult.status });
|
|
2125
|
+
}
|
|
2126
|
+
const canonicalPath = canonResult.pathname;
|
|
2127
|
+
let canonicalReq = req;
|
|
2128
|
+
if (url.pathname !== canonicalPath) {
|
|
2129
|
+
const canonicalUrl = new URL(req.url);
|
|
2130
|
+
canonicalUrl.pathname = canonicalPath;
|
|
2131
|
+
canonicalReq = new Request(canonicalUrl.toString(), req);
|
|
2132
|
+
}
|
|
2087
2133
|
let result;
|
|
2088
|
-
if (proxyResolver) result = await outcomeToResponse(config, await runProxyPhase(config, proxyResolver,
|
|
2089
|
-
req,
|
|
2134
|
+
if (proxyResolver) result = await outcomeToResponse(config, await runProxyPhase(config, proxyResolver, canonicalReq, method, canonicalPath), {
|
|
2135
|
+
req: canonicalReq,
|
|
2090
2136
|
method,
|
|
2091
|
-
path
|
|
2137
|
+
path: canonicalPath
|
|
2092
2138
|
});
|
|
2093
|
-
else result = await handleRequest(config,
|
|
2139
|
+
else result = await handleRequest(config, canonicalReq, method, canonicalPath);
|
|
2094
2140
|
await setSpanAttribute("http.response.status_code", result.status);
|
|
2095
2141
|
if (serverTiming === "detailed") {
|
|
2096
2142
|
const timingHeader = getServerTimingHeader();
|