Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
72a14e0
test: pin store urls and the binary post gate
benpeter Aug 1, 2026
c2613bb
fix: derive daCtx.sourcePath and repoint store urls
benpeter Aug 1, 2026
458bba7
test: pin the post gate against charset and binary types
benpeter Aug 1, 2026
acfa57e
fix: accept only text/html on a file post, ignoring charset and case
benpeter Aug 1, 2026
9d5d4d0
test: refuse a post that does not address an html document
benpeter Aug 1, 2026
532b67a
fix: refuse a post that does not address an html document
benpeter Aug 1, 2026
48cb632
test: pin the 415 contract and drop the duplicated type matrices
benpeter Aug 1, 2026
b66a96b
refactor: read ue-service with searchParams.get
benpeter Aug 1, 2026
b0cfa3c
docs: drop the isHtmlPostType jsdoc
benpeter Aug 1, 2026
1af5bcc
refactor: build the da-admin source url in one place
benpeter Aug 1, 2026
be3a892
test: pin how the content source is resolved
benpeter Aug 3, 2026
e53df66
feat: resolve the content source from the sidekick config
benpeter Aug 3, 2026
065e201
test: pin each store's url shape and write body
benpeter Aug 3, 2026
6471fa0
feat: add the store adapter and the source-bus path case
benpeter Aug 3, 2026
f8db1d1
test: pin the stamp that links a read to its write
benpeter Aug 3, 2026
a80ee1d
feat: add the source stamp
benpeter Aug 3, 2026
3cfecf1
test: pin routed reads and the stamp they leave
benpeter Aug 3, 2026
b1a8c90
test: pin that an unusable admin host answers unknown
benpeter Aug 3, 2026
a54a5b5
feat: route reads to the store that holds the site
benpeter Aug 3, 2026
0f90abb
test: pin conditional writes and the store-moved refusal
benpeter Aug 3, 2026
eabea90
feat: make a source-bus write conditional on the read that fed it
benpeter Aug 3, 2026
6ff6120
test: pin that a store url must open the source, not appear inside it
benpeter Aug 3, 2026
da9016f
test: pin what a media read does when the source is unresolved
benpeter Aug 3, 2026
02d19e4
test: pin that a UE session can save more than once
benpeter Aug 3, 2026
2fa26f7
fix: drop the version pin, which refused every save after the first
benpeter Aug 3, 2026
28146a5
test: pin reusing a lookup across a page's image reads
benpeter Aug 3, 2026
23148d1
perf: reuse a store lookup across one page's image reads
benpeter Aug 3, 2026
5b098f4
test: pin that a write never reuses a stored lookup
benpeter Aug 3, 2026
2ef6440
refactor: ask the AEM API for the store, and drop what guarded nothing
benpeter Aug 3, 2026
9d75dd7
test: pin that auth failures answer 401 and an unreachable store answ…
benpeter Aug 3, 2026
5eed17b
fix: answer 401 on an auth failure, and 503 when a store will not answer
benpeter Aug 3, 2026
a3fe1a7
test: pin the asset race when the store could not answer
benpeter Aug 3, 2026
63c43fe
fix: let a store that could not answer win the asset race
benpeter Aug 3, 2026
a82d1c0
test: pin the lookup timeout and the half-parsed request guard
benpeter Aug 3, 2026
1fcd2cd
test: pin the /ping fast path for a source-bus read
benpeter Aug 3, 2026
0ba2098
test: pin the fast path wired into reads, and never into writes
benpeter Aug 3, 2026
cf427c1
feat: take /ping's fast answer on a read
benpeter Aug 3, 2026
ddb40b7
test: pin that a fast answer needs a base that parses
benpeter Aug 3, 2026
010823f
fix: refuse a fast answer whose base is not a url
benpeter Aug 3, 2026
b9e4053
test: pin the fast path's refusal branch, its method, and a dotted di…
benpeter Aug 3, 2026
d9def90
Merge branch 'main' into fix/dactx-source-path
benpeter Aug 3, 2026
6467175
Merge branch 'fix/dactx-source-path' into feat/hlx6-source-bus
benpeter Aug 3, 2026
acee189
Merge remote-tracking branch 'origin/main' into feat/hlx6-source-bus
benpeter Aug 3, 2026
a5a72df
fix: canonical source-bus paths and fewer local catches
benpeter Aug 6, 2026
2767e33
Merge remote-tracking branch 'origin/main' into feat/hlx6-source-bus
benpeter Aug 6, 2026
2f6b18b
test: cover the worker.fetch backstop, 500 with CORS on a throwing ha…
benpeter Aug 6, 2026
0c202d5
fix: decide the store on /ping alone, refuse source-bus writes
benpeter Aug 7, 2026
f41ce8e
fix: refuse the read when /ping cannot say which store holds the site
benpeter Aug 7, 2026
9119b98
test: x-error on the 503s says why the store did not answer
benpeter Aug 10, 2026
75fd95b
fix: carry the store failure cause on the 503s as x-error
benpeter Aug 10, 2026
82edec1
test: the /ping probe reports its failure by throwing
benpeter Aug 10, 2026
b522480
fix: put the real /ping cause in x-error, not a category
benpeter Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/handlers/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,18 @@ export default async function getHandler({ req, env, daCtx }) {
handleAEMProxyRequest({ req, env, daCtx }),
]);

const storeRes = daSourceGetRes.status === 'fulfilled' ? daSourceGetRes.value : undefined;
const aemRes = aemProxyRes.status === 'fulfilled' ? aemProxyRes.value : undefined;

let response;
if (daSourceGetRes.status === 'fulfilled' && daSourceGetRes.value.status === 200) {
response = daSourceGetRes.value;
} else if (aemProxyRes.status === 'fulfilled') {
response = aemProxyRes.value;
if (storeRes?.status === 200) {
response = storeRes;
} else if (aemRes?.status === 200) {
Comment thread
benpeter marked this conversation as resolved.
response = aemRes;
} else if (storeRes && storeRes.status >= 500) {
response = storeRes;
} else if (aemRes) {
response = aemRes;
} else {
return get404();
}
Expand Down
15 changes: 12 additions & 3 deletions src/handlers/head.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,19 @@ export default async function headHandler({ req, env, daCtx }) {
aemHead({ req, env, daCtx }),
]);

if (daSourceHeadRes.status === 'fulfilled' && daSourceHeadRes.value.status === 200) {
return daSourceHeadRes.value;
}
const storeRes = daSourceHeadRes.status === 'fulfilled' ? daSourceHeadRes.value : undefined;
const aemResponse = aemHeadRes.status === 'fulfilled' ? aemHeadRes.value : null;

if (storeRes?.status === 200) {
return storeRes;
}
if (aemResponse?.status === 200) {
return aemResponse;
}
// the store could not answer, so neither can we; the proxy's 404 would claim it does not exist
if (storeRes && storeRes.status >= 500) {
return storeRes;
}
if (aemResponse && aemResponse.status < 500) {
return aemResponse;
}
Expand Down
3 changes: 3 additions & 0 deletions src/handlers/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { get404 } from '../responses/index.js';
import { daSourcePost } from '../routes/da-admin.js';

export default async function postHandler({ req, env, daCtx }) {
if (!daCtx.site) return get404();

// for now forward all POST requests to the da-admin
return daSourcePost({ req, env, daCtx });
}
35 changes: 20 additions & 15 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,26 @@ export default {
const daCtx = getDaCtx(req);

let resp;
switch (req.method) {
case 'OPTIONS':
resp = await optionsHandler({ req });
break;
case 'HEAD':
resp = await headHandler({ req, env, daCtx });
break;
case 'GET':
resp = await getHandler({ req, env, daCtx });
break;
case 'POST':
resp = await postHandlers({ req, env, daCtx });
break;
default:
resp = unknownHandler();
try {
switch (req.method) {
case 'OPTIONS':
resp = await optionsHandler({ req });
break;
case 'HEAD':
resp = await headHandler({ req, env, daCtx });
break;
case 'GET':
resp = await getHandler({ req, env, daCtx });
break;
case 'POST':
resp = await postHandlers({ req, env, daCtx });
break;
default:
resp = unknownHandler();
}
} catch (e) {
console.error(`500 ${req.method} ${url.pathname}: ${e.name}: ${e.message}`, e);
resp = new Response(null, { status: 500 });
}
return withCorsHeaders(resp, req);
},
Expand Down
44 changes: 44 additions & 0 deletions src/responses/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
*/
import { DEFAULT_UNAUTHORIZED_HTML_MESSAGE } from '../utils/constants.js';

const RETRY_AFTER_SECONDS = '5';

// a 503 says the store did not answer and the body says it again. Only `x-error` says which of a
// rate limit, a timeout or a dropped connection it was, without reading the worker log.
function retryHeaders(error) {
const headers = [['Retry-After', RETRY_AFTER_SECONDS]];
if (error) headers.push(['x-error', error]);
return headers;
}

export function daResp({
body, status, contentType, contentLength, headers: extraHeaders,
}) {
Expand Down Expand Up @@ -47,10 +57,44 @@ export function get415(message = '') {
return daResp({ body: message, status: 415, contentType: 'text/html' });
}

export function get503(message = '', error = '') {
return daResp({
body: message,
status: 503,
contentType: 'text/html',
headers: retryHeaders(error),
});
}

// a refused write is never rendered. The Universal Editor Service embeds the body verbatim in
// its problem+json error string, so plain text is what an author is shown.
export function post503(message = '', error = '') {
return daResp({
body: message,
status: 503,
contentType: 'text/plain; charset=utf-8',
headers: retryHeaders(error),
});
}

// RFC 9110 requires an Allow header on a 405, and reads are what is left once the write is gone.
export function post405(message = '') {
return daResp({
body: message,
status: 405,
contentType: 'text/plain; charset=utf-8',
headers: [['Allow', 'GET, HEAD, OPTIONS']],
});
}

export function head401() {
return new Response(null, { status: 401 });
}

export function head503(error = '') {
return new Response(null, { status: 503, headers: retryHeaders(error) });
}

export function head404() {
return new Response(null, { status: 404 });
}
Expand Down
156 changes: 114 additions & 42 deletions src/routes/da-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,46 @@ import {
applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse,
} from '../utils/quick-edit.js';
import {
daResp, get401, get404, get415, head401,
daResp, get401, get404, get415, get503, head401, head503, post405, post503,
} from '../responses/index.js';
import { BRANCH_NOT_FOUND_HTML_MESSAGE, DEFAULT_HTML_TEMPLATE, UNAUTHORIZED_HTML_MESSAGE } from '../utils/constants.js';
import {
BRANCH_NOT_FOUND_HTML_MESSAGE,
DEFAULT_HTML_TEMPLATE,
SOURCE_BUS_READ_ONLY_MESSAGE,
SOURCE_UNDETERMINED_MESSAGE,
SOURCE_UNREACHABLE_HTML_MESSAGE,
SOURCE_UNREACHABLE_MESSAGE,
UNAUTHORIZED_HTML_MESSAGE,
} from '../utils/constants.js';
import { getSiteConfig } from '../storage/config.js';
import isSourceBus from '../storage/source-bus.js';
import getStore from '../storage/store.js';
import { restoreAbsoluteImages } from '../render/rewrite-images.js';

const HTML_POST_TYPE = 'text/html';

/**
* Renders a failure for the `x-error` header.
*/
function causeOf(e) {
return `${e?.name ?? 'Error'}: ${e?.message ?? e}`
.replace(/[^\x20-\x7e]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 1024);
}

function probeFailed(e, method, sourcePath) {
const cause = `/ping failed: ${causeOf(e)}`;
console.warn(`503 ${method} ${sourcePath}, ${cause}`);
return cause;
}

export function isHtmlPostType(type) {
if (!type) return true;
return type.split(';')[0].trim().toLowerCase() === HTML_POST_TYPE;
}

function getSourceUrl(env, { org, site, sourcePath }) {
return new URL(`/source/${org}/${site}${sourcePath}`, env.DA_ADMIN);
}

async function getFileBody(data) {
const text = await data.text();
return { body: text, type: data.type };
Expand Down Expand Up @@ -80,6 +103,39 @@ async function getPageTemplate(env, daCtx, aemCtx) {
return DEFAULT_HTML_TEMPLATE;
}

/**
* Sends a request to a store and reports why it could not be reached at all.
*
* @returns {Promise<{response?: Response, error?: string}>}
*/
async function reachStore(store, send) {
try {
return { response: await send() };
} catch (e) {
const error = causeOf(e);
console.warn(`503 ${store.url}, the store could not be reached: ${error}`);
return { error };
}
}

/**
* Reads from the store that holds the site.
*
* @returns {Promise<{response?: Response, error?: string}>} `error` says why there is no response
*/
async function readSource(env, daCtx, init) {
let onSourceBus;
try {
onSourceBus = await isSourceBus(env, daCtx);
} catch (e) {
return { error: probeFailed(e, init.method, daCtx.sourcePath) };
}

const store = getStore(env, daCtx, onSourceBus);
console.log(`-> ${init.method} ${store.url.toString()}`);
return reachStore(store, () => store.fetch(store.url, init));
}

export async function daSourceGet({ req, env, daCtx }) {
const { ext, authToken } = daCtx;

Expand All @@ -100,17 +156,20 @@ export async function daSourceGet({ req, env, daCtx }) {
headers.set('Authorization', authToken);

if (ext !== 'html') {
// for non-HTML files, simply proxy the request without processing
const adminUrl = getSourceUrl(env, daCtx);
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 });
// for non-HTML files, simply proxy the request without processing. A refusal is passed on as
// itself: nothing renders an image, so the da:401 shell would only corrupt it.
const { response, error } = await readSource(env, daCtx, { method: 'GET', headers });
if (!response) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, error);
console.log(`<- ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText });
return response;
}

// get the AEM parts (head.html)
// the store lookup costs a round trip, so it runs alongside head.html rather than after it
const aemCtx = getAemCtx(env, daCtx);
const headHtml = await getAEMHtml(aemCtx, '/head.html');
const [headHtml, { response: sourceResp, error: sourceError }] = await Promise.all([
getAEMHtml(aemCtx, '/head.html'),
readSource(env, daCtx, { method: 'GET', headers }),
]);
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.
Expand All @@ -119,22 +178,24 @@ export async function daSourceGet({ req, env, daCtx }) {
}
return get404(BRANCH_NOT_FOUND_HTML_MESSAGE);
}
if (!sourceResp) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, sourceError);
console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText });

// get the content from DA admin
const adminUrl = getSourceUrl(env, daCtx);
// the store is the only thing to see the token, and the authorbus extension recovers off the
// da:401 meta rather than the status, so a refusal from the store gets that shell
if (sourceResp.status === 401 || sourceResp.status === 403) {
return daResp({ body: UNAUTHORIZED_HTML_MESSAGE, status: sourceResp.status, contentType: 'text/html' });
}

// 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 });
// only a 404 means "this document is not here". Composing the starter template over anything
// else hands the author a blank page to save over a document that exists.
if (sourceResp.status !== 200 && sourceResp.status !== 404) {
return sourceResp;
}

// 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
Expand Down Expand Up @@ -174,10 +235,9 @@ export async function daSourceHead({ env, daCtx }) {
const headers = new Headers();
headers.set('Authorization', authToken);

const adminUrl = getSourceUrl(env, daCtx);
console.log(`-> HEAD ${adminUrl.toString()}`);
const response = await env.daadmin.fetch(adminUrl, { method: 'HEAD', headers });
console.log(`<- HEAD ${adminUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText });
const { response, error } = await readSource(env, daCtx, { method: 'HEAD', headers });
if (!response) return head503(error);
console.log(`<- HEAD ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText });
return new Response(null, { status: response.status, headers: response.headers });
}

Expand Down Expand Up @@ -213,22 +273,34 @@ export async function daSourcePost({ req, env, daCtx }) {

minifyWhitespace(bodyNode);

// 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 };
const adminUrl = getSourceUrl(env, daCtx);
// eslint-disable-next-line no-param-reassign
req = new Request(adminUrl, {

// the payload is settled, so the only question left is where it goes
let onSourceBus;
try {
onSourceBus = await isSourceBus(env, daCtx);
} catch (e) {
const cause = probeFailed(e, 'POST', sourcePath);
return post503(SOURCE_UNDETERMINED_MESSAGE, cause);
}

if (onSourceBus) {
console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`);
return post405(SOURCE_BUS_READ_ONLY_MESSAGE);
}

// da-admin takes the document as a `data` form part
const store = getStore(env, daCtx, onSourceBus);
const body = new FormData();
body.set('data', new Blob([bodyContent], { type: 'text/html' }));
console.log(`-> ${store.url.toString()}`);
const { response, error } = await reachStore(store, () => store.fetch(new Request(store.url, {
method: 'POST',
body,
headers,
});
console.log(`-> ${adminUrl.toString()}`);
const response = await env.daadmin.fetch(req);
console.log(`<- ${adminUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText });
headers: { Authorization: authToken },
})));
if (!response) return post503(SOURCE_UNREACHABLE_MESSAGE, error);
console.log(`<- ${store.url.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText });
return response;
}

Expand Down
Loading
Loading