Skip to content
8 changes: 6 additions & 2 deletions packages/vinext/src/server/app-router-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ import {
handleConfiguredImageOptimization,
isImageOptimizationPath,
} from "./image-optimization.js";
import { finalizeMissingStaticAssetResponse, resolveStaticAssetSignal } from "./worker-utils.js";
import {
createStaticAssetRequest,
finalizeMissingStaticAssetResponse,
resolveStaticAssetSignal,
} from "./worker-utils.js";
import {
cloneRequestWithHeaders,
filterInternalHeaders,
Expand Down Expand Up @@ -178,7 +182,7 @@ async function handleRequest(
const assetFetcher = env.ASSETS;
const assetResponse = await resolveStaticAssetSignal(response, {
fetchAsset: (path) =>
Promise.resolve(assetFetcher.fetch(new Request(new URL(path, request.url)))),
Promise.resolve(assetFetcher.fetch(createStaticAssetRequest(path, request))),
});
if (assetResponse) response = assetResponse;
}
Expand Down
110 changes: 108 additions & 2 deletions packages/vinext/src/server/http-conditional.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parseHttpDate } from "./http-date.js";

/**
* Test an If-None-Match field value against a current entity tag.
*
Expand Down Expand Up @@ -39,15 +41,53 @@ export function matchesIfNoneMatch(ifNoneMatch: string | undefined, etag: string
return sawTag && matched;
}

/** Test an If-Match field using Next.js's weak/strong-equivalent comparison. */
export function matchesIfMatch(ifMatch: string | undefined, etag: string | undefined): boolean {
if (!ifMatch) return false;

let index = skipOptionalWhitespace(ifMatch, 0);
if (ifMatch[index] === "*") {
return skipOptionalWhitespace(ifMatch, index + 1) === ifMatch.length;
}

if (!etag) return false;
const current = parseEntityTag(etag, 0);
if (!current || current.end !== etag.length) return false;
const currentOpaqueTag = etag.slice(current.opaqueStart, current.opaqueEnd);

let matched = false;
let sawTag = false;
while (index < ifMatch.length) {
if (ifMatch[index] === ",") {
index = skipOptionalWhitespace(ifMatch, index + 1);
continue;
}

const candidate = parseEntityTag(ifMatch, index);
if (!candidate) return false;
sawTag = true;
matched ||= ifMatch.slice(candidate.opaqueStart, candidate.opaqueEnd) === currentOpaqueTag;

index = skipOptionalWhitespace(ifMatch, candidate.end);
if (index === ifMatch.length) break;
if (ifMatch[index] !== ",") return false;
index = skipOptionalWhitespace(ifMatch, index + 1);
}

return sawTag && matched;
}

type ParsedEntityTag = {
weak: boolean;
opaqueStart: number;
opaqueEnd: number;
end: number;
};

function parseEntityTag(value: string, start: number): ParsedEntityTag | null {
let index = start;
if (value.startsWith("W/", index)) index += 2;
const weak = value.startsWith("W/", index);
if (weak) index += 2;
if (value[index] !== '"') return null;

const opaqueStart = ++index;
Expand All @@ -62,11 +102,77 @@ function parseEntityTag(value: string, start: number): ParsedEntityTag | null {
}
if (value[index] !== '"') return null;

return { opaqueStart, opaqueEnd: index, end: index + 1 };
return { weak, opaqueStart, opaqueEnd: index, end: index + 1 };
}

function skipOptionalWhitespace(value: string, start: number): number {
let index = start;
while (value[index] === " " || value[index] === "\t") index++;
return index;
}

type StaticConditionalHeaders = {
cacheControl?: string | string[];
ifMatch?: string | string[];
ifUnmodifiedSince?: string | string[];
ifNoneMatch?: string | string[];
ifModifiedSince?: string | string[];
};

export type StaticPreconditionResult = "proceed" | "not-modified" | "precondition-failed";

/** Evaluate static representation preconditions in RFC 9110 section 13.2.2 order. */
export function evaluateStaticPreconditions(
headers: StaticConditionalHeaders,
method: string | undefined,
etag: string | undefined,
mtimeMs: number,
): StaticPreconditionResult {
const ifMatch = joinHeader(headers.ifMatch);
if (ifMatch !== undefined) {
if (!matchesIfMatch(ifMatch, etag)) return "precondition-failed";
Comment thread
james-elicx marked this conversation as resolved.
} else {
const ifUnmodifiedSince = joinHeader(headers.ifUnmodifiedSince);
if (ifUnmodifiedSince) {
const unmodifiedSinceMs = parseHttpDate(ifUnmodifiedSince);
if (
Number.isFinite(unmodifiedSinceMs) &&
Number.isFinite(mtimeMs) &&
Math.floor(mtimeMs / 1000) > Math.floor(unmodifiedSinceMs / 1000)
) {
return "precondition-failed";
}
}
}

const ifNoneMatch = joinHeader(headers.ifNoneMatch);
if (ifNoneMatch !== undefined) {
const matches =
ifNoneMatch.trim() === "*" || (etag !== undefined && matchesIfNoneMatch(ifNoneMatch, etag));
Comment thread
james-elicx marked this conversation as resolved.
if (!matches) return "proceed";
if (method !== "GET" && method !== "HEAD") return "precondition-failed";

const cacheControl = joinHeader(headers.cacheControl);
return cacheControl && /(?:^|,)\s*no-cache\s*(?:,|$)/i.test(cacheControl)
Comment thread
james-elicx marked this conversation as resolved.
? "proceed"
: "not-modified";
}

if (method !== "GET" && method !== "HEAD") return "proceed";

const cacheControl = joinHeader(headers.cacheControl);
if (cacheControl && /(?:^|,)\s*no-cache\s*(?:,|$)/i.test(cacheControl)) return "proceed";

const ifModifiedSince = joinHeader(headers.ifModifiedSince);
if (!ifModifiedSince) return "proceed";
const modifiedSinceMs = parseHttpDate(ifModifiedSince);
if (!Number.isFinite(modifiedSinceMs)) return "proceed";
return Number.isFinite(mtimeMs) &&
Math.floor(mtimeMs / 1000) <= Math.floor(modifiedSinceMs / 1000)
? "not-modified"
: "proceed";
}

function joinHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value.join(", ") : value;
}
135 changes: 135 additions & 0 deletions packages/vinext/src/server/http-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
const IMF_FIXDATE_RE =
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
const RFC850_DATE_RE =
/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
const ASCTIME_DATE_RE =
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: (\d)|(\d{2})) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
Comment thread
james-elicx marked this conversation as resolved.

const WEEKDAY_INDEX: Record<string, number> = {
Sun: 0,
Sunday: 0,
Mon: 1,
Monday: 1,
Tue: 2,
Tuesday: 2,
Wed: 3,
Wednesday: 3,
Thu: 4,
Thursday: 4,
Fri: 5,
Friday: 5,
Sat: 6,
Saturday: 6,
};
const MONTH_INDEX: Record<string, number> = {
Jan: 0,
Feb: 1,
Mar: 2,
Apr: 3,
May: 4,
Jun: 5,
Jul: 6,
Aug: 7,
Sep: 8,
Oct: 9,
Nov: 10,
Dec: 11,
};

/** Parse only the three HTTP-date wire formats accepted by RFC 9110. */
export function parseHttpDate(value: string): number {
const imf = IMF_FIXDATE_RE.exec(value);
if (imf) {
return timestampFromHttpDateParts(imf[1], imf[2], imf[3], imf[4], imf[5], imf[6], imf[7]);
}

const rfc850 = RFC850_DATE_RE.exec(value);
if (rfc850) {
const now = new Date();
let year = now.getUTCFullYear() - (now.getUTCFullYear() % 100) + Number(rfc850[4]);
const candidateTimestamp = timestampForRfc850YearWindow(
rfc850[2],
rfc850[3],
String(year),
rfc850[5],
rfc850[6],
rfc850[7],
);
const fiftyYearsFromNow = new Date(now);
fiftyYearsFromNow.setUTCFullYear(now.getUTCFullYear() + 50);
if (candidateTimestamp > fiftyYearsFromNow.getTime()) year -= 100;
Comment thread
james-elicx marked this conversation as resolved.
Comment thread
james-elicx marked this conversation as resolved.
return timestampFromHttpDateParts(
rfc850[1],
rfc850[2],
rfc850[3],
String(year),
rfc850[5],
rfc850[6],
rfc850[7],
);
}

const asctime = ASCTIME_DATE_RE.exec(value);
if (asctime) {
return timestampFromHttpDateParts(
asctime[1],
asctime[3] || asctime[4],
asctime[2],
asctime[8],
asctime[5],
asctime[6],
asctime[7],
);
}

return Number.NaN;
}

function timestampForRfc850YearWindow(
dayValue: string,
monthValue: string,
yearValue: string,
hourValue: string,
minuteValue: string,
secondValue: string,
): number {
// Normalize solely for the 50-year comparison. Final calendar and weekday
// validation happens after the RFC 850 century adjustment.
const date = new Date(0);
date.setUTCFullYear(Number(yearValue), MONTH_INDEX[monthValue], Number(dayValue));
date.setUTCHours(Number(hourValue), Number(minuteValue), Number(secondValue), 0);
return date.getTime();
}

function timestampFromHttpDateParts(
weekday: string | undefined,
dayValue: string,
monthValue: string,
yearValue: string,
hourValue: string,
minuteValue: string,
secondValue: string,
): number {
const day = Number(dayValue);
const month = MONTH_INDEX[monthValue];
const year = Number(yearValue);
const hour = Number(hourValue);
const minute = Number(minuteValue);
const second = Number(secondValue);

const date = new Date(0);
date.setUTCFullYear(year, month, day);
date.setUTCHours(hour, minute, second, 0);
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month ||
date.getUTCDate() !== day ||
date.getUTCHours() !== hour ||
date.getUTCMinutes() !== minute ||
date.getUTCSeconds() !== second ||
(weekday !== undefined && date.getUTCDay() !== WEEKDAY_INDEX[weekday])
) {
return Number.NaN;
}
return date.getTime();
}
80 changes: 80 additions & 0 deletions packages/vinext/src/server/http-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { parseHttpDate } from "./http-date.js";

export type ByteRange =
| { kind: "ignore" }
| { kind: "unsatisfiable" }
| { kind: "range"; start: number; end: number };

/**
* Parse a single bytes range. Unsupported range units, malformed values, and
* multipart ranges are ignored, which RFC 9110 permits servers to do.
*/
export function parseByteRange(value: string | undefined, size: number): ByteRange {
if (!value || value.slice(0, 6).toLowerCase() !== "bytes=") return { kind: "ignore" };

const spec = value.slice("bytes=".length).trim();
if (spec.includes(",")) return { kind: "ignore" };

const match = /^(\d*)-(\d*)$/.exec(spec);
if (!match || (!match[1] && !match[2])) return { kind: "ignore" };

if (!match[1]) {
const suffixLength = parseDecimalInteger(match[2]);
if (suffixLength === "overflow") {
if (size === 0) return { kind: "unsatisfiable" };
return { kind: "range", start: 0, end: size - 1 };
}
if (suffixLength === 0 || size === 0) return { kind: "unsatisfiable" };
return {
kind: "range",
start: Math.max(size - suffixLength, 0),
end: size - 1,
};
}

const start = parseDecimalInteger(match[1]);
const requestedEnd = match[2] ? parseDecimalInteger(match[2]) : size - 1;
// The wire grammar has no JavaScript-safe-integer limit. A start beyond
// that limit is necessarily beyond any file size Node can address, while an
// overflowing end still denotes the remainder of the representation.
if (start === "overflow") return { kind: "unsatisfiable" };
if (requestedEnd === "overflow") {
if (start >= size) return { kind: "unsatisfiable" };
return { kind: "range", start, end: size - 1 };
}
if (start >= size || requestedEnd < start) return { kind: "unsatisfiable" };

return {
kind: "range",
start,
end: Math.min(requestedEnd, size - 1),
};
}

/**
* If-Range requires strong entity-tag comparison. Date validators are matched
* at HTTP-date (whole-second) precision because filesystem mtimes are finer,
* and allow a range when the representation has not changed since that date.
* Vinext's static ETags are currently weak, so its emitted Last-Modified value
* is the validator that can enable a range response on a later request.
*/
export function ifRangeAllowsRange(
value: string | undefined,
etag: string,
mtimeMs: number,
): boolean {
if (!value) return true;

const trimmed = value.trim();
if (trimmed.startsWith('"') || trimmed.startsWith("W/")) {
return !trimmed.startsWith("W/") && !etag.startsWith("W/") && trimmed === etag;
Comment thread
james-elicx marked this conversation as resolved.
}

const timestamp = parseHttpDate(trimmed);
return Number.isFinite(timestamp) && Math.floor(mtimeMs / 1000) * 1000 <= timestamp;
Comment thread
james-elicx marked this conversation as resolved.
}

function parseDecimalInteger(value: string): number | "overflow" {
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : "overflow";
}
Loading
Loading