Cache spec fetch responses when no custom headers are forwarded, using public s-maxage=3600 with stale-while-revalidate. Requests with auth headers bypass the cache to avoid leaking credentials.
Unified · Split
| @@ -24,11 +24,18 @@ const blockedForwardedHeaderNames = new Set([ · 23 unmodified lines | ||
| 24 | 24 | "referer", |
| 25 | 25 | ]); |
| 26 | 26 | |
| 27 | −function noStoreJson(payload: unknown, status: number): Response { | |
| 27 | +function jsonResponse( | |
| 28 | + payload: unknown, | |
| 29 | + status: number, | |
| 30 | + options?: { cacheable?: boolean }, | |
| 31 | +): Response { | |
| 32 | + const cacheable = options?.cacheable === true; | |
| 28 | 33 | return Response.json(payload, { |
| 29 | 34 | status, |
| 30 | 35 | headers: { |
| 31 | − "cache-control": "no-store", | |
| 36 | + "cache-control": cacheable | |
| 37 | + ? "public, s-maxage=3600, stale-while-revalidate=86400" | |
| 38 | + : "no-store", | |
| 32 | 39 | }, |
| 33 | 40 | }); |
| 34 | 41 | } |
| @@ -142,14 +149,14 @@ function extractErrorDetail(text: string): string | undefined { · 107 unmodified lines | ||
| 142 | 149 | export async function POST(request: Request): Promise<Response> { |
| 143 | 150 | const parsedRequest = requestSchema.safeParse(await request.json().catch(() => null)); |
| 144 | 151 | if (!parsedRequest.success) { |
| 145 | − return noStoreJson({ detail: "Invalid OpenAPI inspection request" }, 400); | |
| 152 | + return jsonResponse({ detail: "Invalid OpenAPI inspection request" }, 400); | |
| 146 | 153 | } |
| 147 | 154 | |
| 148 | 155 | let parsedUrl: URL; |
| 149 | 156 | try { |
| 150 | 157 | parsedUrl = new URL(parsedRequest.data.specUrl); |
| 151 | 158 | } catch { |
| 152 | − return noStoreJson({ detail: "Spec URL is invalid" }, 400); | |
| 159 | + return jsonResponse({ detail: "Spec URL is invalid" }, 400); | |
| 153 | 160 | } |
| 154 | 161 | |
| 155 | 162 | const protocol = parsedUrl.protocol.toLowerCase(); |
| @@ -157,25 +164,28 @@ export async function POST(request: Request): Promise<Response> { · 1 unmodified line | ||
| 157 | 164 | const localAllowed = process.env.NODE_ENV !== "production"; |
| 158 | 165 | |
| 159 | 166 | if (parsedUrl.username || parsedUrl.password) { |
| 160 | − return noStoreJson({ detail: "Credentials in spec URL are not allowed" }, 400); | |
| 167 | + return jsonResponse({ detail: "Credentials in spec URL are not allowed" }, 400); | |
| 161 | 168 | } |
| 162 | 169 | |
| 163 | 170 | if (protocol !== "https:" && protocol !== "http:") { |
| 164 | − return noStoreJson({ detail: "Spec URL must use https:// (or http:// for localhost in dev)" }, 400); | |
| 171 | + return jsonResponse({ detail: "Spec URL must use https:// (or http:// for localhost in dev)" }, 400); | |
| 165 | 172 | } |
| 166 | 173 | |
| 167 | 174 | if (protocol === "http:" && !(localAllowed && isLocalHost(hostname))) { |
| 168 | − return noStoreJson({ detail: "Spec URL must use https://" }, 400); | |
| 175 | + return jsonResponse({ detail: "Spec URL must use https://" }, 400); | |
| 169 | 176 | } |
| 170 | 177 | |
| 171 | 178 | if (isPrivateIp(hostname) && !(localAllowed && isLocalHost(hostname))) { |
| 172 | − return noStoreJson({ detail: "Private or local hosts are not allowed" }, 400); | |
| 179 | + return jsonResponse({ detail: "Private or local hosts are not allowed" }, 400); | |
| 173 | 180 | } |
| 174 | 181 | |
| 175 | 182 | if (!isIP(normalizeIpHost(hostname)) && !isPublicDnsHostname(hostname)) { |
| 176 | − return noStoreJson({ detail: "Spec host must be a public DNS host" }, 400); | |
| 183 | + return jsonResponse({ detail: "Spec host must be a public DNS host" }, 400); | |
| 177 | 184 | } |
| 178 | 185 | |
| 186 | + const forwardedHeaders = sanitizeForwardHeaders(parsedRequest.data.headers); | |
| 187 | + const cacheableRequest = Object.keys(forwardedHeaders).length === 0; | |
| 188 | + | |
| 179 | 189 | const controller = new AbortController(); |
| 180 | 190 | const timeoutId = setTimeout(() => controller.abort(), INSPECTION_TIMEOUT_MS); |
| 181 | 191 | |
| @@ -184,15 +194,16 @@ export async function POST(request: Request): Promise<Response> { · 2 unmodified lines | ||
| 184 | 194 | method: "GET", |
| 185 | 195 | headers: { |
| 186 | 196 | Accept: "application/json, application/yaml, text/yaml, text/plain;q=0.9, */*;q=0.8", |
| 187 | − ...sanitizeForwardHeaders(parsedRequest.data.headers), | |
| 197 | + ...forwardedHeaders, | |
| 188 | 198 | }, |
| 189 | 199 | signal: controller.signal, |
| 190 | − cache: "no-store", | |
| 200 | + cache: cacheableRequest ? "force-cache" : "no-store", | |
| 201 | + ...(cacheableRequest ? { next: { revalidate: 3600 } } : {}), | |
| 191 | 202 | }); |
| 192 | 203 | |
| 193 | 204 | if (!response.ok) { |
| 194 | 205 | const detail = extractErrorDetail(await response.text().catch(() => "")); |
| 195 | − return noStoreJson({ | |
| 206 | + return jsonResponse({ | |
| 196 | 207 | status: response.status, |
| 197 | 208 | statusText: response.statusText, |
| 198 | 209 | detail, |
| @@ -207,19 +218,19 @@ export async function POST(request: Request): Promise<Response> { · 8 unmodified lines | ||
| 207 | 218 | contentType, |
| 208 | 219 | }); |
| 209 | 220 | |
| 210 | − return noStoreJson({ | |
| 221 | + return jsonResponse({ | |
| 211 | 222 | status: response.status, |
| 212 | 223 | statusText: response.statusText, |
| 213 | 224 | spec: inspected.spec, |
| 214 | 225 | inferredAuth: inspected.inferredAuth, |
| 215 | − }, 200); | |
| 226 | + }, 200, { cacheable: cacheableRequest }); | |
| 216 | 227 | } catch (error) { |
| 217 | 228 | if (isAbortError(error)) { |
| 218 | − return noStoreJson({ detail: "Request timed out while fetching spec" }, 504); | |
| 229 | + return jsonResponse({ detail: "Request timed out while fetching spec" }, 504); | |
| 219 | 230 | } |
| 220 | 231 | |
| 221 | 232 | const detail = error instanceof Error ? error.message : "Failed to fetch spec"; |
| 222 | − return noStoreJson({ detail }, 502); | |
| 233 | + return jsonResponse({ detail }, 502); | |
| 223 | 234 | } finally { |
| 224 | 235 | clearTimeout(timeoutId); |
| 225 | 236 | } |