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/README.md CHANGED
@@ -188,7 +188,8 @@ opennextjs-azure deploy \
188
188
  [--resource-group <name>] \
189
189
  [--location <region>] \
190
190
  [--environment dev|staging|prod] \
191
- [--skip-infrastructure]
191
+ [--skip-infrastructure] \
192
+ [--skip-resource-checks]
192
193
 
193
194
  # View live logs in Azure Portal
194
195
  opennextjs-azure tail \
@@ -1,5 +1,6 @@
1
1
  import { HttpRequest } from '@azure/functions';
2
2
  import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
3
+ import { Buffer } from 'node:buffer';
3
4
 
4
5
  /**
5
6
  * Converts Azure HTTP requests to OpenNext InternalEvent format
@@ -11,7 +12,8 @@ declare function convertFromAzureHttp(request: HttpRequest): Promise<InternalEve
11
12
  declare function convertToAzureHttp(result: InternalResult): Promise<{
12
13
  status: number;
13
14
  headers: Record<string, string>;
14
- body?: string;
15
+ cookies: string[];
16
+ body?: Buffer;
15
17
  }>;
16
18
  declare const _default: {
17
19
  convertFrom: typeof convertFromAzureHttp;
@@ -1,5 +1,6 @@
1
1
  import { HttpRequest } from '@azure/functions';
2
2
  import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
3
+ import { Buffer } from 'node:buffer';
3
4
 
4
5
  /**
5
6
  * Converts Azure HTTP requests to OpenNext InternalEvent format
@@ -11,7 +12,8 @@ declare function convertFromAzureHttp(request: HttpRequest): Promise<InternalEve
11
12
  declare function convertToAzureHttp(result: InternalResult): Promise<{
12
13
  status: number;
13
14
  headers: Record<string, string>;
14
- body?: string;
15
+ cookies: string[];
16
+ body?: Buffer;
15
17
  }>;
16
18
  declare const _default: {
17
19
  convertFrom: typeof convertFromAzureHttp;
@@ -14,7 +14,8 @@ async function convertFromAzureHttp(request) {
14
14
  }
15
15
  });
16
16
  const headers = {};
17
- for (const [key, value] of Object.entries(request.headers)) {
17
+ const headerEntries = typeof request.headers?.entries === "function" ? request.headers.entries() : Object.entries(request.headers);
18
+ for (const [key, value] of headerEntries) {
18
19
  if (value) {
19
20
  headers[key.toLowerCase()] = value;
20
21
  }
@@ -29,7 +30,8 @@ async function convertFromAzureHttp(request) {
29
30
  }
30
31
  });
31
32
  }
32
- const body = request.method !== "GET" && request.method !== "HEAD" ? Buffer.from(await request.arrayBuffer()) : void 0;
33
+ const body = request.method !== "GET" && request.method !== "HEAD" ? await readRequestBody(request) : void 0;
34
+ const remoteAddress = (headers["x-forwarded-for"] || headers["x-real-ip"] || "::1").split(",")[0].trim();
33
35
  return {
34
36
  type: "core",
35
37
  method: request.method,
@@ -39,9 +41,22 @@ async function convertFromAzureHttp(request) {
39
41
  headers,
40
42
  query,
41
43
  cookies,
42
- remoteAddress: headers["x-forwarded-for"] || headers["x-real-ip"] || "::1"
44
+ remoteAddress
43
45
  };
44
46
  }
47
+ async function readRequestBody(request) {
48
+ const req = request;
49
+ if (typeof req.arrayBuffer === "function") {
50
+ return Buffer.from(await req.arrayBuffer());
51
+ }
52
+ if (req.bufferBody != null) {
53
+ return Buffer.isBuffer(req.bufferBody) ? req.bufferBody : Buffer.from(req.bufferBody);
54
+ }
55
+ if (req.rawBody != null) {
56
+ return Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(String(req.rawBody));
57
+ }
58
+ return void 0;
59
+ }
45
60
  function normalizePath(pathname) {
46
61
  if (!pathname || pathname === "/" || pathname === "") {
47
62
  return "/";
@@ -53,11 +68,14 @@ function normalizePath(pathname) {
53
68
  }
54
69
  async function convertToAzureHttp(result) {
55
70
  const headers = {};
71
+ const cookies = [];
56
72
  for (const [key, value] of Object.entries(result.headers)) {
57
73
  if (value === null || value === void 0) {
58
74
  continue;
59
75
  }
60
- if (Array.isArray(value)) {
76
+ if (key.toLowerCase() === "set-cookie") {
77
+ cookies.push(...Array.isArray(value) ? value.map(String) : [String(value)]);
78
+ } else if (Array.isArray(value)) {
61
79
  headers[key] = value.join(", ");
62
80
  } else {
63
81
  headers[key] = String(value);
@@ -79,12 +97,12 @@ async function convertToAzureHttp(result) {
79
97
  } finally {
80
98
  reader.releaseLock();
81
99
  }
82
- const buffer = Buffer.concat(chunks);
83
- body = result.isBase64Encoded ? buffer.toString("base64") : buffer.toString("utf8");
100
+ body = Buffer.concat(chunks);
84
101
  }
85
102
  return {
86
103
  status: result.statusCode,
87
104
  headers,
105
+ cookies,
88
106
  body
89
107
  };
90
108
  }
@@ -0,0 +1,26 @@
1
+ interface RevalidateRecord {
2
+ host: string;
3
+ url: string;
4
+ id: string;
5
+ }
6
+ interface RevalidateEvent {
7
+ type: "revalidate";
8
+ records: RevalidateRecord[];
9
+ }
10
+ /**
11
+ * Converts an Azure Storage Queue message into OpenNext's revalidate event.
12
+ *
13
+ * The producer (overrides/queue/azure-queue.ts) sends base64-encoded JSON
14
+ * {host, url, lastModified, eTag, deduplicationId, groupId}. The queue
15
+ * trigger decodes the base64 and the worker parses the JSON, so the item
16
+ * usually arrives as an object. Strings are handled too.
17
+ */
18
+ declare function convertFromQueueMessage(queueItem: unknown): Promise<RevalidateEvent>;
19
+ declare function convertToQueueResult(revalidateEvent: RevalidateEvent): Promise<RevalidateEvent>;
20
+ declare const _default: {
21
+ convertFrom: typeof convertFromQueueMessage;
22
+ convertTo: typeof convertToQueueResult;
23
+ name: string;
24
+ };
25
+
26
+ export { _default as default };
@@ -0,0 +1,26 @@
1
+ interface RevalidateRecord {
2
+ host: string;
3
+ url: string;
4
+ id: string;
5
+ }
6
+ interface RevalidateEvent {
7
+ type: "revalidate";
8
+ records: RevalidateRecord[];
9
+ }
10
+ /**
11
+ * Converts an Azure Storage Queue message into OpenNext's revalidate event.
12
+ *
13
+ * The producer (overrides/queue/azure-queue.ts) sends base64-encoded JSON
14
+ * {host, url, lastModified, eTag, deduplicationId, groupId}. The queue
15
+ * trigger decodes the base64 and the worker parses the JSON, so the item
16
+ * usually arrives as an object. Strings are handled too.
17
+ */
18
+ declare function convertFromQueueMessage(queueItem: unknown): Promise<RevalidateEvent>;
19
+ declare function convertToQueueResult(revalidateEvent: RevalidateEvent): Promise<RevalidateEvent>;
20
+ declare const _default: {
21
+ convertFrom: typeof convertFromQueueMessage;
22
+ convertTo: typeof convertToQueueResult;
23
+ name: string;
24
+ };
25
+
26
+ export { _default as default };
@@ -0,0 +1,30 @@
1
+ async function convertFromQueueMessage(queueItem) {
2
+ let message = queueItem;
3
+ if (typeof message === "string") {
4
+ try {
5
+ message = JSON.parse(message);
6
+ } catch {
7
+ message = JSON.parse(Buffer.from(message, "base64").toString("utf8"));
8
+ }
9
+ }
10
+ return {
11
+ type: "revalidate",
12
+ records: [
13
+ {
14
+ host: message.host,
15
+ url: message.url,
16
+ id: message.deduplicationId || `${message.host}${message.url}`
17
+ }
18
+ ]
19
+ };
20
+ }
21
+ async function convertToQueueResult(revalidateEvent) {
22
+ return revalidateEvent;
23
+ }
24
+ const azureQueueRevalidate = {
25
+ convertFrom: convertFromQueueMessage,
26
+ convertTo: convertToQueueResult,
27
+ name: "azure-queue-revalidate"
28
+ };
29
+
30
+ export { azureQueueRevalidate as default };
@@ -1,10 +1,17 @@
1
1
  import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
2
2
  import { WrapperHandler } from '@opennextjs/aws/types/overrides.js';
3
3
 
4
+ /**
5
+ * Parses a Set-Cookie header string into the structured Cookie object the
6
+ * Azure Functions v3 http output binding expects (`context.res.cookies`).
7
+ * The v3 model has no other way to emit multiple Set-Cookie headers, and
8
+ * comma-joining them corrupts cookies (RFC 6265).
9
+ */
10
+ declare function parseSetCookie(setCookie: string): Record<string, unknown> | null;
4
11
  declare const _default: {
5
12
  wrapper: WrapperHandler<InternalEvent, InternalResult>;
6
13
  name: string;
7
14
  supportStreaming: true;
8
15
  };
9
16
 
10
- export { _default as default };
17
+ export { _default as default, parseSetCookie };
@@ -1,10 +1,17 @@
1
1
  import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
2
2
  import { WrapperHandler } from '@opennextjs/aws/types/overrides.js';
3
3
 
4
+ /**
5
+ * Parses a Set-Cookie header string into the structured Cookie object the
6
+ * Azure Functions v3 http output binding expects (`context.res.cookies`).
7
+ * The v3 model has no other way to emit multiple Set-Cookie headers, and
8
+ * comma-joining them corrupts cookies (RFC 6265).
9
+ */
10
+ declare function parseSetCookie(setCookie: string): Record<string, unknown> | null;
4
11
  declare const _default: {
5
12
  wrapper: WrapperHandler<InternalEvent, InternalResult>;
6
13
  name: string;
7
14
  supportStreaming: true;
8
15
  };
9
16
 
10
- export { _default as default };
17
+ export { _default as default, parseSetCookie };
@@ -1,20 +1,87 @@
1
1
  import { Writable } from 'node:stream';
2
+ import { readFileSync } from 'node:fs';
2
3
 
3
4
  const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 204, 205, 304]);
4
5
  const STATIC_ASSET_PATTERNS = [
5
- /^\/_next\/static\//,
6
6
  /^\/favicon\.ico$/,
7
7
  /^\/robots\.txt$/,
8
8
  /^\/sitemap\.xml$/,
9
- /^\/[^\/]+\.(svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$/
9
+ /^\/[^/]+\.(svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$/
10
10
  ];
11
+ const METADATA_ROUTE_PATTERN = /^\/(opengraph-image|twitter-image|icon\d*|apple-icon\d*|manifest)\.[a-z0-9]+$/;
12
+ let uploadedRootAssets = null;
13
+ try {
14
+ const manifest = JSON.parse(readFileSync("static-assets.json", "utf8"));
15
+ if (Array.isArray(manifest)) {
16
+ uploadedRootAssets = new Set(manifest);
17
+ }
18
+ } catch {
19
+ }
11
20
  function isStaticAssetRequest(pathname) {
21
+ if (pathname.startsWith("/_next/static/")) {
22
+ return true;
23
+ }
24
+ if (uploadedRootAssets) {
25
+ const isRootLevel = pathname.startsWith("/") && !pathname.slice(1).includes("/");
26
+ return isRootLevel && uploadedRootAssets.has(pathname.slice(1));
27
+ }
28
+ if (METADATA_ROUTE_PATTERN.test(pathname)) {
29
+ return false;
30
+ }
12
31
  return STATIC_ASSET_PATTERNS.some((pattern) => pattern.test(pathname));
13
32
  }
33
+ function parseSetCookie(setCookie) {
34
+ const parts = setCookie.split(";");
35
+ const [nameValue, ...attrs] = parts;
36
+ const eq = nameValue.indexOf("=");
37
+ if (eq === -1)
38
+ return null;
39
+ const cookie = {
40
+ name: nameValue.slice(0, eq).trim(),
41
+ value: nameValue.slice(eq + 1).trim()
42
+ };
43
+ for (const attr of attrs) {
44
+ const [rawKey, ...rawVal] = attr.split("=");
45
+ const key = rawKey.trim().toLowerCase();
46
+ const value = rawVal.join("=").trim();
47
+ switch (key) {
48
+ case "expires": {
49
+ const expires = new Date(value);
50
+ if (!Number.isNaN(expires.getTime())) {
51
+ cookie.expires = expires;
52
+ }
53
+ break;
54
+ }
55
+ case "max-age": {
56
+ const maxAge = Number(value);
57
+ if (!Number.isNaN(maxAge)) {
58
+ cookie.maxAge = maxAge;
59
+ }
60
+ break;
61
+ }
62
+ case "domain":
63
+ cookie.domain = value;
64
+ break;
65
+ case "path":
66
+ cookie.path = value;
67
+ break;
68
+ case "samesite":
69
+ cookie.sameSite = value;
70
+ break;
71
+ case "secure":
72
+ cookie.secure = true;
73
+ break;
74
+ case "httponly":
75
+ cookie.httpOnly = true;
76
+ break;
77
+ }
78
+ }
79
+ return cookie;
80
+ }
14
81
  const handler = async (handler2, converter) => async (context, request) => {
15
82
  try {
16
83
  const internalEvent = await converter.convertFrom(request);
17
- if (isStaticAssetRequest(internalEvent.rawPath)) {
84
+ if (isStaticAssetRequest(internalEvent.rawPath) && process.env.AZURE_STORAGE_ACCOUNT_NAME) {
18
85
  const blobUrl = `https://${process.env.AZURE_STORAGE_ACCOUNT_NAME}.blob.core.windows.net/assets${internalEvent.rawPath}`;
19
86
  const cacheControl = internalEvent.rawPath.startsWith("/_next/static/") ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate";
20
87
  context.res = {
@@ -32,13 +99,12 @@ const handler = async (handler2, converter) => async (context, request) => {
32
99
  writeHeaders(prelude) {
33
100
  const { statusCode, cookies, headers } = prelude;
34
101
  const responseHeaders = { ...headers };
35
- if (cookies.length > 0) {
36
- responseHeaders["set-cookie"] = cookies.join(", ");
37
- }
102
+ const responseCookies = cookies.map(parseSetCookie).filter(Boolean);
38
103
  if (NULL_BODY_STATUSES.has(statusCode)) {
39
104
  context.res = {
40
105
  status: statusCode,
41
- headers: responseHeaders
106
+ headers: responseHeaders,
107
+ ...responseCookies.length > 0 ? { cookies: responseCookies } : {}
42
108
  };
43
109
  return new Writable({
44
110
  write(chunk, encoding, callback) {
@@ -56,19 +122,23 @@ const handler = async (handler2, converter) => async (context, request) => {
56
122
  callback();
57
123
  },
58
124
  final(callback) {
59
- const body = Buffer.concat(chunks);
60
- const bodyString = body.toString("utf8");
61
125
  context.res = {
62
126
  status: statusCode,
63
127
  headers: responseHeaders,
64
- body: bodyString
128
+ ...responseCookies.length > 0 ? { cookies: responseCookies } : {},
129
+ body: Buffer.concat(chunks),
130
+ isRaw: true
65
131
  };
66
132
  callback();
67
133
  resolveStream?.();
134
+ },
135
+ destroy(error, callback) {
136
+ resolveStream?.();
137
+ callback(error);
68
138
  }
69
139
  });
70
140
  },
71
- retainChunks: true
141
+ retainChunks: false
72
142
  };
73
143
  await handler2(internalEvent, { streamCreator });
74
144
  if (streamFinished) {
@@ -102,4 +172,4 @@ const azureFunctions = {
102
172
  supportStreaming: true
103
173
  };
104
174
 
105
- export { azureFunctions as default };
175
+ export { azureFunctions as default, parseSetCookie };
@@ -1,17 +1,36 @@
1
1
  import { Writable } from 'node:stream';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { BlobServiceClient } from '@azure/storage-blob';
4
+ import { parseSetCookie } from './azure-functions.js';
5
+ import { BLOB_RETRY_OPTIONS } from '../../overrides/incrementalCache/azure-blob.js';
6
+ import 'node:fs';
7
+ import '../../config/index.js';
4
8
 
5
9
  const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 204, 205, 304]);
6
10
  const { AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_ACCOUNT_NAME } = process.env;
7
11
  const CACHE_CONTAINER = "optimized-images";
12
+ let cachedContainerClient = null;
8
13
  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");
14
+ if (!cachedContainerClient) {
15
+ if (!AZURE_STORAGE_CONNECTION_STRING && !AZURE_STORAGE_ACCOUNT_NAME) {
16
+ throw new Error("Azure Storage connection string or account name must be defined");
17
+ }
18
+ const blobServiceClient = AZURE_STORAGE_CONNECTION_STRING ? BlobServiceClient.fromConnectionString(AZURE_STORAGE_CONNECTION_STRING, BLOB_RETRY_OPTIONS) : new BlobServiceClient(
19
+ `https://${AZURE_STORAGE_ACCOUNT_NAME}.blob.core.windows.net`,
20
+ void 0,
21
+ BLOB_RETRY_OPTIONS
22
+ );
23
+ cachedContainerClient = blobServiceClient.getContainerClient(CACHE_CONTAINER);
11
24
  }
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);
25
+ return cachedContainerClient.getBlockBlobClient(key);
26
+ }
27
+ function acceptFormat(event) {
28
+ const accept = event.headers?.accept || "";
29
+ if (accept.includes("image/avif"))
30
+ return "avif";
31
+ if (accept.includes("image/webp"))
32
+ return "webp";
33
+ return "orig";
15
34
  }
16
35
  function computeCacheKey(event) {
17
36
  const { query } = event;
@@ -19,15 +38,11 @@ function computeCacheKey(event) {
19
38
  const width = Array.isArray(query?.w) ? query.w[0] : query?.w || "0";
20
39
  const quality = Array.isArray(query?.q) ? query.q[0] : query?.q || "75";
21
40
  const hash = createHash("sha256").update(url).digest("hex").substring(0, 16);
22
- return `${hash}/w${width}_q${quality}.cache`;
41
+ return `${hash}/w${width}_q${quality}_${acceptFormat(event)}.cache`;
23
42
  }
24
43
  async function getCachedImage(cacheKey) {
25
44
  try {
26
45
  const blobClient = getBlobClient(cacheKey);
27
- const exists = await blobClient.exists();
28
- if (!exists) {
29
- return null;
30
- }
31
46
  const downloadResponse = await blobClient.download();
32
47
  if (!downloadResponse.readableStreamBody) {
33
48
  return null;
@@ -36,7 +51,11 @@ async function getCachedImage(cacheKey) {
36
51
  for await (const chunk of downloadResponse.readableStreamBody) {
37
52
  chunks.push(Buffer.from(chunk));
38
53
  }
39
- return Buffer.concat(chunks);
54
+ return {
55
+ buffer: Buffer.concat(chunks),
56
+ contentType: downloadResponse.contentType,
57
+ cacheControl: downloadResponse.cacheControl
58
+ };
40
59
  } catch (error) {
41
60
  return null;
42
61
  }
@@ -61,17 +80,19 @@ const handler = async (handler2, converter) => async (context, request) => {
61
80
  try {
62
81
  const internalEvent = await converter.convertFrom(request);
63
82
  const cacheKey = computeCacheKey(internalEvent);
64
- const cachedBuffer = await getCachedImage(cacheKey);
65
- if (cachedBuffer) {
66
- process.stderr.write(`[ImageCache] \u2713 Cache HIT
67
- `);
83
+ const cached = await getCachedImage(cacheKey);
84
+ if (cached) {
68
85
  context.res = {
69
86
  status: 200,
70
87
  headers: {
71
- "Content-Type": "image/webp",
72
- "Cache-Control": "public,max-age=31536000,immutable"
88
+ // Serve the stored type; hardcoding webp mislabels
89
+ // avif/gif/svg passthroughs.
90
+ "Content-Type": cached.contentType || "image/webp",
91
+ "Cache-Control": cached.cacheControl || "public,max-age=31536000,immutable",
92
+ Vary: "Accept"
73
93
  },
74
- body: cachedBuffer
94
+ body: cached.buffer,
95
+ isRaw: true
75
96
  };
76
97
  return;
77
98
  }
@@ -86,13 +107,12 @@ const handler = async (handler2, converter) => async (context, request) => {
86
107
  const { statusCode, cookies, headers } = prelude;
87
108
  responseContentType = headers["Content-Type"] || headers["content-type"] || "image/webp";
88
109
  const responseHeaders = { ...headers };
89
- if (cookies.length > 0) {
90
- responseHeaders["set-cookie"] = cookies.join(", ");
91
- }
110
+ const responseCookies = cookies.map(parseSetCookie).filter(Boolean);
92
111
  if (NULL_BODY_STATUSES.has(statusCode)) {
93
112
  context.res = {
94
113
  status: statusCode,
95
- headers: responseHeaders
114
+ headers: responseHeaders,
115
+ ...responseCookies.length > 0 ? { cookies: responseCookies } : {}
96
116
  };
97
117
  return new Writable({
98
118
  write(_chunk, _encoding, callback) {
@@ -111,14 +131,23 @@ const handler = async (handler2, converter) => async (context, request) => {
111
131
  },
112
132
  final(callback) {
113
133
  const body = Buffer.concat(chunks);
114
- processedBuffer = body;
134
+ if (statusCode === 200) {
135
+ processedBuffer = body;
136
+ }
137
+ responseHeaders["Vary"] = "Accept";
115
138
  context.res = {
116
139
  status: statusCode,
117
140
  headers: responseHeaders,
118
- body
141
+ ...responseCookies.length > 0 ? { cookies: responseCookies } : {},
142
+ body,
143
+ isRaw: true
119
144
  };
120
145
  callback();
121
146
  resolveStream?.();
147
+ },
148
+ destroy(error, callback) {
149
+ resolveStream?.();
150
+ callback(error);
122
151
  }
123
152
  });
124
153
  return writable;
@@ -0,0 +1,9 @@
1
+ import { WrapperHandler } from '@opennextjs/aws/types/overrides.js';
2
+
3
+ declare const _default: {
4
+ wrapper: WrapperHandler<any, any>;
5
+ name: string;
6
+ supportStreaming: boolean;
7
+ };
8
+
9
+ export { _default as default };
@@ -0,0 +1,9 @@
1
+ import { WrapperHandler } from '@opennextjs/aws/types/overrides.js';
2
+
3
+ declare const _default: {
4
+ wrapper: WrapperHandler<any, any>;
5
+ name: string;
6
+ supportStreaming: boolean;
7
+ };
8
+
9
+ export { _default as default };
@@ -0,0 +1,17 @@
1
+ const handler = async (handler2, converter) => async (context, queueItem) => {
2
+ const event = await converter.convertFrom(queueItem);
3
+ const result = await handler2(event);
4
+ const failed = result?.records ?? [];
5
+ if (failed.length > 0) {
6
+ const urls = failed.map((r) => r.url).join(", ");
7
+ throw new Error(`Revalidation failed for: ${urls}`);
8
+ }
9
+ context.log?.(`Revalidated ${event.records.length} page(s)`);
10
+ };
11
+ const azureQueueRevalidate = {
12
+ wrapper: handler,
13
+ name: "azure-queue-revalidate",
14
+ supportStreaming: false
15
+ };
16
+
17
+ export { azureQueueRevalidate as default };
package/dist/cli/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from 'commander';
3
- import { r as redX, g as greenCheck, p as promptForInput, i as init, b as build, d as deploy } from '../deploy.js';
2
+ import { createRequire } from 'node:module';
3
+ import { Command, Option } from 'commander';
4
+ import { v as validateAzureNames, r as redX, g as greenCheck, p as promptForInput, i as init, b as build, d as deploy } from '../deploy.js';
4
5
  import { exec } from 'node:child_process';
5
6
  import { promisify } from 'node:util';
6
7
  import fs from 'node:fs/promises';
@@ -8,6 +9,10 @@ import path from 'node:path';
8
9
  import 'node:url';
9
10
  import '@opennextjs/aws/build.js';
10
11
  import 'node:fs';
12
+ import '@azure/storage-blob';
13
+ import '@azure/data-tables';
14
+ import '../overrides/tagCache/azure-table.js';
15
+ import '../config/index.js';
11
16
  import 'node:readline';
12
17
 
13
18
  const execAsync$2 = promisify(exec);
@@ -25,6 +30,7 @@ async function tail(options) {
25
30
  const appName = options?.appName || config.appName;
26
31
  const resourceGroup = options?.resourceGroup || config.resourceGroup;
27
32
  const environment = config.environment || "dev";
33
+ validateAzureNames({ appName, resourceGroup, environment });
28
34
  if (!appName || !resourceGroup) {
29
35
  console.error("\u274C Missing required information!");
30
36
  console.error(" Provide --app-name and --resource-group or run from a project with azure.config.json\n");
@@ -73,6 +79,7 @@ async function health(options) {
73
79
  const appName = options?.appName || config.appName;
74
80
  const resourceGroup = options?.resourceGroup || config.resourceGroup;
75
81
  const environment = config.environment || "dev";
82
+ validateAzureNames({ appName, resourceGroup, environment });
76
83
  if (!appName || !resourceGroup) {
77
84
  console.error(`${redX()} Missing required information!`);
78
85
  console.error(" Provide --app-name and --resource-group or run from a project with azure.config.json\n");
@@ -462,6 +469,7 @@ async function deleteResourceGroup(options) {
462
469
  console.error(" Provide --resource-group\n");
463
470
  process.exit(1);
464
471
  }
472
+ validateAzureNames({ resourceGroup });
465
473
  try {
466
474
  const { stdout } = await execAsync(
467
475
  `az group show --name ${resourceGroup} --query '{Location:location, State:properties.provisioningState}' -o json`
@@ -493,7 +501,7 @@ ${redX()} Deletion cancelled. Resource group name did not match.
493
501
  }
494
502
  console.log(`
495
503
  Deleting resource group "${resourceGroup}"...`);
496
- if (options.noWait) {
504
+ if (options.wait === false) {
497
505
  console.log("Initiating deletion in the background...\n");
498
506
  try {
499
507
  await execAsync(`az group delete --name ${resourceGroup} --yes --no-wait`);
@@ -525,15 +533,17 @@ ${colors.yellow}Note:${colors.reset} Deletion typically takes 3-5 minutes to com
525
533
  }
526
534
  }
527
535
 
536
+ const require = createRequire(import.meta.url);
537
+ const { version } = require("../../package.json");
528
538
  const program = new Command();
529
- program.name("opennextjs-azure").description("CLI tool for building and deploying Next.js apps to Azure").version("0.1.2");
539
+ program.name("opennextjs-azure").description("CLI tool for building and deploying Next.js apps to Azure").version(version);
530
540
  program.command("init").description("Initialize Azure infrastructure in your project").option("--scaffold", "Scaffold a new Next.js project if in empty directory").option("--no-typescript", "Disable TypeScript (default: enabled)").option("--no-tailwind", "Disable Tailwind CSS (default: enabled with v3)").option("--no-eslint", "Disable ESLint (default: enabled)").option("--no-src-dir", "Disable src/ directory (default: enabled)").option("--no-app-router", "Use Pages Router instead of App Router").option("--import-alias <alias>", "Import alias (default: @/*)").option("--package-manager <pm>", "Package manager: npm, yarn, pnpm, bun (default: pnpm)").action(async (options) => {
531
541
  await init(options);
532
542
  });
533
543
  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) => {
534
544
  await build(options.config);
535
545
  });
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) => {
546
+ 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").addOption(new Option("-e, --environment <env>", "Deployment environment").choices(["dev", "staging", "prod"])).option("--skip-infrastructure", "Skip infrastructure provisioning").option("--skip-resource-checks", "Skip Azure resource validation checks (permissions, quota, providers)").action(async (options) => {
537
547
  await deploy(options);
538
548
  });
539
549
  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) => {
@@ -17,6 +17,15 @@ function defineAzureConfig(config = {}) {
17
17
  imageOptimization: {
18
18
  loader: resolveImageLoader(config.imageLoader)
19
19
  },
20
+ // Upstream's declared revalidate converter type says {host, url} but
21
+ // its runtime handler reads event.records[]. These match the runtime,
22
+ // hence the casts.
23
+ revalidate: {
24
+ override: {
25
+ wrapper: () => import('../adapters/wrappers/azure-queue-revalidate.js').then((m) => m.default),
26
+ converter: () => import('../adapters/converters/azure-queue-revalidate.js').then((m) => m.default)
27
+ }
28
+ },
20
29
  dangerous: config.dangerous,
21
30
  buildCommand: config.buildCommand,
22
31
  buildOutputPath: config.buildOutputPath || ".",