From f0571443d22a1c8cf1c68199dab3d6f0a017d10c Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 11:10:55 +0200 Subject: [PATCH 01/28] test: cover Helix 6 source routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/routes/da-admin.test.js | 10 + test/routes/source-routing.test.js | 385 +++++++++++++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 test/routes/source-routing.test.js diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 2824b9c9..4bc787c6 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -49,6 +49,16 @@ describe('daSourceGet', () => { // record which composition / instrumentation calls happen and with what let calls; + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('', { status: 200 }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); const mockDaSourceGet = async (overrides = {}) => { // 'headHtml' in overrides (rather than a destructured default) so passing diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js new file mode 100644 index 00000000..4f3980c7 --- /dev/null +++ b/test/routes/source-routing.test.js @@ -0,0 +1,385 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import assert from 'assert'; +import esmock from 'esmock'; +import { getDaCtx } from '../../src/utils/daCtx.js'; + +const ORG = 'org'; +const SITE = 'site'; +const AUTH = 'Bearer test-token'; +const DA_ADMIN = 'https://admin.da.live'; +const HLX_ADMIN = 'https://admin.hlx.page'; +const AEM_API = 'https://api.aem.live'; +const PING_URL = `${HLX_ADMIN}/ping/${ORG}/${SITE}`; + +function callDetails(input, init = {}) { + const request = input instanceof Request ? input : null; + return { + input, + url: request?.url ?? String(input), + method: init.method ?? request?.method ?? 'GET', + headers: new Headers(init.headers ?? request?.headers), + body: init.body, + }; +} + +describe('source backend routing', () => { + let originalFetch; + let originalDateNow; + let fetchCalls; + let daAdminCalls; + let fetchResponse; + let daAdminResponse; + let composedBodies; + let env; + + const authedRequest = (path, init = {}) => new Request( + `https://main--${SITE}--${ORG}.preview.da.live${path}`, + { + ...init, + headers: { + Authorization: AUTH, + ...init.headers, + }, + }, + ); + + const loadRoutes = async () => esmock('../../src/routes/da-admin.js', { + '../../src/utils/aemCtx.js': { + getAemCtx: () => ({}), + getAEMHtml: async () => '', + }, + '../../src/render/compose.js': { + composeHtml: async (daCtx, aemCtx, bodyHtml) => { + composedBodies.push(bodyHtml); + return { bodyHtml }; + }, + serializeHtml: ({ bodyHtml }) => `${bodyHtml}`, + }, + '../../src/helpers/source.js': { + default: async () => ({ data: '
edited
' }), + }, + '../../src/ue/attributes.js': { + removeUEAttributes: (node) => node, + unwrapParagraphs: (node) => node, + }, + '../../src/render/rewrite-images.js': { + restoreAbsoluteImages: () => {}, + }, + }); + + beforeEach(() => { + originalFetch = globalThis.fetch; + originalDateNow = Date.now; + fetchCalls = []; + daAdminCalls = []; + composedBodies = []; + fetchResponse = async () => new Response('', { status: 404 }); + daAdminResponse = async () => new Response('', { status: 404 }); + + globalThis.fetch = async (input, init) => { + const details = callDetails(input, init); + fetchCalls.push(details); + return fetchResponse(details); + }; + env = { + DA_ADMIN, + daadmin: { + fetch: async (input, init) => { + const details = callDetails(input, init); + daAdminCalls.push(details); + return daAdminResponse(details); + }, + }, + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Date.now = originalDateNow; + }); + + function upgradeResponse(upgraded = true) { + return new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + } + + it('GETs a source-bus asset from api.aem.live', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/image.png`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) return new Response('source image', { status: 200 }); + return new Response('', { status: 500 }); + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(await response.text(), 'source image'); + assert.strictEqual(daAdminCalls.length, 0); + assert.strictEqual(fetchCalls[0].url, PING_URL); + assert.strictEqual(fetchCalls[0].headers.get('Authorization'), null); + assert.strictEqual(fetchCalls[1].url, sourceUrl); + assert.strictEqual(fetchCalls[1].headers.get('Authorization'), AUTH); + }); + + it('composes source-bus HTML through the existing preview pipeline', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) { + return new Response('
source page
', { status: 200 }); + } + return new Response('', { status: 500 }); + }; + const req = authedRequest('/page'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(composedBodies, ['
source page
']); + assert.strictEqual(await response.text(), '
source page
'); + assert.strictEqual(daAdminCalls.length, 0); + }); + + it('does not fall back to legacy content when a source-bus resource is missing', async () => { + fetchResponse = async ({ url }) => ( + url === PING_URL ? upgradeResponse() : new Response('', { status: 404 }) + ); + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/missing.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(response.status, 404); + assert.strictEqual(daAdminCalls.length, 0); + }); + + it('GETs legacy content when ping has no upgrade header', async () => { + fetchResponse = async ({ url }) => ( + url === PING_URL ? upgradeResponse(false) : new Response('', { status: 500 }) + ); + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await response.text(), 'legacy image'); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(daAdminCalls.length, 1); + assert.strictEqual( + daAdminCalls[0].url, + `${DA_ADMIN}/source/${ORG}/${SITE}/image.png`, + ); + assert.strictEqual(daAdminCalls[0].headers.get('Authorization'), AUTH); + }); + + [401, 403, 404, 500].forEach((status) => { + it(`falls back to legacy GET when ping returns ${status}`, async () => { + fetchResponse = async () => new Response('', { status }); + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await response.text(), 'legacy image'); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(daAdminCalls.length, 1); + }); + }); + + it('falls back to legacy GET when ping fails', async () => { + fetchResponse = async () => { + throw new TypeError('network failure'); + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await response.text(), 'legacy image'); + assert.strictEqual(daAdminCalls.length, 1); + }); + + it('HEADs a source-bus resource through api.aem.live', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/image.png`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) { + return new Response(null, { + status: 200, + headers: { 'Content-Type': 'image/png', 'Content-Length': '42' }, + }); + } + return new Response('', { status: 500 }); + }; + const req = authedRequest('/image.png', { method: 'HEAD' }); + const daCtx = getDaCtx(req); + const { daSourceHead } = await loadRoutes(); + + const response = await daSourceHead({ env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get('Content-Type'), 'image/png'); + assert.strictEqual(response.headers.get('Content-Length'), '42'); + assert.deepStrictEqual(fetchCalls.map(({ method }) => method), ['GET', 'HEAD']); + assert.strictEqual(daAdminCalls.length, 0); + }); + + it('POSTs raw HTML to api.aem.live and does not write to da-admin', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) return new Response('', { status: 201 }); + return new Response('', { status: 500 }); + }; + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 201); + const post = fetchCalls.find(({ method }) => method === 'POST'); + assert.ok(post); + assert.strictEqual(post.url, sourceUrl); + assert.strictEqual(post.headers.get('Authorization'), AUTH); + assert.strictEqual(post.headers.get('Content-Type'), 'text/html'); + assert.strictEqual(post.body, '
edited
'); + assert.strictEqual(daAdminCalls.length, 0); + }); + + it('POSTs FormData to da-admin and does not write to api.aem.live for legacy sites', async () => { + fetchResponse = async () => upgradeResponse(false); + daAdminResponse = async () => new Response('', { status: 200 }); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(daAdminCalls.length, 1); + assert.strictEqual(daAdminCalls[0].method, 'POST'); + const body = await daAdminCalls[0].input.clone().formData(); + assert.strictEqual( + await body.get('data').text(), + '
edited
', + ); + }); + + [401, 403, 404, 500].forEach((status) => { + it(`returns 503 without writing when POST ping returns ${status}`, async () => { + fetchResponse = async () => new Response('', { status }); + daAdminResponse = async () => new Response('', { status: 200 }); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 503); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(daAdminCalls.length, 0); + }); + }); + + [ + new TypeError('network failure'), + new DOMException('timed out', 'AbortError'), + ].forEach((error) => { + it(`returns 503 without writing when POST ping fails with ${error.name}`, async () => { + fetchResponse = async () => { + throw error; + }; + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 503); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(daAdminCalls.length, 0); + }); + }); + + it('refreshes the routing decision after five minutes to allow rollback', async () => { + let now = 1_000; + let upgraded = true; + Date.now = () => now; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(upgraded); + return new Response('source image', { status: 200 }); + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const first = await daSourceGet({ req, env, daCtx }); + upgraded = false; + now += 300_001; + const second = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await first.text(), 'source image'); + assert.strictEqual(await second.text(), 'legacy image'); + assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); + assert.strictEqual(daAdminCalls.length, 1); + }); + + it('uses a stale routing decision when a refresh fails', async () => { + let now = 1_000; + let pingStatus = 200; + Date.now = () => now; + fetchResponse = async ({ url }) => { + if (url === PING_URL) { + return pingStatus === 200 + ? upgradeResponse() + : new Response('', { status: pingStatus }); + } + return new Response('source image', { status: 200 }); + }; + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const first = await daSourceGet({ req, env, daCtx }); + pingStatus = 500; + now += 300_001; + const second = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await first.text(), 'source image'); + assert.strictEqual(await second.text(), 'source image'); + assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); + assert.strictEqual(daAdminCalls.length, 0); + }); +}); From bf254ed684ddb51ebb59d03aa15e5ac4fbc37f36 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 11:20:58 +0200 Subject: [PATCH 02/28] test: cover ping timeout recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/routes/source-routing.test.js | 50 +++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index 4f3980c7..c92d7241 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -213,19 +213,24 @@ describe('source backend routing', () => { }); }); - it('falls back to legacy GET when ping fails', async () => { - fetchResponse = async () => { - throw new TypeError('network failure'); - }; - daAdminResponse = async () => new Response('legacy image', { status: 200 }); - const req = authedRequest('/image.png'); - const daCtx = getDaCtx(req); - const { daSourceGet } = await loadRoutes(); + [ + new TypeError('network failure'), + new DOMException('timed out', 'TimeoutError'), + ].forEach((error) => { + it(`falls back to legacy GET when ping fails with ${error.name}`, async () => { + fetchResponse = async () => { + throw error; + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); - const response = await daSourceGet({ req, env, daCtx }); + const response = await daSourceGet({ req, env, daCtx }); - assert.strictEqual(await response.text(), 'legacy image'); - assert.strictEqual(daAdminCalls.length, 1); + assert.strictEqual(await response.text(), 'legacy image'); + assert.strictEqual(daAdminCalls.length, 1); + }); }); it('HEADs a source-bus resource through api.aem.live', async () => { @@ -314,7 +319,7 @@ describe('source backend routing', () => { [ new TypeError('network failure'), - new DOMException('timed out', 'AbortError'), + new DOMException('timed out', 'TimeoutError'), ].forEach((error) => { it(`returns 503 without writing when POST ping fails with ${error.name}`, async () => { fetchResponse = async () => { @@ -332,6 +337,27 @@ describe('source backend routing', () => { }); }); + it('retries after an unexpected probe error instead of caching the rejection', async () => { + let firstProbe = true; + fetchResponse = async ({ url }) => { + if (url === PING_URL && firstProbe) { + firstProbe = false; + throw new Error('unexpected failure'); + } + if (url === PING_URL) return upgradeResponse(); + return new Response('source image', { status: 200 }); + }; + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + await assert.rejects(() => daSourceGet({ req, env, daCtx }), /unexpected failure/); + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await response.text(), 'source image'); + assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); + }); + it('refreshes the routing decision after five minutes to allow rollback', async () => { let now = 1_000; let upgraded = true; From 1eb95c9823e197d0940975ef49ef899b476721a7 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 11:21:27 +0200 Subject: [PATCH 03/28] feat: route source-bus traffic through Helix 6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/routes/da-admin.js | 156 +++++++++++++++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 22 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 588d9165..cf1e81e5 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -28,6 +28,76 @@ import { BRANCH_NOT_FOUND_HTML_MESSAGE, DEFAULT_HTML_TEMPLATE, UNAUTHORIZED_HTML import { getSiteConfig } from '../storage/config.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; +const AEM_API = 'https://api.aem.live'; +const HLX_ADMIN = 'https://admin.hlx.page'; +const UPGRADE_CACHE_TTL = 5 * 60 * 1000; +const UPGRADE_ERROR_TTL = 5 * 1000; +const UPGRADE_PROBE_TIMEOUT = 2 * 1000; +const sourceBackendCache = new Map(); + +function aemApiSourceUrl(org, site, path) { + return `${AEM_API}/${org}/sites/${site}/source${path}`; +} + +async function probeHlx6(org, site) { + const pingUrl = `${HLX_ADMIN}/ping/${org}/${site}`; + try { + const response = await fetch(pingUrl, { + signal: AbortSignal.timeout(UPGRADE_PROBE_TIMEOUT), + }); + if (response.status !== 200) { + console.warn(`Unable to determine source backend: ${pingUrl} returned ${response.status}`); + return undefined; + } + return response.headers.get('x-api-upgrade-available') === 'true'; + } catch (e) { + if (e instanceof TypeError || e.name === 'AbortError' || e.name === 'TimeoutError') { + console.warn(`Unable to determine source backend: ${pingUrl}`, e); + return undefined; + } + throw e; + } +} + +async function resolveHlx6(org, site) { + const key = `${org}/${site}`; + const now = Date.now(); + const cached = sourceBackendCache.get(key); + if (cached?.expiresAt > now) return cached.value; + if (cached?.pending) return cached.pending; + + const staleValue = cached?.value; + const pending = probeHlx6(org, site) + .then((value) => { + if (value === undefined) { + sourceBackendCache.set(key, { + value: staleValue, + expiresAt: Date.now() + UPGRADE_ERROR_TTL, + }); + return staleValue; + } + + sourceBackendCache.set(key, { + value, + expiresAt: Date.now() + UPGRADE_CACHE_TTL, + }); + return value; + }) + .catch((e) => { + if (staleValue === undefined) { + sourceBackendCache.delete(key); + } else { + sourceBackendCache.set(key, { + value: staleValue, + expiresAt: Date.now() + UPGRADE_ERROR_TTL, + }); + } + throw e; + }); + sourceBackendCache.set(key, { ...cached, pending }); + return pending; +} + async function getFileBody(data) { const text = await data.text(); return { body: text, type: data.type }; @@ -90,21 +160,31 @@ export async function daSourceGet({ req, env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); + const hlx6Promise = resolveHlx6(org, site); + if (ext !== 'html') { /* for non-HTML files, simply proxy the request without processing and ensure that extensions are not duplicated */ - const adminUrl = new URL(`/source/${org}/${site}${path}`, env.DA_ADMIN); - console.log(`-> ${adminUrl.toString()}`); - const response = await env.daadmin.fetch(adminUrl, { method: 'GET', headers }); - console.log(`<- ${adminUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + const hlx6 = await hlx6Promise; + const sourceUrl = hlx6 + ? aemApiSourceUrl(org, site, path) + : new URL(`/source/${org}/${site}${path}`, env.DA_ADMIN); + console.log(`-> ${sourceUrl.toString()}`); + const response = hlx6 + ? await fetch(sourceUrl, { method: 'GET', headers }) + : await env.daadmin.fetch(sourceUrl, { method: 'GET', headers }); + console.log(`<- ${sourceUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } // get the AEM parts (head.html) const aemCtx = getAemCtx(env, daCtx); - const headHtml = await getAEMHtml(aemCtx, '/head.html'); + const [headHtml, hlx6] = await Promise.all([ + getAEMHtml(aemCtx, '/head.html'), + hlx6Promise, + ]); if (!headHtml) { // quick-edit still needs a working shell (with the import map) so the editor // can load into this page, even when the AEM branch doesn't exist yet. @@ -114,24 +194,19 @@ export async function daSourceGet({ req, env, daCtx }) { return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); } - // get the content from DA admin - const adminUrl = new URL( - `/source/${org}/${site}${path}.${ext}`, - env.DA_ADMIN, - ); - - // eslint-disable-next-line no-param-reassign - req = new Request(adminUrl, { - method: 'GET', - headers, - }); - console.log(`-> ${adminUrl.toString()}`); - const daAdminResp = await env.daadmin.fetch(req); - console.log(`<- ${adminUrl.toString()}. ${daAdminResp.status} ${daAdminResp.statusText}`, { status: daAdminResp.status, statusText: daAdminResp.statusText }); + const sourceUrl = hlx6 + ? aemApiSourceUrl(org, site, `${path}.${ext}`) + : new URL(`/source/${org}/${site}${path}.${ext}`, env.DA_ADMIN); + const sourceRequest = new Request(sourceUrl, { method: 'GET', headers }); + console.log(`-> ${sourceUrl.toString()}`); + const sourceResp = hlx6 + ? await fetch(sourceRequest) + : await env.daadmin.fetch(sourceRequest); + console.log(`<- ${sourceUrl.toString()}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); // use the stored content when available, otherwise fall back to a template - const bodyHtml = daAdminResp && daAdminResp.status === 200 - ? await daAdminResp.text() + const bodyHtml = sourceResp.status === 200 + ? await sourceResp.text() : await getPageTemplate(env, daCtx, aemCtx, headHtml); // compose the page the same way for every request type @@ -174,6 +249,15 @@ export async function daSourceHead({ env, daCtx }) { headers.set('Authorization', authToken); const adminPath = ext !== 'html' ? path : `${path}.${ext}`; + const hlx6 = await resolveHlx6(org, site); + if (hlx6) { + const sourceUrl = aemApiSourceUrl(org, site, adminPath); + console.log(`-> HEAD ${sourceUrl}`); + const response = await fetch(sourceUrl, { method: 'HEAD', headers }); + console.log(`<- HEAD ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + return new Response(null, { status: response.status, headers: response.headers }); + } + const adminUrl = new URL(`/source/${org}/${site}${adminPath}`, env.DA_ADMIN); console.log(`-> HEAD ${adminUrl.toString()}`); const response = await env.daadmin.fetch(adminUrl, { method: 'HEAD', headers }); @@ -205,9 +289,37 @@ export async function daSourcePost({ req, env, daCtx }) { minifyWhitespace(bodyNode); + const bodyContent = toHtml(bodyNode); + const hlx6 = await resolveHlx6(org, site); + if (hlx6 === undefined) { + return new Response('Unable to determine source backend', { + status: 503, + headers: { 'Retry-After': '5' }, + }); + } + + if (hlx6) { + const sourceUrl = aemApiSourceUrl( + org, + site, + ext !== 'html' ? path : `${path}.${ext}`, + ); + const headers = { + Authorization: authToken, + 'Content-Type': 'text/html', + }; + console.log(`-> ${sourceUrl}`); + const response = await fetch(sourceUrl, { + method: 'POST', + body: bodyContent, + headers, + }); + console.log(`<- ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + return response; + } + // create new POST request with the body content const body = new FormData(); - const bodyContent = toHtml(bodyNode); const data = new Blob([bodyContent], { type: 'text/html' }); body.set('data', data); const headers = { Authorization: authToken }; From d67853a125d1abd62275b1107b90c15aec566477 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 13:50:24 +0200 Subject: [PATCH 04/28] test: require fresh source routing probes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/routes/source-routing.test.js | 40 ++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index c92d7241..ba9edf71 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -36,7 +36,6 @@ function callDetails(input, init = {}) { describe('source backend routing', () => { let originalFetch; - let originalDateNow; let fetchCalls; let daAdminCalls; let fetchResponse; @@ -81,7 +80,6 @@ describe('source backend routing', () => { beforeEach(() => { originalFetch = globalThis.fetch; - originalDateNow = Date.now; fetchCalls = []; daAdminCalls = []; composedBodies = []; @@ -107,7 +105,6 @@ describe('source backend routing', () => { afterEach(() => { globalThis.fetch = originalFetch; - Date.now = originalDateNow; }); function upgradeResponse(upgraded = true) { @@ -358,10 +355,8 @@ describe('source backend routing', () => { assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); }); - it('refreshes the routing decision after five minutes to allow rollback', async () => { - let now = 1_000; + it('probes before every request so routing changes apply immediately', async () => { let upgraded = true; - Date.now = () => now; fetchResponse = async ({ url }) => { if (url === PING_URL) return upgradeResponse(upgraded); return new Response('source image', { status: 200 }); @@ -370,10 +365,9 @@ describe('source backend routing', () => { const req = authedRequest('/image.png'); const daCtx = getDaCtx(req); const { daSourceGet } = await loadRoutes(); - const first = await daSourceGet({ req, env, daCtx }); upgraded = false; - now += 300_001; + upgraded = false; const second = await daSourceGet({ req, env, daCtx }); assert.strictEqual(await first.text(), 'source image'); @@ -382,10 +376,8 @@ describe('source backend routing', () => { assert.strictEqual(daAdminCalls.length, 1); }); - it('uses a stale routing decision when a refresh fails', async () => { - let now = 1_000; + it('does not use a stale routing decision when a later ping fails', async () => { let pingStatus = 200; - Date.now = () => now; fetchResponse = async ({ url }) => { if (url === PING_URL) { return pingStatus === 200 @@ -394,18 +386,40 @@ describe('source backend routing', () => { } return new Response('source image', { status: 200 }); }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); const req = authedRequest('/image.png'); const daCtx = getDaCtx(req); const { daSourceGet } = await loadRoutes(); const first = await daSourceGet({ req, env, daCtx }); pingStatus = 500; - now += 300_001; const second = await daSourceGet({ req, env, daCtx }); assert.strictEqual(await first.text(), 'source image'); - assert.strictEqual(await second.text(), 'source image'); + assert.strictEqual(await second.text(), 'legacy image'); assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); + assert.strictEqual(daAdminCalls.length, 1); + }); + + it('probes independently for GET, HEAD, and POST', async () => { + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return upgradeResponse(); + if (method === 'HEAD') return new Response(null, { status: 200 }); + if (method === 'POST') return new Response('', { status: 201 }); + return new Response('source image', { status: 200 }); + }; + const { daSourceGet, daSourceHead, daSourcePost } = await loadRoutes(); + const getReq = authedRequest('/image.png'); + const headReq = authedRequest('/image.png', { method: 'HEAD' }); + const postReq = authedRequest('/page', { method: 'POST' }); + + await daSourceGet({ req: getReq, env, daCtx: getDaCtx(getReq) }); + await daSourceHead({ env, daCtx: getDaCtx(headReq) }); + await daSourcePost({ req: postReq, env, daCtx: getDaCtx(postReq) }); + + const pingCalls = fetchCalls.filter(({ url }) => url === PING_URL); + assert.strictEqual(pingCalls.length, 3); + assert.ok(pingCalls.every(({ method }) => method === 'GET')); assert.strictEqual(daAdminCalls.length, 0); }); }); From 344999339636cac947438e8d20b524fffb4a17d8 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 13:50:56 +0200 Subject: [PATCH 05/28] refactor: remove source routing cache (KISS) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/routes/da-admin.js | 48 +++--------------------------------------- 1 file changed, 3 insertions(+), 45 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index cf1e81e5..bd16ab27 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -30,10 +30,7 @@ import { restoreAbsoluteImages } from '../render/rewrite-images.js'; const AEM_API = 'https://api.aem.live'; const HLX_ADMIN = 'https://admin.hlx.page'; -const UPGRADE_CACHE_TTL = 5 * 60 * 1000; -const UPGRADE_ERROR_TTL = 5 * 1000; const UPGRADE_PROBE_TIMEOUT = 2 * 1000; -const sourceBackendCache = new Map(); function aemApiSourceUrl(org, site, path) { return `${AEM_API}/${org}/sites/${site}/source${path}`; @@ -59,45 +56,6 @@ async function probeHlx6(org, site) { } } -async function resolveHlx6(org, site) { - const key = `${org}/${site}`; - const now = Date.now(); - const cached = sourceBackendCache.get(key); - if (cached?.expiresAt > now) return cached.value; - if (cached?.pending) return cached.pending; - - const staleValue = cached?.value; - const pending = probeHlx6(org, site) - .then((value) => { - if (value === undefined) { - sourceBackendCache.set(key, { - value: staleValue, - expiresAt: Date.now() + UPGRADE_ERROR_TTL, - }); - return staleValue; - } - - sourceBackendCache.set(key, { - value, - expiresAt: Date.now() + UPGRADE_CACHE_TTL, - }); - return value; - }) - .catch((e) => { - if (staleValue === undefined) { - sourceBackendCache.delete(key); - } else { - sourceBackendCache.set(key, { - value: staleValue, - expiresAt: Date.now() + UPGRADE_ERROR_TTL, - }); - } - throw e; - }); - sourceBackendCache.set(key, { ...cached, pending }); - return pending; -} - async function getFileBody(data) { const text = await data.text(); return { body: text, type: data.type }; @@ -160,7 +118,7 @@ export async function daSourceGet({ req, env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); - const hlx6Promise = resolveHlx6(org, site); + const hlx6Promise = probeHlx6(org, site); if (ext !== 'html') { /* @@ -249,7 +207,7 @@ export async function daSourceHead({ env, daCtx }) { headers.set('Authorization', authToken); const adminPath = ext !== 'html' ? path : `${path}.${ext}`; - const hlx6 = await resolveHlx6(org, site); + const hlx6 = await probeHlx6(org, site); if (hlx6) { const sourceUrl = aemApiSourceUrl(org, site, adminPath); console.log(`-> HEAD ${sourceUrl}`); @@ -290,7 +248,7 @@ export async function daSourcePost({ req, env, daCtx }) { minifyWhitespace(bodyNode); const bodyContent = toHtml(bodyNode); - const hlx6 = await resolveHlx6(org, site); + const hlx6 = await probeHlx6(org, site); if (hlx6 === undefined) { return new Response('Unable to determine source backend', { status: 503, From f266c2028b8f173bb310a5f762619f2c70f8b1ab Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 13:53:30 +0200 Subject: [PATCH 06/28] test: clean routing transition setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/routes/source-routing.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index ba9edf71..42606dc5 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -367,7 +367,6 @@ describe('source backend routing', () => { const { daSourceGet } = await loadRoutes(); const first = await daSourceGet({ req, env, daCtx }); upgraded = false; - upgraded = false; const second = await daSourceGet({ req, env, daCtx }); assert.strictEqual(await first.text(), 'source image'); From 309cc2d6d218cfb81c91bdf42eab8662ac8db66f Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 14:38:15 +0200 Subject: [PATCH 07/28] test: cover workerd probe failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/routes/source-routing.test.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index 42606dc5..9dceeef4 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -211,7 +211,7 @@ describe('source backend routing', () => { }); [ - new TypeError('network failure'), + new Error('Network connection lost.'), new DOMException('timed out', 'TimeoutError'), ].forEach((error) => { it(`falls back to legacy GET when ping fails with ${error.name}`, async () => { @@ -315,7 +315,7 @@ describe('source backend routing', () => { }); [ - new TypeError('network failure'), + new Error('Network connection lost.'), new DOMException('timed out', 'TimeoutError'), ].forEach((error) => { it(`returns 503 without writing when POST ping fails with ${error.name}`, async () => { @@ -334,24 +334,26 @@ describe('source backend routing', () => { }); }); - it('retries after an unexpected probe error instead of caching the rejection', async () => { + it('falls back after a probe error and probes again on the next request', async () => { let firstProbe = true; fetchResponse = async ({ url }) => { if (url === PING_URL && firstProbe) { firstProbe = false; - throw new Error('unexpected failure'); + throw new Error('internal error; reference = test'); } if (url === PING_URL) return upgradeResponse(); return new Response('source image', { status: 200 }); }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); const req = authedRequest('/image.png'); const daCtx = getDaCtx(req); const { daSourceGet } = await loadRoutes(); - await assert.rejects(() => daSourceGet({ req, env, daCtx }), /unexpected failure/); - const response = await daSourceGet({ req, env, daCtx }); + const first = await daSourceGet({ req, env, daCtx }); + const second = await daSourceGet({ req, env, daCtx }); - assert.strictEqual(await response.text(), 'source image'); + assert.strictEqual(await first.text(), 'legacy image'); + assert.strictEqual(await second.text(), 'source image'); assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); }); From e5d514620d37830ff7b5f9c7e703fc10240b6221 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 14:38:49 +0200 Subject: [PATCH 08/28] fix: handle all source probe failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/routes/da-admin.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index bd16ab27..ac563015 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -48,11 +48,8 @@ async function probeHlx6(org, site) { } return response.headers.get('x-api-upgrade-available') === 'true'; } catch (e) { - if (e instanceof TypeError || e.name === 'AbortError' || e.name === 'TimeoutError') { - console.warn(`Unable to determine source backend: ${pingUrl}`, e); - return undefined; - } - throw e; + console.warn(`Unable to determine source backend: ${pingUrl}`, e); + return undefined; } } From 91c565ed28428a798ce6ee34de259e5d47808c0f Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 14:48:53 +0200 Subject: [PATCH 09/28] test: align unknown source writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/routes/source-routing.test.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index 9dceeef4..2f48fb49 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -299,7 +299,7 @@ describe('source backend routing', () => { }); [401, 403, 404, 500].forEach((status) => { - it(`returns 503 without writing when POST ping returns ${status}`, async () => { + it(`POSTs to da-admin when ping returns ${status}`, async () => { fetchResponse = async () => new Response('', { status }); daAdminResponse = async () => new Response('', { status: 200 }); const req = authedRequest('/page', { method: 'POST' }); @@ -308,9 +308,11 @@ describe('source backend routing', () => { const response = await daSourcePost({ req, env, daCtx }); - assert.strictEqual(response.status, 503); + assert.strictEqual(response.status, 200); assert.strictEqual(fetchCalls.length, 1); - assert.strictEqual(daAdminCalls.length, 0); + assert.strictEqual(fetchCalls[0].method, 'GET'); + assert.strictEqual(daAdminCalls.length, 1); + assert.strictEqual(daAdminCalls[0].method, 'POST'); }); }); @@ -318,19 +320,22 @@ describe('source backend routing', () => { new Error('Network connection lost.'), new DOMException('timed out', 'TimeoutError'), ].forEach((error) => { - it(`returns 503 without writing when POST ping fails with ${error.name}`, async () => { + it(`POSTs to da-admin when ping fails with ${error.name}`, async () => { fetchResponse = async () => { throw error; }; + daAdminResponse = async () => new Response('', { status: 200 }); const req = authedRequest('/page', { method: 'POST' }); const daCtx = getDaCtx(req); const { daSourcePost } = await loadRoutes(); const response = await daSourcePost({ req, env, daCtx }); - assert.strictEqual(response.status, 503); + assert.strictEqual(response.status, 200); assert.strictEqual(fetchCalls.length, 1); - assert.strictEqual(daAdminCalls.length, 0); + assert.strictEqual(fetchCalls[0].method, 'GET'); + assert.strictEqual(daAdminCalls.length, 1); + assert.strictEqual(daAdminCalls[0].method, 'POST'); }); }); From 237e5558e8ac4fae6c4dc2f07d9d62ed1bca0830 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 14:49:18 +0200 Subject: [PATCH 10/28] fix: align unknown source routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/routes/da-admin.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index ac563015..278e12f6 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -246,13 +246,6 @@ export async function daSourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); const hlx6 = await probeHlx6(org, site); - if (hlx6 === undefined) { - return new Response('Unable to determine source backend', { - status: 503, - headers: { 'Retry-After': '5' }, - }); - } - if (hlx6) { const sourceUrl = aemApiSourceUrl( org, From df3c112120779946a70e236a493a85b073753b9a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 14:50:05 +0200 Subject: [PATCH 11/28] test: preserve source read failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/routes/source-routing.test.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index 2f48fb49..65244aeb 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -158,6 +158,32 @@ describe('source backend routing', () => { assert.strictEqual(daAdminCalls.length, 0); }); + [401, 403, 500, 503].forEach((status) => { + it(`returns source-bus HTML status ${status} without composing a page`, async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) { + return new Response('source failed', { + status, + headers: { 'X-Error': 'source failed' }, + }); + } + return new Response('', { status: 500 }); + }; + const req = authedRequest('/page'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(response.status, status); + assert.strictEqual(response.headers.get('X-Error'), 'source failed'); + assert.strictEqual(await response.text(), 'source failed'); + assert.deepStrictEqual(composedBodies, []); + }); + }); + it('does not fall back to legacy content when a source-bus resource is missing', async () => { fetchResponse = async ({ url }) => ( url === PING_URL ? upgradeResponse() : new Response('', { status: 404 }) From e52569b2f44b3d13232bb3d7e57854b4aca8bcca Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 14:50:38 +0200 Subject: [PATCH 12/28] fix: preserve source read failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/routes/da-admin.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 278e12f6..f24775e8 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -159,6 +159,10 @@ export async function daSourceGet({ req, env, daCtx }) { : await env.daadmin.fetch(sourceRequest); console.log(`<- ${sourceUrl.toString()}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); + if (sourceResp.status !== 200 && sourceResp.status !== 404) { + return sourceResp; + } + // use the stored content when available, otherwise fall back to a template const bodyHtml = sourceResp.status === 200 ? await sourceResp.text() From 65c2cd0ce0d6a2467cf0ed6fdee6d8c1709fd824 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 15:52:19 +0200 Subject: [PATCH 13/28] test: route uncertain writes by backend evidence --- test/routes/source-routing.test.js | 113 +++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 6 deletions(-) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index 65244aeb..387c0a77 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -36,8 +36,10 @@ function callDetails(input, init = {}) { describe('source backend routing', () => { let originalFetch; + let originalWarn; let fetchCalls; let daAdminCalls; + let warnings; let fetchResponse; let daAdminResponse; let composedBodies; @@ -80,8 +82,10 @@ describe('source backend routing', () => { beforeEach(() => { originalFetch = globalThis.fetch; + originalWarn = console.warn; fetchCalls = []; daAdminCalls = []; + warnings = []; composedBodies = []; fetchResponse = async () => new Response('', { status: 404 }); daAdminResponse = async () => new Response('', { status: 404 }); @@ -91,6 +95,7 @@ describe('source backend routing', () => { fetchCalls.push(details); return fetchResponse(details); }; + console.warn = (...args) => warnings.push(args); env = { DA_ADMIN, daadmin: { @@ -105,6 +110,7 @@ describe('source backend routing', () => { afterEach(() => { globalThis.fetch = originalFetch; + console.warn = originalWarn; }); function upgradeResponse(upgraded = true) { @@ -302,6 +308,7 @@ describe('source backend routing', () => { assert.strictEqual(post.headers.get('Content-Type'), 'text/html'); assert.strictEqual(post.body, '
edited
'); assert.strictEqual(daAdminCalls.length, 0); + assert.strictEqual(fetchCalls.filter(({ method }) => method === 'HEAD').length, 0); }); it('POSTs FormData to da-admin and does not write to api.aem.live for legacy sites', async () => { @@ -317,6 +324,7 @@ describe('source backend routing', () => { assert.strictEqual(fetchCalls.length, 1); assert.strictEqual(daAdminCalls.length, 1); assert.strictEqual(daAdminCalls[0].method, 'POST'); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'HEAD').length, 0); const body = await daAdminCalls[0].input.clone().formData(); assert.strictEqual( await body.get('data').text(), @@ -324,6 +332,95 @@ describe('source backend routing', () => { ); }); + [ + { + title: 'uses api.aem.live when only source bus has the document', + aemHead: 200, + daHead: 404, + backend: 'aem', + }, + { + title: 'uses da-admin when only legacy DA has the document', + aemHead: 404, + daHead: 200, + backend: 'da', + }, + { + title: 'uses da-admin for a new document missing from both backends', + aemHead: 404, + daHead: 404, + backend: 'da', + }, + { + title: 'uses api.aem.live when both backends have the document', + aemHead: 200, + daHead: 200, + backend: 'aem', + warns: true, + }, + ].forEach(({ + title, aemHead, daHead, backend, warns, + }) => { + it(title, async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return new Response('', { status: 500 }); + if (url === sourceUrl && method === 'HEAD') { + return new Response(null, { status: aemHead }); + } + if (url === sourceUrl && method === 'POST') { + return new Response('', { status: 201 }); + } + return new Response('', { status: 500 }); + }; + daAdminResponse = async ({ method }) => ( + new Response(null, { status: method === 'HEAD' ? daHead : 200 }) + ); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, backend === 'aem' ? 201 : 200); + assert.strictEqual(fetchCalls.filter(({ method }) => method === 'HEAD').length, 1); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'HEAD').length, 1); + assert.strictEqual( + fetchCalls.filter(({ method }) => method === 'POST').length, + backend === 'aem' ? 1 : 0, + ); + assert.strictEqual( + daAdminCalls.filter(({ method }) => method === 'POST').length, + backend === 'da' ? 1 : 0, + ); + assert.strictEqual(warnings.length, warns ? 2 : 1); + }); + }); + + it('uses da-admin when a backend HEAD fails', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return new Response('', { status: 500 }); + if (url === sourceUrl && method === 'HEAD') { + throw new Error('Network connection lost.'); + } + return new Response('', { status: 500 }); + }; + daAdminResponse = async ({ method }) => ( + new Response(null, { status: method === 'HEAD' ? 200 : 200 }) + ); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(fetchCalls.filter(({ method }) => method === 'POST').length, 0); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'HEAD').length, 1); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'POST').length, 1); + }); + [401, 403, 404, 500].forEach((status) => { it(`POSTs to da-admin when ping returns ${status}`, async () => { fetchResponse = async () => new Response('', { status }); @@ -335,10 +432,12 @@ describe('source backend routing', () => { const response = await daSourcePost({ req, env, daCtx }); assert.strictEqual(response.status, 200); - assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(fetchCalls.length, 2); assert.strictEqual(fetchCalls[0].method, 'GET'); - assert.strictEqual(daAdminCalls.length, 1); - assert.strictEqual(daAdminCalls[0].method, 'POST'); + assert.strictEqual(fetchCalls[1].method, 'HEAD'); + assert.strictEqual(daAdminCalls.length, 2); + assert.strictEqual(daAdminCalls[0].method, 'HEAD'); + assert.strictEqual(daAdminCalls[1].method, 'POST'); }); }); @@ -358,10 +457,12 @@ describe('source backend routing', () => { const response = await daSourcePost({ req, env, daCtx }); assert.strictEqual(response.status, 200); - assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(fetchCalls.length, 2); assert.strictEqual(fetchCalls[0].method, 'GET'); - assert.strictEqual(daAdminCalls.length, 1); - assert.strictEqual(daAdminCalls[0].method, 'POST'); + assert.strictEqual(fetchCalls[1].method, 'HEAD'); + assert.strictEqual(daAdminCalls.length, 2); + assert.strictEqual(daAdminCalls[0].method, 'HEAD'); + assert.strictEqual(daAdminCalls[1].method, 'POST'); }); }); From 7b65a910099fc741009db7448dc576570f867d61 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 15:52:54 +0200 Subject: [PATCH 14/28] fix: resolve uncertain writes by backend evidence --- src/routes/da-admin.js | 44 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index f24775e8..fe44494f 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -53,6 +53,30 @@ async function probeHlx6(org, site) { } } +async function resolveUncertainWriteBackend({ + org, site, aemPath, daPath, authToken, env, +}) { + const headers = new Headers({ Authorization: authToken }); + const aemUrl = aemApiSourceUrl(org, site, aemPath); + const daUrl = new URL(`/source/${org}/${site}${daPath}`, env.DA_ADMIN); + + try { + const [aemResponse, daResponse] = await Promise.all([ + fetch(aemUrl, { method: 'HEAD', headers }), + env.daadmin.fetch(daUrl, { method: 'HEAD', headers }), + ]); + if (aemResponse.status === 200) { + if (daResponse.status === 200) { + console.warn(`Source document exists in both backends: ${org}/${site}${aemPath}. Using api.aem.live.`); + } + return true; + } + } catch (e) { + console.warn(`Unable to determine source backend from document HEADs: ${org}/${site}${aemPath}. Using da-admin.`, e); + } + return false; +} + async function getFileBody(data) { const text = await data.text(); return { body: text, type: data.type }; @@ -249,13 +273,21 @@ export async function daSourcePost({ req, env, daCtx }) { minifyWhitespace(bodyNode); const bodyContent = toHtml(bodyNode); - const hlx6 = await probeHlx6(org, site); - if (hlx6) { - const sourceUrl = aemApiSourceUrl( + const aemPath = ext !== 'html' ? path : `${path}.${ext}`; + const daPath = `${path}.${ext}`; + let hlx6 = await probeHlx6(org, site); + if (hlx6 === undefined) { + hlx6 = await resolveUncertainWriteBackend({ org, site, - ext !== 'html' ? path : `${path}.${ext}`, - ); + aemPath, + daPath, + authToken, + env, + }); + } + if (hlx6) { + const sourceUrl = aemApiSourceUrl(org, site, aemPath); const headers = { Authorization: authToken, 'Content-Type': 'text/html', @@ -276,7 +308,7 @@ export async function daSourcePost({ req, env, daCtx }) { body.set('data', data); const headers = { Authorization: authToken }; const adminUrl = new URL( - `/source/${org}/${site}${path}.${ext}`, + `/source/${org}/${site}${daPath}`, env.DA_ADMIN, ); // eslint-disable-next-line no-param-reassign From cbba3baee84488c195a7df5f4d6296a7d777fdbe Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 16:33:58 +0200 Subject: [PATCH 15/28] test: preserve fulfilled source HEAD evidence --- test/routes/source-routing.test.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index 387c0a77..d49d1982 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -421,6 +421,34 @@ describe('source backend routing', () => { assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'POST').length, 1); }); + it('uses api.aem.live when its HEAD succeeds and da-admin HEAD fails', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return new Response('', { status: 500 }); + if (url === sourceUrl && method === 'HEAD') { + return new Response(null, { status: 200 }); + } + if (url === sourceUrl && method === 'POST') { + return new Response('', { status: 201 }); + } + return new Response('', { status: 500 }); + }; + daAdminResponse = async ({ method }) => { + if (method === 'HEAD') throw new Error('Network connection lost.'); + return new Response('', { status: 200 }); + }; + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 201); + assert.strictEqual(fetchCalls.filter(({ method }) => method === 'POST').length, 1); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'HEAD').length, 1); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'POST').length, 0); + }); + [401, 403, 404, 500].forEach((status) => { it(`POSTs to da-admin when ping returns ${status}`, async () => { fetchResponse = async () => new Response('', { status }); From 29a77574a05a9cfa66e3dfd875c7c2d5f664b6d8 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 28 Jul 2026 16:35:37 +0200 Subject: [PATCH 16/28] fix: keep fulfilled HEAD evidence --- src/routes/da-admin.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index fe44494f..106001b7 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -60,19 +60,19 @@ async function resolveUncertainWriteBackend({ const aemUrl = aemApiSourceUrl(org, site, aemPath); const daUrl = new URL(`/source/${org}/${site}${daPath}`, env.DA_ADMIN); - try { - const [aemResponse, daResponse] = await Promise.all([ - fetch(aemUrl, { method: 'HEAD', headers }), - env.daadmin.fetch(daUrl, { method: 'HEAD', headers }), - ]); - if (aemResponse.status === 200) { - if (daResponse.status === 200) { - console.warn(`Source document exists in both backends: ${org}/${site}${aemPath}. Using api.aem.live.`); - } - return true; + const [aemResult, daResult] = await Promise.allSettled([ + fetch(aemUrl, { method: 'HEAD', headers }), + env.daadmin.fetch(daUrl, { method: 'HEAD', headers }), + ]); + if (aemResult.status === 'fulfilled' && aemResult.value.status === 200) { + if (daResult.status === 'fulfilled' && daResult.value.status === 200) { + console.warn(`Source document exists in both backends: ${org}/${site}${aemPath}. Using api.aem.live.`); } - } catch (e) { - console.warn(`Unable to determine source backend from document HEADs: ${org}/${site}${aemPath}. Using da-admin.`, e); + return true; + } + const failure = [aemResult, daResult].find(({ status }) => status === 'rejected'); + if (failure) { + console.warn(`Unable to determine source backend from document HEADs: ${org}/${site}${aemPath}. Using da-admin.`, failure.reason); } return false; } From 3d12ed876f98696a843a2d80671a5f704c88ae61 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 10:47:12 +0200 Subject: [PATCH 17/28] test: reject source routing warnings --- test/routes/source-routing.test.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index d49d1982..9a3be470 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -356,10 +356,9 @@ describe('source backend routing', () => { aemHead: 200, daHead: 200, backend: 'aem', - warns: true, }, ].forEach(({ - title, aemHead, daHead, backend, warns, + title, aemHead, daHead, backend, }) => { it(title, async () => { const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; @@ -393,7 +392,7 @@ describe('source backend routing', () => { daAdminCalls.filter(({ method }) => method === 'POST').length, backend === 'da' ? 1 : 0, ); - assert.strictEqual(warnings.length, warns ? 2 : 1); + assert.strictEqual(warnings.length, 0); }); }); From 0222c9f1e4db70dd5d06ca774934e89810172196 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 10:47:28 +0200 Subject: [PATCH 18/28] fix: remove source routing warnings --- src/routes/da-admin.js | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 106001b7..e6cb7429 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -43,12 +43,10 @@ async function probeHlx6(org, site) { signal: AbortSignal.timeout(UPGRADE_PROBE_TIMEOUT), }); if (response.status !== 200) { - console.warn(`Unable to determine source backend: ${pingUrl} returned ${response.status}`); return undefined; } return response.headers.get('x-api-upgrade-available') === 'true'; - } catch (e) { - console.warn(`Unable to determine source backend: ${pingUrl}`, e); + } catch { return undefined; } } @@ -60,20 +58,13 @@ async function resolveUncertainWriteBackend({ const aemUrl = aemApiSourceUrl(org, site, aemPath); const daUrl = new URL(`/source/${org}/${site}${daPath}`, env.DA_ADMIN); - const [aemResult, daResult] = await Promise.allSettled([ + const [aemResult] = await Promise.allSettled([ fetch(aemUrl, { method: 'HEAD', headers }), env.daadmin.fetch(daUrl, { method: 'HEAD', headers }), ]); if (aemResult.status === 'fulfilled' && aemResult.value.status === 200) { - if (daResult.status === 'fulfilled' && daResult.value.status === 200) { - console.warn(`Source document exists in both backends: ${org}/${site}${aemPath}. Using api.aem.live.`); - } return true; } - const failure = [aemResult, daResult].find(({ status }) => status === 'rejected'); - if (failure) { - console.warn(`Unable to determine source backend from document HEADs: ${org}/${site}${aemPath}. Using da-admin.`, failure.reason); - } return false; } From a70838a052f7735e83671c68ee619545c84e65d1 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 10:48:24 +0200 Subject: [PATCH 19/28] test: reject source bus request logs --- test/routes/source-routing.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index 9a3be470..fcbe17e3 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -36,9 +36,11 @@ function callDetails(input, init = {}) { describe('source backend routing', () => { let originalFetch; + let originalLog; let originalWarn; let fetchCalls; let daAdminCalls; + let logs; let warnings; let fetchResponse; let daAdminResponse; @@ -82,9 +84,11 @@ describe('source backend routing', () => { beforeEach(() => { originalFetch = globalThis.fetch; + originalLog = console.log; originalWarn = console.warn; fetchCalls = []; daAdminCalls = []; + logs = []; warnings = []; composedBodies = []; fetchResponse = async () => new Response('', { status: 404 }); @@ -95,6 +99,7 @@ describe('source backend routing', () => { fetchCalls.push(details); return fetchResponse(details); }; + console.log = (...args) => logs.push(args); console.warn = (...args) => warnings.push(args); env = { DA_ADMIN, @@ -110,6 +115,7 @@ describe('source backend routing', () => { afterEach(() => { globalThis.fetch = originalFetch; + console.log = originalLog; console.warn = originalWarn; }); @@ -141,6 +147,7 @@ describe('source backend routing', () => { assert.strictEqual(fetchCalls[0].headers.get('Authorization'), null); assert.strictEqual(fetchCalls[1].url, sourceUrl); assert.strictEqual(fetchCalls[1].headers.get('Authorization'), AUTH); + assert.strictEqual(logs.length, 0); }); it('composes source-bus HTML through the existing preview pipeline', async () => { @@ -162,6 +169,7 @@ describe('source backend routing', () => { assert.deepStrictEqual(composedBodies, ['
source page
']); assert.strictEqual(await response.text(), '
source page
'); assert.strictEqual(daAdminCalls.length, 0); + assert.strictEqual(logs.length, 0); }); [401, 403, 500, 503].forEach((status) => { @@ -285,6 +293,7 @@ describe('source backend routing', () => { assert.strictEqual(response.headers.get('Content-Length'), '42'); assert.deepStrictEqual(fetchCalls.map(({ method }) => method), ['GET', 'HEAD']); assert.strictEqual(daAdminCalls.length, 0); + assert.strictEqual(logs.length, 0); }); it('POSTs raw HTML to api.aem.live and does not write to da-admin', async () => { @@ -309,6 +318,7 @@ describe('source backend routing', () => { assert.strictEqual(post.body, '
edited
'); assert.strictEqual(daAdminCalls.length, 0); assert.strictEqual(fetchCalls.filter(({ method }) => method === 'HEAD').length, 0); + assert.strictEqual(logs.length, 0); }); it('POSTs FormData to da-admin and does not write to api.aem.live for legacy sites', async () => { From ecf9410170bdf2b7fc7af6eed807bffdbc90cd2b Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 10:49:25 +0200 Subject: [PATCH 20/28] fix: remove source bus request logs --- src/routes/da-admin.js | 49 ++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index e6cb7429..f14c4c94 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -138,14 +138,14 @@ export async function daSourceGet({ req, env, daCtx }) { and ensure that extensions are not duplicated */ const hlx6 = await hlx6Promise; - const sourceUrl = hlx6 - ? aemApiSourceUrl(org, site, path) - : new URL(`/source/${org}/${site}${path}`, env.DA_ADMIN); - console.log(`-> ${sourceUrl.toString()}`); - const response = hlx6 - ? await fetch(sourceUrl, { method: 'GET', headers }) - : await env.daadmin.fetch(sourceUrl, { method: 'GET', headers }); - console.log(`<- ${sourceUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + if (hlx6) { + const sourceUrl = aemApiSourceUrl(org, site, path); + return fetch(sourceUrl, { method: 'GET', headers }); + } + const adminUrl = new URL(`/source/${org}/${site}${path}`, env.DA_ADMIN); + console.log(`-> ${adminUrl.toString()}`); + const response = await env.daadmin.fetch(adminUrl, { method: 'GET', headers }); + console.log(`<- ${adminUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } @@ -164,15 +164,26 @@ export async function daSourceGet({ req, env, daCtx }) { return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); } - const sourceUrl = hlx6 - ? aemApiSourceUrl(org, site, `${path}.${ext}`) - : new URL(`/source/${org}/${site}${path}.${ext}`, env.DA_ADMIN); - const sourceRequest = new Request(sourceUrl, { method: 'GET', headers }); - console.log(`-> ${sourceUrl.toString()}`); - const sourceResp = hlx6 - ? await fetch(sourceRequest) - : await env.daadmin.fetch(sourceRequest); - console.log(`<- ${sourceUrl.toString()}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); + let sourceResp; + if (hlx6) { + const sourceUrl = aemApiSourceUrl(org, site, `${path}.${ext}`); + const sourceRequest = new Request(sourceUrl, { method: 'GET', headers }); + sourceResp = await fetch(sourceRequest); + } else { + const adminUrl = new URL( + `/source/${org}/${site}${path}.${ext}`, + env.DA_ADMIN, + ); + // eslint-disable-next-line no-param-reassign + req = new Request(adminUrl, { + method: 'GET', + headers, + }); + console.log(`-> ${adminUrl.toString()}`); + const daAdminResp = await env.daadmin.fetch(req); + console.log(`<- ${adminUrl.toString()}. ${daAdminResp.status} ${daAdminResp.statusText}`, { status: daAdminResp.status, statusText: daAdminResp.statusText }); + sourceResp = daAdminResp; + } if (sourceResp.status !== 200 && sourceResp.status !== 404) { return sourceResp; @@ -226,9 +237,7 @@ export async function daSourceHead({ env, daCtx }) { const hlx6 = await probeHlx6(org, site); if (hlx6) { const sourceUrl = aemApiSourceUrl(org, site, adminPath); - console.log(`-> HEAD ${sourceUrl}`); const response = await fetch(sourceUrl, { method: 'HEAD', headers }); - console.log(`<- HEAD ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return new Response(null, { status: response.status, headers: response.headers }); } @@ -283,13 +292,11 @@ export async function daSourcePost({ req, env, daCtx }) { Authorization: authToken, 'Content-Type': 'text/html', }; - console.log(`-> ${sourceUrl}`); const response = await fetch(sourceUrl, { method: 'POST', body: bodyContent, headers, }); - console.log(`<- ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } From 74b1399203a3030bf6ef3631e772f7fe3679ddab Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 14:42:06 +0200 Subject: [PATCH 21/28] fix(dry): don't repeat default method Co-authored-by: Tobias Bocanegra --- src/routes/da-admin.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index f14c4c94..d6c88932 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -167,8 +167,7 @@ export async function daSourceGet({ req, env, daCtx }) { let sourceResp; if (hlx6) { const sourceUrl = aemApiSourceUrl(org, site, `${path}.${ext}`); - const sourceRequest = new Request(sourceUrl, { method: 'GET', headers }); - sourceResp = await fetch(sourceRequest); + sourceResp = await fetch(sourceUrl, { headers }); } else { const adminUrl = new URL( `/source/${org}/${site}${path}.${ext}`, From 0cc671ec9c513a1cb64479086f5f910b7a6f033a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 15:02:20 +0200 Subject: [PATCH 22/28] refactor: simplify da-admin source fetch Co-authored-by: Tobias Bocanegra --- src/routes/da-admin.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index d6c88932..4bef02e2 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -173,13 +173,8 @@ export async function daSourceGet({ req, env, daCtx }) { `/source/${org}/${site}${path}.${ext}`, env.DA_ADMIN, ); - // eslint-disable-next-line no-param-reassign - req = new Request(adminUrl, { - method: 'GET', - headers, - }); console.log(`-> ${adminUrl.toString()}`); - const daAdminResp = await env.daadmin.fetch(req); + const daAdminResp = await env.daadmin.fetch(adminUrl, { headers }); console.log(`<- ${adminUrl.toString()}. ${daAdminResp.status} ${daAdminResp.statusText}`, { status: daAdminResp.status, statusText: daAdminResp.statusText }); sourceResp = daAdminResp; } From 12e6198ffd6753fb2c361d51104f71d68b6007d0 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 15:07:07 +0200 Subject: [PATCH 23/28] refactor: return source HEAD response directly Co-authored-by: Tobias Bocanegra --- src/routes/da-admin.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 4bef02e2..2b8ee7d8 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -231,8 +231,7 @@ export async function daSourceHead({ env, daCtx }) { const hlx6 = await probeHlx6(org, site); if (hlx6) { const sourceUrl = aemApiSourceUrl(org, site, adminPath); - const response = await fetch(sourceUrl, { method: 'HEAD', headers }); - return new Response(null, { status: response.status, headers: response.headers }); + return fetch(sourceUrl, { method: 'HEAD', headers }); } const adminUrl = new URL(`/source/${org}/${site}${adminPath}`, env.DA_ADMIN); From 42d21ba2a5b49939ccb96e70cb5f0c0a4b751ce8 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 15:08:46 +0200 Subject: [PATCH 24/28] refactor: don't repeat default method Co-authored-by: Tobias Bocanegra --- src/routes/da-admin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 2b8ee7d8..2b97546f 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -140,7 +140,7 @@ export async function daSourceGet({ req, env, daCtx }) { const hlx6 = await hlx6Promise; if (hlx6) { const sourceUrl = aemApiSourceUrl(org, site, path); - return fetch(sourceUrl, { method: 'GET', headers }); + return fetch(sourceUrl, { headers }); } const adminUrl = new URL(`/source/${org}/${site}${path}`, env.DA_ADMIN); console.log(`-> ${adminUrl.toString()}`); From a6b7579185f4d0f74bc215804761497b3a6cde6b Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 15:15:34 +0200 Subject: [PATCH 25/28] test: require source bus request logs --- test/routes/source-routing.test.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index fcbe17e3..7a16bff5 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -147,7 +147,10 @@ describe('source backend routing', () => { assert.strictEqual(fetchCalls[0].headers.get('Authorization'), null); assert.strictEqual(fetchCalls[1].url, sourceUrl); assert.strictEqual(fetchCalls[1].headers.get('Authorization'), AUTH); - assert.strictEqual(logs.length, 0); + assert.deepStrictEqual(logs.map(([message]) => message), [ + `-> ${sourceUrl}`, + `<- ${sourceUrl}. 200 `, + ]); }); it('composes source-bus HTML through the existing preview pipeline', async () => { @@ -169,7 +172,10 @@ describe('source backend routing', () => { assert.deepStrictEqual(composedBodies, ['
source page
']); assert.strictEqual(await response.text(), '
source page
'); assert.strictEqual(daAdminCalls.length, 0); - assert.strictEqual(logs.length, 0); + assert.deepStrictEqual(logs.map(([message]) => message), [ + `-> ${sourceUrl}`, + `<- ${sourceUrl}. 200 `, + ]); }); [401, 403, 500, 503].forEach((status) => { @@ -293,7 +299,10 @@ describe('source backend routing', () => { assert.strictEqual(response.headers.get('Content-Length'), '42'); assert.deepStrictEqual(fetchCalls.map(({ method }) => method), ['GET', 'HEAD']); assert.strictEqual(daAdminCalls.length, 0); - assert.strictEqual(logs.length, 0); + assert.deepStrictEqual(logs.map(([message]) => message), [ + `-> HEAD ${sourceUrl}`, + `<- HEAD ${sourceUrl}. 200 `, + ]); }); it('POSTs raw HTML to api.aem.live and does not write to da-admin', async () => { @@ -318,7 +327,10 @@ describe('source backend routing', () => { assert.strictEqual(post.body, '
edited
'); assert.strictEqual(daAdminCalls.length, 0); assert.strictEqual(fetchCalls.filter(({ method }) => method === 'HEAD').length, 0); - assert.strictEqual(logs.length, 0); + assert.deepStrictEqual(logs.map(([message]) => message), [ + `-> ${sourceUrl}`, + `<- ${sourceUrl}. 201 `, + ]); }); it('POSTs FormData to da-admin and does not write to api.aem.live for legacy sites', async () => { From d769ad64579a15217f9dafcfe416b3cddda8f00d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 15:22:49 +0200 Subject: [PATCH 26/28] fix: align source bus request logging --- src/routes/da-admin.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 2b97546f..6a708edb 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -51,6 +51,13 @@ async function probeHlx6(org, site) { } } +/** + * Resolves the write backend after the site probe fails. + * Both backends are HEADed concurrently. A source-bus 200 selects api.aem.live. + * Every other result selects da-admin, including a missing document or failed HEAD. + * + * @returns {Promise} true for api.aem.live, false for da-admin + */ async function resolveUncertainWriteBackend({ org, site, aemPath, daPath, authToken, env, }) { @@ -140,7 +147,10 @@ export async function daSourceGet({ req, env, daCtx }) { const hlx6 = await hlx6Promise; if (hlx6) { const sourceUrl = aemApiSourceUrl(org, site, path); - return fetch(sourceUrl, { headers }); + console.log(`-> ${sourceUrl}`); + const response = await fetch(sourceUrl, { headers }); + console.log(`<- ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + return response; } const adminUrl = new URL(`/source/${org}/${site}${path}`, env.DA_ADMIN); console.log(`-> ${adminUrl.toString()}`); @@ -167,7 +177,9 @@ export async function daSourceGet({ req, env, daCtx }) { let sourceResp; if (hlx6) { const sourceUrl = aemApiSourceUrl(org, site, `${path}.${ext}`); + console.log(`-> ${sourceUrl}`); sourceResp = await fetch(sourceUrl, { headers }); + console.log(`<- ${sourceUrl}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); } else { const adminUrl = new URL( `/source/${org}/${site}${path}.${ext}`, @@ -231,7 +243,10 @@ export async function daSourceHead({ env, daCtx }) { const hlx6 = await probeHlx6(org, site); if (hlx6) { const sourceUrl = aemApiSourceUrl(org, site, adminPath); - return fetch(sourceUrl, { method: 'HEAD', headers }); + console.log(`-> HEAD ${sourceUrl}`); + const response = await fetch(sourceUrl, { method: 'HEAD', headers }); + console.log(`<- HEAD ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + return response; } const adminUrl = new URL(`/source/${org}/${site}${adminPath}`, env.DA_ADMIN); @@ -266,6 +281,8 @@ export async function daSourcePost({ req, env, daCtx }) { minifyWhitespace(bodyNode); const bodyContent = toHtml(bodyNode); + // api.aem.live keeps explicit file extensions. Preserve da-admin's existing + // `${path}.${ext}` construction; HTML paths are the same for both backends. const aemPath = ext !== 'html' ? path : `${path}.${ext}`; const daPath = `${path}.${ext}`; let hlx6 = await probeHlx6(org, site); @@ -285,11 +302,13 @@ export async function daSourcePost({ req, env, daCtx }) { Authorization: authToken, 'Content-Type': 'text/html', }; + console.log(`-> ${sourceUrl}`); const response = await fetch(sourceUrl, { method: 'POST', body: bodyContent, headers, }); + console.log(`<- ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } From 339fb37aed304e5268441721250d710b6e384664 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 15:37:52 +0200 Subject: [PATCH 27/28] test: preserve explicit source extensions --- test/routes/source-routing.test.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js index 7a16bff5..e8320a3d 100644 --- a/test/routes/source-routing.test.js +++ b/test/routes/source-routing.test.js @@ -442,6 +442,36 @@ describe('source backend routing', () => { assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'POST').length, 1); }); + it('preserves explicit extensions when an uncertain write uses da-admin', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/sheet.json`; + const adminUrl = `${DA_ADMIN}/source/${ORG}/${SITE}/sheet.json`; + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return new Response('', { status: 500 }); + if (url === sourceUrl && method === 'HEAD') { + return new Response(null, { status: 404 }); + } + return new Response('', { status: 500 }); + }; + daAdminResponse = async ({ method }) => ( + new Response(null, { status: method === 'HEAD' ? 200 : 200 }) + ); + const req = authedRequest('/sheet.json', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual( + daAdminCalls.find(({ method }) => method === 'HEAD').url, + adminUrl, + ); + assert.strictEqual( + daAdminCalls.find(({ method }) => method === 'POST').url, + adminUrl, + ); + }); + it('uses api.aem.live when its HEAD succeeds and da-admin HEAD fails', async () => { const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; fetchResponse = async ({ url, method }) => { From d25d4c1a2b52feace38c8a40cce11cf47ca21835 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 29 Jul 2026 15:38:22 +0200 Subject: [PATCH 28/28] fix: preserve explicit source extensions --- src/routes/da-admin.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 6a708edb..7d7aa17d 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -59,11 +59,11 @@ async function probeHlx6(org, site) { * @returns {Promise} true for api.aem.live, false for da-admin */ async function resolveUncertainWriteBackend({ - org, site, aemPath, daPath, authToken, env, + org, site, sourcePath, authToken, env, }) { const headers = new Headers({ Authorization: authToken }); - const aemUrl = aemApiSourceUrl(org, site, aemPath); - const daUrl = new URL(`/source/${org}/${site}${daPath}`, env.DA_ADMIN); + const aemUrl = aemApiSourceUrl(org, site, sourcePath); + const daUrl = new URL(`/source/${org}/${site}${sourcePath}`, env.DA_ADMIN); const [aemResult] = await Promise.allSettled([ fetch(aemUrl, { method: 'HEAD', headers }), @@ -281,23 +281,20 @@ export async function daSourcePost({ req, env, daCtx }) { minifyWhitespace(bodyNode); const bodyContent = toHtml(bodyNode); - // api.aem.live keeps explicit file extensions. Preserve da-admin's existing - // `${path}.${ext}` construction; HTML paths are the same for both backends. - const aemPath = ext !== 'html' ? path : `${path}.${ext}`; - const daPath = `${path}.${ext}`; + // daCtx.path contains explicit file extensions; append only inferred HTML. + const sourcePath = ext !== 'html' ? path : `${path}.${ext}`; let hlx6 = await probeHlx6(org, site); if (hlx6 === undefined) { hlx6 = await resolveUncertainWriteBackend({ org, site, - aemPath, - daPath, + sourcePath, authToken, env, }); } if (hlx6) { - const sourceUrl = aemApiSourceUrl(org, site, aemPath); + const sourceUrl = aemApiSourceUrl(org, site, sourcePath); const headers = { Authorization: authToken, 'Content-Type': 'text/html', @@ -318,7 +315,7 @@ export async function daSourcePost({ req, env, daCtx }) { body.set('data', data); const headers = { Authorization: authToken }; const adminUrl = new URL( - `/source/${org}/${site}${daPath}`, + `/source/${org}/${site}${sourcePath}`, env.DA_ADMIN, ); // eslint-disable-next-line no-param-reassign