opennextjs-azure 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![NPM Downloads](https://img.shields.io/npm/dt/opennextjs-azure)](https://www.npmjs.com/package/opennextjs-azure)
7
7
  [![License: MIT](https://img.shields.io/npm/l/opennextjs-azure)](https://opensource.org/licenses/MIT)
8
8
 
9
- Built on the [OpenNext](https://opennext.js.org) framework, this adapter brings native Next.js support to Azure Functions.
9
+ Built on the [OpenNext](https://opennext.js.org) framework, this adapter brings native Next.js support to Azure Functions with a Vercel-grade developer experience.
10
10
 
11
11
  ![Deploy Screenshot](./docs/deploy-screenshot.png)
12
12
 
@@ -23,6 +23,7 @@ Azure Functions is Microsoft's serverless compute platform—comparable to AWS L
23
23
  | Next.js Feature | Azure Implementation |
24
24
  | -------------------------------------- | --------------------------------------------------------------- |
25
25
  | Incremental Static Regeneration | Azure Blob Storage |
26
+ | Image Optimization | Azure Blob Storage with automatic caching |
26
27
  | Streaming SSR | Azure Functions with Node.js streams |
27
28
  | `revalidateTag()` / `revalidatePath()` | Azure Table Storage + Queue Storage |
28
29
  | Fetch caching | Azure Blob Storage with build ID namespacing |
@@ -139,6 +140,12 @@ Converts between Azure Functions HTTP triggers and Next.js InternalEvent/Interna
139
140
  - **Tag Cache:** Azure Table Storage maps tags → paths for `revalidateTag()`
140
141
  - **Revalidation Queue:** Azure Queue Storage triggers on-demand regeneration
141
142
 
143
+ **Image Optimization:**
144
+
145
+ - **Source Images:** Loaded from `assets` blob container
146
+ - **Optimized Cache:** Processed images cached in `optimized-images` container to avoid re-processing
147
+ - **Processing:** Processing is done with the `sharp` library, which gets added for you during deployment.
148
+
142
149
  **Build Process:**
143
150
  Uses OpenNext's AWS build with Azure-specific overrides, then adds Azure Functions metadata (`host.json`, `function.json`) for v3 programming model.
144
151
 
@@ -0,0 +1,20 @@
1
+ import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
2
+ import { OpenNextHandler, OpenNextHandlerOptions } from '@opennextjs/aws/types/overrides.js';
3
+
4
+ /**
5
+ * Azure Image Optimization Handler
6
+ *
7
+ * Wraps OpenNext's default image optimization handler with Azure Blob caching.
8
+ *
9
+ * When AZURE_IMAGE_OPTIMIZATION_CACHE=true:
10
+ * - First request: Loads from "assets", processes, caches in "optimized-images", returns
11
+ * - Subsequent requests: Returns from "optimized-images" cache (no processing)
12
+ *
13
+ * When AZURE_IMAGE_OPTIMIZATION_CACHE=false:
14
+ * - Every request processes the image (no caching)
15
+ */
16
+ declare function createImageOptimizationHandler(): Promise<OpenNextHandler<InternalEvent, InternalResult>>;
17
+ declare const handler: OpenNextHandler<InternalEvent, InternalResult>;
18
+ declare function defaultHandler(event: InternalEvent, options?: OpenNextHandlerOptions): Promise<InternalResult>;
19
+
20
+ export { createImageOptimizationHandler, defaultHandler, handler };
@@ -0,0 +1,20 @@
1
+ import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
2
+ import { OpenNextHandler, OpenNextHandlerOptions } from '@opennextjs/aws/types/overrides.js';
3
+
4
+ /**
5
+ * Azure Image Optimization Handler
6
+ *
7
+ * Wraps OpenNext's default image optimization handler with Azure Blob caching.
8
+ *
9
+ * When AZURE_IMAGE_OPTIMIZATION_CACHE=true:
10
+ * - First request: Loads from "assets", processes, caches in "optimized-images", returns
11
+ * - Subsequent requests: Returns from "optimized-images" cache (no processing)
12
+ *
13
+ * When AZURE_IMAGE_OPTIMIZATION_CACHE=false:
14
+ * - Every request processes the image (no caching)
15
+ */
16
+ declare function createImageOptimizationHandler(): Promise<OpenNextHandler<InternalEvent, InternalResult>>;
17
+ declare const handler: OpenNextHandler<InternalEvent, InternalResult>;
18
+ declare function defaultHandler(event: InternalEvent, options?: OpenNextHandlerOptions): Promise<InternalResult>;
19
+
20
+ export { createImageOptimizationHandler, defaultHandler, handler };
@@ -0,0 +1,11 @@
1
+ async function createImageOptimizationHandler() {
2
+ const { defaultHandler: defaultHandler2 } = await import('@opennextjs/aws/adapters/image-optimization-adapter.js');
3
+ const { createCachedImageOptimizationHandler } = await import('../overrides/imageOptimization/azure-cached.js');
4
+ return createCachedImageOptimizationHandler(defaultHandler2);
5
+ }
6
+ const handler = await createImageOptimizationHandler();
7
+ async function defaultHandler(event, options) {
8
+ return handler(event, options);
9
+ }
10
+
11
+ export { createImageOptimizationHandler, defaultHandler, handler };
@@ -3,7 +3,6 @@ import { Writable } from 'node:stream';
3
3
  const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 204, 205, 304]);
4
4
  const STATIC_ASSET_PATTERNS = [
5
5
  /^\/_next\/static\//,
6
- /^\/_next\/data\//,
7
6
  /^\/favicon\.ico$/,
8
7
  /^\/robots\.txt$/,
9
8
  /^\/sitemap\.xml$/,
@@ -17,11 +16,12 @@ const handler = async (handler2, converter) => async (context, request) => {
17
16
  const internalEvent = await converter.convertFrom(request);
18
17
  if (isStaticAssetRequest(internalEvent.rawPath)) {
19
18
  const blobUrl = `https://${process.env.AZURE_STORAGE_ACCOUNT_NAME}.blob.core.windows.net/assets${internalEvent.rawPath}`;
19
+ const cacheControl = internalEvent.rawPath.startsWith("/_next/static/") ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate";
20
20
  context.res = {
21
21
  status: 301,
22
22
  headers: {
23
23
  Location: blobUrl,
24
- "Cache-Control": "public, max-age=31536000, immutable"
24
+ "Cache-Control": cacheControl
25
25
  }
26
26
  };
27
27
  return;
@@ -0,0 +1,10 @@
1
+ import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
2
+ import { WrapperHandler } from '@opennextjs/aws/types/overrides.js';
3
+
4
+ declare const _default: {
5
+ name: string;
6
+ wrapper: WrapperHandler<InternalEvent, InternalResult>;
7
+ supportStreaming: boolean;
8
+ };
9
+
10
+ export { _default as default };
@@ -0,0 +1,10 @@
1
+ import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
2
+ import { WrapperHandler } from '@opennextjs/aws/types/overrides.js';
3
+
4
+ declare const _default: {
5
+ name: string;
6
+ wrapper: WrapperHandler<InternalEvent, InternalResult>;
7
+ supportStreaming: boolean;
8
+ };
9
+
10
+ export { _default as default };
@@ -0,0 +1,151 @@
1
+ import { Writable } from 'node:stream';
2
+ import { createHash } from 'node:crypto';
3
+ import { BlobServiceClient } from '@azure/storage-blob';
4
+
5
+ const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 204, 205, 304]);
6
+ const { AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_ACCOUNT_NAME } = process.env;
7
+ const CACHE_CONTAINER = "optimized-images";
8
+ function getBlobClient(key) {
9
+ if (!AZURE_STORAGE_CONNECTION_STRING && !AZURE_STORAGE_ACCOUNT_NAME) {
10
+ throw new Error("Azure Storage connection string or account name must be defined");
11
+ }
12
+ const blobServiceClient = AZURE_STORAGE_CONNECTION_STRING ? BlobServiceClient.fromConnectionString(AZURE_STORAGE_CONNECTION_STRING) : new BlobServiceClient(`https://${AZURE_STORAGE_ACCOUNT_NAME}.blob.core.windows.net`);
13
+ const containerClient = blobServiceClient.getContainerClient(CACHE_CONTAINER);
14
+ return containerClient.getBlockBlobClient(key);
15
+ }
16
+ function computeCacheKey(event) {
17
+ const { query } = event;
18
+ const url = Array.isArray(query?.url) ? query.url[0] : query?.url || "";
19
+ const width = Array.isArray(query?.w) ? query.w[0] : query?.w || "0";
20
+ const quality = Array.isArray(query?.q) ? query.q[0] : query?.q || "75";
21
+ const hash = createHash("sha256").update(url).digest("hex").substring(0, 16);
22
+ return `${hash}/w${width}_q${quality}.cache`;
23
+ }
24
+ async function getCachedImage(cacheKey) {
25
+ try {
26
+ const blobClient = getBlobClient(cacheKey);
27
+ const exists = await blobClient.exists();
28
+ if (!exists) {
29
+ return null;
30
+ }
31
+ const downloadResponse = await blobClient.download();
32
+ if (!downloadResponse.readableStreamBody) {
33
+ return null;
34
+ }
35
+ const chunks = [];
36
+ for await (const chunk of downloadResponse.readableStreamBody) {
37
+ chunks.push(Buffer.from(chunk));
38
+ }
39
+ return Buffer.concat(chunks);
40
+ } catch (error) {
41
+ return null;
42
+ }
43
+ }
44
+ async function setCachedImage(cacheKey, buffer, contentType) {
45
+ try {
46
+ const blobClient = getBlobClient(cacheKey);
47
+ await blobClient.upload(buffer, buffer.length, {
48
+ blobHTTPHeaders: {
49
+ blobContentType: contentType,
50
+ blobCacheControl: "public,max-age=31536000,immutable"
51
+ }
52
+ });
53
+ process.stderr.write(`[ImageCache] \u2713 Cached ${buffer.length} bytes
54
+ `);
55
+ } catch (error) {
56
+ process.stderr.write(`[ImageCache] \u2717 Cache failed: ${error.message}
57
+ `);
58
+ }
59
+ }
60
+ const handler = async (handler2, converter) => async (context, request) => {
61
+ try {
62
+ const internalEvent = await converter.convertFrom(request);
63
+ const cacheKey = computeCacheKey(internalEvent);
64
+ const cachedBuffer = await getCachedImage(cacheKey);
65
+ if (cachedBuffer) {
66
+ process.stderr.write(`[ImageCache] \u2713 Cache HIT
67
+ `);
68
+ context.res = {
69
+ status: 200,
70
+ headers: {
71
+ "Content-Type": "image/webp",
72
+ "Cache-Control": "public,max-age=31536000,immutable"
73
+ },
74
+ body: cachedBuffer
75
+ };
76
+ return;
77
+ }
78
+ process.stderr.write(`[ImageCache] Cache MISS - processing...
79
+ `);
80
+ let streamFinished = null;
81
+ let resolveStream = null;
82
+ let processedBuffer = null;
83
+ let responseContentType = "image/webp";
84
+ const streamCreator = {
85
+ writeHeaders(prelude) {
86
+ const { statusCode, cookies, headers } = prelude;
87
+ responseContentType = headers["Content-Type"] || headers["content-type"] || "image/webp";
88
+ const responseHeaders = { ...headers };
89
+ if (cookies.length > 0) {
90
+ responseHeaders["set-cookie"] = cookies.join(", ");
91
+ }
92
+ if (NULL_BODY_STATUSES.has(statusCode)) {
93
+ context.res = {
94
+ status: statusCode,
95
+ headers: responseHeaders
96
+ };
97
+ return new Writable({
98
+ write(_chunk, _encoding, callback) {
99
+ callback();
100
+ }
101
+ });
102
+ }
103
+ streamFinished = new Promise((resolve) => {
104
+ resolveStream = resolve;
105
+ });
106
+ const chunks = [];
107
+ const writable = new Writable({
108
+ write(chunk, _encoding, callback) {
109
+ chunks.push(Buffer.from(chunk));
110
+ callback();
111
+ },
112
+ final(callback) {
113
+ const body = Buffer.concat(chunks);
114
+ processedBuffer = body;
115
+ context.res = {
116
+ status: statusCode,
117
+ headers: responseHeaders,
118
+ body
119
+ };
120
+ callback();
121
+ resolveStream?.();
122
+ }
123
+ });
124
+ return writable;
125
+ }
126
+ };
127
+ await handler2(internalEvent, { streamCreator });
128
+ if (streamFinished) {
129
+ await streamFinished;
130
+ }
131
+ if (processedBuffer && responseContentType) {
132
+ await setCachedImage(cacheKey, processedBuffer, responseContentType);
133
+ }
134
+ } catch (error) {
135
+ console.error("Image optimization error:", error);
136
+ context.res = {
137
+ status: 500,
138
+ headers: {
139
+ "Content-Type": "text/plain"
140
+ },
141
+ body: "Internal server error"
142
+ };
143
+ }
144
+ };
145
+ const azureImageOptimization = {
146
+ name: "azure-image-optimization",
147
+ wrapper: handler,
148
+ supportStreaming: true
149
+ };
150
+
151
+ export { azureImageOptimization as default };
package/dist/cli/index.js CHANGED
@@ -222,6 +222,27 @@ async function checkStorageAccount(appName, resourceGroup, environment) {
222
222
  details: `Status: ${account.Status}`
223
223
  };
224
224
  }
225
+ try {
226
+ const { stdout: staticFileStdout } = await execAsync$1(
227
+ `az storage blob list --account-name ${account.Name} --container-name assets --prefix _next/static --query '[0].name' -o tsv --only-show-errors 2>/dev/null || echo ""`
228
+ );
229
+ const sampleFile = staticFileStdout.trim();
230
+ if (sampleFile) {
231
+ const { stdout: cacheStdout } = await execAsync$1(
232
+ `az storage blob show --account-name ${account.Name} --container-name assets --name '${sampleFile}' --query 'properties.contentSettings.cacheControl' -o tsv --only-show-errors 2>/dev/null || echo ""`
233
+ );
234
+ const cacheControl = cacheStdout.trim();
235
+ const expectedCache = "public, max-age=31536000, immutable";
236
+ if (cacheControl !== expectedCache) {
237
+ return {
238
+ passed: false,
239
+ message: "Static asset cache headers misconfigured",
240
+ details: `${sampleFile.split("/").pop()} has '${cacheControl || "none"}' (expected '${expectedCache}')`
241
+ };
242
+ }
243
+ }
244
+ } catch {
245
+ }
225
246
  return {
226
247
  passed: true,
227
248
  message: "Storage account healthy",
@@ -296,11 +317,23 @@ async function checkFunctionAppStatus(appName, resourceGroup, environment) {
296
317
  details: `State: ${funcApp.State}`
297
318
  };
298
319
  }
299
- return {
300
- passed: true,
301
- message: "Function app running",
302
- details: `URL: https://${funcApp.DefaultHostName}`
303
- };
320
+ const url = `https://${funcApp.DefaultHostName}`;
321
+ try {
322
+ const startTime = Date.now();
323
+ await execAsync$1(`curl -I -s -o /dev/null -w "%{http_code}" --max-time 10 "${url}" || echo "000"`);
324
+ const responseTime = Date.now() - startTime;
325
+ return {
326
+ passed: true,
327
+ message: "Function app running and responding",
328
+ details: `URL: ${url} (${responseTime}ms)`
329
+ };
330
+ } catch {
331
+ return {
332
+ passed: true,
333
+ message: "Function app running",
334
+ details: `URL: ${url} (HTTP check failed, but app is running)`
335
+ };
336
+ }
304
337
  } catch (error) {
305
338
  return {
306
339
  passed: false,
@@ -500,7 +533,7 @@ program.command("init").description("Initialize Azure infrastructure in your pro
500
533
  program.command("build").description("Build Next.js app for Azure deployment").option("-c, --config <path>", "Path to open-next.config.ts file").action(async (options) => {
501
534
  await build(options.config);
502
535
  });
503
- program.command("deploy").description("Deploy Next.js app to Azure (provisions infrastructure + deploys)").option("-n, --app-name <name>", "Application name (overrides azure.config.json)").option("-g, --resource-group <name>", "Azure resource group name").option("-l, --location <location>", "Azure region").option("-e, --environment <env>", "Environment: dev, staging, or prod").option("--skip-infrastructure", "Skip infrastructure provisioning").action(async (options) => {
536
+ program.command("deploy").description("Deploy Next.js app to Azure (provisions infrastructure + deploys)").option("-n, --app-name <name>", "Application name (overrides azure.config.json)").option("-g, --resource-group <name>", "Azure resource group name").option("-l, --location <location>", "Azure region").option("-e, --environment <env>", "Environment: dev, staging, or prod").option("--skip-infrastructure", "Skip infrastructure provisioning").option("--skip-resource-checks", "Skip Azure resource validation checks (permissions, quota, providers)").action(async (options) => {
504
537
  await deploy(options);
505
538
  });
506
539
  program.command("tail").description("Open Azure Portal Log Stream in browser (live logs)").option("-n, --app-name <name>", "Application name").option("-g, --resource-group <name>", "Azure resource group name").action(async (options) => {
@@ -1,3 +1,3 @@
1
1
  import '@opennextjs/aws/types/open-next.js';
2
- export { d as defineAzureConfig, g as getAzureConfig } from '../shared/opennextjs-azure.d619537c.mjs';
2
+ export { d as defineAzureConfig, g as getAzureConfig } from '../shared/opennextjs-azure.279bd73d.mjs';
3
3
  import '@opennextjs/aws/types/overrides.js';
@@ -1,3 +1,3 @@
1
1
  import '@opennextjs/aws/types/open-next.js';
2
- export { d as defineAzureConfig, g as getAzureConfig } from '../shared/opennextjs-azure.d619537c.js';
2
+ export { d as defineAzureConfig, g as getAzureConfig } from '../shared/opennextjs-azure.279bd73d.js';
3
3
  import '@opennextjs/aws/types/overrides.js';
@@ -14,6 +14,9 @@ function defineAzureConfig(config = {}) {
14
14
  middleware: config.middleware || {
15
15
  external: false
16
16
  },
17
+ imageOptimization: {
18
+ loader: resolveImageLoader(config.imageLoader)
19
+ },
17
20
  dangerous: config.dangerous,
18
21
  buildCommand: config.buildCommand,
19
22
  buildOutputPath: config.buildOutputPath || ".",
@@ -48,6 +51,15 @@ function resolveQueue(value) {
48
51
  }
49
52
  return () => value;
50
53
  }
54
+ function resolveImageLoader(value) {
55
+ if (!value || value === "azure-blob") {
56
+ return () => import('../overrides/imageLoader/azure-blob.js').then((m) => m.default);
57
+ }
58
+ if (typeof value === "function") {
59
+ return value;
60
+ }
61
+ return () => value;
62
+ }
51
63
  function getAzureConfig() {
52
64
  return {
53
65
  deployment: {