opennextjs-azure 0.1.3 → 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 +301 -33
  18. package/dist/index.d.mts +2 -0
  19. package/dist/index.d.ts +2 -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 +13 -4
  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",
@@ -459,27 +636,34 @@ async function deploy$1(options) {
459
636
  resourceGroup = `${appName}-rg`,
460
637
  location = "eastus",
461
638
  environment = "dev",
462
- skipInfrastructure = false
639
+ skipInfrastructure = false,
640
+ skipResourceChecks = false
463
641
  } = options;
464
642
  console.log(`Deploying ${appName} to Azure (${environment} environment)`);
465
643
  try {
466
644
  await checkAzureCLI();
467
645
  await checkAzureLogin();
468
- await checkAzureSubscriptionPermissions();
469
- await checkLocation(location);
470
- await checkRequiredProviders(options.applicationInsights);
471
- await checkQuotaAvailability(location, environment);
472
646
  await checkBuildOutput();
647
+ if (!skipResourceChecks) {
648
+ await checkAzureSubscriptionPermissions();
649
+ await checkLocation(location);
650
+ await checkRequiredProviders(options.applicationInsights);
651
+ await checkQuotaAvailability(location, environment);
652
+ } else {
653
+ console.log("Skipping resource checks (--skip-resource-checks)");
654
+ }
473
655
  if (skipInfrastructure) {
474
656
  await checkExistingInfrastructure(appName, resourceGroup, environment);
475
657
  }
476
658
  let deploymentOutputs;
659
+ const expectedFunctionAppName = `${appName}-func-${environment}`;
477
660
  if (!skipInfrastructure) {
478
661
  await syncBicepTemplate();
479
662
  console.log("Provisioning Azure infrastructure...");
480
663
  console.log(` Resource Group: ${resourceGroup}`);
481
664
  console.log(` Location: ${location}`);
482
665
  console.log(` Environment: ${environment}`);
666
+ const runFromPackage = await readRunFromPackage(resourceGroup, expectedFunctionAppName);
483
667
  deploymentOutputs = await provisionInfrastructure({
484
668
  appName,
485
669
  resourceGroup,
@@ -487,13 +671,22 @@ async function deploy$1(options) {
487
671
  environment,
488
672
  applicationInsights: options.applicationInsights ?? false
489
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
+ }
490
679
  console.log(` ${greenCheck()} Infrastructure ready`);
491
680
  } else {
492
681
  console.log("Skipping infrastructure provisioning");
493
682
  }
683
+ const storageAccountName = deploymentOutputs?.storageAccount || await getStorageAccountName(resourceGroup);
494
684
  console.log("Uploading static assets...");
495
- await uploadStaticAssets(appName, resourceGroup);
685
+ await uploadStaticAssets(storageAccountName);
496
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`);
497
690
  console.log("Deploying Function App...");
498
691
  const functionAppName = deploymentOutputs?.functionApp || `${appName}-func-${environment}`;
499
692
  await deployFunctionApp(functionAppName, resourceGroup);
@@ -566,7 +759,7 @@ async function checkQuotaAvailability(location, environment) {
566
759
  const ep1Limit = ep1Quota?.properties?.limit?.value || 0;
567
760
  const skuMap = {
568
761
  dev: { name: "Y1 (Consumption)", quota: y1Limit, type: "Dynamic" },
569
- staging: { name: "EP1 (Elastic Premium)", quota: ep1Limit, type: "ElasticPremium" },
762
+ staging: { name: "Y1 (Consumption)", quota: y1Limit, type: "Dynamic" },
570
763
  prod: { name: "EP1 (Elastic Premium)", quota: ep1Limit, type: "ElasticPremium" }
571
764
  };
572
765
  const requiredSku = skuMap[environment];
@@ -781,11 +974,21 @@ async function syncBicepTemplate() {
781
974
  }
782
975
  async function provisionInfrastructure(options) {
783
976
  const { appName, resourceGroup, location, environment, applicationInsights } = options;
784
- await execAsync$1(`az group create --name ${resourceGroup} --location ${location}`);
977
+ const { stdout: rgExists } = await execAsync$1(`az group exists --name ${resourceGroup}`);
978
+ if (rgExists.trim() === "true") {
979
+ const { stdout: rgLocation } = await execAsync$1(`az group show --name ${resourceGroup} --query location -o tsv`);
980
+ if (rgLocation.trim() !== location) {
981
+ console.warn(
982
+ ` ${colors.yellow}Warning:${colors.reset} Resource group "${resourceGroup}" exists in ${rgLocation.trim()}, but ${location} was specified`
983
+ );
984
+ }
985
+ } else {
986
+ await execAsync$1(`az group create --name ${resourceGroup} --location ${location}`);
987
+ }
785
988
  const bicepPath = path.join(process.cwd(), "infrastructure/main.bicep");
786
989
  const enableAppInsights = applicationInsights ? "true" : "false";
787
990
  const { stdout } = await execAsync$1(
788
- `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`
789
992
  );
790
993
  return JSON.parse(stdout);
791
994
  }
@@ -803,31 +1006,56 @@ async function patchCSSForBlobStorage(assetsPath) {
803
1006
  await fs.writeFile(filePath, content, "utf-8");
804
1007
  }
805
1008
  }
806
- async function uploadStaticAssets(appName, resourceGroup) {
807
- const assetsPath = path.join(process.cwd(), ".open-next/assets");
808
- await patchCSSForBlobStorage(assetsPath);
1009
+ async function getStorageAccountName(resourceGroup) {
809
1010
  const { stdout } = await execAsync$1(
810
1011
  `az storage account list --resource-group ${resourceGroup} --query "[0].name" -o tsv`
811
1012
  );
812
- 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);
813
1038
  await execAsync$1(
814
- `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 }
815
1041
  );
816
1042
  const nextStaticPath = path.join(assetsPath, "_next/static");
817
1043
  if (existsSync(nextStaticPath)) {
818
1044
  await execAsync$1(
819
- `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 }
820
1047
  );
821
1048
  }
822
1049
  }
823
1050
  async function deployFunctionApp(functionAppName, resourceGroup) {
824
1051
  const functionsPath = path.join(process.cwd(), ".open-next/server-functions/default");
825
1052
  const zipPath = path.join(process.cwd(), ".open-next/function-app.zip");
826
- 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`, {
827
1055
  maxBuffer: 100 * 1024 * 1024
828
1056
  });
829
1057
  await execAsync$1(
830
- `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}"`,
831
1059
  {
832
1060
  maxBuffer: 100 * 1024 * 1024
833
1061
  }
@@ -835,6 +1063,41 @@ async function deployFunctionApp(functionAppName, resourceGroup) {
835
1063
  await fs.unlink(zipPath);
836
1064
  }
837
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
+
838
1101
  const execAsync = promisify(exec);
839
1102
  async function promptForInput(question) {
840
1103
  const rl = readline.createInterface({
@@ -932,6 +1195,7 @@ async function deploy(options) {
932
1195
  if (!resourceGroup) {
933
1196
  const result = await selectResourceGroup();
934
1197
  resourceGroup = result.name;
1198
+ validateAzureNames({ resourceGroup });
935
1199
  if (result.isNew) {
936
1200
  resourceGroupLocation = result.location;
937
1201
  } else {
@@ -939,12 +1203,16 @@ async function deploy(options) {
939
1203
  }
940
1204
  }
941
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 });
942
1209
  await deploy$1({
943
- appName: options.appName || config.appName || path.basename(cwd),
1210
+ appName,
944
1211
  resourceGroup,
945
- location: resourceGroupLocation || options.location || config.location || "eastus",
1212
+ location,
946
1213
  environment,
947
1214
  skipInfrastructure: options.skipInfrastructure,
1215
+ skipResourceChecks: options.skipResourceChecks,
948
1216
  applicationInsights: config.applicationInsights ?? false
949
1217
  });
950
1218
  }
@@ -960,4 +1228,4 @@ async function getResourceGroupLocation(resourceGroup) {
960
1228
  }
961
1229
  }
962
1230
 
963
- 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;
@@ -30,6 +31,7 @@ declare function deploy(options: {
30
31
  location?: string;
31
32
  environment?: "dev" | "staging" | "prod";
32
33
  skipInfrastructure?: boolean;
34
+ skipResourceChecks?: boolean;
33
35
  }): Promise<void>;
34
36
 
35
37
  export { build, deploy, init };
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;
@@ -30,6 +31,7 @@ declare function deploy(options: {
30
31
  location?: string;
31
32
  environment?: "dev" | "staging" | "prod";
32
33
  skipInfrastructure?: boolean;
34
+ skipResourceChecks?: boolean;
33
35
  }): Promise<void>;
34
36
 
35
37
  export { build, deploy, init };
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 };