Skip to content

Commit 170421f

Browse files
committed
fix(cli): centralize Next public CDN rewrite in packaging
Move public asset URL rewrite and launch.static.include emission into packageBuildOutput so adapters only stage publicStaticDir. Gate include on baseUrl, enumerate public roots in deploy-metadata, and expand tests.
1 parent 7f79bf3 commit 170421f

9 files changed

Lines changed: 571 additions & 145 deletions

File tree

‎packages/cli/src/cmd/build/adapters/nextjs.ts‎

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import { copyRuntimeManifests, installDependencies, runBuildCommand } from './ge
1717
import { prepareNextCdnBuild } from './cdn-recipes.ts';
1818
import { toPosixPath } from '../deploy-ignore.ts';
1919
import { resetOutputDir } from './reset-output-dir.ts';
20-
import { rewritePublicAssetUrlsInTree } from '../package/public-cdn.ts';
21-
import { resolveAgentuityCdnBase } from './cdn-origin.ts';
2220

2321
/**
2422
* Walk the standalone tree for the first `server.js`, skipping
@@ -241,8 +239,8 @@ export const nextjsAdapter: BuildAdapter = {
241239

242240
// Copy `public/` next to server.js for the same reason.
243241
// Next assetPrefix only rewrites /_next/* — public/ files
244-
// (e.g. /next.svg) stay origin-relative unless we CDN them
245-
// and rewrite references when a CDN base is known.
242+
// (e.g. /next.svg) stay origin-relative. Packaging owns CDN
243+
// rewrite + launch.static.include when publicStaticDir is set.
246244
let packagedPublicDir: string | undefined;
247245
if (existsSync(publicPath)) {
248246
const publicDst = join(serverDir, 'public');
@@ -251,24 +249,6 @@ export const nextjsAdapter: BuildAdapter = {
251249
packagedPublicDir = publicDst;
252250
}
253251

254-
// When CDN base is set, rewrite root-absolute public URLs in
255-
// the packaged tree so logos/images hit the CDN, not origin.
256-
const cdnBase =
257-
cdnPrep.cdnBase ??
258-
resolveAgentuityCdnBase({
259-
cdnBaseUrl: options.cdnBaseUrl,
260-
deploymentId: options.deploymentId,
261-
});
262-
if (cdnBase && packagedPublicDir) {
263-
const rw = rewritePublicAssetUrlsInTree(serverDir, packagedPublicDir, cdnBase);
264-
if (rw.publicFileCount > 0) {
265-
logs.push(
266-
`✓ Next.js CDN: rewrote public/ refs in ${rw.filesChanged}/${rw.filesScanned} files ` +
267-
`(${rw.publicFileCount} public asset(s) → ${cdnBase})`
268-
);
269-
}
270-
}
271-
272252
logs.push(
273253
`✓ Standalone output packaged (server entry: ${
274254
workingDirectory ? `${workingDirectory}/${serverEntryRel}` : serverEntryRel
@@ -293,6 +273,8 @@ export const nextjsAdapter: BuildAdapter = {
293273
// and the package manifests Hadron needs to install production
294274
// dependencies before launch. This path is brittle (`next start`
295275
// needs the full Next.js install) so we warn the user.
276+
// Public CDN rewrite / launch.static.include require standalone
277+
// packaging (publicStaticDir is only set on that path).
296278
logger.debug('No standalone output found — copying full .next directory');
297279
const nextDst = join(outputDir, '.next');
298280
cpSync(join(projectDir, '.next'), nextDst, { recursive: true });

‎packages/cli/src/cmd/build/package/index.ts‎

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,31 @@
22
* Build output packaging.
33
*
44
* After a framework adapter builds the project, the packager:
5-
* 1. Generates launch metadata (how to start the app)
6-
* 2. Writes launch.json
5+
* 1. Rewrites root-public asset URLs onto the CDN base when applicable
6+
* 2. Generates launch metadata (how to start the app)
7+
* 3. Writes launch.json
78
*
89
* The output is a self-contained directory ready for deployment
910
* as a buildpack image layer or traditional zip upload.
11+
*
12+
* Public CDN policy lives here (not in framework adapters): adapters only
13+
* stage files and set `BuildResult.publicStaticDir`; packaging owns
14+
* rewrite + `launch.static.include`.
1015
*/
1116

1217
import type { DetectedFramework } from '../detect/types.ts';
1318
import type { MonorepoContext } from '../detect/monorepo.ts';
1419
import type { BuildResult } from '../adapters/types.ts';
20+
import { resolveAgentuityCdnBase } from '../adapters/cdn-origin.ts';
1521
import {
1622
generateLaunchMetadata,
23+
isSplitCdnLayout,
1724
readUserLaunchOverride,
25+
resolveProcessRoot,
1826
writeLaunchMetadata,
1927
type LaunchMetadata,
2028
} from './launch.ts';
29+
import { rewritePublicAssetUrlsInTree } from './public-cdn.ts';
2130

2231
export interface PackageResult {
2332
/** Absolute path to the packaged output */
@@ -31,13 +40,42 @@ export interface PackageResult {
3140

3241
/** Path to static assets (if any) */
3342
staticDir?: string;
43+
44+
/** Human-readable packaging log lines (e.g. public URL rewrites) */
45+
logs: string[];
3446
}
3547

3648
export interface PackageBuildOutputOptions {
3749
/** Explicit CDN base URL from `--cdn-base-url` (written to launch.static.baseUrl). */
3850
cdnBaseUrl?: string;
3951
}
4052

53+
/**
54+
* When CDN base is known and packaging staged a split-layout public root,
55+
* rewrite root-absolute `/file` refs under the process tree to the CDN base.
56+
*/
57+
function rewriteStagedPublicAssets(
58+
buildResult: BuildResult,
59+
monorepo: MonorepoContext | undefined,
60+
cdnBase: string
61+
): string[] {
62+
const logs: string[] = [];
63+
if (!buildResult.publicStaticDir) return logs;
64+
65+
const publicPath = buildResult.staticAssetPublicPath ?? '';
66+
if (!isSplitCdnLayout(publicPath)) return logs;
67+
68+
const treeRoot = resolveProcessRoot(buildResult, monorepo);
69+
const rw = rewritePublicAssetUrlsInTree(treeRoot, buildResult.publicStaticDir, cdnBase);
70+
if (rw.publicFileCount > 0) {
71+
logs.push(
72+
`✓ CDN: rewrote public/ refs in ${rw.filesChanged}/${rw.filesScanned} files ` +
73+
`(${rw.publicFileCount} public asset(s) → ${cdnBase})`
74+
);
75+
}
76+
return logs;
77+
}
78+
4179
/**
4280
* Package a build result into a deployment-ready directory.
4381
*
@@ -54,6 +92,14 @@ export async function packageBuildOutput(
5492
options?: PackageBuildOutputOptions
5593
): Promise<PackageResult> {
5694
const override = projectDir ? await readUserLaunchOverride(projectDir) : null;
95+
const logs: string[] = [];
96+
97+
// Same CDN chain as launch.static.baseUrl / adapters. Rewrite before
98+
// writing launch.json so staged HTML/JS already point at the CDN.
99+
const cdnBase = resolveAgentuityCdnBase({ cdnBaseUrl: options?.cdnBaseUrl });
100+
if (cdnBase) {
101+
logs.push(...rewriteStagedPublicAssets(buildResult, monorepo, cdnBase));
102+
}
57103

58104
// Generate launch metadata (with optional user override applied).
59105
// In monorepo mode, every process inherits the subpackage as its
@@ -74,18 +120,16 @@ export async function packageBuildOutput(
74120
launch,
75121
hasStaticAssets: !!buildResult.staticDir || !!launch.static,
76122
staticDir: buildResult.staticDir,
123+
logs,
77124
};
78125
}
79126

80-
// Re-export
127+
// Re-export public launch contract types (not rewrite helpers — those stay
128+
// package-internal / testable via their module path).
81129
export type {
82130
LaunchMetadata,
83131
LaunchStaticAssets,
84132
LaunchStaticRoot,
85133
ProcessDefinition,
86134
} from './launch.ts';
87-
export {
88-
listPublicRelativeFiles,
89-
rewritePublicAssetUrlsInText,
90-
rewritePublicAssetUrlsInTree,
91-
} from './public-cdn.ts';
135+
export { joinCdnAssetUrl } from './launch.ts';

‎packages/cli/src/cmd/build/package/launch.ts‎

Lines changed: 81 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -206,20 +206,11 @@ export interface ProcessDefinition {
206206
}
207207

208208
/**
209-
* Static/CDN asset locations recorded in launch.json.
210-
*
211-
* Consumers (CDN upload, pilot) compose public URLs as:
212-
* `{baseUrl}{publicPath}/{pathWithinDirectory}`
209+
* One CDN upload root.
213210
*
214-
* `baseUrl` is optional: when set (from `--cdn-base-url` or the platform
215-
* default), frameworks that bake asset URLs at build time should have
216-
* used the same prefix. When omitted, the platform may still upload
217-
* files under `publicPath` using its own CDN root.
218-
*/
219-
/**
220-
* One CDN upload root. Consumers compose:
221-
* `{baseUrl}{publicPath}/{pathWithinDirectory}`
222-
* with empty publicPath meaning files live at the CDN base root.
211+
* Compose the public object URL with {@link joinCdnAssetUrl}:
212+
* - non-empty publicPath → `{baseUrl}{publicPath}/{pathWithinDirectory}`
213+
* - empty publicPath → `{baseUrl}{pathWithinDirectory}` (CDN base root)
223214
*/
224215
export interface LaunchStaticRoot {
225216
/**
@@ -237,17 +228,17 @@ export interface LaunchStaticRoot {
237228
/**
238229
* Static/CDN asset locations recorded in launch.json.
239230
*
240-
* Consumers (CDN upload, pilot) compose public URLs as:
241-
* `{baseUrl}{publicPath}/{pathWithinDirectory}`
242-
*
243231
* The primary `directory`/`publicPath` is the framework build tree.
244232
* Optional `include` lists extra roots (Next `public/`) that must also
245-
* be uploaded when CDN base is set — `assetPrefix` alone does not cover them.
233+
* be uploaded when `baseUrl` is set — `assetPrefix` alone does not cover them.
246234
*
247235
* `baseUrl` is optional: when set (from `--cdn-base-url` or the platform
248236
* default), frameworks that bake asset URLs at build time should have
249237
* used the same prefix. When omitted, the platform may still upload
250238
* files under `publicPath` using its own CDN root.
239+
*
240+
* `include` is only emitted when `baseUrl` is known and packaging staged a
241+
* split-layout public root (`publicStaticDir` + non-empty primary publicPath).
251242
*/
252243
export interface LaunchStaticAssets extends LaunchStaticRoot {
253244
/**
@@ -258,10 +249,29 @@ export interface LaunchStaticAssets extends LaunchStaticRoot {
258249
/**
259250
* Extra CDN roots (e.g. Next `public/` with publicPath `""`).
260251
* Deploy clients should upload each root the same way as the primary.
252+
* Present only when {@link baseUrl} is set.
261253
*/
262254
include?: LaunchStaticRoot[];
263255
}
264256

257+
/**
258+
* Join a CDN object URL without double-slashes when `publicPath` is empty.
259+
*
260+
* @param baseUrl - absolute CDN base (trailing slash optional)
261+
* @param publicPath - URL path prefix, no leading slash (`''` → base root)
262+
* @param pathWithinDirectory - path relative to that root
263+
*/
264+
export function joinCdnAssetUrl(
265+
baseUrl: string,
266+
publicPath: string,
267+
pathWithinDirectory: string
268+
): string {
269+
const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
270+
const prefix = publicPath.replace(/^\/+|\/+$/g, '');
271+
const rel = pathWithinDirectory.replace(/^\/+/, '');
272+
return prefix ? `${base}${prefix}/${rel}` : `${base}${rel}`;
273+
}
274+
265275
/**
266276
* Complete launch metadata written to the output directory.
267277
*/
@@ -305,6 +315,47 @@ function toPosixPath(p: string): string {
305315
return p.split('\\').join('/');
306316
}
307317

318+
/**
319+
* Absolute process cwd used for launch-relative paths: workingDirectory
320+
* when set, else monorepo subpath, else the deploy/output root.
321+
*/
322+
export function resolveProcessRoot(
323+
buildResult: Pick<BuildResult, 'outputDir' | 'workingDirectory'>,
324+
monorepo?: MonorepoContext
325+
): string {
326+
const workRel = buildResult.workingDirectory ?? monorepo?.subpath;
327+
return workRel ? join(buildResult.outputDir, workRel) : buildResult.outputDir;
328+
}
329+
330+
/**
331+
* Relativize an absolute staged path under `processRoot` (posix).
332+
* Returns `undefined` if the path escapes the root (and optional
333+
* output-root fallback when `allowOutputFallback` is set).
334+
*/
335+
export function relativeToProcessRoot(
336+
absPath: string,
337+
processRoot: string,
338+
options?: { allowOutputFallback?: boolean; outputDir?: string }
339+
): string | undefined {
340+
let rel = toPosixPath(relative(processRoot, absPath));
341+
if (!rel || rel === '') return '.';
342+
if (!rel.startsWith('..')) return rel;
343+
if (options?.allowOutputFallback && options.outputDir) {
344+
// Fallback: static staged outside subpath but still under output.
345+
rel = toPosixPath(relative(options.outputDir, absPath));
346+
if (!rel.startsWith('..')) return rel || '.';
347+
}
348+
return undefined;
349+
}
350+
351+
/**
352+
* True when the build uses a split CDN layout: built assets under a
353+
* non-empty publicPath (e.g. `_next/static`) plus a separate public root.
354+
*/
355+
export function isSplitCdnLayout(publicPath: string): boolean {
356+
return publicPath !== '';
357+
}
358+
308359
/**
309360
* Resolve the static asset block for launch.json from the staged build
310361
* result (preferred) or framework detection fallback.
@@ -324,20 +375,11 @@ export function resolveLaunchStatic(
324375
// (adapter packaging layout wins; monorepo.subpath is the fallback
325376
// for adapters that do not set workingDirectory); otherwise to the
326377
// deploy/output root.
327-
const workRel = buildResult.workingDirectory ?? monorepo?.subpath;
328-
const processRoot = workRel ? join(buildResult.outputDir, workRel) : buildResult.outputDir;
329-
let rel = toPosixPath(relative(processRoot, buildResult.staticDir));
330-
if (!rel || rel === '') {
331-
directory = '.';
332-
} else if (!rel.startsWith('..')) {
333-
directory = rel;
334-
} else {
335-
// Fallback: relative to output root (e.g. static staged outside subpath).
336-
rel = toPosixPath(relative(buildResult.outputDir, buildResult.staticDir));
337-
if (!rel.startsWith('..')) {
338-
directory = rel || '.';
339-
}
340-
}
378+
const processRoot = resolveProcessRoot(buildResult, monorepo);
379+
directory = relativeToProcessRoot(buildResult.staticDir, processRoot, {
380+
allowOutputFallback: true,
381+
outputDir: buildResult.outputDir,
382+
});
341383
} else if (framework.staticDir) {
342384
// Build did not resolve an absolute staged path, but detection knew
343385
// where assets should live relative to the project/working dir.
@@ -350,17 +392,15 @@ export function resolveLaunchStatic(
350392
// then AGENTUITY_CDN_BASE_URL / AGENTUITY_CDN_ORIGIN / deployment id.
351393
const baseUrl = resolveAgentuityCdnBase({ cdnBaseUrl });
352394

353-
// Extra roots (Next public/): only when packaging staged them and the
354-
// primary publicPath is a non-empty build prefix (split CDN layout).
395+
// Extra roots (Next public/): only when CDN base is known, packaging
396+
// staged publicStaticDir, and primary publicPath is a non-empty build
397+
// prefix (split CDN layout). Without baseUrl, deployers use the primary
398+
// root only — include would be meaningless without a CDN target.
355399
const include: LaunchStaticRoot[] = [];
356-
if (buildResult.publicStaticDir && publicPath !== '') {
357-
const workRel = buildResult.workingDirectory ?? monorepo?.subpath;
358-
const processRoot = workRel ? join(buildResult.outputDir, workRel) : buildResult.outputDir;
359-
let pubRel = toPosixPath(relative(processRoot, buildResult.publicStaticDir));
360-
if (!pubRel || pubRel === '') {
361-
pubRel = '.';
362-
}
363-
if (!pubRel.startsWith('..') && pubRel !== directory) {
400+
if (baseUrl && buildResult.publicStaticDir && isSplitCdnLayout(publicPath)) {
401+
const processRoot = resolveProcessRoot(buildResult, monorepo);
402+
const pubRel = relativeToProcessRoot(buildResult.publicStaticDir, processRoot);
403+
if (pubRel && pubRel !== directory) {
364404
include.push({ directory: pubRel, publicPath: '' });
365405
}
366406
}

0 commit comments

Comments
 (0)