opennextjs-azure 0.1.4 → 0.2.0

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 (35) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/converters/azure-http.d.mts +3 -1
  3. package/dist/adapters/converters/azure-http.d.ts +3 -1
  4. package/dist/adapters/converters/azure-http.js +24 -6
  5. package/dist/adapters/converters/azure-queue-revalidate.d.mts +26 -0
  6. package/dist/adapters/converters/azure-queue-revalidate.d.ts +26 -0
  7. package/dist/adapters/converters/azure-queue-revalidate.js +30 -0
  8. package/dist/adapters/wrappers/azure-functions.d.mts +8 -1
  9. package/dist/adapters/wrappers/azure-functions.d.ts +8 -1
  10. package/dist/adapters/wrappers/azure-functions.js +82 -12
  11. package/dist/adapters/wrappers/azure-image-optimization.js +53 -24
  12. package/dist/adapters/wrappers/azure-queue-revalidate.d.mts +9 -0
  13. package/dist/adapters/wrappers/azure-queue-revalidate.d.ts +9 -0
  14. package/dist/adapters/wrappers/azure-queue-revalidate.js +17 -0
  15. package/dist/cli/index.js +15 -5
  16. package/dist/config/index.js +9 -0
  17. package/dist/deploy.js +279 -27
  18. package/dist/index.d.mts +1 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +1 -1
  21. package/dist/infrastructure/main.bicep +13 -6
  22. package/dist/overrides/incrementalCache/azure-blob.d.mts +17 -1
  23. package/dist/overrides/incrementalCache/azure-blob.d.ts +17 -1
  24. package/dist/overrides/incrementalCache/azure-blob.js +20 -11
  25. package/dist/overrides/tagCache/azure-table.d.mts +24 -1
  26. package/dist/overrides/tagCache/azure-table.d.ts +24 -1
  27. package/dist/overrides/tagCache/azure-table.js +84 -27
  28. package/infrastructure/main.bicep +13 -6
  29. package/package.json +99 -90
  30. package/dist/adapters/image-optimization.d.mts +0 -20
  31. package/dist/adapters/image-optimization.d.ts +0 -20
  32. package/dist/adapters/image-optimization.js +0 -11
  33. package/dist/overrides/imageOptimization/azure-cached.d.mts +0 -6
  34. package/dist/overrides/imageOptimization/azure-cached.d.ts +0 -6
  35. package/dist/overrides/imageOptimization/azure-cached.js +0 -115
package/dist/deploy.js CHANGED
@@ -5,6 +5,9 @@ import { exec } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { build as build$1 } from '@opennextjs/aws/build.js';
7
7
  import fs$1, { existsSync } from 'node:fs';
8
+ import { BlobServiceClient } from '@azure/storage-blob';
9
+ import { TableClient } from '@azure/data-tables';
10
+ import { encodeTableKey } from './overrides/tagCache/azure-table.js';
8
11
  import readline from 'node:readline';
9
12
 
10
13
  const execAsync$3 = promisify(exec);
@@ -227,6 +230,35 @@ function redX() {
227
230
  }
228
231
 
229
232
  const execAsync$2 = promisify(exec);
233
+ async function readInstalledVersion(dep) {
234
+ try {
235
+ const pkgPath = path.join(process.cwd(), "node_modules", dep, "package.json");
236
+ const pkg = JSON.parse(await fs.readFile(pkgPath, "utf-8"));
237
+ return typeof pkg.version === "string" ? pkg.version : null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+ async function pruneWrongPlatformBinaries(nodeModulesPath) {
243
+ const nextScope = path.join(nodeModulesPath, "@next");
244
+ try {
245
+ for (const entry of await fs.readdir(nextScope)) {
246
+ if (entry.startsWith("swc-")) {
247
+ await fs.rm(path.join(nextScope, entry), { recursive: true, force: true });
248
+ }
249
+ }
250
+ } catch {
251
+ }
252
+ const imgScope = path.join(nodeModulesPath, "@img");
253
+ try {
254
+ for (const entry of await fs.readdir(imgScope)) {
255
+ if (!entry.includes("linux-x64") && !entry.includes("wasm")) {
256
+ await fs.rm(path.join(imgScope, entry), { recursive: true, force: true });
257
+ }
258
+ }
259
+ } catch {
260
+ }
261
+ }
230
262
  async function prepareFunctions() {
231
263
  const functionsDir = path.join(process.cwd(), ".open-next/server-functions/default");
232
264
  try {
@@ -251,7 +283,14 @@ async function prepareFunctions() {
251
283
  },
252
284
  extensions: {
253
285
  http: {
254
- routePrefix: ""
286
+ routePrefix: "",
287
+ // Bound per-instance concurrency and queueing so bursts
288
+ // trigger scale-out (the Consumption defaults queue ~200
289
+ // requests on a single 1-core SSR worker) while keeping
290
+ // enough queue to ride out the scale-out delay without
291
+ // shedding 429s.
292
+ maxConcurrentRequests: 16,
293
+ maxOutstandingRequests: 100
255
294
  }
256
295
  }
257
296
  };
@@ -341,24 +380,72 @@ async function prepareFunctions() {
341
380
  console.log(` ${greenCheck()} Image optimization function added`);
342
381
  } catch {
343
382
  }
383
+ const revalidationDir = path.join(process.cwd(), ".open-next/revalidation-function");
384
+ try {
385
+ await fs.access(path.join(revalidationDir, "index.mjs"));
386
+ console.log(" Adding revalidation queue consumer...");
387
+ const revalidateFunctionDir = path.join(functionsDir, "revalidate");
388
+ await fs.mkdir(revalidateFunctionDir, { recursive: true });
389
+ const revalidateFunctionJson = {
390
+ bindings: [
391
+ {
392
+ type: "queueTrigger",
393
+ direction: "in",
394
+ name: "queueItem",
395
+ // Binding expression: resolves the AZURE_QUEUE_NAME app
396
+ // setting (set by the bicep template), so the consumer
397
+ // follows the same queue the producer writes to.
398
+ queueName: "%AZURE_QUEUE_NAME%",
399
+ connection: "AZURE_STORAGE_CONNECTION_STRING"
400
+ }
401
+ ],
402
+ scriptFile: "../index-revalidate.mjs",
403
+ entryPoint: "handler"
404
+ };
405
+ await fs.writeFile(
406
+ path.join(revalidateFunctionDir, "function.json"),
407
+ JSON.stringify(revalidateFunctionJson, null, 2)
408
+ );
409
+ await fs.copyFile(path.join(revalidationDir, "index.mjs"), path.join(functionsDir, "index-revalidate.mjs"));
410
+ await fs.copyFile(
411
+ path.join(revalidationDir, "prerender-manifest.json"),
412
+ path.join(functionsDir, "prerender-manifest.json")
413
+ );
414
+ console.log(` ${greenCheck()} Revalidation queue consumer added`);
415
+ } catch {
416
+ }
344
417
  console.log(` ${greenCheck()} Azure Functions metadata created`);
345
418
  console.log("Installing minimal runtime dependencies...");
346
419
  try {
347
420
  const originalPackageJson = JSON.parse(await fs.readFile(path.join(functionsDir, "package.json"), "utf-8"));
421
+ const runtimeDependencies = {};
422
+ for (const [dep, spec] of Object.entries(
423
+ originalPackageJson.dependencies ?? {}
424
+ )) {
425
+ if (/^(file:|link:|workspace:|portal:|catalog:)/.test(spec)) {
426
+ const installedVersion = await readInstalledVersion(dep);
427
+ if (installedVersion && !spec.startsWith("file:") && !spec.startsWith("link:")) {
428
+ runtimeDependencies[dep] = installedVersion;
429
+ } else if (/^(workspace:|catalog:)/.test(spec)) {
430
+ console.warn(` Skipping "${dep}" (${spec}): not resolvable outside the workspace`);
431
+ }
432
+ continue;
433
+ }
434
+ runtimeDependencies[dep] = spec;
435
+ }
436
+ runtimeDependencies.next ||= "latest";
437
+ runtimeDependencies.react ||= "latest";
438
+ runtimeDependencies["react-dom"] ||= "latest";
348
439
  const minimalPackageJson = {
349
440
  name: originalPackageJson.name || "nextjs-app",
350
441
  version: originalPackageJson.version || "1.0.0",
351
442
  private: true,
352
- dependencies: {
353
- next: originalPackageJson.dependencies?.next || "latest",
354
- react: originalPackageJson.dependencies?.react || "latest",
355
- "react-dom": originalPackageJson.dependencies?.["react-dom"] || "latest"
356
- }
443
+ dependencies: runtimeDependencies
357
444
  };
358
445
  await fs.writeFile(path.join(functionsDir, "package.json"), JSON.stringify(minimalPackageJson, null, 2));
359
446
  const nodeModulesPath = path.join(functionsDir, "node_modules");
360
447
  await fs.rm(nodeModulesPath, { recursive: true, force: true });
361
- await execAsync$2("npm install --production --no-package-lock --loglevel=error", {
448
+ await execAsync$2("npm install --omit=dev --no-package-lock --loglevel=error --os=linux --cpu=x64 --libc=glibc", {
362
449
  cwd: functionsDir
363
450
  });
364
451
  console.log(` ${greenCheck()} Runtime dependencies installed`);
@@ -371,12 +458,20 @@ async function prepareFunctions() {
371
458
  await fs.access(imageOptDir2);
372
459
  console.log("Installing Sharp with Linux x64 binaries for image optimization...");
373
460
  await execAsync$2(
374
- "npm install --force sharp@0.33.5 @img/sharp-linux-x64@0.33.5 @img/sharp-libvips-linux-x64@1.0.4",
461
+ "npm install --force --no-package-lock --os=linux --cpu=x64 --libc=glibc sharp@0.33.5 @img/sharp-linux-x64@0.33.5 @img/sharp-libvips-linux-x64@1.0.4",
375
462
  { cwd: functionsDir }
376
463
  );
377
464
  console.log(` ${greenCheck()} Sharp with Linux x64 binaries installed`);
378
465
  } catch (error) {
379
466
  }
467
+ await pruneWrongPlatformBinaries(path.join(functionsDir, "node_modules"));
468
+ await fs.rm(path.join(functionsDir, "package-lock.json"), { force: true });
469
+ try {
470
+ const assetsDir = path.join(process.cwd(), ".open-next/assets");
471
+ const rootAssets = (await fs.readdir(assetsDir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
472
+ await fs.writeFile(path.join(functionsDir, "static-assets.json"), JSON.stringify(rootAssets));
473
+ } catch {
474
+ }
380
475
  }
381
476
 
382
477
  async function build(configPath) {
@@ -391,11 +486,12 @@ async function build(configPath) {
391
486
  const { createRequire } = await import('node:module');
392
487
  const require = createRequire(import.meta.url);
393
488
  const packagePath = path.dirname(require.resolve("opennextjs-azure/package.json"));
394
- const wrapperPath = path.join(packagePath, "dist/adapters/wrappers/azure-functions.js");
395
- const converterPath = path.join(packagePath, "dist/adapters/converters/azure-http.js");
396
- const incrementalCachePath = path.join(packagePath, "dist/overrides/incrementalCache/azure-blob.js");
397
- const tagCachePath = path.join(packagePath, "dist/overrides/tagCache/azure-table.js");
398
- const queuePath = path.join(packagePath, "dist/overrides/queue/azure-queue.js");
489
+ const distPath = (rel) => path.join(packagePath, rel).split(path.sep).join("/");
490
+ const wrapperPath = distPath("dist/adapters/wrappers/azure-functions.js");
491
+ const converterPath = distPath("dist/adapters/converters/azure-http.js");
492
+ const incrementalCachePath = distPath("dist/overrides/incrementalCache/azure-blob.js");
493
+ const tagCachePath = distPath("dist/overrides/tagCache/azure-table.js");
494
+ const queuePath = distPath("dist/overrides/queue/azure-queue.js");
399
495
  tempConfigPath = path.join(baseDir, "open-next.config.ts");
400
496
  const configContent = `// @ts-nocheck
401
497
  export default {
@@ -413,6 +509,12 @@ export default {
413
509
  middleware: {
414
510
  external: false,
415
511
  },
512
+ revalidate: {
513
+ override: {
514
+ wrapper: () => import("${distPath("dist/adapters/wrappers/azure-queue-revalidate.js")}").then(m => m.default),
515
+ converter: () => import("${distPath("dist/adapters/converters/azure-queue-revalidate.js")}").then(m => m.default),
516
+ },
517
+ },
416
518
  buildOutputPath: ".",
417
519
  appPath: ".",
418
520
  };
@@ -446,6 +548,81 @@ export default {
446
548
  }
447
549
  }
448
550
 
551
+ async function seedCacheAssets(connectionString, options = {}) {
552
+ const openNextDir = options.openNextDir ?? path.join(process.cwd(), ".open-next");
553
+ const containerName = options.containerName ?? process.env.AZURE_STORAGE_CONTAINER_NAME ?? "nextjs-cache";
554
+ const tableName = options.tableName ?? process.env.AZURE_TABLE_NAME ?? "nextjstags";
555
+ await Promise.all([
556
+ seedIncrementalCache(connectionString, path.join(openNextDir, "cache"), containerName),
557
+ seedTagTable(connectionString, path.join(openNextDir, "dynamodb-provider/dynamodb-cache.json"), tableName)
558
+ ]);
559
+ }
560
+ async function runPool(items, limit, fn) {
561
+ let index = 0;
562
+ let done = 0;
563
+ await Promise.all(
564
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
565
+ for (let i = index++; i < items.length; i = index++) {
566
+ await fn(items[i]);
567
+ done++;
568
+ }
569
+ })
570
+ );
571
+ return done;
572
+ }
573
+ async function seedIncrementalCache(connectionString, cacheDir, containerName = "nextjs-cache") {
574
+ let files;
575
+ try {
576
+ files = (await fs.readdir(cacheDir, { recursive: true, withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => path.join(entry.parentPath ?? entry.path, entry.name));
577
+ } catch {
578
+ return 0;
579
+ }
580
+ const container = BlobServiceClient.fromConnectionString(connectionString).getContainerClient(containerName);
581
+ await container.createIfNotExists();
582
+ const keyPrefix = process.env.AZURE_CACHE_KEY_PREFIX ? `${process.env.AZURE_CACHE_KEY_PREFIX}/` : "";
583
+ return runPool(files, 8, async (file) => {
584
+ const blobName = keyPrefix + path.relative(cacheDir, file).split(path.sep).join("/");
585
+ await container.getBlockBlobClient(blobName).uploadFile(file, {
586
+ blobHTTPHeaders: { blobContentType: "application/json" }
587
+ });
588
+ });
589
+ }
590
+ async function seedTagTable(connectionString, seedFile, tableName = "nextjstags") {
591
+ let entries;
592
+ try {
593
+ entries = JSON.parse(await fs.readFile(seedFile, "utf-8"));
594
+ } catch {
595
+ return 0;
596
+ }
597
+ const table = TableClient.fromConnectionString(connectionString, tableName, {
598
+ allowInsecureConnection: connectionString.includes("http://")
599
+ });
600
+ await table.createTable().catch((error) => {
601
+ if (error.statusCode !== 409)
602
+ throw error;
603
+ });
604
+ return runPool(entries, 8, async (entry) => {
605
+ await Promise.all([
606
+ table.upsertEntity(
607
+ {
608
+ partitionKey: encodeTableKey(entry.tag.S),
609
+ rowKey: encodeTableKey(entry.path.S),
610
+ revalidatedAt: Number(entry.revalidatedAt.N)
611
+ },
612
+ "Merge"
613
+ ),
614
+ table.upsertEntity(
615
+ {
616
+ partitionKey: encodeTableKey(`path#${entry.path.S}`),
617
+ rowKey: encodeTableKey(entry.tag.S),
618
+ revalidatedAt: Number(entry.revalidatedAt.N)
619
+ },
620
+ "Merge"
621
+ )
622
+ ]);
623
+ });
624
+ }
625
+
449
626
  const execAsync$1 = promisify(exec);
450
627
  const colors = {
451
628
  green: "\x1B[32m",
@@ -479,12 +656,14 @@ async function deploy$1(options) {
479
656
  await checkExistingInfrastructure(appName, resourceGroup, environment);
480
657
  }
481
658
  let deploymentOutputs;
659
+ const expectedFunctionAppName = `${appName}-func-${environment}`;
482
660
  if (!skipInfrastructure) {
483
661
  await syncBicepTemplate();
484
662
  console.log("Provisioning Azure infrastructure...");
485
663
  console.log(` Resource Group: ${resourceGroup}`);
486
664
  console.log(` Location: ${location}`);
487
665
  console.log(` Environment: ${environment}`);
666
+ const runFromPackage = await readRunFromPackage(resourceGroup, expectedFunctionAppName);
488
667
  deploymentOutputs = await provisionInfrastructure({
489
668
  appName,
490
669
  resourceGroup,
@@ -492,13 +671,22 @@ async function deploy$1(options) {
492
671
  environment,
493
672
  applicationInsights: options.applicationInsights ?? false
494
673
  });
674
+ if (runFromPackage?.startsWith("http")) {
675
+ await execAsync$1(
676
+ `az functionapp config appsettings set --resource-group ${resourceGroup} --name ${expectedFunctionAppName} --settings "WEBSITE_RUN_FROM_PACKAGE=${runFromPackage}" --output none`
677
+ );
678
+ }
495
679
  console.log(` ${greenCheck()} Infrastructure ready`);
496
680
  } else {
497
681
  console.log("Skipping infrastructure provisioning");
498
682
  }
683
+ const storageAccountName = deploymentOutputs?.storageAccount || await getStorageAccountName(resourceGroup);
499
684
  console.log("Uploading static assets...");
500
- await uploadStaticAssets(appName, resourceGroup);
685
+ await uploadStaticAssets(storageAccountName);
501
686
  console.log(` ${greenCheck()} Assets uploaded`);
687
+ console.log("Seeding ISR cache and tag table...");
688
+ await seedRuntimeCaches(resourceGroup, storageAccountName);
689
+ console.log(` ${greenCheck()} Cache seeded`);
502
690
  console.log("Deploying Function App...");
503
691
  const functionAppName = deploymentOutputs?.functionApp || `${appName}-func-${environment}`;
504
692
  await deployFunctionApp(functionAppName, resourceGroup);
@@ -571,7 +759,7 @@ async function checkQuotaAvailability(location, environment) {
571
759
  const ep1Limit = ep1Quota?.properties?.limit?.value || 0;
572
760
  const skuMap = {
573
761
  dev: { name: "Y1 (Consumption)", quota: y1Limit, type: "Dynamic" },
574
- staging: { name: "EP1 (Elastic Premium)", quota: ep1Limit, type: "ElasticPremium" },
762
+ staging: { name: "Y1 (Consumption)", quota: y1Limit, type: "Dynamic" },
575
763
  prod: { name: "EP1 (Elastic Premium)", quota: ep1Limit, type: "ElasticPremium" }
576
764
  };
577
765
  const requiredSku = skuMap[environment];
@@ -800,7 +988,7 @@ async function provisionInfrastructure(options) {
800
988
  const bicepPath = path.join(process.cwd(), "infrastructure/main.bicep");
801
989
  const enableAppInsights = applicationInsights ? "true" : "false";
802
990
  const { stdout } = await execAsync$1(
803
- `az deployment group create --resource-group ${resourceGroup} --template-file ${bicepPath} --parameters appName=${appName} environment=${environment} enableApplicationInsights=${enableAppInsights} --query 'properties.outputs.deploymentInfo.value' --output json`
991
+ `az deployment group create --resource-group ${resourceGroup} --template-file "${bicepPath}" --parameters appName=${appName} environment=${environment} enableApplicationInsights=${enableAppInsights} --query 'properties.outputs.deploymentInfo.value' --output json`
804
992
  );
805
993
  return JSON.parse(stdout);
806
994
  }
@@ -818,31 +1006,56 @@ async function patchCSSForBlobStorage(assetsPath) {
818
1006
  await fs.writeFile(filePath, content, "utf-8");
819
1007
  }
820
1008
  }
821
- async function uploadStaticAssets(appName, resourceGroup) {
822
- const assetsPath = path.join(process.cwd(), ".open-next/assets");
823
- await patchCSSForBlobStorage(assetsPath);
1009
+ async function getStorageAccountName(resourceGroup) {
824
1010
  const { stdout } = await execAsync$1(
825
1011
  `az storage account list --resource-group ${resourceGroup} --query "[0].name" -o tsv`
826
1012
  );
827
- const storageAccountName = stdout.trim();
1013
+ const name = stdout.trim();
1014
+ if (!name) {
1015
+ throw new Error(`No storage account found in resource group "${resourceGroup}"`);
1016
+ }
1017
+ return name;
1018
+ }
1019
+ async function readRunFromPackage(resourceGroup, functionAppName) {
1020
+ try {
1021
+ const { stdout } = await execAsync$1(
1022
+ `az functionapp config appsettings list --resource-group ${resourceGroup} --name ${functionAppName} --query "[?name=='WEBSITE_RUN_FROM_PACKAGE'].value | [0]" -o tsv`
1023
+ );
1024
+ return stdout.trim() || null;
1025
+ } catch {
1026
+ return null;
1027
+ }
1028
+ }
1029
+ async function seedRuntimeCaches(resourceGroup, storageAccountName) {
1030
+ const { stdout: connectionString } = await execAsync$1(
1031
+ `az storage account show-connection-string --resource-group ${resourceGroup} --name ${storageAccountName} --query connectionString -o tsv`
1032
+ );
1033
+ await seedCacheAssets(connectionString.trim());
1034
+ }
1035
+ async function uploadStaticAssets(storageAccountName) {
1036
+ const assetsPath = path.join(process.cwd(), ".open-next/assets");
1037
+ await patchCSSForBlobStorage(assetsPath);
828
1038
  await execAsync$1(
829
- `az storage blob upload-batch --account-name ${storageAccountName} --destination assets --source ${assetsPath} --content-cache-control "public, max-age=0, must-revalidate" --overwrite`
1039
+ `az storage blob upload-batch --account-name ${storageAccountName} --destination assets --source "${assetsPath}" --content-cache-control "public, max-age=0, must-revalidate" --overwrite`,
1040
+ { maxBuffer: 100 * 1024 * 1024 }
830
1041
  );
831
1042
  const nextStaticPath = path.join(assetsPath, "_next/static");
832
1043
  if (existsSync(nextStaticPath)) {
833
1044
  await execAsync$1(
834
- `az storage blob upload-batch --account-name ${storageAccountName} --destination assets --source ${nextStaticPath} --destination-path _next/static --content-cache-control "public, max-age=31536000, immutable" --overwrite`
1045
+ `az storage blob upload-batch --account-name ${storageAccountName} --destination assets --source "${nextStaticPath}" --destination-path _next/static --content-cache-control "public, max-age=31536000, immutable" --overwrite`,
1046
+ { maxBuffer: 100 * 1024 * 1024 }
835
1047
  );
836
1048
  }
837
1049
  }
838
1050
  async function deployFunctionApp(functionAppName, resourceGroup) {
839
1051
  const functionsPath = path.join(process.cwd(), ".open-next/server-functions/default");
840
1052
  const zipPath = path.join(process.cwd(), ".open-next/function-app.zip");
841
- await execAsync$1(`cd ${functionsPath} && zip -r ${zipPath} . -q`, {
1053
+ await fs.rm(zipPath, { force: true });
1054
+ await execAsync$1(`cd "${functionsPath}" && zip -r "${zipPath}" . -q`, {
842
1055
  maxBuffer: 100 * 1024 * 1024
843
1056
  });
844
1057
  await execAsync$1(
845
- `az functionapp deployment source config-zip --resource-group ${resourceGroup} --name ${functionAppName} --src ${zipPath}`,
1058
+ `az functionapp deployment source config-zip --resource-group ${resourceGroup} --name ${functionAppName} --src "${zipPath}"`,
846
1059
  {
847
1060
  maxBuffer: 100 * 1024 * 1024
848
1061
  }
@@ -850,6 +1063,41 @@ async function deployFunctionApp(functionAppName, resourceGroup) {
850
1063
  await fs.unlink(zipPath);
851
1064
  }
852
1065
 
1066
+ const RULES = {
1067
+ appName: {
1068
+ pattern: /^[a-zA-Z0-9][a-zA-Z0-9-]{0,58}[a-zA-Z0-9]$/,
1069
+ hint: "2-60 alphanumeric characters or hyphens, starting and ending with an alphanumeric"
1070
+ },
1071
+ resourceGroup: {
1072
+ pattern: /^[a-zA-Z0-9_()\-.]{1,90}$/,
1073
+ hint: "1-90 alphanumerics, underscores, parentheses, hyphens, or periods"
1074
+ },
1075
+ location: {
1076
+ pattern: /^[a-z0-9]{3,30}$/,
1077
+ hint: "a lowercase Azure region name like eastus or westeurope"
1078
+ },
1079
+ environment: {
1080
+ pattern: /^(dev|staging|prod)$/,
1081
+ hint: "one of: dev, staging, prod"
1082
+ }
1083
+ };
1084
+ function validateAzureName(kind, value) {
1085
+ if (value === void 0)
1086
+ return;
1087
+ const rule = RULES[kind];
1088
+ if (!rule.pattern.test(value)) {
1089
+ console.error(`\u274C Invalid ${kind}: ${JSON.stringify(value)}`);
1090
+ console.error(` Expected ${rule.hint}.`);
1091
+ process.exit(1);
1092
+ }
1093
+ }
1094
+ function validateAzureNames(values) {
1095
+ validateAzureName("appName", values.appName);
1096
+ validateAzureName("resourceGroup", values.resourceGroup);
1097
+ validateAzureName("location", values.location);
1098
+ validateAzureName("environment", values.environment);
1099
+ }
1100
+
853
1101
  const execAsync = promisify(exec);
854
1102
  async function promptForInput(question) {
855
1103
  const rl = readline.createInterface({
@@ -947,6 +1195,7 @@ async function deploy(options) {
947
1195
  if (!resourceGroup) {
948
1196
  const result = await selectResourceGroup();
949
1197
  resourceGroup = result.name;
1198
+ validateAzureNames({ resourceGroup });
950
1199
  if (result.isNew) {
951
1200
  resourceGroupLocation = result.location;
952
1201
  } else {
@@ -954,10 +1203,13 @@ async function deploy(options) {
954
1203
  }
955
1204
  }
956
1205
  const environment = options.environment || config.environment || await selectEnvironment();
1206
+ const appName = options.appName || config.appName || path.basename(cwd);
1207
+ const location = resourceGroupLocation || options.location || config.location || "eastus";
1208
+ validateAzureNames({ appName, resourceGroup, location, environment });
957
1209
  await deploy$1({
958
- appName: options.appName || config.appName || path.basename(cwd),
1210
+ appName,
959
1211
  resourceGroup,
960
- location: resourceGroupLocation || options.location || config.location || "eastus",
1212
+ location,
961
1213
  environment,
962
1214
  skipInfrastructure: options.skipInfrastructure,
963
1215
  skipResourceChecks: options.skipResourceChecks,
@@ -976,4 +1228,4 @@ async function getResourceGroupLocation(resourceGroup) {
976
1228
  }
977
1229
  }
978
1230
 
979
- export { build as b, deploy as d, greenCheck as g, init as i, promptForInput as p, redX as r };
1231
+ export { build as b, deploy as d, greenCheck as g, init as i, promptForInput as p, redX as r, validateAzureNames as v };
package/dist/index.d.mts CHANGED
@@ -7,6 +7,7 @@ export { default as azureHttpConverter } from './adapters/converters/azure-http.
7
7
  import '@opennextjs/aws/types/open-next.js';
8
8
  import '@opennextjs/aws/types/overrides.js';
9
9
  import '@azure/functions';
10
+ import 'node:buffer';
10
11
 
11
12
  interface ScaffoldOptions {
12
13
  typescript?: boolean;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { default as azureHttpConverter } from './adapters/converters/azure-http.
7
7
  import '@opennextjs/aws/types/open-next.js';
8
8
  import '@opennextjs/aws/types/overrides.js';
9
9
  import '@azure/functions';
10
+ import 'node:buffer';
10
11
 
11
12
  interface ScaffoldOptions {
12
13
  typescript?: boolean;
package/dist/index.js CHANGED
@@ -12,9 +12,9 @@ import 'node:child_process';
12
12
  import 'node:util';
13
13
  import '@opennextjs/aws/build.js';
14
14
  import 'node:fs';
15
- import 'node:readline';
16
15
  import '@azure/storage-blob';
17
16
  import '@azure/data-tables';
17
+ import 'node:readline';
18
18
  import '@azure/storage-queue';
19
19
  import 'node:stream';
20
20
  import 'node:buffer';
@@ -136,6 +136,9 @@ resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
136
136
  kind: 'functionapp'
137
137
  properties: {
138
138
  reserved: true // Linux
139
+ // Elastic Premium defaults to a max of 1 worker when this is omitted,
140
+ // which pins the prod plan to a single instance. Y1 ignores it.
141
+ maximumElasticWorkerCount: environment == 'prod' ? 20 : 1
139
142
  }
140
143
  }
141
144
 
@@ -170,12 +173,10 @@ resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
170
173
  value: 'node'
171
174
  }
172
175
  {
173
- name: 'WEBSITE_NODE_DEFAULT_VERSION'
174
- value: '~${nodeVersion}'
175
- }
176
- {
177
- name: 'WEBSITE_RUN_FROM_PACKAGE'
178
- value: '1'
176
+ // A second worker process buys resilience to GC stalls; SSR is
177
+ // single-threaded per worker. Memory-bounded on Y1 (1.5 GB).
178
+ name: 'FUNCTIONS_WORKER_PROCESS_COUNT'
179
+ value: '2'
179
180
  }
180
181
  {
181
182
  name: 'AzureWebJobsDisableHomepage'
@@ -223,6 +224,12 @@ resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
223
224
 
224
225
  ftpsState: 'Disabled'
225
226
  minTlsVersion: '1.2'
227
+ // Always-ready SSR on Elastic Premium; both stay null on Y1.
228
+ // WEBSITE_RUN_FROM_PACKAGE is deliberately absent: '1' is unsupported
229
+ // on Linux Consumption, and the deploy step sets the blob-URL form
230
+ // that provisioning must not overwrite.
231
+ minimumElasticInstanceCount: environment == 'prod' ? 1 : null
232
+ preWarmedInstanceCount: environment == 'prod' ? 1 : null
226
233
  cors: {
227
234
  allowedOrigins: ['*']
228
235
  }
@@ -1,5 +1,18 @@
1
1
  import { IncrementalCache, CacheEntryType, WithLastModified, CacheValue } from '@opennextjs/aws/types/overrides.js';
2
2
 
3
+ /**
4
+ * Retry policy for blob calls on the request hot path. The SDK's default
5
+ * first retry waits 4s; fail fast instead and let cache layers treat
6
+ * errors as misses. Shared with the image-optimization wrapper.
7
+ */
8
+ declare const BLOB_RETRY_OPTIONS: {
9
+ retryOptions: {
10
+ maxTries: number;
11
+ retryDelayInMs: number;
12
+ maxRetryDelayInMs: number;
13
+ tryTimeoutInMs: number;
14
+ };
15
+ };
3
16
  /**
4
17
  * Azure Blob Storage implementation of IncrementalCache.
5
18
  *
@@ -13,6 +26,9 @@ declare class AzureBlobIncrementalCache implements IncrementalCache {
13
26
  /**
14
27
  * Builds the blob key path, mimicking S3 structure:
15
28
  * [prefix]/[__fetch]/[buildId]/[key].[extension]
29
+ *
30
+ * Keys must match the .open-next/cache layout the seeder uploads, so no
31
+ * container-name prefix and no leading slash.
16
32
  */
17
33
  private buildBlobKey;
18
34
  get<CacheType extends CacheEntryType = "cache">(key: string, cacheType?: CacheType): Promise<WithLastModified<CacheValue<CacheType>> | null>;
@@ -20,4 +36,4 @@ declare class AzureBlobIncrementalCache implements IncrementalCache {
20
36
  delete(key: string): Promise<void>;
21
37
  }
22
38
 
23
- export { AzureBlobIncrementalCache as default };
39
+ export { BLOB_RETRY_OPTIONS, AzureBlobIncrementalCache as default };
@@ -1,5 +1,18 @@
1
1
  import { IncrementalCache, CacheEntryType, WithLastModified, CacheValue } from '@opennextjs/aws/types/overrides.js';
2
2
 
3
+ /**
4
+ * Retry policy for blob calls on the request hot path. The SDK's default
5
+ * first retry waits 4s; fail fast instead and let cache layers treat
6
+ * errors as misses. Shared with the image-optimization wrapper.
7
+ */
8
+ declare const BLOB_RETRY_OPTIONS: {
9
+ retryOptions: {
10
+ maxTries: number;
11
+ retryDelayInMs: number;
12
+ maxRetryDelayInMs: number;
13
+ tryTimeoutInMs: number;
14
+ };
15
+ };
3
16
  /**
4
17
  * Azure Blob Storage implementation of IncrementalCache.
5
18
  *
@@ -13,6 +26,9 @@ declare class AzureBlobIncrementalCache implements IncrementalCache {
13
26
  /**
14
27
  * Builds the blob key path, mimicking S3 structure:
15
28
  * [prefix]/[__fetch]/[buildId]/[key].[extension]
29
+ *
30
+ * Keys must match the .open-next/cache layout the seeder uploads, so no
31
+ * container-name prefix and no leading slash.
16
32
  */
17
33
  private buildBlobKey;
18
34
  get<CacheType extends CacheEntryType = "cache">(key: string, cacheType?: CacheType): Promise<WithLastModified<CacheValue<CacheType>> | null>;
@@ -20,4 +36,4 @@ declare class AzureBlobIncrementalCache implements IncrementalCache {
20
36
  delete(key: string): Promise<void>;
21
37
  }
22
38
 
23
- export { AzureBlobIncrementalCache as default };
39
+ export { BLOB_RETRY_OPTIONS, AzureBlobIncrementalCache as default };
@@ -1,6 +1,9 @@
1
- import { BlobServiceClient } from '@azure/storage-blob';
1
+ import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob';
2
2
  import { getAzureConfig } from '../../config/index.js';
3
3
 
4
+ const BLOB_RETRY_OPTIONS = {
5
+ retryOptions: { maxTries: 3, retryDelayInMs: 300, maxRetryDelayInMs: 2e3, tryTimeoutInMs: 1e4 }
6
+ };
4
7
  class AzureBlobIncrementalCache {
5
8
  name = "azure-blob";
6
9
  containerClient;
@@ -9,27 +12,33 @@ class AzureBlobIncrementalCache {
9
12
  const connectionString = storage.connectionString;
10
13
  const accountName = storage.accountName;
11
14
  const accountKey = storage.accountKey;
15
+ const clientOptions = BLOB_RETRY_OPTIONS;
12
16
  if (connectionString) {
13
- const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
17
+ const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString, clientOptions);
14
18
  this.containerClient = blobServiceClient.getContainerClient(storage.containerName || "nextjs-cache");
15
19
  } else if (accountName && accountKey) {
16
- const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, {
17
- accountName,
18
- accountKey
19
- });
20
+ const credential = new StorageSharedKeyCredential(accountName, accountKey);
21
+ const blobServiceClient = new BlobServiceClient(
22
+ `https://${accountName}.blob.core.windows.net`,
23
+ credential,
24
+ clientOptions
25
+ );
20
26
  this.containerClient = blobServiceClient.getContainerClient(storage.containerName || "nextjs-cache");
21
27
  }
22
28
  }
23
29
  /**
24
30
  * Builds the blob key path, mimicking S3 structure:
25
31
  * [prefix]/[__fetch]/[buildId]/[key].[extension]
32
+ *
33
+ * Keys must match the .open-next/cache layout the seeder uploads, so no
34
+ * container-name prefix and no leading slash.
26
35
  */
27
36
  buildBlobKey(key, cacheType = "cache") {
28
- const { storage } = getAzureConfig();
29
37
  const { NEXT_BUILD_ID } = process.env;
30
- const prefix = storage.containerName || "";
38
+ const prefix = process.env.AZURE_CACHE_KEY_PREFIX || "";
31
39
  const type = cacheType === "fetch" ? "__fetch" : "";
32
- return [prefix, type, NEXT_BUILD_ID, cacheType === "fetch" ? key : `${key}.${cacheType}`].filter(Boolean).join("/");
40
+ const cleanKey = key.replace(/^\/+/, "");
41
+ return [prefix, type, NEXT_BUILD_ID, cacheType === "fetch" ? cleanKey : `${cleanKey}.${cacheType}`].filter(Boolean).join("/");
33
42
  }
34
43
  async get(key, cacheType) {
35
44
  try {
@@ -63,7 +72,7 @@ class AzureBlobIncrementalCache {
63
72
  const blobKey = this.buildBlobKey(key, cacheType);
64
73
  const blobClient = this.containerClient.getBlockBlobClient(blobKey);
65
74
  const content = JSON.stringify(value);
66
- await blobClient.upload(content, content.length, {
75
+ await blobClient.upload(content, Buffer.byteLength(content), {
67
76
  blobHTTPHeaders: {
68
77
  blobContentType: "application/json"
69
78
  }
@@ -86,4 +95,4 @@ class AzureBlobIncrementalCache {
86
95
  }
87
96
  }
88
97
 
89
- export { AzureBlobIncrementalCache as default };
98
+ export { BLOB_RETRY_OPTIONS, AzureBlobIncrementalCache as default };