Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions WORKLOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Worklog

## 2026-08-07

### nx2/utils/api.js — remove stage-content.da.live rewrite workaround

`80f9db79` removed the `content.da.live` → `stage-content.da.live` contentUrl rewrite from `source.uploadMedia`'s non-hlx6 branch (added `3300b1ee`/`3070fa63`, see `2026-08-06` below), plus its three dedicated tests. Server-side fix landed on stage-admin.da.live — it now returns the correct content host directly, so the client-side rewrite is no longer needed. This makes bug fix #2 in the `2026-08-06` entry below (the body-stream-already-read fix) moot: it only mattered inside the now-deleted rewrite branch.

## 2026-08-06

### nx2/utils/api.js — tests for `source.uploadMedia`, plus two bug fixes found while writing them

Added test coverage for the new `source.uploadMedia({ org, site, path, body })` method (added in `3300b1ee`, "feat: add media upload api"): legacy delegation to `_saveDA` as FormData, the stage `content.da.live` → `stage-content.da.live` contentUrl rewrite, hlx6 POSTs to the AEM media route with the correct `content-type` header, `contentUrl` prefix-stripping against the site's `aem.page` origin, non-ok passthrough for both branches, and the `/org/site/path` string call form. 11 new tests in `test/nx2/utils/api.test.js`.

Two bugs surfaced while writing the tests (both fixed, confirmed with the author):
1. The non-hlx6 branch fell through to the hlx6 media POST whenever `DA_ADMIN` wasn't exactly `'https://stage-admin.da.live'` — i.e. for any ordinary non-hlx6 site in most environments, `uploadMedia` made a second, unintended request to the hlx6-only endpoint after `_saveDA` had already completed. Fixed by returning after the `_saveDA` call unconditionally.
2. In this repo's test/dev env `DA_ADMIN` *is* `'https://stage-admin.da.live'`, so the stage-content rewrite branch always runs for non-hlx6 uploads. When the returned `contentUrl`'s host wasn't `content.da.live` (no rewrite needed), the code had already consumed the response body via `resp.json()` and then returned that same (now-drained) `Response` — any caller subsequently calling `resp.json()` would get a "body stream already read" error. Fixed by always returning `adaptJsonResponse(resp, json)` in that branch, rewritten or not, so callers get a fresh readable response either way.

## 2026-07-30

### nx2/utils/api.js — normalize hlx6 `source.save` response to `{ source: { contentUrl } }` (#631)
Expand Down
120 changes: 95 additions & 25 deletions nx2/utils/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const config = {
const resp = await daFetch({ url });
if (resp.ok) {
const cfg = object2sheet(await resp.json());
resp.json = () => cfg;
return adaptJsonResponse(resp, cfg);
}
return resp;
}
Expand Down Expand Up @@ -296,31 +296,95 @@ export const source = {
return { ok: true, items, continuationToken: nextToken, permissions };
}),

save: withArgs(async ({ org, site, path, body }) => {
const hlx6 = await isHlx6(org, site);
save: withArgs(async (opts) => {
const { org, site } = opts;
if (await isHlx6(org, site)) {
// eslint-disable-next-line no-underscore-dangle
return source._saveHlx6(opts);
}
// eslint-disable-next-line no-underscore-dangle
return source._saveDA(opts);
}),

_saveHlx6: withArgs(async ({ org, site, path, body }) => {
const url = await getDaApiPath(SOURCE, org, site, path);
const opts = { method: 'POST' };
const ext = Object.keys(TYPE_MAP).find((e) => path.toLowerCase().endsWith(e));
if (hlx6) {
opts.body = body;
if (ext) opts.headers = { 'Content-Type': TYPE_MAP[ext] };
const resp = await daFetch({ url, opts });
// hlx6 source save returns an empty body, whereas DA returns
// { source: { contentUrl } }. Normalize the success case to that shape
// so callers can read source.contentUrl uniformly across hlx5/hlx6.
// contentUrl comes from the response's location header (resolved
// against the request url) since the server may write the source to a
// different canonical path than the one requested.
const location = resp.headers.get('location') || '';
const sourceUrl = new URL(location, url).href;
return resp.ok ? withSourceJson(resp, sourceUrl) : resp;
const opts = {
method: 'POST',
body,
};
const contentType = findContentType(path);
if (contentType) {
opts.headers = { 'Content-Type': contentType };
}
const resp = await daFetch({ url, opts });
// hlx6 source save returns an empty body, whereas DA returns
// { source: { contentUrl } }. Normalize the success case to that shape
// so callers can read source.contentUrl uniformly across hlx5/hlx6.
// contentUrl comes from the response's location header (resolved
// against the request url) since the server may write the source to a
// different canonical path than the one requested.
const location = resp.headers.get('location') || '';
const sourceUrl = new URL(location, url).href;
return resp.ok
? adaptJsonResponse(resp, { source: { contentUrl: sourceUrl } })
: resp;
}),

_saveDA: withArgs(async ({ org, site, path, body }) => {
const url = await getDaApiPath(SOURCE, org, site, path);
const formData = new FormData();
formData.append('data', new Blob([body], { type: TYPE_MAP[ext] }));
formData.append('data', new Blob([body], { type: findContentType(path) }));
const opts = {
method: 'POST',
body: formData,
};
opts.body = formData;
return daFetch({ url, opts });
}),

// special method to upload media. for hlx6, this will use the api service's '/media' route,
// for non hlx6 it will just use the normal source save for now.
uploadMedia: withArgs(async ({ org, site, path, body }) => {
const hlx6 = await isHlx6(org, site);
if (!hlx6) {
// fall back to original source store
// eslint-disable-next-line no-underscore-dangle
return source._saveDA({ org, site, path, body });
}
const url = `${AEM_API}/${org}/sites/${site}/media${path}`;
const opts = {
method: 'POST',
body,
headers: {
'content-type': findContentType(path) || 'application/octet-stream',
},
};
const resp = await daFetch({ url, opts });
if (resp.ok) {
const json = await resp.json();
// {
// uri: 'https://main--site--org.aem.page/media_....,
// meta: {
// type: 'image/png',
// width: 640,
// height: 480,
// },
const pfx = `https://main--${site}--${org}.aem.page/`;
let contentUrl = json.uri;
if (contentUrl.startsWith(pfx)) {
contentUrl = `./${contentUrl.substring(pfx.length)}`;
}
return adaptJsonResponse(resp, {
source: {
contentUrl,
},
// exact use to be defined
meta: json.meta,
});
}
return resp;
}),

// HEAD request — the value is in the response headers (doc-id, last-modified, etc.).
getMetadata: withArgs(async ({ org, site, path }) => {
const url = await getDaApiPath(SOURCE, org, site, path);
Expand Down Expand Up @@ -622,6 +686,15 @@ const TYPE_MAP = {
'.pdf': 'application/pdf',
};

/**
* finds the content type by path extension
* @param path
*/
function findContentType(path) {
const ext = Object.keys(TYPE_MAP).find((e) => path.toLowerCase().endsWith(e));
return TYPE_MAP[ext];
}

// DA-owned endpoints proxied between DA_ADMIN and AEM_API.
async function getDaApiPath(api, org, site, path = '') {
const hlx6 = await isHlx6(org, site);
Expand Down Expand Up @@ -700,12 +773,9 @@ function normalizePath(path) {
return path.startsWith('/') ? path : `/${path}`;
}

// Shadow a Response's `json()` so it resolves to the DA-shaped
// `{ source: { contentUrl } }`. Used for hlx6 saves, whose body is empty —
// preserves the original ok/status/headers/permissions.
function withSourceJson(resp, contentUrl) {
resp.json = async () => ({ source: { contentUrl } });
return resp;
// Create a new response with a different JSON response body
function adaptJsonResponse(resp, obj) {
return new Response(JSON.stringify(obj), resp);
}
Comment on lines +777 to 779

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anfibiacreativa better :-) ?


function jsonOpts(method, payload) {
Expand Down
88 changes: 88 additions & 0 deletions test/nx2/utils/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,94 @@ describe('api.js', () => {
expect(resp.status).to.equal(403);
});

it('source.uploadMedia legacy delegates to _saveDA as FormData with no double-fetch', async () => {
restoreFetch();
installFetch({
body: JSON.stringify({ source: { contentUrl: 'https://stage-content.da.live/o/s/img.png' } }),
});
const { org: o, site: s } = makeOrgSite();
const data = new Blob(['binary'], { type: 'image/png' });
await source.uploadMedia({ org: o, site: s, path: '/img.png', body: data });
const last = lastCall();
expect(last.method).to.equal('POST');
expect(last.body).to.be.instanceof(FormData);
const stored = last.body.get('data');
expect(stored).to.be.instanceof(Blob);
expect(stored.size).to.equal(data.size);
expect(calls.some((c) => c.url.includes('/media'))).to.equal(false);
});

it('source.uploadMedia hlx6 POSTs to the media route with content-type header and raw body', async () => {
const { org: o, site: s } = makeOrgSite({ hlx6: true });
restoreFetch();
installFetch({
body: JSON.stringify({ uri: `https://main--${s}--${o}.aem.page/media_1.png`, meta: {} }),
});
const blob = new Blob(['binary'], { type: 'image/png' });
await source.uploadMedia({ org: o, site: s, path: '/img.png', body: blob });
const last = lastCall();
expect(last.url).to.equal(`${AEM_API}/${o}/sites/${s}/media/img.png`);
expect(last.method).to.equal('POST');
expect(last.headers['content-type']).to.equal('image/png');
expect(last.body).to.equal(blob);
});

it('source.uploadMedia hlx6 falls back to application/octet-stream for unknown extensions', async () => {
const { org: o, site: s } = makeOrgSite({ hlx6: true });
restoreFetch();
installFetch({
body: JSON.stringify({ uri: `https://main--${s}--${o}.aem.page/media_1`, meta: {} }),
});
await source.uploadMedia({ org: o, site: s, path: '/file.xyz', body: new Blob(['x']) });
expect(lastCall().headers['content-type']).to.equal('application/octet-stream');
});

it('source.uploadMedia hlx6 normalizes contentUrl by stripping the site aem.page prefix', async () => {
restoreFetch();
const { org: o, site: s } = makeOrgSite({ hlx6: true });
installFetch({
body: JSON.stringify({
uri: `https://main--${s}--${o}.aem.page/media_123.png`,
meta: { type: 'image/png', width: 640, height: 480 },
}),
});
const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) });
const json = await resp.json();
expect(json.source.contentUrl).to.equal('./media_123.png');
expect(json.meta).to.deep.equal({ type: 'image/png', width: 640, height: 480 });
});

it('source.uploadMedia hlx6 leaves contentUrl unchanged when uri does not match the site prefix', async () => {
restoreFetch();
installFetch({
body: JSON.stringify({ uri: 'https://cdn.example.com/media_999.png', meta: {} }),
});
const { org: o, site: s } = makeOrgSite({ hlx6: true });
const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) });
const json = await resp.json();
expect(json.source.contentUrl).to.equal('https://cdn.example.com/media_999.png');
});

it('source.uploadMedia hlx6 returns the raw response on non-ok status without parsing the body', async () => {
restoreFetch();
installFetch({ status: 404, body: '' });
const { org: o, site: s } = makeOrgSite({ hlx6: true });
const resp = await source.uploadMedia({ org: o, site: s, path: '/img.png', body: new Blob(['x']) });
expect(resp.ok).to.equal(false);
expect(resp.status).to.equal(404);
});

it('source.uploadMedia accepts a path string with extras', async () => {
const { org: o, site: s } = makeOrgSite({ hlx6: true });
restoreFetch();
installFetch({
body: JSON.stringify({ uri: `https://main--${s}--${o}.aem.page/media_1.png`, meta: {} }),
});
const blob = new Blob(['x'], { type: 'image/png' });
await source.uploadMedia(`/${o}/${s}/img.png`, { body: blob });
expect(lastCall().url).to.equal(`${AEM_API}/${o}/sites/${s}/media/img.png`);
});

it('source.getMetadata sends HEAD and returns { ok, status, headers }', async () => {
restoreFetch();
installFetch({ status: 200, headers: { 'last-modified': 'Mon, 01 Jan 2025 00:00:00 GMT' } });
Expand Down
Loading