opennextjs-azure 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/converters/azure-http.d.mts +3 -1
  3. package/dist/adapters/converters/azure-http.d.ts +3 -1
  4. package/dist/adapters/converters/azure-http.js +24 -6
  5. package/dist/adapters/converters/azure-queue-revalidate.d.mts +26 -0
  6. package/dist/adapters/converters/azure-queue-revalidate.d.ts +26 -0
  7. package/dist/adapters/converters/azure-queue-revalidate.js +30 -0
  8. package/dist/adapters/wrappers/azure-functions.d.mts +8 -1
  9. package/dist/adapters/wrappers/azure-functions.d.ts +8 -1
  10. package/dist/adapters/wrappers/azure-functions.js +82 -12
  11. package/dist/adapters/wrappers/azure-image-optimization.js +53 -24
  12. package/dist/adapters/wrappers/azure-queue-revalidate.d.mts +9 -0
  13. package/dist/adapters/wrappers/azure-queue-revalidate.d.ts +9 -0
  14. package/dist/adapters/wrappers/azure-queue-revalidate.js +17 -0
  15. package/dist/cli/index.js +15 -5
  16. package/dist/config/index.js +9 -0
  17. package/dist/deploy.js +301 -33
  18. package/dist/index.d.mts +2 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +1 -1
  21. package/dist/infrastructure/main.bicep +13 -6
  22. package/dist/overrides/incrementalCache/azure-blob.d.mts +17 -1
  23. package/dist/overrides/incrementalCache/azure-blob.d.ts +17 -1
  24. package/dist/overrides/incrementalCache/azure-blob.js +20 -11
  25. package/dist/overrides/tagCache/azure-table.d.mts +24 -1
  26. package/dist/overrides/tagCache/azure-table.d.ts +24 -1
  27. package/dist/overrides/tagCache/azure-table.js +84 -27
  28. package/infrastructure/main.bicep +13 -6
  29. package/package.json +13 -4
  30. package/dist/adapters/image-optimization.d.mts +0 -20
  31. package/dist/adapters/image-optimization.d.ts +0 -20
  32. package/dist/adapters/image-optimization.js +0 -11
  33. package/dist/overrides/imageOptimization/azure-cached.d.mts +0 -6
  34. package/dist/overrides/imageOptimization/azure-cached.d.ts +0 -6
  35. package/dist/overrides/imageOptimization/azure-cached.js +0 -115
@@ -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 blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, {
17
- accountName,
18
- accountKey
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 = storage.containerName || "";
38
+ const prefix = process.env.AZURE_CACHE_KEY_PREFIX || "";
31
39
  const type = cacheType === "fetch" ? "__fetch" : "";
32
- return [prefix, type, NEXT_BUILD_ID, cacheType === "fetch" ? key : `${key}.${cacheType}`].filter(Boolean).join("/");
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.length, {
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(`https://${accountName}.table.core.windows.net`, tableName, credential);
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
- const { NEXT_BUILD_ID } = process.env;
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 queryKey = this.buildKey(path);
78
+ const pathKey = this.buildPathKey(path);
49
79
  const entities = this.tableClient.listEntities({
50
- queryOptions: { filter: `RowKey eq '${queryKey}'` }
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.partitionKey) {
55
- const { NEXT_BUILD_ID: buildId } = process.env;
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 queryKey = this.buildKey(path);
97
+ const pathKey = this.buildPathKey(path);
70
98
  const entities = this.tableClient.listEntities({
71
99
  queryOptions: {
72
- filter: `RowKey eq '${queryKey}' and RevalidatedAt gt ${lastModified ?? 0}L`
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
- try {
89
- for (const { tag, path, revalidatedAt } of tags) {
90
- const entity = {
91
- partitionKey: this.buildKey(tag),
92
- rowKey: this.buildKey(path),
93
- revalidatedAt: revalidatedAt ?? Date.now()
94
- };
95
- await this.tableClient.upsertEntity(entity, "Merge");
96
- }
97
- } catch (error) {
98
- process.stderr.write(`Failed to write tags to Azure Table: ${error}
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
- name: 'WEBSITE_NODE_DEFAULT_VERSION'
174
- value: '~${nodeVersion}'
175
- }
176
- {
177
- name: 'WEBSITE_RUN_FROM_PACKAGE'
178
- value: '1'
176
+ // A second worker process buys resilience to GC stalls; SSR is
177
+ // single-threaded per worker. Memory-bounded on Y1 (1.5 GB).
178
+ name: 'FUNCTIONS_WORKER_PROCESS_COUNT'
179
+ value: '2'
179
180
  }
180
181
  {
181
182
  name: 'AzureWebJobsDisableHomepage'
@@ -223,6 +224,12 @@ resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
223
224
 
224
225
  ftpsState: 'Disabled'
225
226
  minTlsVersion: '1.2'
227
+ // Always-ready SSR on Elastic Premium; both stay null on Y1.
228
+ // WEBSITE_RUN_FROM_PACKAGE is deliberately absent: '1' is unsupported
229
+ // on Linux Consumption, and the deploy step sets the blob-URL form
230
+ // that provisioning must not overwrite.
231
+ minimumElasticInstanceCount: environment == 'prod' ? 1 : null
232
+ preWarmedInstanceCount: environment == 'prod' ? 1 : null
226
233
  cors: {
227
234
  allowedOrigins: ['*']
228
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opennextjs-azure",
3
- "version": "0.1.3",
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": ">=18.0.0"
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 };