Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
152523b
docs(MWPW-191059): add Freyja preview migration spec and implementati…
Axelcureno Apr 1, 2026
a7dd497
feat(MWPW-191059): add Freyja and Odin preview URL constants to paths.js
Axelcureno Apr 1, 2026
41ba251
feat(MWPW-191059): add auth header injection and Freyja-first fallbac…
Axelcureno Apr 1, 2026
ba6ff8f
test(MWPW-191059): update pipeline and replace test mocks for Freyja …
Axelcureno Apr 1, 2026
6d05b53
test(MWPW-191059): update Nala variation spec for Freyja preview URLs
Axelcureno Apr 1, 2026
e0a787d
feat(MWPW-191059): switch fragment-client preview to Freyja v2 with O…
Axelcureno Apr 1, 2026
fecc895
feat(MWPW-191059): switch mas-repository preview URLs to Freyja v2
Axelcureno Apr 1, 2026
4a8b27b
feat(MWPW-191059): add backend toggle, manual token injection, and be…
Axelcureno Apr 1, 2026
67bd494
feat(MWPW-191059): add standalone Freyja benchmark page and configura…
Axelcureno Apr 2, 2026
60957df
Cache Freyja token to sessionStorage after IMS initialization
Axelcureno Apr 3, 2026
89b0a66
Improve Freyja token caching to only cache after user sign-in
Axelcureno Apr 3, 2026
d2976dd
Use Freyja endpoint in fragment-client when token is available
Axelcureno Apr 3, 2026
2196ec6
Fix Freyja Authorization header not being sent in fragment-client.js
Axelcureno Apr 3, 2026
44cdd31
Use production Freyja endpoint instead of localhost
Axelcureno Apr 3, 2026
03ae91f
Merge branch 'main' into MWPW-191059
npeltier Apr 7, 2026
c72cddf
Merge branch 'main' into MWPW-191059
Axelcureno Apr 9, 2026
e060cb1
MWPW-191059: address PR review feedback — remove Odin fallback, conso…
Axelcureno Apr 9, 2026
8eb6848
Merge branch 'main' into MWPW-191059
honstar Apr 10, 2026
68aee50
Merge branch 'main' into MWPW-191059
Axelcureno Apr 13, 2026
1c0bd44
remove docs/superpowers from tracking, add to .gitignore
Axelcureno Apr 13, 2026
cae09d2
MWPW-191059 - use env-aware Freyja URL when aem.env param is set
Axelcureno Apr 15, 2026
d2d3fe5
MWPW-191059 - also use env-aware Freyja URL when freyjaToken is expli…
Axelcureno Apr 15, 2026
a6fab73
MWPW-191059 - point Studio preview at MAS gateway, drop Odin/Freyja-d…
Axelcureno Apr 21, 2026
385bcea
MWPW-191059 - migrate editor translated-locale discovery to gateway p…
Axelcureno Apr 21, 2026
d05668e
MWPW-191059 - remove masFreyjaToken session-storage plumbing
Axelcureno Apr 21, 2026
3b7b1ee
MWPW-191059 - swap ODIN_PREVIEW_URL/FREYJA_PREVIEW_URL for GATEWAY_PR…
Axelcureno Apr 21, 2026
d713277
MWPW-191059 - update doc comment and drop obsolete Freyja-vs-Odin ben…
Axelcureno Apr 21, 2026
7d7206d
MWPW-191059 - rebuild web-components dist after removing masFreyjaToken
Axelcureno Apr 21, 2026
5364aac
Merge remote-tracking branch 'origin/main' into MWPW-191059
Apr 21, 2026
2513393
MWPW-191059 - remove Authorization header injection from internalFetch
Apr 21, 2026
812b539
MWPW-191059 - revert unjustified refactoring of internalFetch
Apr 21, 2026
96fa975
MWPW-191059 - delete io/www/test/fragment/common.test.js
Apr 21, 2026
00307ff
MWPW-191059 - keep dictionary fetchTimeout at 10s
Apr 21, 2026
cb35e47
MWPW-191059 - fix prettier formatting on merged test files
Apr 21, 2026
efd76ee
MWPW-191059 - point variations self-ref test at gateway, not Freyja d…
Apr 21, 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
703 changes: 703 additions & 0 deletions docs/superpowers/plans/2026-03-31-freyja-preview-migration.md

Large diffs are not rendered by default.

153 changes: 153 additions & 0 deletions docs/superpowers/specs/2026-03-31-freyja-preview-migration-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# MWPW-191059: Switch MAS Studio Preview to Freyja v2

**Status:** Draft
**Author:** Axel Cureno Basurto
**Date:** 2026-03-31
**Priority:** Critical
**Related:** ODIN-900 (Enable Freyja v2 preview for MAS)

## Problem

MAS Studio uses Odin Preview (`odinpreview.corp.adobe.com`) to resolve content fragment previews. This has high latency due to large payload sizes, impacting Content QA workflows. Freyja v2 (`preview-p22655-e59433.adobeaemcloud.com`) is a faster preview service already in test by the Odin team (ODIN-900).

## Goal

Replace Odin Preview with Freyja v2 as the preview backend for MAS Studio. Provide a temporary fallback to Odin during the cutover period. Remove the fallback once Freyja is validated.

## Constraints

- Freyja v2 requires VPN + bearer token (Odin Preview requires neither)
- Freyja uses a different URL path: `/adobe/contentFragments/` vs Odin's `/adobe/sites/cf/fragments/`
- The IMS bearer token must come from `window.adobeIMS.getAccessToken().token`, not `sessionStorage.masAccessToken` (which is a manually-pasted workaround token)
- Seven fetch call sites across four transformers (`fetchFragment`, `replace`, `settings`, `customize`) all route through `rootURL()` in `paths.js` — all must switch to Freyja
- `mas-repository.js` has two hardcoded Odin URLs outside the pipeline that must also be updated
- The `io/www` pipeline is transport-agnostic — fallback logic in `common.js` must be generic (keyed on context properties, not browser APIs)

## Architecture

### Current Flow

```
Studio JS --> fetch(odinpreview.corp.adobe.com/...) --> Odin Preview (no auth)
^-- 7 call sites across 4 transformers + 2 in mas-repository.js
^-- all route through rootURL(preview) in paths.js
```

### Target Flow

```
Studio JS --> fetch(preview-p22655-e59433.adobeaemcloud.com/...) --> Freyja v2 (bearer token)
^-- same 7 + 2 call sites, URL changed via preview.url on context
^-- auth header injected via context.authToken in internalFetch
^-- fallback to Odin on failure (temporary)
```

### URL Routing

All preview URLs are built through `rootURL(preview)` in `io/www/src/fragment/utils/paths.js:8`. The `preview.url` property on the pipeline context is the sole URL override mechanism. No new routing mechanism is needed — change the URL, and all 7 transformer call sites switch automatically.

### Auth Header Injection

The pipeline's `internalFetch` in `io/www/src/fragment/utils/common.js` currently sends no auth headers client-side. A new optional `context.authToken` property is added. When present, `internalFetch` includes `Authorization: Bearer ${context.authToken}` in the request headers. This is transport-agnostic — server-side callers can also use it.

### Fallback Strategy

During the cutover period, `internalFetch` in `common.js` accepts an optional `context.fallbackUrl` property. When a preview fetch to Freyja fails (network error, 5xx, timeout on the first attempt only — no retries for the primary attempt), `internalFetch` retries once against the Odin URL constructed from `context.fallbackUrl`. The fallback attempt uses the existing retry logic (up to 3 retries).

Console logging: `[preview] Freyja OK` or `[preview] Freyja failed (${reason}), falling back to Odin`. Silent to the user.

When Freyja is validated stable, remove `fallbackUrl` from all context objects. The fallback code path in `internalFetch` becomes dead code and is removed.

### Timeout Budget

To prevent fallback from blowing the `mainTimeout` budget:
- Primary Freyja attempt: single fetch, no retries, respects `fetchTimeout`
- On failure: fall back to Odin with standard retry logic (up to 3 retries)
- Worst case: 1x `fetchTimeout` (Freyja) + 3x `fetchTimeout` (Odin retries) must fit within `mainTimeout`
- Current budgets: `mainTimeout: 20s`, `fetchTimeout: 15s` — these need adjustment. Recommended: `fetchTimeout: 4s` for Freyja (it should be fast), standard 10s for Odin fallback retries.

## Changes

### `io/www/src/fragment/utils/paths.js`

- Add `FREYJA_PREVIEW_URL` constant: `https://preview-p22655-e59433.adobeaemcloud.com/adobe/contentFragments`
- Add `ODIN_PREVIEW_URL` constant: `https://odinpreview.corp.adobe.com/adobe/sites/cf/fragments`
- Export both as named exports
- `rootURL()` unchanged — it already uses `preview.url`

### `io/www/src/fragment/utils/common.js`

- In `internalFetch`/`fetchAttempt`: read `context.authToken` and include as `Authorization: Bearer` header when present
- Add fallback logic: when `context.fallbackUrl` is set and the primary fetch fails on first attempt, replace the Freyja base URL in the request path with `context.fallbackUrl` (e.g., swap `preview-p22655-e59433.adobeaemcloud.com/adobe/contentFragments` for `odinpreview.corp.adobe.com/adobe/sites/cf/fragments`) and retry with standard retry logic, omitting the `Authorization` header
- Console log the outcome when `context.preview` is set

### `studio/libs/fragment-client.js`

- Change `DEFAULT_CONTEXT.preview.url` from `https://odinpreview.corp.adobe.com/adobe/sites/cf/fragments` to the Freyja URL (imported from `paths.js`)
- Add `authToken` to context, sourced from `window.adobeIMS.getAccessToken().token`
- Add `fallbackUrl` set to `ODIN_PREVIEW_URL` (temporary)

### `studio/src/mas-repository.js`

- Lines 574, 602: replace hardcoded Odin preview URLs with `FREYJA_PREVIEW_URL` imported from `paths.js`
- Pass `authToken` from `window.adobeIMS.getAccessToken().token` in both preview context objects
- Add `fallbackUrl: ODIN_PREVIEW_URL` to both contexts (temporary)

### `studio/src/constants.js`

- `ODIN_PREVIEW_ORIGIN` (line 252): keep as-is during cutover, remove when Odin is sunsetted

## Test Changes

### Updated Tests

- `io/www/test/client/fragment-client.test.js` — update mock preview URLs
- `io/www/test/fragment/pipeline.test.js` — update mock preview URLs
- `io/www/test/fragment/replace.test.js` — update mock preview URLs
- `nala/studio/regional-variations/specs/variations.spec.js` — update mock preview URLs

### New Tests

- `io/www/test/fragment/utils/common.test.js` (or extend existing):
- `authToken` present: `Authorization` header included in fetch
- `authToken` absent: no `Authorization` header
- `fallbackUrl` present + primary fails: retries with fallback URL
- `fallbackUrl` present + primary succeeds: no fallback attempted
- Timeout budget: fallback completes within `mainTimeout`
- Both fail: error propagated

## Discovery Step

Before implementation, manually verify Freyja returns a valid response for a known fragment:

```bash
curl -H "Authorization: Bearer <IMS_TOKEN>" \
"https://preview-p22655-e59433.adobeaemcloud.com/adobe/contentFragments/f17d9a60-6205-49f5-8aa4-4f8e662268ed?references=all-hydrated"
```

Compare response shape with Odin. If incompatible, add a response normalizer in `common.js` before the response is returned to the transformer.

## Removal Plan

When Freyja is validated stable:
1. Remove `fallbackUrl` from context objects in `fragment-client.js` and `mas-repository.js`
2. Remove fallback logic from `internalFetch` in `common.js`
3. Remove `ODIN_PREVIEW_URL` from `paths.js`
4. Remove `ODIN_PREVIEW_ORIGIN` from `constants.js`
5. Remove fallback tests

## Files Changed

| File | Action |
|------|--------|
| `io/www/src/fragment/utils/paths.js` | Modified — add URL constants |
| `io/www/src/fragment/utils/common.js` | Modified — auth header + fallback |
| `studio/libs/fragment-client.js` | Modified — update preview URL + add auth/fallback context |
| `studio/src/mas-repository.js` | Modified — update 2 preview URLs + add auth/fallback context |
| `io/www/test/client/fragment-client.test.js` | Modified — update mock URLs |
| `io/www/test/fragment/pipeline.test.js` | Modified — update mock URLs |
| `io/www/test/fragment/replace.test.js` | Modified — update mock URLs |
| `nala/studio/regional-variations/specs/variations.spec.js` | Modified — update mock URLs |
| `io/www/test/fragment/utils/common.test.js` | Modified — add fallback + auth tests |

**Total: 0 new files, 9 modified files.**
81 changes: 81 additions & 0 deletions freyja-bench.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!doctype html>
<html>
<head>
<title>Freyja vs Odin Benchmark</title>
<style>
body { font-family: monospace; padding: 2rem; background: #1e1e1e; color: #d4d4d4; }
pre { background: #252526; padding: 1rem; border-radius: 4px; overflow: auto; }
.label { color: #9cdcfe; } .val { color: #4ec9b0; } .err { color: #f48771; }
</style>
</head>
<body>
<h2>Freyja vs Odin Benchmark</h2>
<pre id="output">Initializing...</pre>
<script>
// Freyja proxied via localhost:9006 to avoid CORS preflight issue (OPTIONS returns 403 at Freyja directly)
const FREYJA = 'http://localhost:9006/adobe/contentFragments';
const ODIN = 'https://odinpreview.corp.adobe.com/adobe/sites/cf/fragments';
const RUNS = 3;

// Read fragment ID from ?id= param or use default
const params = new URLSearchParams(location.search);
const FRAGMENT_ID = params.get('id') || '0ef2a804-e788-4959-abb8-b4d96a18b0ef';

// Token: set via sessionStorage — in browser console:
// sessionStorage.setItem('masFreyjaToken', window.adobeIMS.getAccessToken().token)
const TOKEN = sessionStorage.getItem('masFreyjaToken') || '';

const out = document.getElementById('output');
const log = (msg) => { out.textContent += '\n' + msg; console.log(msg); };

async function benchEndpoint(label, url, headers) {
const timings = [], statuses = [];
for (let i = 0; i < RUNS; i++) {
const t0 = performance.now();
try {
const r = await fetch(`${url}/${FRAGMENT_ID}?references=all-hydrated`, { headers });
timings.push(Math.round(performance.now() - t0));
statuses.push(r.status);
} catch (e) {
timings.push(null);
statuses.push('ERR: ' + e.message.substring(0, 40));
}
}
const valid = timings.filter(t => t !== null);
return {
label,
avg_ms: valid.length ? Math.round(valid.reduce((a, b) => a + b, 0) / valid.length) : 'N/A',
min_ms: valid.length ? Math.min(...valid) : 'N/A',
max_ms: valid.length ? Math.max(...valid) : 'N/A',
status: statuses.join(', '),
runs: timings
};
}

(async () => {
out.textContent = `Fragment: ${FRAGMENT_ID}\nToken: ${TOKEN ? TOKEN.substring(0, 40) + '...' : 'MISSING — run in console: sessionStorage.setItem(\'masFreyjaToken\', window.adobeIMS.getAccessToken().token)'}\n`;

if (!TOKEN) {
log('\n⚠ No token. Freyja will likely return 401. Set sessionStorage.masFreyjaToken first.');
}

log('\nFetching Freyja...');
const freyja = await benchEndpoint('Freyja', FREYJA, TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {});
log(` avg=${freyja.avg_ms}ms min=${freyja.min_ms}ms max=${freyja.max_ms}ms status=${freyja.status}`);

log('\nFetching Odin...');
const odin = await benchEndpoint('Odin', ODIN, {});
log(` avg=${odin.avg_ms}ms min=${odin.min_ms}ms max=${odin.max_ms}ms status=${odin.status}`);

if (typeof freyja.avg_ms === 'number' && typeof odin.avg_ms === 'number') {
const speedup = (odin.avg_ms / freyja.avg_ms).toFixed(1);
log(`\n✓ Speedup: ${speedup}x (Freyja is ${speedup}x faster)`);
}

console.table({ Freyja: freyja, Odin: odin });
log('\nSee console.table for full results. Call window.rerun() to run again.');
window.rerun = () => location.reload();
})();
</script>
</body>
</html>
42 changes: 36 additions & 6 deletions io/www/src/fragment/utils/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ function measureTiming(context, label, startLabel = label) {
async function fetchAttempt(path, context, timeout, marker) {
try {
mark(context, marker);
const responsePromise = fetch(path, {
headers: context.DEFAULT_HEADERS,
});
const headers = { ...context.DEFAULT_HEADERS };
if (context.authToken) {
headers['Authorization'] = `Bearer ${context.authToken}`;
}
const responsePromise = fetch(path, { headers });

// Race the fetch promise with a timeout
const response = await Promise.race([responsePromise, createTimeoutPromise(timeout)]);
Expand Down Expand Up @@ -104,23 +106,51 @@ async function fetchAttempt(path, context, timeout, marker) {
async function internalFetch(path, context, marker) {
mark(context, `${marker}`);
const { retries = 3, fetchTimeout = 2000, retryDelay = 100 } = context.networkConfig || {};

// Primary attempt (single try when fallbackUrl is set, full retries otherwise)
const primaryRetries = context.fallbackUrl ? 1 : retries;
let response = await fetchWithRetries(path, context, fetchTimeout, primaryRetries, retryDelay, marker);

// Fallback: if primary failed and fallbackUrl is configured, try Odin
if (context.fallbackUrl && response.status !== 200) {
const fallbackPath = path.replace(context.preview?.url, context.fallbackUrl);
log(`[preview] Freyja failed (${response.status}), falling back to Odin`, context);
const fallbackContext = { ...context, authToken: undefined, fallbackUrl: undefined };
response = await fetchWithRetries(
fallbackPath,
fallbackContext,
fetchTimeout,
retries,
retryDelay,
`${marker}-fallback`,
);
if (response.status === 200) {
log('[preview] Odin fallback OK', context);
}
} else if (context.fallbackUrl && response.status === 200) {
log('[preview] Freyja OK', context);
}

measureTiming(context, `main-fetch-${marker}`, marker);
return response;
}

async function fetchWithRetries(path, context, fetchTimeout, retries, retryDelay, marker) {
let delay = retryDelay;
let response;
for (let attempt = 0; attempt < retries; attempt++) {
// Race the fetch promise with a timeout
response = await fetchAttempt(path, context, fetchTimeout, `fetch-${marker}-${attempt}`);
if ([503, 504].includes(response.status)) {
log(
`fetch ${path} (attempt #${attempt}) failed with status ${response.status}, retrying in ${delay}ms...`,
context,
);
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
delay *= 2;
} else {
break;
}
}
measureTiming(context, `main-fetch-${marker}`, marker);
return response;
}

Expand Down
6 changes: 5 additions & 1 deletion io/www/src/fragment/utils/paths.js
Comment thread
honstar marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ const MAS_ROOT = '/content/dam/mas';

const FRAGMENT_URL_PREFIX = 'https://odin.adobe.com/adobe/sites/fragments';

const FREYJA_PREVIEW_URL = 'https://preview-p22655-e59433.adobeaemcloud.com/adobe/contentFragments';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is hardcoded to prod, what about supporting non-prod environments via aem.env?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it's not really prod, but "preview" freyja
prod is odin.adobe.com

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Well, with prod I meant the prod environment in AEMaaCS speak. e59433 is prod, regardless if it's odin or freyja.


const ODIN_PREVIEW_URL = 'https://odinpreview.corp.adobe.com/adobe/sites/cf/fragments';

const PATH_TOKENS = /\/content\/dam\/mas\/(?<surface>[\w-_]+)\/(?<parsedLocale>[\w-_]+)\/(?<fragmentPath>.+)/;

function rootURL(preview) {
Expand Down Expand Up @@ -43,4 +47,4 @@ function odinUrl(surface, { locale, fragmentPath, preview }) {
return `${rootURL(preview)}?path=${MAS_ROOT}/${surface}/${locale}/${fragmentPath}`;
}

export { PATH_TOKENS, FRAGMENT_URL_PREFIX, MAS_ROOT, odinUrl, odinId, odinReferences };
export { PATH_TOKENS, FRAGMENT_URL_PREFIX, FREYJA_PREVIEW_URL, ODIN_PREVIEW_URL, MAS_ROOT, odinUrl, odinId, odinReferences };
7 changes: 6 additions & 1 deletion io/www/test/client/fragment-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const localStorageStub = {
let objectKeysStub;

describe('FragmentClient', () => {
const baseUrl = 'https://odinpreview.corp.adobe.com/adobe/sites/cf/fragments';
const baseUrl = 'https://preview-p22655-e59433.adobeaemcloud.com/adobe/contentFragments';
let fetchStub;

before(() => {
Expand All @@ -42,6 +42,7 @@ describe('FragmentClient', () => {
// Stub window.localStorage
globalThis.window = globalThis.window || { localStorage: {} };
sinon.stub(globalThis.window, 'localStorage').value(localStorageStub);
globalThis.window.adobeIMS = { getAccessToken: () => ({ token: 'test-ims-token' }) };
globalThis.localStorage = localStorageStub;
objectKeysStub = sinon.stub(Object, 'keys').callThrough();
objectKeysStub.withArgs(localStorageStub).callsFake(() => Object.keys(storage));
Expand Down Expand Up @@ -144,10 +145,14 @@ describe('FragmentClient', () => {

it('maps non-200 preview pipeline to body.message, logs, and preserves status in fullContext', async () => {
const fragmentId = 'non-existent';
const odinBaseUrl = 'https://odinpreview.corp.adobe.com/adobe/sites/cf/fragments';

fetchStub
.withArgs(`${baseUrl}/${fragmentId}?references=all-hydrated`)
.returns(createResponse(404, { detail: 'Not Found' }, 'Not Found'));
fetchStub
.withArgs(`${odinBaseUrl}/${fragmentId}?references=all-hydrated`)
.returns(createResponse(404, { detail: 'Not Found' }, 'Not Found'));

const consoleErrorSpy = sinon.spy(console, 'error');
try {
Expand Down
Loading
Loading