| name | fastly-compute-well-known-spa-fallback |
| description | Fix .well-known files (apple-app-site-association, assetlinks.json) being served as HTML
by the @fastly/compute-js-static-publish SPA fallback instead of JSON. Use when: (1) iOS
Universal Links or Android App Links are broken because the verification files return
text/html instead of application/json, (2) PublisherServer with spaFile config intercepts
/.well-known/ paths and returns index.html (200, text/html) instead of 404 for missing
files, (3) apple-app-site-association (no file extension) gets wrong content type even
when correctly stored in KV. Covers both apex domain and subdomain handler patterns.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-02-23T00:00:00.000Z" |
Fastly Compute: .well-known Files Intercepted by SPA Fallback
Problem
When using @fastly/compute-js-static-publish with SPA fallback configured (spaFile: "/index.html"), the PublisherServer.serveRequest() returns index.html with status 200 and Content-Type text/html for ANY path not found in the KV store. This includes /.well-known/apple-app-site-association and /.well-known/assetlinks.json, which iOS and Android require to be served as application/json.
Symptoms:
- iOS Universal Links don't work (Apple's verification fetches
.well-known/apple-app-site-association and gets HTML)
- Android App Links don't work (Google's verifier fetches
.well-known/assetlinks.json and gets HTML)
curl -I https://yourdomain.com/.well-known/apple-app-site-association shows Content-Type: text/html
- The files ARE published to KV (confirmed by
npm run fastly:publish) but still return HTML
Non-Obvious Root Causes
-
SPA fallback returns 200, not 404: The publisher's SPA mode returns index.html with HTTP 200 for missing paths. You cannot distinguish "file served" from "fallback served" by status code alone - you must inspect the Content-Type.
-
apple-app-site-association has no file extension: The static publisher infers MIME type from file extension. With no extension, it cannot detect application/json, so even when the file IS published to KV, it may get application/octet-stream or be served incorrectly.
-
includeWellKnown: true in publish-content.config.js is necessary but not sufficient: It ensures the files are uploaded to KV, but doesn't prevent the SPA fallback from intercepting the requests, and doesn't fix the content-type for extension-less files.
-
Both apex domain and subdomain handlers need the fix: If your Compute handler has separate code paths for subdomains vs apex, both paths must intercept .well-known/ requests before reaching the SPA fallback.
Solution
Step 1: Ensure Files Are Published
In publish-content.config.js, confirm includeWellKnown: true is set:
module.exports = {
includeWellKnown: true,
};
Then publish static content:
npm run fastly:publish
Step 2: Intercept .well-known Paths Before SPA Fallback
In your Compute entry point (compute-js/src/index.js), add a .well-known handler
BEFORE any call to publisherServer.serveRequest(request) that has SPA fallback enabled.
Critical guard: Check that the publisher response is NOT text/html - if it is,
the SPA fallback fired (file not in KV), so return 404 instead of the HTML.
if (url.pathname.startsWith('/.well-known/')) {
if (url.pathname === '/.well-known/nostr.json') {
return handleNip05(url);
}
const wkResponse = await publisherServer.serveRequest(request);
if (
wkResponse != null &&
wkResponse.status === 200 &&
!wkResponse.headers.get('Content-Type')?.includes('text/html')
) {
const headers = new Headers(wkResponse.headers);
const isJsonFile =
url.pathname.endsWith('.json') ||
url.pathname.endsWith('/apple-app-site-association') ||
url.pathname === '/.well-known/apple-app-site-association';
headers.set(
'Content-Type',
isJsonFile ? 'application/json' : (headers.get('Content-Type') || 'application/octet-stream')
);
headers.set('Cache-Control', 'public, max-age=3600');
headers.append('Vary', 'X-Original-Host');
return new Response(wkResponse.body, { status: 200, headers });
}
return new Response('Not Found', { status: 404 });
}
Step 3: Apply the Same Fix in Subdomain Handlers
If you have separate handling for subdomain requests, add the same guard there too.
Subdomain paths hit a different code branch before reaching the apex domain handler:
if (subdomain) {
if (url.pathname.startsWith('/.well-known/')) {
if (url.pathname === '/.well-known/nostr.json') {
return handleSubdomainNip05(subdomain);
}
const wkResponse = await publisherServer.serveRequest(request);
if (
wkResponse != null &&
wkResponse.status === 200 &&
!wkResponse.headers.get('Content-Type')?.includes('text/html')
) {
const headers = new Headers(wkResponse.headers);
const contentType =
url.pathname.endsWith('.json') || url.pathname.endsWith('/apple-app-site-association')
? 'application/json'
: headers.get('Content-Type') || 'application/octet-stream';
headers.set('Content-Type', contentType);
headers.set('Cache-Control', 'public, max-age=3600');
return new Response(wkResponse.body, { status: 200, headers });
}
return new Response('Not Found', { status: 404 });
}
}
Verification
curl -sI https://yourdomain.com/.well-known/apple-app-site-association | grep -i content-type
curl -s https://yourdomain.com/.well-known/apple-app-site-association | head -c 100
curl -sI https://yourdomain.com/.well-known/assetlinks.json | grep -i content-type
curl -sI https://yourdomain.com/.well-known/nonexistent-file
Complete Working Example
From compute-js/src/index.js in divine-web:
if (url.pathname.startsWith('/.well-known/')) {
if (url.pathname === '/.well-known/nostr.json') {
return await handleNip05(url);
}
const wkResponse = await publisherServer.serveRequest(request);
if (wkResponse != null && wkResponse.status === 200 && !wkResponse.headers.get('Content-Type')?.includes('text/html')) {
const headers = new Headers(wkResponse.headers);
const contentType = url.pathname.endsWith('.json') || url.pathname.endsWith('/apple-app-site-association')
? 'application/json'
: headers.get('Content-Type') || 'application/octet-stream';
headers.set('Content-Type', contentType);
headers.set('Cache-Control', 'public, max-age=3600');
headers.append('Vary', 'X-Original-Host');
return new Response(wkResponse.body, {
status: 200,
headers,
});
}
return new Response('Not Found', { status: 404 });
}
Deployment Checklist
After making code changes:
npm run fastly:publish
npm run fastly:deploy
Notes
- This pattern applies to any Fastly Compute service using
@fastly/compute-js-static-publish with spaFile configured.
- The SPA fallback is intentional for client-side routing, but it breaks any path that needs a real 404 (like
.well-known verification files).
- The content-type detection by file extension is a fundamental limitation of static publishing - extension-less files always need explicit handling.
- If you serve multiple domains (apex + subdomains), each code path that can call
publisherServer.serveRequest() needs the .well-known interception guard.
- After
fastly:publish, allow up to 2-3 minutes for KV propagation before testing.
References