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
4 changes: 4 additions & 0 deletions app/api/ads/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export async function GET(request: NextRequest) {
const ip = clientIpFromHeaders(request.headers);
const geo = await lookupGeo(ip).catch(() => null);
const fill = await serveAd(slotId, VIDEO_FORMAT_ID, {
// The one path allowed past fitAdFormat's refusal of streaming formats.
// It also suppresses markup rendering, so this cannot become a way to
// draw a video creative as a banner.
streaming: true,
ip,
country: geo?.countryCode ?? null,
device: parseDevice(request.headers.get("user-agent")).deviceType,
Expand Down
30 changes: 26 additions & 4 deletions lib/ads/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type AdCreative,
type AdFormatId,
} from "./creative";
import { fitAdFormat, FEED_FORMAT_ID, TERMINAL_FORMAT_ID } from "./formats";
import { isStreamingFormat, fitAdFormat, FEED_FORMAT_ID, TERMINAL_FORMAT_ID } from "./formats";
import { isAdTheme, type AdTheme, type AdThemePref } from "./theme";
import { houseFill, HOUSE_AD_ROTATION_RATE } from "./house";
import { CREDIT_CENTS, DEFAULT_BID_CREDITS, PLATFORM_RATE } from "./pricing";
Expand Down Expand Up @@ -159,6 +159,17 @@ export function resolveThemePref(
}

export type ServeContext = {
/**
* The caller is filling a streaming break, not a page.
*
* This is the ONLY way a streaming format reaches selection, and it comes
* with an obligation: nothing on this path may render the creative as
* markup. fitAdFormat refuses streaming formats precisely because serveAd
* produces HTML and ASCII, and a video creative drawn as a banner is the
* leak that guard exists to prevent. So the flag opens the gate and closes
* the renderer in the same move — see where `html` and `text` are built.
*/
streaming?: boolean;
visitorId?: string | null;
ip?: string | null;
country?: string | null;
Expand Down Expand Up @@ -207,7 +218,14 @@ export async function serveAd(
// while it fits. Constrained to the slot's own list, so this can never turn a
// servable request into an empty fill. Callers read the format actually
// served back off `fill.creative.format`.
const format = fitAdFormat(requestedFormat, ctx.width, slot.formats);
// A streaming break negotiates nothing: there is no width to fit and no
// smaller unit to fall back to. The slot must offer the format outright,
// which keeps the publisher in control of what their break can carry.
const format = ctx.streaming && isStreamingFormat(requestedFormat)
? (Array.isArray(slot.formats) && slot.formats.includes(requestedFormat)
? requestedFormat
: null)
: fitAdFormat(requestedFormat, ctx.width, slot.formats);
if (!format) return null;

// `theme` rides behind `add column if not exists`, and migrations here are
Expand Down Expand Up @@ -478,8 +496,12 @@ export async function serveAd(
refSlug: campaign.ref_slug,
creative,
clickUrl,
html: renderCreativeHtml(creative, clickUrl, { theme }),
text: renderCreativeText(creative, clickUrl),
// Empty for a streaming fill, and deliberately so. The caller wants a media
// URL, and rendering this creative as a banner is exactly the thing
// fitAdFormat's refusal was protecting against — the guard has to hold on
// the one path that is allowed past it.
html: ctx.streaming ? "" : renderCreativeHtml(creative, clickUrl, { theme }),
text: ctx.streaming ? "" : renderCreativeText(creative, clickUrl),
tier,
trendTopics: matchFor(campaign.id).topics,
promo: trend.any && promoActiveFor(trend, campaign.id),
Expand Down
10 changes: 10 additions & 0 deletions tests/ads-video-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,13 @@ describe("the design-object write path refuses video", () => {
expect(DESIGN_FORMAT_IDS.length).toBe(AD_FORMAT_IDS.length - STREAMING_FORMAT_IDS.length);
});
});

describe("the streaming gate has exactly one door", () => {
it("still refuses a streaming format through the display path", () => {
// fitAdFormat is the gate in front of the HTML renderer. It must keep
// refusing video no matter how a slot is configured, because everything
// past it draws markup.
expect(fitAdFormat(VIDEO_FORMAT_ID, 1920, [VIDEO_FORMAT_ID])).toBeNull();
expect(fitAdFormat(VIDEO_FORMAT_ID, null, [VIDEO_FORMAT_ID])).toBeNull();
});
});