@@ -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 */
224215export 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 */
252243export 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