@bleedingdev/modern-js-app-tools-extensions 3.9.0-ultramodern.15 → 3.9.0-ultramodern.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/cjs/cloudflare/artifacts.js +8 -19
  2. package/dist/cjs/cloudflare/index.js +3 -1
  3. package/dist/cjs/deploy-output/plugin.js +22 -2
  4. package/dist/cjs/deploy-output/public-assets.js +146 -0
  5. package/dist/cjs/release-envelope/framework-output.js +24 -8
  6. package/dist/cjs/release-envelope/plugin.js +9 -1
  7. package/dist/cjs/templates/cloudflare-entry.005-worker-dispatch.mjs +52 -10
  8. package/dist/esm/cloudflare/artifacts.mjs +8 -19
  9. package/dist/esm/cloudflare/index.mjs +3 -1
  10. package/dist/esm/deploy-output/plugin.mjs +18 -1
  11. package/dist/esm/deploy-output/public-assets.mjs +82 -0
  12. package/dist/esm/release-envelope/framework-output.mjs +24 -8
  13. package/dist/esm/release-envelope/plugin.mjs +9 -1
  14. package/dist/esm/templates/cloudflare-entry.005-worker-dispatch.mjs +52 -10
  15. package/dist/esm-node/cloudflare/artifacts.mjs +8 -19
  16. package/dist/esm-node/cloudflare/index.mjs +3 -1
  17. package/dist/esm-node/deploy-output/plugin.mjs +18 -1
  18. package/dist/esm-node/deploy-output/public-assets.mjs +83 -0
  19. package/dist/esm-node/release-envelope/framework-output.mjs +24 -8
  20. package/dist/esm-node/release-envelope/plugin.mjs +9 -1
  21. package/dist/esm-node/templates/cloudflare-entry.005-worker-dispatch.mjs +52 -10
  22. package/dist/types/cloudflare/artifacts.d.ts +7 -6
  23. package/dist/types/config.d.ts +24 -0
  24. package/dist/types/deploy-output/plugin.d.ts +26 -0
  25. package/dist/types/deploy-output/public-assets.d.ts +51 -0
  26. package/dist/types/release-envelope/framework-output.d.ts +6 -2
  27. package/dist/types/release-envelope/plugin.d.ts +4 -0
  28. package/package.json +14 -14
  29. package/src/cloudflare/artifacts.ts +22 -43
  30. package/src/cloudflare/index.ts +4 -2
  31. package/src/config.ts +26 -0
  32. package/src/deploy-output/plugin.ts +54 -0
  33. package/src/deploy-output/public-assets.ts +224 -0
  34. package/src/release-envelope/framework-output.ts +49 -1
  35. package/src/release-envelope/plugin.ts +22 -1
  36. package/src/templates/cloudflare-entry.005-worker-dispatch.mjs +52 -10
@@ -0,0 +1,82 @@
1
+ import promises from "node:fs/promises";
2
+ import node_path from "node:path";
3
+ import { normalizeRelativePath } from "../cloudflare/utils.mjs";
4
+ const DECLARED_PUBLIC_ASSET_DIRECTORY = 'public';
5
+ const CLOUDFLARE_PUBLIC_ASSET_SCOPE = {
6
+ label: 'deploy.worker.publicAssets',
7
+ output: 'Cloudflare public output'
8
+ };
9
+ const NODE_PUBLIC_ASSET_SCOPE = {
10
+ label: 'deploy.node.publicAssets',
11
+ output: 'Node public output'
12
+ };
13
+ const normalizeDeclaredPublicAssets = (assets, scope)=>(assets ?? []).map((asset, index)=>({
14
+ from: normalizeRelativePath(asset?.from, `${scope.label}[${index}].from`, 'app root'),
15
+ to: normalizeRelativePath(asset?.to, `${scope.label}[${index}].to`, scope.output, {
16
+ allowRoot: true
17
+ }),
18
+ index
19
+ }));
20
+ const isMissingPathError = (error)=>error instanceof Error && 'code' in error && 'ENOENT' === error.code;
21
+ const pathExists = async (filePath)=>{
22
+ try {
23
+ await promises.lstat(filePath);
24
+ return true;
25
+ } catch (error) {
26
+ if (isMissingPathError(error)) return false;
27
+ throw error;
28
+ }
29
+ };
30
+ const listDeclaredPublicAssetFiles = async (appDirectory, assets, scope)=>{
31
+ const files = [];
32
+ const owners = new Map();
33
+ const add = (index, logicalPath)=>{
34
+ const owner = owners.get(logicalPath);
35
+ if (void 0 !== owner) throw new Error(`${scope.label}[${index}] and ${scope.label}[${owner}] both stage ${JSON.stringify(logicalPath)}.`);
36
+ owners.set(logicalPath, index);
37
+ files.push({
38
+ index,
39
+ logicalPath
40
+ });
41
+ };
42
+ const visit = async (asset, sourcePath, destination)=>{
43
+ const stat = await promises.lstat(sourcePath);
44
+ if (stat.isSymbolicLink()) throw new Error(`${scope.label}[${asset.index}].from must not contain symbolic links: ${node_path.relative(appDirectory, sourcePath).replace(/\\/gu, '/')}`);
45
+ if (stat.isFile()) {
46
+ if (destination === DECLARED_PUBLIC_ASSET_DIRECTORY) throw new Error(`${scope.label}[${asset.index}].to must name a file when ${scope.label}[${asset.index}].from is a file.`);
47
+ add(asset.index, destination);
48
+ return;
49
+ }
50
+ if (!stat.isDirectory()) throw new Error(`${scope.label}[${asset.index}].from must contain only files and directories: ${node_path.relative(appDirectory, sourcePath).replace(/\\/gu, '/')}`);
51
+ const entries = (await promises.readdir(sourcePath)).sort();
52
+ for (const entry of entries)await visit(asset, node_path.join(sourcePath, entry), node_path.posix.join(destination, entry));
53
+ };
54
+ for (const asset of assets){
55
+ const sourcePath = node_path.join(appDirectory, asset.from);
56
+ if (!await pathExists(sourcePath)) throw new Error(`${scope.label}[${asset.index}].from does not exist: ${asset.from}`);
57
+ await visit(asset, sourcePath, node_path.posix.join(DECLARED_PUBLIC_ASSET_DIRECTORY, asset.to));
58
+ }
59
+ return files;
60
+ };
61
+ const resolveAddedDeclaredPublicAssetPaths = async ({ appDirectory, assets, generatedRoot, scope })=>{
62
+ const added = [];
63
+ for (const file of (await listDeclaredPublicAssetFiles(appDirectory, assets, scope)))if (!await pathExists(node_path.join(generatedRoot, file.logicalPath))) added.push(file.logicalPath);
64
+ return added.sort();
65
+ };
66
+ const stageDeclaredPublicAssets = async ({ appDirectory, outputDirectory, assets, scope })=>{
67
+ const files = await listDeclaredPublicAssetFiles(appDirectory, assets, scope);
68
+ const added = [];
69
+ for (const file of files){
70
+ const asset = assets.find((candidate)=>candidate.index === file.index);
71
+ const relativePath = node_path.posix.relative(node_path.posix.join(DECLARED_PUBLIC_ASSET_DIRECTORY, asset.to), file.logicalPath);
72
+ const destination = node_path.join(outputDirectory, file.logicalPath);
73
+ const replacesGeneratedFile = await pathExists(destination);
74
+ await promises.mkdir(node_path.dirname(destination), {
75
+ recursive: true
76
+ });
77
+ await promises.copyFile(node_path.join(appDirectory, asset.from, relativePath), destination);
78
+ if (!replacesGeneratedFile) added.push(file.logicalPath);
79
+ }
80
+ return added.sort();
81
+ };
82
+ export { CLOUDFLARE_PUBLIC_ASSET_SCOPE, DECLARED_PUBLIC_ASSET_DIRECTORY, NODE_PUBLIC_ASSET_SCOPE, normalizeDeclaredPublicAssets, resolveAddedDeclaredPublicAssetPaths, stageDeclaredPublicAssets };
@@ -14,6 +14,16 @@ const isEffectBffWorkerArtifact = (logicalPath)=>EFFECT_BFF_WORKER_PATTERN.test(
14
14
  const CRAWLER_POLICY_PATH = 'public/robots.txt';
15
15
  const isPublicMetadataPath = (logicalPath)=>'public/_headers' === logicalPath || /^public\/\.well-known\/[^/]+\.json$/u.test(logicalPath);
16
16
  const PUBLIC_BUILD_ARTIFACT_PATH = `public/${ULTRAMODERN_BUILD_ARTIFACT_FILE}`;
17
+ const DECLARED_PUBLIC_ASSET_RUNTIME = 'public-asset';
18
+ const resolveDeclaredPublicAssets = (files, declaredPublicAssets, target)=>{
19
+ const staged = new Set(files);
20
+ const declared = new Set();
21
+ for (const logicalPath of declaredPublicAssets){
22
+ if (!logicalPath.startsWith('public/') || !staged.has(logicalPath)) throw new Error(`[ultramodern-release-envelope] final ${target} staging has no declared public asset file ${JSON.stringify(logicalPath)}.`);
23
+ declared.add(logicalPath);
24
+ }
25
+ return declared;
26
+ };
17
27
  const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
18
28
  const flattenStrings = (value)=>'string' == typeof value ? [
19
29
  value
@@ -402,8 +412,9 @@ const verifyNodeReleaseEnvelopeStaging = async ({ outputDirectory })=>readFramew
402
412
  expectedTarget: 'node',
403
413
  required: true
404
414
  });
405
- const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity, apiOnly)=>{
415
+ const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity, apiOnly, declaredPublicAssets)=>{
406
416
  const files = await collectFiles(outputDirectory);
417
+ const declaredPublicAssetPaths = resolveDeclaredPublicAssets(files, declaredPublicAssets, 'Node');
407
418
  for (const requiredPath of [
408
419
  'index.js',
409
420
  'package.json',
@@ -416,7 +427,7 @@ const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity,
416
427
  ]);
417
428
  const uiClientPaths = files.filter((logicalPath)=>{
418
429
  const topLevel = logicalPath.split('/')[0];
419
- return 'static' === topLevel || 'html' === topLevel || 'public' === topLevel && logicalPath !== CRAWLER_POLICY_PATH && !isPublicMetadataPath(logicalPath) || [
430
+ return 'static' === topLevel || 'html' === topLevel || 'public' === topLevel && logicalPath !== CRAWLER_POLICY_PATH && !isPublicMetadataPath(logicalPath) && !declaredPublicAssetPaths.has(logicalPath) || [
420
431
  'index.html',
421
432
  'mf-manifest.json',
422
433
  'mf-stats.json',
@@ -450,6 +461,7 @@ const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity,
450
461
  'nodejs-deployment'
451
462
  ]));
452
463
  for (const logicalPath of uiClientPaths)runtimeByPath.set(logicalPath, 'browser');
464
+ for (const logicalPath of declaredPublicAssetPaths)runtimeByPath.set(logicalPath, DECLARED_PUBLIC_ASSET_RUNTIME);
453
465
  if (files.includes(CRAWLER_POLICY_PATH)) runtimeByPath.set(CRAWLER_POLICY_PATH, 'crawler-policy');
454
466
  for (const logicalPath of files.filter(isPublicMetadataPath))runtimeByPath.set(logicalPath, 'public-metadata');
455
467
  for (const logicalPath of apiOnly ? [] : ssrPaths)runtimeByPath.set(logicalPath, 'nodejs');
@@ -494,10 +506,10 @@ const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity,
494
506
  }
495
507
  };
496
508
  };
497
- const emitNodeStagedReleaseEnvelope = async ({ distDirectory, outputDirectory })=>{
509
+ const emitNodeStagedReleaseEnvelope = async ({ declaredPublicAssets = [], distDirectory, outputDirectory })=>{
498
510
  const source = await verifyBuildOutputReleaseEnvelope(distDirectory, 'node');
499
511
  if (!source) return;
500
- const { artifacts, surfaces } = await createNodeStagedReleaseArtifactInputs(outputDirectory, source.identity, 0 === source.surfaces.uiClient.length);
512
+ const { artifacts, surfaces } = await createNodeStagedReleaseArtifactInputs(outputDirectory, source.identity, 0 === source.surfaces.uiClient.length, declaredPublicAssets);
501
513
  const staged = await createMicroVerticalReleaseEnvelope({
502
514
  artifactRoot: outputDirectory,
503
515
  target: 'node',
@@ -515,8 +527,9 @@ const stageCloudflareReleaseEnvelope = async ({ distDirectory, outputDirectory }
515
527
  if (await pathExists(stagedEnvelopePath)) throw new Error('[ultramodern-release-envelope] Cloudflare staging contains a stale envelope before final output generation.');
516
528
  return source;
517
529
  };
518
- const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, identity, apiOnly)=>{
530
+ const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, identity, apiOnly, declaredPublicAssets)=>{
519
531
  const files = await collectFiles(outputDirectory);
532
+ const declaredPublicAssetPaths = resolveDeclaredPublicAssets(files, declaredPublicAssets, 'Cloudflare');
520
533
  for (const requiredPath of [
521
534
  'server/index.mjs',
522
535
  'server/modern-worker-manifest.json',
@@ -532,7 +545,7 @@ const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, iden
532
545
  const apiBackendPaths = files.filter(isEffectBffWorkerArtifact);
533
546
  if (!files.some((logicalPath)=>EFFECT_BFF_WORKER_PATTERN.test(logicalPath))) throw new Error('[ultramodern-release-envelope] final Cloudflare staging has no actual Effect API/BFF worker artifact.');
534
547
  const ssrPaths = files.filter((logicalPath)=>(logicalPath.startsWith('server/') || logicalPath.startsWith('worker/')) && COMPILED_MODULE_PATTERN.test(logicalPath) && !isEffectBffWorkerArtifact(logicalPath));
535
- const uiClientPaths = files.filter((logicalPath)=>logicalPath.startsWith('public/') && logicalPath !== CRAWLER_POLICY_PATH && !isPublicMetadataPath(logicalPath) && logicalPath !== PUBLIC_BUILD_ARTIFACT_PATH && logicalPath !== backendManifestPath && logicalPath !== backendContainerPath);
548
+ const uiClientPaths = files.filter((logicalPath)=>logicalPath.startsWith('public/') && logicalPath !== CRAWLER_POLICY_PATH && !isPublicMetadataPath(logicalPath) && !declaredPublicAssetPaths.has(logicalPath) && logicalPath !== PUBLIC_BUILD_ARTIFACT_PATH && logicalPath !== backendManifestPath && logicalPath !== backendContainerPath);
536
549
  if (apiOnly && (uiClientPaths.length > 0 || ssrPaths.some((path)=>'server/index.mjs' !== path))) throw new Error('[ultramodern-release-envelope] final API-only Cloudflare staging contains an undeclared UI/client or SSR surface.');
537
550
  for (const [surface, paths] of [
538
551
  [
@@ -561,6 +574,9 @@ const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, iden
561
574
  for (const logicalPath of paths)runtimeByPath.set(logicalPath, runtime);
562
575
  };
563
576
  add(uiClientPaths, 'browser');
577
+ add([
578
+ ...declaredPublicAssetPaths
579
+ ], DECLARED_PUBLIC_ASSET_RUNTIME);
564
580
  if (files.includes(CRAWLER_POLICY_PATH)) runtimeByPath.set(CRAWLER_POLICY_PATH, 'crawler-policy');
565
581
  add(files.filter(isPublicMetadataPath), 'public-metadata');
566
582
  add(apiOnly ? [] : ssrPaths, 'workerd');
@@ -609,10 +625,10 @@ const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, iden
609
625
  }
610
626
  };
611
627
  };
612
- const emitCloudflareStagedReleaseEnvelope = async ({ distDirectory, outputDirectory })=>{
628
+ const emitCloudflareStagedReleaseEnvelope = async ({ declaredPublicAssets = [], distDirectory, outputDirectory })=>{
613
629
  const source = await verifyBuildOutputReleaseEnvelope(distDirectory, 'cloudflare');
614
630
  if (!source) return;
615
- const { artifacts, surfaces } = await createCloudflareStagedReleaseArtifactInputs(outputDirectory, source.identity, 0 === source.surfaces.uiClient.length);
631
+ const { artifacts, surfaces } = await createCloudflareStagedReleaseArtifactInputs(outputDirectory, source.identity, 0 === source.surfaces.uiClient.length, declaredPublicAssets);
616
632
  const staged = await createMicroVerticalReleaseEnvelope({
617
633
  artifactRoot: outputDirectory,
618
634
  target: 'cloudflare',
@@ -1,4 +1,5 @@
1
1
  import node_path from "node:path";
2
+ import { NODE_PUBLIC_ASSET_SCOPE, normalizeDeclaredPublicAssets, resolveAddedDeclaredPublicAssetPaths } from "../deploy-output/public-assets.mjs";
2
3
  import { emitFrameworkMicroVerticalReleaseEnvelope, emitNodeStagedReleaseEnvelope, verifyBuildOutputReleaseEnvelope, verifyNodeReleaseEnvelopeStaging } from "./framework-output.mjs";
3
4
  const resolveActiveDeployTarget = (api, resolveDeployTarget)=>{
4
5
  const { metaName } = api.getAppContext();
@@ -11,7 +12,8 @@ const createUltramodernReleaseEnvelopePlugin = ({ resolveDeployTarget })=>({
11
12
  pre: [
12
13
  '@modern-js/backend-federation-build',
13
14
  '@modern-js/plugin-bff',
14
- '@modern-js/deploy-output-aliases'
15
+ '@modern-js/deploy-output-aliases',
16
+ '@modern-js/deploy-output-public-assets'
15
17
  ],
16
18
  post: [
17
19
  '@modern-js/plugin-deploy'
@@ -44,6 +46,12 @@ const createUltramodernReleaseEnvelopePlugin = ({ resolveDeployTarget })=>({
44
46
  const { appDirectory, distDirectory } = api.getAppContext();
45
47
  const outputDirectory = node_path.join(appDirectory, '.output');
46
48
  const releaseEnvelope = await emitNodeStagedReleaseEnvelope({
49
+ declaredPublicAssets: await resolveAddedDeclaredPublicAssetPaths({
50
+ appDirectory,
51
+ assets: normalizeDeclaredPublicAssets(api.getNormalizedConfig().deploy?.node?.publicAssets, NODE_PUBLIC_ASSET_SCOPE),
52
+ generatedRoot: distDirectory,
53
+ scope: NODE_PUBLIC_ASSET_SCOPE
54
+ }),
47
55
  distDirectory,
48
56
  outputDirectory
49
57
  });
@@ -160,14 +160,46 @@ async function dispatchRouteWorker(route, request, env, ctx) {
160
160
  );
161
161
  }
162
162
 
163
- function matchesPrefix(pathname, prefix) {
163
+ // Worker Static Assets decode percent-encoded paths, so an encoded spelling
164
+ // of a mounted prefix (`/%70refix/...`, `/prefix%2F...`) would otherwise skip
165
+ // the BFF or service binding that owns the prefix and reach ASSETS directly.
166
+ // Resolve such spellings to the canonical pathname the owner sees; a
167
+ // backslash or undecodable spelling becomes a path the owner rejects.
168
+ function resolvePrefixPathname(pathname, prefix) {
164
169
  if (!prefix || prefix === '/') {
165
- return true;
170
+ return pathname;
166
171
  }
167
172
 
168
173
  const normalized = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
174
+ let candidate = pathname;
175
+
176
+ for (let attempt = 0; attempt < 4; attempt += 1) {
177
+ if (candidate === normalized || candidate.startsWith(`${normalized}/`)) {
178
+ return candidate;
179
+ }
180
+ if (candidate.startsWith(`${normalized}\\`)) {
181
+ return `${normalized}/__invalid_encoded_path__`;
182
+ }
183
+
184
+ let decoded;
185
+ try {
186
+ decoded = decodeURIComponent(candidate);
187
+ } catch {
188
+ return candidate.toLowerCase().startsWith(normalized.toLowerCase())
189
+ ? `${normalized}/__invalid_encoded_path__`
190
+ : null;
191
+ }
192
+ if (decoded === candidate) {
193
+ return null;
194
+ }
195
+ candidate = decoded;
196
+ }
169
197
 
170
- return pathname === normalized || pathname.startsWith(`${normalized}/`);
198
+ return null;
199
+ }
200
+
201
+ function matchesPrefix(pathname, prefix) {
202
+ return resolvePrefixPathname(pathname, prefix) !== null;
171
203
  }
172
204
 
173
205
  function createRequestForMountedPrefix(request, prefix) {
@@ -177,12 +209,13 @@ function createRequestForMountedPrefix(request, prefix) {
177
209
 
178
210
  const url = new URL(request.url);
179
211
  const normalized = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
212
+ const matchedPathname = resolvePrefixPathname(url.pathname, normalized);
180
213
 
181
- if (!matchesPrefix(url.pathname, normalized)) {
214
+ if (matchedPathname === null) {
182
215
  return request;
183
216
  }
184
217
 
185
- const nextPath = url.pathname.slice(normalized.length) || '/';
218
+ const nextPath = matchedPathname.slice(normalized.length) || '/';
186
219
  url.pathname = nextPath.startsWith('/') ? nextPath : `/${nextPath}`;
187
220
 
188
221
  return new Request(url, request);
@@ -309,12 +342,21 @@ function getEffectBffDispatcher(bff, runtime) {
309
342
  async function dispatchBffRequest(request, env) {
310
343
  const bff = MODERN_WORKER_MANIFEST.bff;
311
344
 
312
- if (
313
- !bff?.worker ||
314
- !matchesPrefix(new URL(request.url).pathname, bff.prefix)
315
- ) {
345
+ const requestUrl = new URL(request.url);
346
+ const matchedPathname = resolvePrefixPathname(
347
+ requestUrl.pathname,
348
+ bff?.prefix,
349
+ );
350
+ if (!bff?.worker || matchedPathname === null) {
316
351
  return null;
317
352
  }
353
+ const canonicalRequest =
354
+ matchedPathname === requestUrl.pathname
355
+ ? request
356
+ : new Request(
357
+ Object.assign(requestUrl, { pathname: matchedPathname }),
358
+ request,
359
+ );
318
360
  if (bff.runtimeFramework !== 'effect') {
319
361
  return createEffectBffDispatcherErrorResponse(
320
362
  bff,
@@ -347,7 +389,7 @@ async function dispatchBffRequest(request, env) {
347
389
  return createEffectBffDispatcherErrorResponse(bff, error);
348
390
  }
349
391
 
350
- return effectDispatcher.dispatch(request, { env });
392
+ return effectDispatcher.dispatch(canonicalRequest, { env });
351
393
  }
352
394
 
353
395
  const directHandler =
@@ -1,6 +1,7 @@
1
1
  import "node:module";
2
2
  import node_path from "node:path";
3
3
  import { fs } from "@modern-js/utils";
4
+ import { CLOUDFLARE_PUBLIC_ASSET_SCOPE, normalizeDeclaredPublicAssets, stageDeclaredPublicAssets } from "../deploy-output/public-assets.mjs";
4
5
  import { RESERVED_ARTIFACT_DESTINATION_DIRECTORIES, RESERVED_ARTIFACT_DESTINATION_FILES, ROUTE_SPEC_OUTPUT } from "./constants.mjs";
5
6
  import { normalizeRelativePath } from "./utils.mjs";
6
7
  const normalizeCloudflareArtifact = (artifact, index)=>{
@@ -16,18 +17,7 @@ const normalizeCloudflareArtifact = (artifact, index)=>{
16
17
  };
17
18
  };
18
19
  const getCloudflareArtifacts = (modernConfig)=>(modernConfig.deploy?.worker?.artifacts ?? []).map(normalizeCloudflareArtifact);
19
- const normalizeCloudflarePublicAsset = (asset, index)=>{
20
- const from = normalizeRelativePath(asset.from, `deploy.worker.publicAssets[${index}].from`, 'app root');
21
- const to = normalizeRelativePath(asset.to, `deploy.worker.publicAssets[${index}].to`, 'Cloudflare public output', {
22
- allowRoot: true
23
- });
24
- return {
25
- from,
26
- to,
27
- index
28
- };
29
- };
30
- const getCloudflarePublicAssets = (modernConfig)=>(modernConfig.deploy?.worker?.publicAssets ?? []).map(normalizeCloudflarePublicAsset);
20
+ const getCloudflarePublicAssets = (modernConfig)=>normalizeDeclaredPublicAssets(modernConfig.deploy?.worker?.publicAssets, CLOUDFLARE_PUBLIC_ASSET_SCOPE);
31
21
  const copyCloudflareArtifacts = async (appDirectory, outputDirectory, artifacts)=>{
32
22
  for (const artifact of artifacts){
33
23
  const sourcePath = node_path.join(appDirectory, artifact.from);
@@ -35,13 +25,12 @@ const copyCloudflareArtifacts = async (appDirectory, outputDirectory, artifacts)
35
25
  await fs.copy(sourcePath, node_path.join(outputDirectory, artifact.to));
36
26
  }
37
27
  };
38
- const copyCloudflarePublicAssets = async (appDirectory, publicDirectory, publicAssets)=>{
39
- for (const asset of publicAssets){
40
- const sourcePath = node_path.join(appDirectory, asset.from);
41
- if (!await fs.pathExists(sourcePath)) throw new Error(`deploy.worker.publicAssets[${asset.index}].from does not exist: ${asset.from}`);
42
- await fs.copy(sourcePath, node_path.join(publicDirectory, asset.to));
43
- }
44
- };
28
+ const copyCloudflarePublicAssets = async (appDirectory, outputDirectory, publicAssets)=>stageDeclaredPublicAssets({
29
+ appDirectory,
30
+ outputDirectory,
31
+ assets: publicAssets,
32
+ scope: CLOUDFLARE_PUBLIC_ASSET_SCOPE
33
+ });
45
34
  const copyCloudflareD1Migrations = async (appDirectory, outputDirectory, modernConfig)=>{
46
35
  for (const [index, database] of (modernConfig.deploy?.worker?.d1Databases ?? []).entries()){
47
36
  if (!database.migrationsDir) continue;
@@ -33,6 +33,7 @@ const createCloudflarePreset = ({ api, appContext, modernConfig })=>{
33
33
  const publicAssetExcludes = getPublicAssetExcludes(appDirectory, modernConfig);
34
34
  const releaseEnvelopeEnabled = api.isPluginExists('@modern-js/ultramodern-release-envelope');
35
35
  let hasReleaseEnvelope = false;
36
+ let declaredPublicAssets = [];
36
37
  return {
37
38
  async prepare () {
38
39
  await fs.remove(outputDirectory);
@@ -75,7 +76,7 @@ const createCloudflarePreset = ({ api, appContext, modernConfig })=>{
75
76
  await fs.writeJSON(outputPlan.paths.workerPackage, outputPlan.packages.worker);
76
77
  }
77
78
  await copyCloudflareArtifacts(appDirectory, outputDirectory, cloudflareArtifacts);
78
- await copyCloudflarePublicAssets(appDirectory, publicDirectory, getCloudflarePublicAssets(modernConfig));
79
+ declaredPublicAssets = await copyCloudflarePublicAssets(appDirectory, outputDirectory, getCloudflarePublicAssets(modernConfig));
79
80
  await copyCloudflareD1Migrations(appDirectory, outputDirectory, modernConfig);
80
81
  await fs.writeJSON(wranglerConfigPath, createWranglerConfig(appDirectory, modernConfig), {
81
82
  spaces: 2
@@ -104,6 +105,7 @@ const createCloudflarePreset = ({ api, appContext, modernConfig })=>{
104
105
  });
105
106
  if (hasReleaseEnvelope) {
106
107
  await emitCloudflareStagedReleaseEnvelope({
108
+ declaredPublicAssets,
107
109
  distDirectory,
108
110
  outputDirectory
109
111
  });
@@ -2,6 +2,7 @@ import "node:module";
2
2
  import { createRequire } from "node:module";
3
3
  import node_path from "node:path";
4
4
  import { preserveNpmAliases, readPackageIdentity } from "./npmAliases.mjs";
5
+ import { NODE_PUBLIC_ASSET_SCOPE, normalizeDeclaredPublicAssets, stageDeclaredPublicAssets } from "./public-assets.mjs";
5
6
  import { resolveDeployTarget as external_target_mjs_resolveDeployTarget } from "./target.mjs";
6
7
  import { fileURLToPath as __rspack_fileURLToPath } from "node:url";
7
8
  var __rspack_import_meta_filename__ = __rspack_fileURLToPath(import.meta.url);
@@ -28,4 +29,20 @@ const createDeployOutputAliasesPlugin = ({ resolveDeployTarget = external_target
28
29
  });
29
30
  }
30
31
  });
31
- export { createDeployOutputAliasesPlugin };
32
+ const createDeployOutputPublicAssetsPlugin = ({ resolveDeployTarget = external_target_mjs_resolveDeployTarget } = {})=>({
33
+ name: '@modern-js/deploy-output-public-assets',
34
+ setup (api) {
35
+ api.onAfterDeploy(async ()=>{
36
+ const { appDirectory, metaName } = api.getAppContext();
37
+ const config = api.getNormalizedConfig();
38
+ if ('modern-js' !== metaName && !config.deploy?.target && !process.env.MODERNJS_DEPLOY || 'node' !== resolveDeployTarget(config)) return;
39
+ await stageDeclaredPublicAssets({
40
+ appDirectory,
41
+ outputDirectory: node_path.join(appDirectory, '.output'),
42
+ assets: normalizeDeclaredPublicAssets(config.deploy?.node?.publicAssets, NODE_PUBLIC_ASSET_SCOPE),
43
+ scope: NODE_PUBLIC_ASSET_SCOPE
44
+ });
45
+ });
46
+ }
47
+ });
48
+ export { createDeployOutputAliasesPlugin, createDeployOutputPublicAssetsPlugin };
@@ -0,0 +1,83 @@
1
+ import "node:module";
2
+ import promises from "node:fs/promises";
3
+ import node_path from "node:path";
4
+ import { normalizeRelativePath } from "../cloudflare/utils.mjs";
5
+ const DECLARED_PUBLIC_ASSET_DIRECTORY = 'public';
6
+ const CLOUDFLARE_PUBLIC_ASSET_SCOPE = {
7
+ label: 'deploy.worker.publicAssets',
8
+ output: 'Cloudflare public output'
9
+ };
10
+ const NODE_PUBLIC_ASSET_SCOPE = {
11
+ label: 'deploy.node.publicAssets',
12
+ output: 'Node public output'
13
+ };
14
+ const normalizeDeclaredPublicAssets = (assets, scope)=>(assets ?? []).map((asset, index)=>({
15
+ from: normalizeRelativePath(asset?.from, `${scope.label}[${index}].from`, 'app root'),
16
+ to: normalizeRelativePath(asset?.to, `${scope.label}[${index}].to`, scope.output, {
17
+ allowRoot: true
18
+ }),
19
+ index
20
+ }));
21
+ const isMissingPathError = (error)=>error instanceof Error && 'code' in error && 'ENOENT' === error.code;
22
+ const pathExists = async (filePath)=>{
23
+ try {
24
+ await promises.lstat(filePath);
25
+ return true;
26
+ } catch (error) {
27
+ if (isMissingPathError(error)) return false;
28
+ throw error;
29
+ }
30
+ };
31
+ const listDeclaredPublicAssetFiles = async (appDirectory, assets, scope)=>{
32
+ const files = [];
33
+ const owners = new Map();
34
+ const add = (index, logicalPath)=>{
35
+ const owner = owners.get(logicalPath);
36
+ if (void 0 !== owner) throw new Error(`${scope.label}[${index}] and ${scope.label}[${owner}] both stage ${JSON.stringify(logicalPath)}.`);
37
+ owners.set(logicalPath, index);
38
+ files.push({
39
+ index,
40
+ logicalPath
41
+ });
42
+ };
43
+ const visit = async (asset, sourcePath, destination)=>{
44
+ const stat = await promises.lstat(sourcePath);
45
+ if (stat.isSymbolicLink()) throw new Error(`${scope.label}[${asset.index}].from must not contain symbolic links: ${node_path.relative(appDirectory, sourcePath).replace(/\\/gu, '/')}`);
46
+ if (stat.isFile()) {
47
+ if (destination === DECLARED_PUBLIC_ASSET_DIRECTORY) throw new Error(`${scope.label}[${asset.index}].to must name a file when ${scope.label}[${asset.index}].from is a file.`);
48
+ add(asset.index, destination);
49
+ return;
50
+ }
51
+ if (!stat.isDirectory()) throw new Error(`${scope.label}[${asset.index}].from must contain only files and directories: ${node_path.relative(appDirectory, sourcePath).replace(/\\/gu, '/')}`);
52
+ const entries = (await promises.readdir(sourcePath)).sort();
53
+ for (const entry of entries)await visit(asset, node_path.join(sourcePath, entry), node_path.posix.join(destination, entry));
54
+ };
55
+ for (const asset of assets){
56
+ const sourcePath = node_path.join(appDirectory, asset.from);
57
+ if (!await pathExists(sourcePath)) throw new Error(`${scope.label}[${asset.index}].from does not exist: ${asset.from}`);
58
+ await visit(asset, sourcePath, node_path.posix.join(DECLARED_PUBLIC_ASSET_DIRECTORY, asset.to));
59
+ }
60
+ return files;
61
+ };
62
+ const resolveAddedDeclaredPublicAssetPaths = async ({ appDirectory, assets, generatedRoot, scope })=>{
63
+ const added = [];
64
+ for (const file of (await listDeclaredPublicAssetFiles(appDirectory, assets, scope)))if (!await pathExists(node_path.join(generatedRoot, file.logicalPath))) added.push(file.logicalPath);
65
+ return added.sort();
66
+ };
67
+ const stageDeclaredPublicAssets = async ({ appDirectory, outputDirectory, assets, scope })=>{
68
+ const files = await listDeclaredPublicAssetFiles(appDirectory, assets, scope);
69
+ const added = [];
70
+ for (const file of files){
71
+ const asset = assets.find((candidate)=>candidate.index === file.index);
72
+ const relativePath = node_path.posix.relative(node_path.posix.join(DECLARED_PUBLIC_ASSET_DIRECTORY, asset.to), file.logicalPath);
73
+ const destination = node_path.join(outputDirectory, file.logicalPath);
74
+ const replacesGeneratedFile = await pathExists(destination);
75
+ await promises.mkdir(node_path.dirname(destination), {
76
+ recursive: true
77
+ });
78
+ await promises.copyFile(node_path.join(appDirectory, asset.from, relativePath), destination);
79
+ if (!replacesGeneratedFile) added.push(file.logicalPath);
80
+ }
81
+ return added.sort();
82
+ };
83
+ export { CLOUDFLARE_PUBLIC_ASSET_SCOPE, DECLARED_PUBLIC_ASSET_DIRECTORY, NODE_PUBLIC_ASSET_SCOPE, normalizeDeclaredPublicAssets, resolveAddedDeclaredPublicAssetPaths, stageDeclaredPublicAssets };
@@ -15,6 +15,16 @@ const isEffectBffWorkerArtifact = (logicalPath)=>EFFECT_BFF_WORKER_PATTERN.test(
15
15
  const CRAWLER_POLICY_PATH = 'public/robots.txt';
16
16
  const isPublicMetadataPath = (logicalPath)=>'public/_headers' === logicalPath || /^public\/\.well-known\/[^/]+\.json$/u.test(logicalPath);
17
17
  const PUBLIC_BUILD_ARTIFACT_PATH = `public/${ULTRAMODERN_BUILD_ARTIFACT_FILE}`;
18
+ const DECLARED_PUBLIC_ASSET_RUNTIME = 'public-asset';
19
+ const resolveDeclaredPublicAssets = (files, declaredPublicAssets, target)=>{
20
+ const staged = new Set(files);
21
+ const declared = new Set();
22
+ for (const logicalPath of declaredPublicAssets){
23
+ if (!logicalPath.startsWith('public/') || !staged.has(logicalPath)) throw new Error(`[ultramodern-release-envelope] final ${target} staging has no declared public asset file ${JSON.stringify(logicalPath)}.`);
24
+ declared.add(logicalPath);
25
+ }
26
+ return declared;
27
+ };
18
28
  const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
19
29
  const flattenStrings = (value)=>'string' == typeof value ? [
20
30
  value
@@ -403,8 +413,9 @@ const verifyNodeReleaseEnvelopeStaging = async ({ outputDirectory })=>readFramew
403
413
  expectedTarget: 'node',
404
414
  required: true
405
415
  });
406
- const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity, apiOnly)=>{
416
+ const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity, apiOnly, declaredPublicAssets)=>{
407
417
  const files = await collectFiles(outputDirectory);
418
+ const declaredPublicAssetPaths = resolveDeclaredPublicAssets(files, declaredPublicAssets, 'Node');
408
419
  for (const requiredPath of [
409
420
  'index.js',
410
421
  'package.json',
@@ -417,7 +428,7 @@ const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity,
417
428
  ]);
418
429
  const uiClientPaths = files.filter((logicalPath)=>{
419
430
  const topLevel = logicalPath.split('/')[0];
420
- return 'static' === topLevel || 'html' === topLevel || 'public' === topLevel && logicalPath !== CRAWLER_POLICY_PATH && !isPublicMetadataPath(logicalPath) || [
431
+ return 'static' === topLevel || 'html' === topLevel || 'public' === topLevel && logicalPath !== CRAWLER_POLICY_PATH && !isPublicMetadataPath(logicalPath) && !declaredPublicAssetPaths.has(logicalPath) || [
421
432
  'index.html',
422
433
  'mf-manifest.json',
423
434
  'mf-stats.json',
@@ -451,6 +462,7 @@ const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity,
451
462
  'nodejs-deployment'
452
463
  ]));
453
464
  for (const logicalPath of uiClientPaths)runtimeByPath.set(logicalPath, 'browser');
465
+ for (const logicalPath of declaredPublicAssetPaths)runtimeByPath.set(logicalPath, DECLARED_PUBLIC_ASSET_RUNTIME);
454
466
  if (files.includes(CRAWLER_POLICY_PATH)) runtimeByPath.set(CRAWLER_POLICY_PATH, 'crawler-policy');
455
467
  for (const logicalPath of files.filter(isPublicMetadataPath))runtimeByPath.set(logicalPath, 'public-metadata');
456
468
  for (const logicalPath of apiOnly ? [] : ssrPaths)runtimeByPath.set(logicalPath, 'nodejs');
@@ -495,10 +507,10 @@ const createNodeStagedReleaseArtifactInputs = async (outputDirectory, identity,
495
507
  }
496
508
  };
497
509
  };
498
- const emitNodeStagedReleaseEnvelope = async ({ distDirectory, outputDirectory })=>{
510
+ const emitNodeStagedReleaseEnvelope = async ({ declaredPublicAssets = [], distDirectory, outputDirectory })=>{
499
511
  const source = await verifyBuildOutputReleaseEnvelope(distDirectory, 'node');
500
512
  if (!source) return;
501
- const { artifacts, surfaces } = await createNodeStagedReleaseArtifactInputs(outputDirectory, source.identity, 0 === source.surfaces.uiClient.length);
513
+ const { artifacts, surfaces } = await createNodeStagedReleaseArtifactInputs(outputDirectory, source.identity, 0 === source.surfaces.uiClient.length, declaredPublicAssets);
502
514
  const staged = await createMicroVerticalReleaseEnvelope({
503
515
  artifactRoot: outputDirectory,
504
516
  target: 'node',
@@ -516,8 +528,9 @@ const stageCloudflareReleaseEnvelope = async ({ distDirectory, outputDirectory }
516
528
  if (await pathExists(stagedEnvelopePath)) throw new Error('[ultramodern-release-envelope] Cloudflare staging contains a stale envelope before final output generation.');
517
529
  return source;
518
530
  };
519
- const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, identity, apiOnly)=>{
531
+ const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, identity, apiOnly, declaredPublicAssets)=>{
520
532
  const files = await collectFiles(outputDirectory);
533
+ const declaredPublicAssetPaths = resolveDeclaredPublicAssets(files, declaredPublicAssets, 'Cloudflare');
521
534
  for (const requiredPath of [
522
535
  'server/index.mjs',
523
536
  'server/modern-worker-manifest.json',
@@ -533,7 +546,7 @@ const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, iden
533
546
  const apiBackendPaths = files.filter(isEffectBffWorkerArtifact);
534
547
  if (!files.some((logicalPath)=>EFFECT_BFF_WORKER_PATTERN.test(logicalPath))) throw new Error('[ultramodern-release-envelope] final Cloudflare staging has no actual Effect API/BFF worker artifact.');
535
548
  const ssrPaths = files.filter((logicalPath)=>(logicalPath.startsWith('server/') || logicalPath.startsWith('worker/')) && COMPILED_MODULE_PATTERN.test(logicalPath) && !isEffectBffWorkerArtifact(logicalPath));
536
- const uiClientPaths = files.filter((logicalPath)=>logicalPath.startsWith('public/') && logicalPath !== CRAWLER_POLICY_PATH && !isPublicMetadataPath(logicalPath) && logicalPath !== PUBLIC_BUILD_ARTIFACT_PATH && logicalPath !== backendManifestPath && logicalPath !== backendContainerPath);
549
+ const uiClientPaths = files.filter((logicalPath)=>logicalPath.startsWith('public/') && logicalPath !== CRAWLER_POLICY_PATH && !isPublicMetadataPath(logicalPath) && !declaredPublicAssetPaths.has(logicalPath) && logicalPath !== PUBLIC_BUILD_ARTIFACT_PATH && logicalPath !== backendManifestPath && logicalPath !== backendContainerPath);
537
550
  if (apiOnly && (uiClientPaths.length > 0 || ssrPaths.some((path)=>'server/index.mjs' !== path))) throw new Error('[ultramodern-release-envelope] final API-only Cloudflare staging contains an undeclared UI/client or SSR surface.');
538
551
  for (const [surface, paths] of [
539
552
  [
@@ -562,6 +575,9 @@ const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, iden
562
575
  for (const logicalPath of paths)runtimeByPath.set(logicalPath, runtime);
563
576
  };
564
577
  add(uiClientPaths, 'browser');
578
+ add([
579
+ ...declaredPublicAssetPaths
580
+ ], DECLARED_PUBLIC_ASSET_RUNTIME);
565
581
  if (files.includes(CRAWLER_POLICY_PATH)) runtimeByPath.set(CRAWLER_POLICY_PATH, 'crawler-policy');
566
582
  add(files.filter(isPublicMetadataPath), 'public-metadata');
567
583
  add(apiOnly ? [] : ssrPaths, 'workerd');
@@ -610,10 +626,10 @@ const createCloudflareStagedReleaseArtifactInputs = async (outputDirectory, iden
610
626
  }
611
627
  };
612
628
  };
613
- const emitCloudflareStagedReleaseEnvelope = async ({ distDirectory, outputDirectory })=>{
629
+ const emitCloudflareStagedReleaseEnvelope = async ({ declaredPublicAssets = [], distDirectory, outputDirectory })=>{
614
630
  const source = await verifyBuildOutputReleaseEnvelope(distDirectory, 'cloudflare');
615
631
  if (!source) return;
616
- const { artifacts, surfaces } = await createCloudflareStagedReleaseArtifactInputs(outputDirectory, source.identity, 0 === source.surfaces.uiClient.length);
632
+ const { artifacts, surfaces } = await createCloudflareStagedReleaseArtifactInputs(outputDirectory, source.identity, 0 === source.surfaces.uiClient.length, declaredPublicAssets);
617
633
  const staged = await createMicroVerticalReleaseEnvelope({
618
634
  artifactRoot: outputDirectory,
619
635
  target: 'cloudflare',
@@ -1,5 +1,6 @@
1
1
  import "node:module";
2
2
  import node_path from "node:path";
3
+ import { NODE_PUBLIC_ASSET_SCOPE, normalizeDeclaredPublicAssets, resolveAddedDeclaredPublicAssetPaths } from "../deploy-output/public-assets.mjs";
3
4
  import { emitFrameworkMicroVerticalReleaseEnvelope, emitNodeStagedReleaseEnvelope, verifyBuildOutputReleaseEnvelope, verifyNodeReleaseEnvelopeStaging } from "./framework-output.mjs";
4
5
  const resolveActiveDeployTarget = (api, resolveDeployTarget)=>{
5
6
  const { metaName } = api.getAppContext();
@@ -12,7 +13,8 @@ const createUltramodernReleaseEnvelopePlugin = ({ resolveDeployTarget })=>({
12
13
  pre: [
13
14
  '@modern-js/backend-federation-build',
14
15
  '@modern-js/plugin-bff',
15
- '@modern-js/deploy-output-aliases'
16
+ '@modern-js/deploy-output-aliases',
17
+ '@modern-js/deploy-output-public-assets'
16
18
  ],
17
19
  post: [
18
20
  '@modern-js/plugin-deploy'
@@ -45,6 +47,12 @@ const createUltramodernReleaseEnvelopePlugin = ({ resolveDeployTarget })=>({
45
47
  const { appDirectory, distDirectory } = api.getAppContext();
46
48
  const outputDirectory = node_path.join(appDirectory, '.output');
47
49
  const releaseEnvelope = await emitNodeStagedReleaseEnvelope({
50
+ declaredPublicAssets: await resolveAddedDeclaredPublicAssetPaths({
51
+ appDirectory,
52
+ assets: normalizeDeclaredPublicAssets(api.getNormalizedConfig().deploy?.node?.publicAssets, NODE_PUBLIC_ASSET_SCOPE),
53
+ generatedRoot: distDirectory,
54
+ scope: NODE_PUBLIC_ASSET_SCOPE
55
+ }),
48
56
  distDirectory,
49
57
  outputDirectory
50
58
  });