Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2ffb0dd
revalidate endpoint created
Alessandro100 Feb 17, 2026
794ce97
cleanup
Alessandro100 Feb 17, 2026
07d7edd
full map view data refactor
Alessandro100 Feb 17, 2026
fb79f30
auth server stronger guest accomodation
Alessandro100 Feb 17, 2026
f90d179
ISR caching e2e tests
Alessandro100 Feb 18, 2026
ca2a888
debugging parameters
Alessandro100 Feb 18, 2026
4487eb4
revalidate endpoint tags
Alessandro100 Feb 18, 2026
e64e784
feed detail data funcitons
Alessandro100 Feb 18, 2026
fb5d6b1
cleanup
Alessandro100 Feb 18, 2026
6d26245
static non auth feed detail page
Alessandro100 Feb 18, 2026
4c2419a
proxy setup for routing auth and non auth pages
Alessandro100 Feb 18, 2026
eb4b5ce
proxy tests
Alessandro100 Feb 18, 2026
a8028a2
fix tests
Alessandro100 Feb 18, 2026
f3d78c6
authenticated feed detail page
Alessandro100 Feb 18, 2026
2291a05
feed detail page cache sequence diagram
Alessandro100 Feb 18, 2026
61a1123
lint fix
Alessandro100 Feb 18, 2026
1c65b72
updated comment
Alessandro100 Feb 18, 2026
0532a1d
logic fix
Alessandro100 Feb 18, 2026
87b2baf
logic fix
Alessandro100 Feb 18, 2026
6bf4e69
revalidate route payload validation
Alessandro100 Feb 18, 2026
c3a091d
lint and comments
Alessandro100 Feb 18, 2026
5732554
e2e tidy
Alessandro100 Feb 19, 2026
3750f5d
revalidate full update
Alessandro100 Feb 19, 2026
56038bc
more generic feed revalidation type
Alessandro100 Feb 23, 2026
08c1595
lint fix
Alessandro100 Feb 23, 2026
840c857
cypress test fix
Alessandro100 Feb 23, 2026
eb9849a
debugging revalidate path on vercel
Alessandro100 Feb 23, 2026
5dffd84
remove debugging
Alessandro100 Feb 23, 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
140 changes: 140 additions & 0 deletions cypress/e2e/feed-isr-caching.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Feed ISR Caching e2e tests (unauthenticated users)
*
* Architecture overview:
* - Unauthenticated users are routed by proxy.ts to /[locale]/feeds/[type]/[id]/static/
* - That route uses `force-static` + `revalidate: 1209600` (14 days)
* - On the first visit, Next.js renders the page and caches it
* - On subsequent visits, Next.js serves the cached HTML without re-rendering
*
* How we detect cache hits/misses:
* - Next.js sets the `x-nextjs-cache` response header on ISR routes:
* MISS → page was freshly rendered (first visit or after revalidation)
* HIT → page was served from the ISR cache
* STALE → page was served from stale cache while revalidation runs in background
* - We intercept the browser's GET request to the feed page and inspect this header.
*
*/

export {};

const TEST_FEED_ID = 'test-516';
const TEST_FEED_DATA_TYPE = 'gtfs';
const FEED_URL = `/feeds/${TEST_FEED_DATA_TYPE}/${TEST_FEED_ID}`;

/**
* Calls the /api/revalidate endpoint to bust the ISR cache for the test feed.
* This simulates what happens when the backend triggers a revalidation webhook
* (e.g. after a feed update), which in production invalidates the cached page.
*
* The REVALIDATE_SECRET must match the value set in the Next.js server's env.
* It is read from Cypress env (loaded from .env.development via cypress.config.ts).
*/
function revalidateTestFeed(): void {
const secret = Cypress.env('REVALIDATE_SECRET') as string;
cy.request({
method: 'POST',
url: '/api/revalidate',
headers: {
'x-revalidate-secret': secret,
'content-type': 'application/json',
},
body: {
type: 'specific-feeds',
feedIds: [TEST_FEED_ID],
},
})
.its('status')
.should('eq', 200);
}

describe('Feed ISR Caching - Unauthenticated', () => {
/**
* Ensure the ISR cache is busted before the suite runs so we always
* start from a known MISS state, regardless of prior test runs.
*/
before(() => {
revalidateTestFeed();
});

describe('First visit (cache MISS)', () => {
it('should render the page dynamically on the first visit', () => {
// Intercept the page request and capture the x-nextjs-cache header.
// The alias lets us assert on the response after cy.visit() resolves.
cy.intercept('GET', FEED_URL).as('feedPageRequest');

cy.visit(FEED_URL, { timeout: 30000 });

// Wait for the page request and assert the cache header is MISS.
// On the very first visit (or after revalidation), Next.js renders
// the page fresh and populates the ISR cache.
cy.wait('@feedPageRequest')
.its('response.headers.x-nextjs-cache')
// MISS means the page was freshly rendered (not served from cache).
// STALE is also acceptable here if a prior cached version existed but
// was invalidated — Next.js serves stale while revalidating in background.
.should('be.oneOf', ['MISS', 'STALE']);

// Sanity check: the page content is actually rendered
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
'contain',
'Metropolitan Transit Authority (MTA)',
);
});
});

describe('Second visit (cache HIT)', () => {
it('should serve the page from the ISR cache on a revisit', () => {
// Intercept the page request again for the second visit.
cy.intercept('GET', FEED_URL).as('feedPageCacheHit');

// Visit the same URL again — Next.js should now serve from ISR cache.
cy.visit(FEED_URL, { timeout: 30000 });

cy.wait('@feedPageCacheHit')
.its('response.headers.x-nextjs-cache')
// HIT means the page was served from the ISR cache without re-rendering.
.should('eq', 'HIT');

// Content should still be correct when served from cache
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
'contain',
'Metropolitan Transit Authority (MTA)',
);
});
});

describe('After revalidation (cache MISS again)', () => {
it('should bust the ISR cache when the revalidate endpoint is called', () => {
// First, confirm the page is currently cached (HIT) before we bust it.
cy.intercept('GET', FEED_URL).as('feedPageBeforeRevalidate');
cy.visit(FEED_URL, { timeout: 30000 });
cy.wait('@feedPageBeforeRevalidate')
.its('response.headers.x-nextjs-cache')
.should('eq', 'HIT');

// Trigger cache invalidation via the revalidate API endpoint.
// This simulates a backend webhook call after a feed update.
revalidateTestFeed();

// Visit the page again — the cache was busted, so Next.js should
// re-render the page (MISS or STALE).
cy.intercept('GET', FEED_URL).as('feedPageAfterRevalidate');
cy.visit(FEED_URL, { timeout: 30000 });

cy.wait('@feedPageAfterRevalidate')
.its('response.headers.x-nextjs-cache')
// After revalidation, the cache is invalidated. Next.js will either:
// - MISS: render fresh immediately
// - STALE: serve the old cache while re-rendering in background
// Either way, the cache was busted — a HIT here would be a failure.
.should('be.oneOf', ['MISS', 'STALE']);

// Content should still be correct after revalidation
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
'contain',
'Metropolitan Transit Authority (MTA)',
);
});
});
});
68 changes: 68 additions & 0 deletions docs/feed-detail-caching-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
```mermaid
sequenceDiagram
autonumber
actor U as User
participant B as Browser
participant P as Next.js Proxy / Middleware
participant EC as Edge CDN (Page Cache)
participant S as Static Feed Pages (anon: /feeds/... and /feeds/.../map)
participant D as Dynamic Feed Pages (authed)
participant FC as Next Fetch Cache (Data Cache)
participant API as External Feed API
participant GCP as GCP Workflow
participant RV as Next.js Revalidate Endpoint

rect rgb(235,245,255)
note over U,API: Request flow (feed detail page)

U->>B: Navigate to /feeds/{type}/{id} (or /map)
B->>P: HTTP GET /feeds/{type}/{id}[/{subpath}]

P->>P: Check cookie "session_md"
alt Not authenticated (no/invalid session_md)
P->>EC: Lookup cached page response (key: full path)
alt Page Cache HIT (edge)
EC-->>B: Return cached HTML/headers
else Page Cache MISS
EC->>S: Render static page (anon)
note over S,FC: 1) Fetch data (cache to speed /map <-> base nav)\n2) Render page\n3) Cache full page at edge
S->>FC: fetch(feedData, cache key = feedId + public) (revalidate: e.g., 2 week)
alt Data Cache HIT
FC-->>S: Return cached data
else Data Cache MISS
FC->>API: GET feed data (public)
API-->>FC: Feed data
FC-->>S: Cached data stored
end
S-->>EC: Store rendered page (TTL ~ 2 week)
EC-->>B: Return rendered HTML
end

else Authenticated (valid session_md)
P->>D: Route to dynamic authed page
note over D,FC: Cache only the API call for 10 minutes\n(per-user-per-feed)
D->>FC: fetch(feedData, cache key = userId + feedId) (revalidate: 10 min)
alt Per-user Data Cache HIT (<=10 min)
FC-->>D: Return cached user-scoped data
else Per-user Data Cache MISS
FC->>API: GET feed data (authed token)
API-->>FC: Feed data
FC-->>D: Cached data stored (10 min)
end
D-->>B: Return fresh HTML (no shared edge page cache)
note over D,B: Authed page response should be private (not shared)\nbut data calls are cached per-user-per-feed
end
end

rect rgb(255,245,235)
note over GCP,RV: External revalidation (invalidate anon caches + data caches)

GCP->>GCP: Detect feed changes (diff / updated_at)
GCP->>RV: POST /api/revalidate (paths or tags) + secret
RV->>EC: Invalidate edge page cache (anon paths: base + /map)
RV->>FC: Invalidate data cache (public feed data tag/key)
FC-->>RV: OK
EC-->>RV: OK
RV-->>GCP: 200 success
end
```
2 changes: 0 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,6 @@
"cancel": "Cancel"
},
"fullMapView": {
"disabledTitle": "Full map view disabled",
"disabledDescription": "The full map view feature is disabled at the moment. Please try again later.",
"dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn't specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they're combined into one for display.",
"clearAll": "Clear All",
"hideStops": "Hide Stops",
Expand Down
2 changes: 0 additions & 2 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,6 @@
"cancel": "Cancel"
},
"fullMapView": {
"disabledTitle": "Full map view disabled",
"disabledDescription": "The full map view feature is disabled at the moment. Please try again later.",
"dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn't specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they're combined into one for display.",
"clearAll": "Clear All",
"hideStops": "Hide Stops",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "CI=true jest",
"e2e:setup": "concurrently -k -n \"next,firebase\" -c \"cyan,magenta\" \"NEXT_PUBLIC_API_MOCKING=enabled next dev -p 3001\" \"firebase emulators:start --only auth --project mobility-feeds-dev\"",
"e2e:setup": "next build && concurrently -k -n \"next,firebase\" -c \"cyan,magenta\" \"NEXT_PUBLIC_API_MOCKING=enabled next start -p 3001\" \"firebase emulators:start --only auth --project mobility-feeds-dev\"",
"e2e:run": "CYPRESS_BASE_URL=http://localhost:3001 cypress run",
"e2e:open": "CYPRESS_BASE_URL=http://localhost:3001 cypress open",
"firebase:auth:emulator:dev": "firebase emulators:start --only auth --project mobility-feeds-dev",
Expand Down
50 changes: 50 additions & 0 deletions src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type ReactNode } from 'react';
import { notFound } from 'next/navigation';
import { headers } from 'next/headers';
import { fetchCompleteFeedData } from '../lib/feed-data';
import { AUTHED_PROXY_HEADER } from '../../../../../utils/proxy-helpers';

/**
* Force dynamic rendering for authenticated route.
* This allows cookie() and headers() access.
*/
export const dynamic = 'force-dynamic';

interface Props {
children: ReactNode;
params: Promise<{ feedDataType: string; feedId: string }>;
}

/**
* Shared layout for AUTHENTICATED feed pages.
*
* This route is reached via proxy rewrite when a session cookie exists.
* It uses cookie-based auth to attach user identity to API calls.
*
* SECURITY: This route is protected from direct access by checking for a
* custom header that only the proxy sets. Direct navigation to /authed/...
* will return 404.
*
*/
export default async function AuthedFeedLayout({
children,
params,
}: Props): Promise<React.ReactElement> {
// Block direct access - only allow requests that came through the proxy
const headersList = await headers();
if (headersList.get(AUTHED_PROXY_HEADER) !== '1') {
notFound();
}

const { feedId, feedDataType } = await params;

// Fetch complete feed data (cached per-user)
// This will be reused by child pages without additional API calls
const feedData = await fetchCompleteFeedData(feedDataType, feedId);

if (feedData == null) {
notFound();
}

return <>{children}</>;
}
38 changes: 38 additions & 0 deletions src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import FullMapView from '../../../../../../screens/Feed/components/FullMapView';
import { type ReactElement } from 'react';
import { fetchCompleteFeedData } from '../../lib/feed-data';

interface Props {
params: Promise<{ feedDataType: string; feedId: string }>;
}

/**
* Force dynamic rendering for authenticated route.
* This allows cookie() and headers() access.
*/
export const dynamic = 'force-dynamic';

/**
* Full map view page for AUTHENTICATED users.
*
* This route is reached via proxy rewrite when a session cookie exists.
* Uses cookie-based auth to:
* - Attach user identity to API calls
* - Provide user session to FullMapView for user-specific features
*
* Pre-fetches feed data server-side (cached per-request via React cache())
* before rendering. FullMapView uses Redux for client-side state management.
*/
export default async function AuthedFullMapViewPage({
params,
}: Props): Promise<ReactElement> {
const { feedId, feedDataType } = await params;

const feedData = await fetchCompleteFeedData(feedDataType, feedId);

if (feedData == null) {
return <div>Feed not found</div>;
}

return <FullMapView feedData={feedData} />;
}
70 changes: 70 additions & 0 deletions src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { type ReactElement } from 'react';
import FeedView from '../../../../../screens/Feed/FeedView';
import type { Metadata, ResolvingMetadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { fetchCompleteFeedData } from '../lib/feed-data';
import { generateFeedMetadata } from '../lib/generate-feed-metadata';

interface Props {
params: Promise<{ locale: string; feedDataType: string; feedId: string }>;
}

export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const { locale, feedId, feedDataType } = await params;
const t = await getTranslations({ locale });

// Use complete feed data fetcher - same cache as page component
const feedData = await fetchCompleteFeedData(feedDataType, feedId);

return generateFeedMetadata({
feed: feedData?.feed,
t,
gtfsFeeds: feedData?.relatedFeeds ?? [],
gtfsRtFeeds: feedData?.relatedGtfsRtFeeds ?? [],
});
}

/**
* Feed detail page for AUTHENTICATED users.
*
* This route is reached via proxy rewrite when a session cookie exists.
* Uses cookie-based auth to attach user identity to API calls, enabling
* user-specific features and access control.
*
* Data is fetched via React cache() for per-request deduplication.
*/
export default async function AuthedFeedPage({
params,
}: Props): Promise<ReactElement> {
const { feedId, feedDataType } = await params;

const feedData = await fetchCompleteFeedData(feedDataType, feedId);

if (feedData == null) {
return <div>Feed not found</div>;
}

const {
feed,
initialDatasets,
relatedFeeds,
relatedGtfsRtFeeds,
totalRoutes,
routeTypes,
} = feedData;

return (
<FeedView
feed={feed}
feedDataType={feedDataType}
initialDatasets={initialDatasets}
relatedFeeds={relatedFeeds}
relatedGtfsRtFeeds={relatedGtfsRtFeeds}
totalRoutes={totalRoutes}
routeTypes={routeTypes}
/>
);
}
Loading