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
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { BlobServiceClient } from '@azure/storage-blob';
|
|
1
|
+
import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob';
|
|
2
2
|
import { getAzureConfig } from '../../config/index.js';
|
|
3
3
|
|
|
4
|
+
const BLOB_RETRY_OPTIONS = {
|
|
5
|
+
retryOptions: { maxTries: 3, retryDelayInMs: 300, maxRetryDelayInMs: 2e3, tryTimeoutInMs: 1e4 }
|
|
6
|
+
};
|
|
4
7
|
class AzureBlobIncrementalCache {
|
|
5
8
|
name = "azure-blob";
|
|
6
9
|
containerClient;
|
|
@@ -9,27 +12,33 @@ class AzureBlobIncrementalCache {
|
|
|
9
12
|
const connectionString = storage.connectionString;
|
|
10
13
|
const accountName = storage.accountName;
|
|
11
14
|
const accountKey = storage.accountKey;
|
|
15
|
+
const clientOptions = BLOB_RETRY_OPTIONS;
|
|
12
16
|
if (connectionString) {
|
|
13
|
-
const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
|
|
17
|
+
const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString, clientOptions);
|
|
14
18
|
this.containerClient = blobServiceClient.getContainerClient(storage.containerName || "nextjs-cache");
|
|
15
19
|
} else if (accountName && accountKey) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const credential = new StorageSharedKeyCredential(accountName, accountKey);
|
|
21
|
+
const blobServiceClient = new BlobServiceClient(
|
|
22
|
+
`https://${accountName}.blob.core.windows.net`,
|
|
23
|
+
credential,
|
|
24
|
+
clientOptions
|
|
25
|
+
);
|
|
20
26
|
this.containerClient = blobServiceClient.getContainerClient(storage.containerName || "nextjs-cache");
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
24
30
|
* Builds the blob key path, mimicking S3 structure:
|
|
25
31
|
* [prefix]/[__fetch]/[buildId]/[key].[extension]
|
|
32
|
+
*
|
|
33
|
+
* Keys must match the .open-next/cache layout the seeder uploads, so no
|
|
34
|
+
* container-name prefix and no leading slash.
|
|
26
35
|
*/
|
|
27
36
|
buildBlobKey(key, cacheType = "cache") {
|
|
28
|
-
const { storage } = getAzureConfig();
|
|
29
37
|
const { NEXT_BUILD_ID } = process.env;
|
|
30
|
-
const prefix =
|
|
38
|
+
const prefix = process.env.AZURE_CACHE_KEY_PREFIX || "";
|
|
31
39
|
const type = cacheType === "fetch" ? "__fetch" : "";
|
|
32
|
-
|
|
40
|
+
const cleanKey = key.replace(/^\/+/, "");
|
|
41
|
+
return [prefix, type, NEXT_BUILD_ID, cacheType === "fetch" ? cleanKey : `${cleanKey}.${cacheType}`].filter(Boolean).join("/");
|
|
33
42
|
}
|
|
34
43
|
async get(key, cacheType) {
|
|
35
44
|
try {
|
|
@@ -63,7 +72,7 @@ class AzureBlobIncrementalCache {
|
|
|
63
72
|
const blobKey = this.buildBlobKey(key, cacheType);
|
|
64
73
|
const blobClient = this.containerClient.getBlockBlobClient(blobKey);
|
|
65
74
|
const content = JSON.stringify(value);
|
|
66
|
-
await blobClient.upload(content, content
|
|
75
|
+
await blobClient.upload(content, Buffer.byteLength(content), {
|
|
67
76
|
blobHTTPHeaders: {
|
|
68
77
|
blobContentType: "application/json"
|
|
69
78
|
}
|
|
@@ -86,4 +95,4 @@ class AzureBlobIncrementalCache {
|
|
|
86
95
|
}
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
export { AzureBlobIncrementalCache as default };
|
|
98
|
+
export { BLOB_RETRY_OPTIONS, AzureBlobIncrementalCache as default };
|
|
@@ -16,11 +16,34 @@ declare class AzureTableTagCache implements OriginalTagCache {
|
|
|
16
16
|
name: string;
|
|
17
17
|
private tableClient;
|
|
18
18
|
constructor();
|
|
19
|
+
/**
|
|
20
|
+
* Keys arrive from Next with a leading slash ("/isr") while seeded rows
|
|
21
|
+
* use posix-joined values ("<buildId>/isr") — strip it so both sides
|
|
22
|
+
* land on the same key, matching the blob cache's normalization.
|
|
23
|
+
*/
|
|
19
24
|
private buildKey;
|
|
25
|
+
/**
|
|
26
|
+
* Reverse-index partition key for a path. Every tag/path pair is written
|
|
27
|
+
* twice, (PK=tag, RK=path) and (PK=path#..., RK=tag), so getByPath and
|
|
28
|
+
* getLastModified are partition queries. Azure Tables' only index is
|
|
29
|
+
* PartitionKey+RowKey; a RowKey-only filter scans every partition, one
|
|
30
|
+
* round trip per 1,000 rows.
|
|
31
|
+
*/
|
|
32
|
+
private buildPathKey;
|
|
33
|
+
private stripBuildId;
|
|
34
|
+
/** Escapes a value for interpolation into an OData filter string literal. */
|
|
35
|
+
private odataEscape;
|
|
20
36
|
getByTag(tag: string): Promise<string[]>;
|
|
21
37
|
getByPath(path: string): Promise<string[]>;
|
|
22
38
|
getLastModified(path: string, lastModified?: number): Promise<number>;
|
|
23
39
|
writeTags(tags: OriginalTagCacheWriteInput[]): Promise<void>;
|
|
24
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Azure Table Storage forbids '/', '\', '#', '?' and control characters in
|
|
43
|
+
* PartitionKey/RowKey. Percent-encodes the forbidden set (plus '%' itself so
|
|
44
|
+
* decoding is unambiguous). Exported for the deploy-time table seeder.
|
|
45
|
+
*/
|
|
46
|
+
declare function encodeTableKey(part: string): string;
|
|
47
|
+
declare function decodeTableKey(part: string): string;
|
|
25
48
|
|
|
26
|
-
export { AzureTableTagCache as default };
|
|
49
|
+
export { decodeTableKey, AzureTableTagCache as default, encodeTableKey };
|
|
@@ -16,11 +16,34 @@ declare class AzureTableTagCache implements OriginalTagCache {
|
|
|
16
16
|
name: string;
|
|
17
17
|
private tableClient;
|
|
18
18
|
constructor();
|
|
19
|
+
/**
|
|
20
|
+
* Keys arrive from Next with a leading slash ("/isr") while seeded rows
|
|
21
|
+
* use posix-joined values ("<buildId>/isr") — strip it so both sides
|
|
22
|
+
* land on the same key, matching the blob cache's normalization.
|
|
23
|
+
*/
|
|
19
24
|
private buildKey;
|
|
25
|
+
/**
|
|
26
|
+
* Reverse-index partition key for a path. Every tag/path pair is written
|
|
27
|
+
* twice, (PK=tag, RK=path) and (PK=path#..., RK=tag), so getByPath and
|
|
28
|
+
* getLastModified are partition queries. Azure Tables' only index is
|
|
29
|
+
* PartitionKey+RowKey; a RowKey-only filter scans every partition, one
|
|
30
|
+
* round trip per 1,000 rows.
|
|
31
|
+
*/
|
|
32
|
+
private buildPathKey;
|
|
33
|
+
private stripBuildId;
|
|
34
|
+
/** Escapes a value for interpolation into an OData filter string literal. */
|
|
35
|
+
private odataEscape;
|
|
20
36
|
getByTag(tag: string): Promise<string[]>;
|
|
21
37
|
getByPath(path: string): Promise<string[]>;
|
|
22
38
|
getLastModified(path: string, lastModified?: number): Promise<number>;
|
|
23
39
|
writeTags(tags: OriginalTagCacheWriteInput[]): Promise<void>;
|
|
24
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Azure Table Storage forbids '/', '\', '#', '?' and control characters in
|
|
43
|
+
* PartitionKey/RowKey. Percent-encodes the forbidden set (plus '%' itself so
|
|
44
|
+
* decoding is unambiguous). Exported for the deploy-time table seeder.
|
|
45
|
+
*/
|
|
46
|
+
declare function encodeTableKey(part: string): string;
|
|
47
|
+
declare function decodeTableKey(part: string): string;
|
|
25
48
|
|
|
26
|
-
export { AzureTableTagCache as default };
|
|
49
|
+
export { decodeTableKey, AzureTableTagCache as default, encodeTableKey };
|
|
@@ -11,29 +11,59 @@ class AzureTableTagCache {
|
|
|
11
11
|
const accountName = storage.accountName;
|
|
12
12
|
const accountKey = storage.accountKey;
|
|
13
13
|
const tableName = storage.tableName || "nextjstags";
|
|
14
|
+
const clientOptions = {
|
|
15
|
+
retryOptions: { maxRetries: 3, maxRetryDelayInMs: 2e3 }
|
|
16
|
+
};
|
|
14
17
|
if (connectionString) {
|
|
15
|
-
this.tableClient = TableClient.fromConnectionString(connectionString, tableName);
|
|
18
|
+
this.tableClient = TableClient.fromConnectionString(connectionString, tableName, clientOptions);
|
|
16
19
|
} else if (accountName && accountKey) {
|
|
17
20
|
const credential = new AzureNamedKeyCredential(accountName, accountKey);
|
|
18
|
-
this.tableClient = new TableClient(
|
|
21
|
+
this.tableClient = new TableClient(
|
|
22
|
+
`https://${accountName}.table.core.windows.net`,
|
|
23
|
+
tableName,
|
|
24
|
+
credential,
|
|
25
|
+
clientOptions
|
|
26
|
+
);
|
|
19
27
|
}
|
|
20
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Keys arrive from Next with a leading slash ("/isr") while seeded rows
|
|
31
|
+
* use posix-joined values ("<buildId>/isr") — strip it so both sides
|
|
32
|
+
* land on the same key, matching the blob cache's normalization.
|
|
33
|
+
*/
|
|
21
34
|
buildKey(key) {
|
|
22
35
|
const { NEXT_BUILD_ID } = process.env;
|
|
23
|
-
return `${NEXT_BUILD_ID}/${key}
|
|
36
|
+
return encodeTableKey(`${NEXT_BUILD_ID}/${key.replace(/^\/+/, "")}`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reverse-index partition key for a path. Every tag/path pair is written
|
|
40
|
+
* twice, (PK=tag, RK=path) and (PK=path#..., RK=tag), so getByPath and
|
|
41
|
+
* getLastModified are partition queries. Azure Tables' only index is
|
|
42
|
+
* PartitionKey+RowKey; a RowKey-only filter scans every partition, one
|
|
43
|
+
* round trip per 1,000 rows.
|
|
44
|
+
*/
|
|
45
|
+
buildPathKey(path) {
|
|
46
|
+
const { NEXT_BUILD_ID } = process.env;
|
|
47
|
+
return encodeTableKey(`path#${NEXT_BUILD_ID}/${path.replace(/^\/+/, "")}`);
|
|
48
|
+
}
|
|
49
|
+
stripBuildId(encodedKey) {
|
|
50
|
+
const { NEXT_BUILD_ID } = process.env;
|
|
51
|
+
return decodeTableKey(encodedKey).replace(`${NEXT_BUILD_ID}/`, "");
|
|
52
|
+
}
|
|
53
|
+
/** Escapes a value for interpolation into an OData filter string literal. */
|
|
54
|
+
odataEscape(value) {
|
|
55
|
+
return value.replace(/'/g, "''");
|
|
24
56
|
}
|
|
25
57
|
async getByTag(tag) {
|
|
26
58
|
try {
|
|
27
59
|
const queryKey = this.buildKey(tag);
|
|
28
60
|
const entities = this.tableClient.listEntities({
|
|
29
|
-
queryOptions: { filter: `PartitionKey eq '${queryKey}'` }
|
|
61
|
+
queryOptions: { filter: `PartitionKey eq '${this.odataEscape(queryKey)}'` }
|
|
30
62
|
});
|
|
31
63
|
const paths = [];
|
|
32
64
|
for await (const entity of entities) {
|
|
33
65
|
if (entity.rowKey) {
|
|
34
|
-
|
|
35
|
-
const path = entity.rowKey.toString().replace(`${NEXT_BUILD_ID}/`, "");
|
|
36
|
-
paths.push(path);
|
|
66
|
+
paths.push(this.stripBuildId(entity.rowKey.toString()));
|
|
37
67
|
}
|
|
38
68
|
}
|
|
39
69
|
return paths;
|
|
@@ -45,16 +75,14 @@ class AzureTableTagCache {
|
|
|
45
75
|
}
|
|
46
76
|
async getByPath(path) {
|
|
47
77
|
try {
|
|
48
|
-
const
|
|
78
|
+
const pathKey = this.buildPathKey(path);
|
|
49
79
|
const entities = this.tableClient.listEntities({
|
|
50
|
-
queryOptions: { filter: `
|
|
80
|
+
queryOptions: { filter: `PartitionKey eq '${this.odataEscape(pathKey)}'` }
|
|
51
81
|
});
|
|
52
82
|
const tags = [];
|
|
53
83
|
for await (const entity of entities) {
|
|
54
|
-
if (entity.
|
|
55
|
-
|
|
56
|
-
const tag = entity.partitionKey.toString().replace(`${buildId}/`, "");
|
|
57
|
-
tags.push(tag);
|
|
84
|
+
if (entity.rowKey) {
|
|
85
|
+
tags.push(this.stripBuildId(entity.rowKey.toString()));
|
|
58
86
|
}
|
|
59
87
|
}
|
|
60
88
|
return tags;
|
|
@@ -66,10 +94,10 @@ class AzureTableTagCache {
|
|
|
66
94
|
}
|
|
67
95
|
async getLastModified(path, lastModified) {
|
|
68
96
|
try {
|
|
69
|
-
const
|
|
97
|
+
const pathKey = this.buildPathKey(path);
|
|
70
98
|
const entities = this.tableClient.listEntities({
|
|
71
99
|
queryOptions: {
|
|
72
|
-
filter: `
|
|
100
|
+
filter: `PartitionKey eq '${this.odataEscape(pathKey)}' and revalidatedAt gt ${Number(lastModified ?? 0).toFixed(1)}`
|
|
73
101
|
}
|
|
74
102
|
});
|
|
75
103
|
for await (const entity of entities) {
|
|
@@ -85,20 +113,49 @@ class AzureTableTagCache {
|
|
|
85
113
|
}
|
|
86
114
|
}
|
|
87
115
|
async writeTags(tags) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
116
|
+
const CONCURRENCY = 16;
|
|
117
|
+
const queue = [...tags];
|
|
118
|
+
const failures = [];
|
|
119
|
+
await Promise.all(
|
|
120
|
+
Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
|
|
121
|
+
for (let pair = queue.shift(); pair; pair = queue.shift()) {
|
|
122
|
+
const { tag, path, revalidatedAt } = pair;
|
|
123
|
+
const stamp = revalidatedAt ?? Date.now();
|
|
124
|
+
try {
|
|
125
|
+
await this.tableClient.upsertEntity(
|
|
126
|
+
{
|
|
127
|
+
partitionKey: this.buildPathKey(path),
|
|
128
|
+
rowKey: this.buildKey(tag),
|
|
129
|
+
revalidatedAt: stamp
|
|
130
|
+
},
|
|
131
|
+
"Merge"
|
|
132
|
+
);
|
|
133
|
+
await this.tableClient.upsertEntity(
|
|
134
|
+
{
|
|
135
|
+
partitionKey: this.buildKey(tag),
|
|
136
|
+
rowKey: this.buildKey(path),
|
|
137
|
+
revalidatedAt: stamp
|
|
138
|
+
},
|
|
139
|
+
"Merge"
|
|
140
|
+
);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
failures.push(error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
if (failures.length > 0) {
|
|
148
|
+
process.stderr.write(`Failed to write ${failures.length}/${tags.length} tag pairs to Azure Table: ${failures[0]}
|
|
99
149
|
`);
|
|
150
|
+
throw failures[0];
|
|
100
151
|
}
|
|
101
152
|
}
|
|
102
153
|
}
|
|
154
|
+
function encodeTableKey(part) {
|
|
155
|
+
return part.replace(/[%/\\#?\u0000-\u001f\u007f-\u009f]/g, (c) => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
156
|
+
}
|
|
157
|
+
function decodeTableKey(part) {
|
|
158
|
+
return part.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
159
|
+
}
|
|
103
160
|
|
|
104
|
-
export { AzureTableTagCache as default };
|
|
161
|
+
export { decodeTableKey, AzureTableTagCache as default, encodeTableKey };
|
|
@@ -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
|
-
|
|
174
|
-
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opennextjs-azure",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "True serverless Next.js on Azure Functions with a Vercel-grade developer experience",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -38,6 +38,14 @@
|
|
|
38
38
|
"./overrides/queue/azure-queue.js": {
|
|
39
39
|
"import": "./dist/overrides/queue/azure-queue.js",
|
|
40
40
|
"types": "./dist/overrides/queue/azure-queue.d.ts"
|
|
41
|
+
},
|
|
42
|
+
"./adapters/wrappers/azure-queue-revalidate.js": {
|
|
43
|
+
"import": "./dist/adapters/wrappers/azure-queue-revalidate.js",
|
|
44
|
+
"types": "./dist/adapters/wrappers/azure-queue-revalidate.d.ts"
|
|
45
|
+
},
|
|
46
|
+
"./adapters/converters/azure-queue-revalidate.js": {
|
|
47
|
+
"import": "./dist/adapters/converters/azure-queue-revalidate.js",
|
|
48
|
+
"types": "./dist/adapters/converters/azure-queue-revalidate.d.ts"
|
|
41
49
|
}
|
|
42
50
|
},
|
|
43
51
|
"files": [
|
|
@@ -49,9 +57,10 @@
|
|
|
49
57
|
"dev": "unbuild --stub",
|
|
50
58
|
"clean": "rimraf dist",
|
|
51
59
|
"lint": "eslint src --ext .ts",
|
|
52
|
-
"test": "vitest",
|
|
60
|
+
"test": "vitest run",
|
|
53
61
|
"typecheck": "tsc --noEmit",
|
|
54
|
-
"format": "prettier --write ."
|
|
62
|
+
"format": "prettier --write .",
|
|
63
|
+
"test:watch": "vitest"
|
|
55
64
|
},
|
|
56
65
|
"keywords": [
|
|
57
66
|
"nextjs",
|
|
@@ -93,6 +102,6 @@
|
|
|
93
102
|
"next": ">=13.4.0"
|
|
94
103
|
},
|
|
95
104
|
"engines": {
|
|
96
|
-
"node": ">=
|
|
105
|
+
"node": ">=20.0.0"
|
|
97
106
|
}
|
|
98
107
|
}
|
|
@@ -1,20 +0,0 @@
|
|
|
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 };
|
|
@@ -1,20 +0,0 @@
|
|
|
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 };
|
|
@@ -1,11 +0,0 @@
|
|
|
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 };
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
|
|
2
|
-
import { OpenNextHandler } from '@opennextjs/aws/types/overrides.js';
|
|
3
|
-
|
|
4
|
-
declare function createCachedImageOptimizationHandler(defaultHandler: OpenNextHandler<InternalEvent, InternalResult>): OpenNextHandler<InternalEvent, InternalResult>;
|
|
5
|
-
|
|
6
|
-
export { createCachedImageOptimizationHandler };
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import { InternalEvent, InternalResult } from '@opennextjs/aws/types/open-next.js';
|
|
2
|
-
import { OpenNextHandler } from '@opennextjs/aws/types/overrides.js';
|
|
3
|
-
|
|
4
|
-
declare function createCachedImageOptimizationHandler(defaultHandler: OpenNextHandler<InternalEvent, InternalResult>): OpenNextHandler<InternalEvent, InternalResult>;
|
|
5
|
-
|
|
6
|
-
export { createCachedImageOptimizationHandler };
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { BlobServiceClient } from '@azure/storage-blob';
|
|
3
|
-
import { ReadableStream } from 'node:stream/web';
|
|
4
|
-
|
|
5
|
-
const { AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_ACCOUNT_NAME } = process.env;
|
|
6
|
-
const CACHE_CONTAINER = "optimized-images";
|
|
7
|
-
function getBlobClient(key) {
|
|
8
|
-
if (!AZURE_STORAGE_CONNECTION_STRING && !AZURE_STORAGE_ACCOUNT_NAME) {
|
|
9
|
-
throw new Error("Azure Storage connection string or account name must be defined");
|
|
10
|
-
}
|
|
11
|
-
const blobServiceClient = AZURE_STORAGE_CONNECTION_STRING ? BlobServiceClient.fromConnectionString(AZURE_STORAGE_CONNECTION_STRING) : new BlobServiceClient(`https://${AZURE_STORAGE_ACCOUNT_NAME}.blob.core.windows.net`);
|
|
12
|
-
const containerClient = blobServiceClient.getContainerClient(CACHE_CONTAINER);
|
|
13
|
-
return containerClient.getBlockBlobClient(key);
|
|
14
|
-
}
|
|
15
|
-
function computeCacheKey(event) {
|
|
16
|
-
const { query } = event;
|
|
17
|
-
const url = Array.isArray(query?.url) ? query.url[0] : query?.url || "";
|
|
18
|
-
const width = Array.isArray(query?.w) ? query.w[0] : query?.w || "0";
|
|
19
|
-
const quality = Array.isArray(query?.q) ? query.q[0] : query?.q || "75";
|
|
20
|
-
const hash = createHash("sha256").update(url).digest("hex").substring(0, 16);
|
|
21
|
-
return `${hash}/w${width}_q${quality}.cache`;
|
|
22
|
-
}
|
|
23
|
-
async function getCachedImage(cacheKey) {
|
|
24
|
-
try {
|
|
25
|
-
const blobClient = getBlobClient(cacheKey);
|
|
26
|
-
const exists = await blobClient.exists();
|
|
27
|
-
if (!exists) {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
const downloadResponse = await blobClient.download();
|
|
31
|
-
const properties = await blobClient.getProperties();
|
|
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
|
-
const buffer = Buffer.concat(chunks);
|
|
40
|
-
return {
|
|
41
|
-
type: "core",
|
|
42
|
-
statusCode: 200,
|
|
43
|
-
headers: {
|
|
44
|
-
"Content-Type": properties.contentType || "image/webp",
|
|
45
|
-
"Cache-Control": properties.cacheControl || "public,max-age=31536000,immutable",
|
|
46
|
-
Vary: "Accept"
|
|
47
|
-
},
|
|
48
|
-
body: new ReadableStream({
|
|
49
|
-
start(controller) {
|
|
50
|
-
controller.enqueue(buffer);
|
|
51
|
-
controller.close();
|
|
52
|
-
}
|
|
53
|
-
}),
|
|
54
|
-
isBase64Encoded: true
|
|
55
|
-
};
|
|
56
|
-
} catch (error) {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
async function setCachedImage(cacheKey, result) {
|
|
61
|
-
try {
|
|
62
|
-
if (!result.body) {
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
const chunks = [];
|
|
66
|
-
for await (const chunk of result.body) {
|
|
67
|
-
chunks.push(Buffer.from(chunk));
|
|
68
|
-
}
|
|
69
|
-
const buffer = Buffer.concat(chunks);
|
|
70
|
-
const blobClient = getBlobClient(cacheKey);
|
|
71
|
-
const contentTypeRaw = result.headers?.["Content-Type"] || result.headers?.["content-type"];
|
|
72
|
-
const contentType = Array.isArray(contentTypeRaw) ? contentTypeRaw[0] : contentTypeRaw || "image/webp";
|
|
73
|
-
const cacheControlRaw = result.headers?.["Cache-Control"] || result.headers?.["cache-control"];
|
|
74
|
-
const cacheControl = Array.isArray(cacheControlRaw) ? cacheControlRaw[0] : cacheControlRaw || "public,max-age=31536000,immutable";
|
|
75
|
-
await blobClient.upload(buffer, buffer.length, {
|
|
76
|
-
blobHTTPHeaders: {
|
|
77
|
-
blobContentType: contentType,
|
|
78
|
-
blobCacheControl: cacheControl
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
} catch (error) {
|
|
82
|
-
console.error("Failed to cache optimized image:", error);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
function createCachedImageOptimizationHandler(defaultHandler) {
|
|
86
|
-
return async (event, options) => {
|
|
87
|
-
const cacheKey = computeCacheKey(event);
|
|
88
|
-
const cached = await getCachedImage(cacheKey);
|
|
89
|
-
if (cached) {
|
|
90
|
-
return cached;
|
|
91
|
-
}
|
|
92
|
-
const result = await defaultHandler(event, options);
|
|
93
|
-
if (result.statusCode === 200 && result.body) {
|
|
94
|
-
const chunks = [];
|
|
95
|
-
for await (const chunk of result.body) {
|
|
96
|
-
chunks.push(Buffer.from(chunk));
|
|
97
|
-
}
|
|
98
|
-
const buffer = Buffer.concat(chunks);
|
|
99
|
-
const resultWithBuffer = {
|
|
100
|
-
...result,
|
|
101
|
-
body: new ReadableStream({
|
|
102
|
-
start(controller) {
|
|
103
|
-
controller.enqueue(buffer);
|
|
104
|
-
controller.close();
|
|
105
|
-
}
|
|
106
|
-
})
|
|
107
|
-
};
|
|
108
|
-
await setCachedImage(cacheKey, resultWithBuffer);
|
|
109
|
-
return resultWithBuffer;
|
|
110
|
-
}
|
|
111
|
-
return result;
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export { createCachedImageOptimizationHandler };
|