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...
Scanned 5/27/2026
Install via CLI
openskills install divinevideo/divine-mobile---
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-23
---
# 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
1. **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.
2. **`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.
3. **`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.
4. **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:
```javascript
// publish-content.config.js
module.exports = {
// ...
includeWellKnown: true, // Must be true to include /.well-known/ files
// ...
};
```
Then publish static content:
```bash
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.
```javascript
// In your main handleRequest function, BEFORE the final publisherServer.serveRequest() call:
// Handle .well-known requests (must come before SPA fallback)
if (url.pathname.startsWith('/.well-known/')) {
// Handle NIP-05 or other dynamic .well-known endpoints first
if (url.pathname === '/.well-known/nostr.json') {
return handleNip05(url); // Your custom handler
}
// For all other .well-known files: fetch from static publisher
const wkResponse = await publisherServer.serveRequest(request);
// CRITICAL: Guard against SPA fallback. The publisher returns index.html (text/html)
// for files not in KV. We must detect this and return 404 instead.
if (
wkResponse != null &&
wkResponse.status === 200 &&
!wkResponse.headers.get('Content-Type')?.includes('text/html')
) {
const headers = new Headers(wkResponse.headers);
// Explicitly set correct content type.
// apple-app-site-association has no extension, so the publisher may not detect JSON.
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'); // If using multi-service routing
return new Response(wkResponse.body, { status: 200, headers });
}
// File not in KV (or publisher returned SPA fallback) - return proper 404
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:
```javascript
if (subdomain) {
if (url.pathname.startsWith('/.well-known/')) {
if (url.pathname === '/.well-known/nostr.json') {
return handleSubdomainNip05(subdomain);
}
// Same pattern: intercept, guard against SPA fallback, force JSON content type
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 });
}
// ... rest of subdomain handling
}
```
## Verification
```bash
# Should return application/json, NOT text/html
curl -sI https://yourdomain.com/.well-known/apple-app-site-association | grep -i content-type
# Should return JSON body
curl -s https://yourdomain.com/.well-known/apple-app-site-association | head -c 100
# Android assetlinks.json
curl -sI https://yourdomain.com/.well-known/assetlinks.json | grep -i content-type
# Verify the SPA fallback guard works (path that does NOT exist in KV)
curl -sI https://yourdomain.com/.well-known/nonexistent-file
# Should return 404, not 200
```
## Complete Working Example
From `compute-js/src/index.js` in divine-web:
```javascript
// 4. Handle .well-known requests
if (url.pathname.startsWith('/.well-known/')) {
// 4a. NIP-05 from KV store
if (url.pathname === '/.well-known/nostr.json') {
return await handleNip05(url);
}
// 4b. Serve other .well-known files (apple-app-site-association, assetlinks.json)
// These must be served as JSON, not the SPA fallback.
// apple-app-site-association has no file extension, so the static publisher
// cannot detect its content type - we handle it explicitly here.
const wkResponse = await publisherServer.serveRequest(request);
// Guard: if publisher returns text/html, it's the SPA fallback, not the real file
if (wkResponse != null && wkResponse.status === 200 && !wkResponse.headers.get('Content-Type')?.includes('text/html')) {
const headers = new Headers(wkResponse.headers);
// Ensure correct content type for app association files
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,
});
}
// File not found in KV - return 404 instead of SPA fallback
return new Response('Not Found', { status: 404 });
}
```
## Deployment Checklist
After making code changes:
```bash
# 1. Publish static content first (uploads .well-known files to KV)
npm run fastly:publish
# 2. Deploy the edge worker code (with the .well-known interception logic)
npm run fastly:deploy
# NOTE: Order matters if files weren't in KV before. If you deploy code first,
# it will correctly return 404 for missing files. Then publish uploads the files.
# Either order works - the guard handles both cases.
```
## 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
- [Apple Universal Links documentation](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app)
- [Android App Links documentation](https://developer.android.com/training/app-links/verify-android-applinks)
- [@fastly/compute-js-static-publish on npm](https://www.npmjs.com/package/@fastly/compute-js-static-publish)
- [Fastly Compute KV Store](https://www.fastly.com/documentation/guides/compute/javascript/working-with-kv-store/)
No comments yet. Be the first to comment!