feat: route to the site's content store - #263
Conversation
covers daCtx.sourcePath, the /source/... url handed to env.daadmin.fetch across GET/HEAD/POST, and a 415 for a multipart File that is not text/html. red on main, where / reads /.html and /page.html reads /page.html.html. Relates to #256.
sourcePath comes from the filename rather than ext, so / reads /index.html and /page.html stops reading /page.html.html. ext is unchanged, so the compose vs raw proxy split at da-admin.js:93 holds. drops six reader-less daCtx fields. daSourcePost now 415s a File whose declared type is not text/html, which would otherwise corrupt a binary body once the write lands on the key GET reads, and returns a response instead of undefined so withCorsHeaders cannot throw. Relates to #256.
4 red: text/html with a charset param is refused, octet-stream and text/plain are allowed through onto the key GET reads. the two case rows pass in node because undici normalizes File.type; they are workerd regression guards.
workerd preserves a part's declared type verbatim and reports '' when the client declared none, so the check normalizes before comparing and no longer allows octet-stream or text/plain onto the key GET reads.
3 red. the html serializer rewrites any body, so posting to /Media/Logo.PNG, a string part, or /sheet.json currently writes html onto the canonical key that GET reads. on main those went to a doubled key nothing reads.
ext is html iff sourcePath ends .html, so this covers every non-html target and refuses nothing the universal editor produces. main sent those writes to a doubled key; sourcePath sends them to the key GET reads.
the non-form fallthrough asserted only instanceof Response, so a 200 passed. the part-type tests targeted a non-html path, where the path check now answers first. adds the urlencoded string-part path, which had no test.
drops the intermediate object. on a duplicated param this takes the first value where fromEntries took the last; only 'local' is acted on, at ue/scaffold.js:36.
the workerd empty-type behaviour it described is in the acfa57e commit message.
the four call sites had the same construction in three spellings. org, site and sourcePath drop out of three destructures as a result.
the sidekick config names the store and 404s when config resolution failed, so it can answer "unknown" where /ping reports legacy.
answers sourcebus with the store base url, legacy, or unknown. a config that could not be resolved lands on unknown rather than being reported as legacy.
the source bus keeps directory and extension case and reads a raw body; da-admin lowercases the whole path and reads a data form part.
each store builds its own url and its own write body, since neither parses the other's shape.
a source-bus read carries its etag so the write is conditional; anything a stamp cannot be trusted to say is not trusted.
carries the store and, on the source bus, the etag the read returned, so the write can be made conditional on it.
503 when the source is unresolved, only 404 means absent, and a UE read stamps the connection uri with the store and etag it read.
a throw escapes into withCorsHeaders, which reads response.headers and throws again.
503 when the content source is unresolved, since guessing serves a migrated site its stale copy. only 404 means the document is absent. a UE read stamps the connection uri with the store and etag it read, which the write that follows uses as its precondition. the write path routes here too and gains that precondition in the next commit.
a source-bus save carries the etag its read returned, so a wrong-store or stale write is refused by the store rather than landing. 409 when the read and the save find different stores.
the stamp the read left carries the etag, so a save conditioned on it cannot land on a document the author never saw: a wrong store or a changed page is refused by the store with 412. where the stamp and a fresh lookup name different stores the site moved while the page was open, which is answered 409.
the base is used verbatim as a store url and the author token goes with it. found by a mutation that swapped startsWith for includes and survived.
an image falls through to the published copy on *.aem.page, since the handlers already race the two and an image cannot become a write. mp4 is not raced, so its refusal reaches the caller.
the editor keeps the stamp it was served at page load and posts back to it for every edit, so a precondition pinned to a version lands the first save and refuses the rest.
the editor keeps the connection uri it was served at page load and posts back to it for each edit, so an If-Match on the read's etag lands the first save and 412s the rest. the source bus sets no etag on a write response either, so nothing can refresh it. the stamp now says only which store the read used and whether it found a document. store identity is still carried, by the 409 when the stamp and a fresh lookup disagree.
a previewed page is one worker request per image, each looking the store up again at ~460ms. writes never reuse, and an unresolved answer is never kept.
a previewed page is one worker request per relative image src, each looking the store up again. the sidekick config is no-store and ~460ms, so 8 identical lookups spend 3.8s of origin time. reads reuse an answer for 10s; a write always asks, and an unresolved answer is never kept.
a stale answer would let the 409 miss a site that moved stores inside the reuse window, which is the one case that overwrites a live page. found by a surviving mutation.
the lookup moves to api.aem.live/{org}/sites/{site}/sidekick behind AEM_API. it needs
code:read, which every role that can save already has, and it answers for legacy sites too
since both stores read the same config service.
gone with it: the connection-uri marker and the 409 it fed, which caught only a migration
mid-session and those are managed; the write preconditions, which no read could refresh;
and the 10s lookup reuse, since nothing else on this path caches and the config owner sends
no-cache upstream and no-store down.
…ers 503 reporting an expired session as unresolved answers a retryable 503, so the client never re-authenticates and the da:401 recovery the authorbus extension has never fires. and a throw from either store's fetch escapes into withCorsHeaders, which reads response.headers and turns it into an opaque 500 with no CORS.
| * could not be reached, and when `source.kind` is unauthorized or unknown | ||
| */ | ||
| async function readSource(env, daCtx, init) { | ||
| const config = resolveContentSource(env, daCtx); |
There was a problem hiding this comment.
await only if needed, clear answer from `/ping/ wins
|
|
||
| // the payload is settled, so the only question left is where it goes. A write is the one | ||
| // operation a wrong guess cannot be walked back from. | ||
| const source = await resolveContentSource(env, daCtx); |
There was a problem hiding this comment.
no /ping on write, the slower lookup doesn't matter for one request, and a wrong store here can't be undone
| response = aemProxyRes.value; | ||
| if (storeRes?.status === 200) { | ||
| response = storeRes; | ||
| } else if (aemRes?.status === 200) { |
There was a problem hiding this comment.
published 200 beats store 5xx on the read path (same in head.js)
# Conflicts: # src/responses/index.js # src/routes/da-admin.js # test/routes/da-admin.test.js
|
|
||
| return { | ||
| url: new URL(`/source/${org}/${site}${sourcePath}`, env.DA_ADMIN), | ||
| fetch: (input, init) => env.daadmin.fetch(input, init), |
There was a problem hiding this comment.
a service binding's fetch from the binding throws Illegal invocation in workerd, so it
has to be called on env.daadmin
| ['/folder/content', '/folder/content.html', 'appends .html when the request had no extension'], | ||
| ['/', '/index.html', 'names the root document index.html'], | ||
| ['/sub-folder/', '/sub-folder/index.html', 'names a directory index'], | ||
| ['/Media/Holiday.PNG', '/Media/holiday.PNG', 'lowercases the stem, keeps directory and extension case'], |
There was a problem hiding this comment.
| ['/Media/Holiday.PNG', '/Media/holiday.PNG', 'lowercases the stem, keeps directory and extension case'], | |
| ['/Media/Holiday.PNG', '/media/holiday.png', 'lowercases the stem, keeps directory and extension case'], |
| ['/', '/index.html', 'names the root document index.html'], | ||
| ['/sub-folder/', '/sub-folder/index.html', 'names a directory index'], | ||
| ['/Media/Holiday.PNG', '/Media/holiday.PNG', 'lowercases the stem, keeps directory and extension case'], | ||
| ['/A/B/c.JSON', '/A/B/c.JSON', 'keeps every directory segment as requested'], |
There was a problem hiding this comment.
| ['/A/B/c.JSON', '/A/B/c.JSON', 'keeps every directory segment as requested'], | |
| ['/A/B/c.JSON', '/a/b/c.json', 'keeps every directory segment as requested'], |
| ['/sub-folder/', '/sub-folder/index.html', 'names a directory index'], | ||
| ['/Media/Holiday.PNG', '/Media/holiday.PNG', 'lowercases the stem, keeps directory and extension case'], | ||
| ['/A/B/c.JSON', '/A/B/c.JSON', 'keeps every directory segment as requested'], | ||
| ['/Sub-Folder/', '/Sub-Folder/index.html', 'keeps directory case on a directory index'], |
There was a problem hiding this comment.
| ['/Sub-Folder/', '/Sub-Folder/index.html', 'keeps directory case on a directory index'], | |
| ['/Sub-Folder/', '/sub-folder/index.html', 'keeps directory case on a directory index'], |
| export function sourceBusPath({ path, sourcePath }) { | ||
| const dirEnd = path.lastIndexOf('/'); | ||
| const base = sourcePath.slice(sourcePath.lastIndexOf('/') + 1); | ||
| const baseDot = base.lastIndexOf('.'); | ||
| const requestedDot = path.lastIndexOf('.'); | ||
| // an extension the request carried keeps its case; the `.html` we appended does not have one | ||
| const ext = requestedDot > dirEnd ? path.slice(requestedDot) : base.slice(baseDot); | ||
| return `${path.slice(0, dirEnd)}/${base.slice(0, baseDot)}${ext}`; | ||
| } |
There was a problem hiding this comment.
- we should also sanitize the folder names. see https://github.com/adobe/helix-api-service/pull/352
- extensions are also lowercased
| if (response?.status === 200) return { source: fast, response }; | ||
| // a refusal is about the token, not about which store, so the config read would repeat it | ||
| if (response?.status === 401 || response?.status === 403) return { source: fast, response }; |
There was a problem hiding this comment.
why do you fall through other errors? like 500, or 400, ... 429 ?
| if (response?.status === 200) return { source: fast, response }; | |
| // a refusal is about the token, not about which store, so the config read would repeat it | |
| if (response?.status === 401 || response?.status === 403) return { source: fast, response }; | |
| if (response && response.status !== 404) return { source: fast, response }; |
| console.warn(`503 ${store.url}, the store could not be reached: ${e.name}: ${e.message}`); | ||
| return undefined; |
There was a problem hiding this comment.
why? why are exceptions "swallowed" ? isn't that, well, a exception?
| if (response.status !== 200) return undefined; | ||
| if (response.headers.get(UPGRADE_HEADER) !== 'true') return undefined; | ||
| } catch (e) { | ||
| return undefined; |
There was a problem hiding this comment.
why? if we can't determine the state, we can't just ignore it. then, the request should be aborted.
| try { | ||
| url = new URL(`/ping/${org}/${site}`, env.HLX_ADMIN); | ||
| } catch (e) { | ||
| return undefined; |
There was a problem hiding this comment.
this should never happen, so ignoring the exception is wrong
| try { | ||
| url = new URL(`/${org}/sites/${site}/sidekick`, api); | ||
| } catch (e) { | ||
| return unknown(org, site, `AEM_API is not a url: ${e.message}`); |
There was a problem hiding this comment.
same... ignoring the exception is wrong. the url must not be invalid
| return unknown(org, site, 'no org or site in the request'); | ||
| } | ||
|
|
||
| const api = env.AEM_API?.replace(/\/$/, ''); |
There was a problem hiding this comment.
why not ensure that env.AEM_API never has a trailing slash?
| try { | ||
| response = await fetch(url, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) }); | ||
| } catch (e) { | ||
| return unknown(org, site, `${url} failed with ${e.name}: ${e.message}`); |
There was a problem hiding this comment.
why ignoring the exception?
| try { | ||
| config = await response.json(); | ||
| } catch (e) { | ||
| return unknown(org, site, `${url} did not answer json: ${e.message}`); |
| * with the store base url, `legacy`, `unauthorized` with the status the API gave, or `unknown` | ||
| * with the reason it could not be answered | ||
| */ | ||
| export default async function resolveContentSource(env, daCtx) { |
There was a problem hiding this comment.
why is this needed at all? since you are not really interested in the actual content source, but just if it's source bus. which is already reflected via the upgrade header
There was a problem hiding this comment.
it's needed because no upgrade header doesn't reliably mean "not on sourcebus" RN, see PR description.
that's also what makes the PR so broad (asking sidekick, and swallowing exceptions on the way, because for the question it's trying to answer they only mean 'we can't tell' [which content store] and 503 in that case)
the alternative I guess would be to make /ping more reliable, what this PR does is probably an over-complicated workaround for /ping answering 200 always (or we accept edge cases like: 1) config load blips, ping still says 200, no upgrade header, then 2) saves go to wrong store)
if that's the wrong approach, I can close this. turning to draft for now
tripodsan
left a comment
There was a problem hiding this comment.
I don't really like this PR.
- too many places where exceptions are swallowed, leading to side effects
- I do not understand why the sidekick config is loaded. this is indirectly handled via
/ping - I do not understand why the stores have "write" related code
requires #261
Description
before
one store, hardcoded:
admin.da.live.a site on the hlx6 source bus keeps its content on
api.aem.liveinstead.so on a migrated site
GETserved da-admin's pre-migration copy, or the empty template at HTTP 200POSTwrote that copy back to da-admin, where it is not served/Media/Holiday.PNG404'd (daCtx.sourcePathlowercases the whole path, the source bus keepsdirectory and extension case)
now
a read does both lookups at once. a write fires only the second.
GET {HLX_ADMIN}/ping/{org}/{site}answers sites in the Fastly edge dictionary in~37ms with
x-api-upgrade-available: true. that means source bus,and the base is built as
{AEM_API}/{org}/sites/{site}/source. helix-api-service refuses a sourceurl naming another site (
src/contentproxy/source/utils.js). no other base is legal for this site.x-api-upgrade-availableis usable as a "yes" but not as a "no". helix-admin sets it when theresolved config has
content.source.urlunderhttps://api.aem.live/(
helix-admin/src/index.js:100), so its absence coversso a read takes the fast answer when the store replies 200, or 401/403.
on any other status it waits for the config read.
so a wrong yes costs one 404 and cannot produce the empty template.
GET {AEM_API}/{org}/sites/{site}/sidekickreadscontentSourceUrl.{AEM_API}/https://content.da.live/env.DA_ADMINneeds
code:read, answers for legacy sites too (one config service behind both stores).a write reads only the config: one request where 460ms does not matter, and a wrong
store cannot be reverted.
per store
dataform part, unchangeddaCtx.sourcePathonly 404 from a store means absent. 401, 403, 429, 5xx pass through
(they used to fall through to the empty template at HTTP 200)
a 401 or 403 on an html
GETis answered with theda:401shell the authorbus extension matchesto refetch
/gimme_cookieand refresh. a 503 has noaue:system:abmeta, so the recovery cannotstart. a store that throws is a 503 too. it used to reach
withCorsHeaders, which readsresponse.headers: an opaque 500 without CORS.considered but dropped:
If-Matchon a write. only the source bus sets an etag on a read, and the editor is given theconnection uri once, at page load. UES posts back to it per edit
(
universal-editor-service-plugin-da/src/io.ts:29,52) and returnsupdates[], so the iframe isnot reloaded and the etag is stale after the first save. a write response has no etag either, so
it cannot be refreshed
urn:adobe:aue:system:abmeta content. it does round-trip through UES, soa read could name its store there for a write to compare against. the one use was a 409 when they
disagreed, which needs
content.source.urlto change between the read and the save. to not makeit more complex, this is relying on authors being hands-off during migration
Related Issue
Unblocks adobe/da-live#1066. Supersedes #242.
How Has This Been Tested
41 checks driving the handlers against
api.aem.live,admin.hlx.pageandadmin.da.live, on ascratch site, objects read back and deleted.
/pinganswered the enrolled site in 31ms and the legacy site in 434ms, so the fast yes isthe edge and its absence is an origin request
AEM_APIthat is unset, scheme-less or has a trailing space answers 503 on a real/pingyes. it used to build
undefined/org/sites/..., which threw out ofworker.fetchcontent.da.liveand onaem.page: was a 404 throughgetHandler, now 200 onue.da.live, onpreview.da.liveand with?quick-edit.that is #1066's 404
If-Matchon the read's etag. now 6× 201,and the sixth is what is stored
da:401shell. 403 passes through/Media/LiveProbe.PNGwas a 404, now 200dataform partimage reads race the store against
*.aem.page, which predates this. store at 200 wins, thenthe published copy at 200, then the store's own 503. that last one was the proxy's 404, claiming
the image did not exist.
the shipped sidekick does not rely on
api.aem.live/.../sidekickanswering for legacy sites(
aem-sidekick/src/extension/project.js:245picks the host off/ping), so a 404 fallback toadmin.hlx.page/sidekick/.../config.jsonwould be needed if that changes.