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.
- package/README.md +2 -1
- package/dist/adapters/converters/azure-http.d.mts +3 -1
- package/dist/adapters/converters/azure-http.d.ts +3 -1
- package/dist/adapters/converters/azure-http.js +24 -6
- package/dist/adapters/converters/azure-queue-revalidate.d.mts +26 -0
- package/dist/adapters/converters/azure-queue-revalidate.d.ts +26 -0
- package/dist/adapters/converters/azure-queue-revalidate.js +30 -0
- package/dist/adapters/wrappers/azure-functions.d.mts +8 -1
- package/dist/adapters/wrappers/azure-functions.d.ts +8 -1
- package/dist/adapters/wrappers/azure-functions.js +82 -12
- package/dist/adapters/wrappers/azure-image-optimization.js +53 -24
- package/dist/adapters/wrappers/azure-queue-revalidate.d.mts +9 -0
- package/dist/adapters/wrappers/azure-queue-revalidate.d.ts +9 -0
- package/dist/adapters/wrappers/azure-queue-revalidate.js +17 -0
- package/dist/cli/index.js +15 -5
- package/dist/config/index.js +9 -0
- package/dist/deploy.js +301 -33
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/infrastructure/main.bicep +13 -6
- package/dist/overrides/incrementalCache/azure-blob.d.mts +17 -1
- package/dist/overrides/incrementalCache/azure-blob.d.ts +17 -1
- package/dist/overrides/incrementalCache/azure-blob.js +20 -11
- package/dist/overrides/tagCache/azure-table.d.mts +24 -1
- package/dist/overrides/tagCache/azure-table.d.ts +24 -1
- package/dist/overrides/tagCache/azure-table.js +84 -27
- package/infrastructure/main.bicep +13 -6
- package/package.json +13 -4
- package/dist/adapters/image-optimization.d.mts +0 -20
- package/dist/adapters/image-optimization.d.ts +0 -20
- package/dist/adapters/image-optimization.js +0 -11
- package/dist/overrides/imageOptimization/azure-cached.d.mts +0 -6
- package/dist/overrides/imageOptimization/azure-cached.d.ts +0 -6
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ?
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/^\/[
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (!
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
65
|
-
if (
|
|
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
|
-
|
|
72
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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 {
|
|
3
|
-
import {
|
|
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.
|
|
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(
|
|
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").
|
|
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) => {
|
package/dist/config/index.js
CHANGED
|
@@ -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 || ".",
|