@opennextjs/cloudflare 1.6.0 → 1.6.2

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.
@@ -5,4 +5,5 @@ export declare class DOShardedTagCache extends DurableObject<CloudflareEnv> {
5
5
  getLastRevalidated(tags: string[]): Promise<number>;
6
6
  hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
7
7
  writeTags(tags: string[], lastModified: number): Promise<void>;
8
+ getRevalidationTimes(tags: string[]): Promise<Record<string, number>>;
8
9
  }
@@ -34,4 +34,10 @@ export class DOShardedTagCache extends DurableObject {
34
34
  this.sql.exec(`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`, tag, lastModified);
35
35
  });
36
36
  }
37
+ async getRevalidationTimes(tags) {
38
+ const result = this.sql
39
+ .exec(`SELECT tag, revalidatedAt FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, ...tags)
40
+ .toArray();
41
+ return Object.fromEntries(result.map((row) => [row.tag, row.revalidatedAt]));
42
+ }
37
43
  }
@@ -1,4 +1,5 @@
1
1
  import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
2
+ import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js";
2
3
  export declare const DEFAULT_WRITE_RETRIES = 3;
3
4
  export declare const DEFAULT_NUM_SHARDS = 4;
4
5
  export declare const NAME = "do-sharded-tag-cache";
@@ -34,6 +35,13 @@ interface ShardedDOTagCacheOptions {
34
35
  * @default 5
35
36
  */
36
37
  regionalCacheTtlSec?: number;
38
+ /**
39
+ * Whether to persist missing tags in the regional cache.
40
+ * This is dangerous if you don't invalidate the Cache API when you revalidate tags as you could end up storing stale data in the data cache.
41
+ *
42
+ * @default false
43
+ */
44
+ regionalCacheDangerouslyPersistMissingTags?: boolean;
37
45
  /**
38
46
  * Enable shard replication to handle higher load.
39
47
  *
@@ -91,7 +99,6 @@ export declare class DOId {
91
99
  interface CacheTagKeyOptions {
92
100
  doId: DOId;
93
101
  tags: string[];
94
- type: "boolean" | "number";
95
102
  }
96
103
  declare class ShardedDOTagCache implements NextModeTagCache {
97
104
  private opts;
@@ -145,9 +152,21 @@ declare class ShardedDOTagCache implements NextModeTagCache {
145
152
  writeTags(tags: string[]): Promise<void>;
146
153
  performWriteTagsWithRetry(doId: DOId, tags: string[], lastModified: number, retryNumber?: number): Promise<void>;
147
154
  getCacheInstance(): Promise<Cache | undefined>;
148
- getCacheUrlKey(opts: CacheTagKeyOptions): string;
149
- getFromRegionalCache(opts: CacheTagKeyOptions): Promise<Response | undefined>;
150
- putToRegionalCache(optsKey: CacheTagKeyOptions, value: number | boolean): Promise<void>;
155
+ getCacheUrlKey(doId: DOId, tag: string): string;
156
+ /**
157
+ * Get the last revalidation time for the tags from the regional cache
158
+ * If the cache is not enabled, it will return an empty array
159
+ * @returns An array of objects with the tag and the last revalidation time
160
+ */
161
+ getFromRegionalCache(opts: CacheTagKeyOptions): Promise<{
162
+ tag: string;
163
+ time: number;
164
+ }[]>;
165
+ putToRegionalCache(optsKey: CacheTagKeyOptions, stub: DurableObjectStub<DOShardedTagCache>): Promise<void>;
166
+ /**
167
+ * Deletes the regional cache for the given tags
168
+ * This is used to ensure that the cache is cleared when the tags are revalidated
169
+ */
151
170
  deleteRegionalCache(optsKey: CacheTagKeyOptions): Promise<void>;
152
171
  }
153
172
  declare const _default: (opts?: ShardedDOTagCacheOptions) => ShardedDOTagCache;
@@ -179,26 +179,25 @@ class ShardedDOTagCache {
179
179
  const { isDisabled } = await this.getConfig();
180
180
  if (isDisabled)
181
181
  return 0;
182
+ if (tags.length === 0)
183
+ return 0; // No tags to check
184
+ const deduplicatedTags = Array.from(new Set(tags)); // We deduplicate the tags to avoid unnecessary requests
182
185
  try {
183
- const shardedTagGroups = this.groupTagsByDO({ tags });
186
+ const shardedTagGroups = this.groupTagsByDO({ tags: deduplicatedTags });
184
187
  const shardedTagRevalidationOutcomes = await Promise.all(shardedTagGroups.map(async ({ doId, tags }) => {
185
- const cachedValue = await this.getFromRegionalCache({ doId, tags, type: "number" });
186
- if (cachedValue) {
187
- const cached = await cachedValue.text();
188
- try {
189
- return parseInt(cached, 10);
190
- }
191
- catch (e) {
192
- debug("Error while parsing cached value", e);
193
- // If we can't parse the cached value, we should just ignore it and go to the durable object
194
- }
188
+ const cachedValue = await this.getFromRegionalCache({ doId, tags });
189
+ // If all the value were found in the regional cache, we can just return the max value
190
+ if (cachedValue.length === tags.length) {
191
+ return Math.max(...cachedValue.map((item) => item.time));
195
192
  }
193
+ // Otherwise we need to check the durable object on the ones that were not found in the cache
194
+ const filteredTags = deduplicatedTags.filter((tag) => !cachedValue.some((item) => item.tag === tag));
196
195
  const stub = this.getDurableObjectStub(doId);
197
- const _lastRevalidated = await stub.getLastRevalidated(tags);
198
- if (!_lastRevalidated) {
199
- getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags, type: "number" }, _lastRevalidated));
200
- }
201
- return _lastRevalidated;
196
+ const lastRevalidated = await stub.getLastRevalidated(filteredTags);
197
+ const result = Math.max(...cachedValue.map((item) => item.time), lastRevalidated);
198
+ // We then need to populate the regional cache with the missing tags
199
+ getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags }, stub));
200
+ return result;
202
201
  }));
203
202
  return Math.max(...shardedTagRevalidationOutcomes);
204
203
  }
@@ -221,17 +220,20 @@ class ShardedDOTagCache {
221
220
  try {
222
221
  const shardedTagGroups = this.groupTagsByDO({ tags });
223
222
  const shardedTagRevalidationOutcomes = await Promise.all(shardedTagGroups.map(async ({ doId, tags }) => {
224
- const cachedValue = await this.getFromRegionalCache({ doId, tags, type: "boolean" });
225
- if (cachedValue) {
226
- return (await cachedValue.text()) === "true";
223
+ const cachedValue = await this.getFromRegionalCache({ doId, tags });
224
+ // If one of the cached values is newer than the lastModified, we can return true
225
+ const cacheHasBeenRevalidated = cachedValue.some((cachedValue) => {
226
+ return (cachedValue.time ?? 0) > (lastModified ?? Date.now());
227
+ });
228
+ if (cacheHasBeenRevalidated) {
229
+ return true;
227
230
  }
228
231
  const stub = this.getDurableObjectStub(doId);
229
232
  const _hasBeenRevalidated = await stub.hasBeenRevalidated(tags, lastModified);
230
- //TODO: Do we want to cache the result if it has been revalidated ?
231
- // If we do so, we risk causing cache MISS even though it has been revalidated elsewhere
232
- // On the other hand revalidating a tag that is used in a lot of places will cause a lot of requests
233
- if (!_hasBeenRevalidated) {
234
- getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags, type: "boolean" }, _hasBeenRevalidated));
233
+ const remainingTags = tags.filter((tag) => !cachedValue.some((item) => item.tag === tag));
234
+ if (remainingTags.length > 0) {
235
+ // We need to put the missing tags in the regional cache
236
+ getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags: remainingTags }, stub));
235
237
  }
236
238
  return _hasBeenRevalidated;
237
239
  }));
@@ -266,10 +268,7 @@ class ShardedDOTagCache {
266
268
  await stub.writeTags(tags, lastModified);
267
269
  // Depending on the shards and the tags, deleting from the regional cache will not work for every tag
268
270
  // We also need to delete both cache
269
- await Promise.all([
270
- this.deleteRegionalCache({ doId, tags, type: "boolean" }),
271
- this.deleteRegionalCache({ doId, tags, type: "number" }),
272
- ]);
271
+ await Promise.all([this.deleteRegionalCache({ doId, tags })]);
273
272
  }
274
273
  catch (e) {
275
274
  error("Error while writing tags", e);
@@ -293,41 +292,79 @@ class ShardedDOTagCache {
293
292
  }
294
293
  return this.localCache;
295
294
  }
296
- getCacheUrlKey(opts) {
297
- const { doId, tags, type } = opts;
298
- return `http://local.cache/shard/${doId.shardId}?type=${type}&tags=${encodeURIComponent(tags.join(";"))}`;
295
+ getCacheUrlKey(doId, tag) {
296
+ return `http://local.cache/shard/${doId.shardId}?tag=${encodeURIComponent(tag)}`;
299
297
  }
298
+ /**
299
+ * Get the last revalidation time for the tags from the regional cache
300
+ * If the cache is not enabled, it will return an empty array
301
+ * @returns An array of objects with the tag and the last revalidation time
302
+ */
300
303
  async getFromRegionalCache(opts) {
301
304
  try {
302
305
  if (!this.opts.regionalCache)
303
- return;
306
+ return [];
304
307
  const cache = await this.getCacheInstance();
305
308
  if (!cache)
306
- return;
307
- return cache.match(this.getCacheUrlKey(opts));
309
+ return [];
310
+ const result = await Promise.all(opts.tags.map(async (tag) => {
311
+ const cachedResponse = await cache.match(this.getCacheUrlKey(opts.doId, tag));
312
+ if (!cachedResponse)
313
+ return null;
314
+ const cachedText = await cachedResponse.text();
315
+ try {
316
+ return { tag, time: parseInt(cachedText, 10) };
317
+ }
318
+ catch (e) {
319
+ debugCache("Error while parsing cached value", e);
320
+ return null;
321
+ }
322
+ }));
323
+ return result.filter((item) => item !== null);
308
324
  }
309
325
  catch (e) {
310
326
  error("Error while fetching from regional cache", e);
327
+ return [];
311
328
  }
312
329
  }
313
- async putToRegionalCache(optsKey, value) {
330
+ async putToRegionalCache(optsKey, stub) {
314
331
  if (!this.opts.regionalCache)
315
332
  return;
316
333
  const cache = await this.getCacheInstance();
317
334
  if (!cache)
318
335
  return;
319
336
  const tags = optsKey.tags;
320
- await cache.put(this.getCacheUrlKey(optsKey), new Response(`${value}`, {
321
- headers: {
322
- "cache-control": `max-age=${this.opts.regionalCacheTtlSec ?? 5}`,
323
- ...(tags.length > 0
324
- ? {
325
- "cache-tag": tags.join(","),
326
- }
327
- : {}),
328
- },
337
+ const tagsLastRevalidated = await stub.getRevalidationTimes(tags);
338
+ await Promise.all(tags.map(async (tag) => {
339
+ let lastRevalidated = tagsLastRevalidated[tag];
340
+ if (lastRevalidated === undefined) {
341
+ if (this.opts.regionalCacheDangerouslyPersistMissingTags) {
342
+ lastRevalidated = 0; // If the tag is not found, we set it to 0 as it means it has never been revalidated before.
343
+ }
344
+ else {
345
+ debugCache("Tag not found in revalidation times", { tag, optsKey });
346
+ return; // If the tag is not found, we skip it
347
+ }
348
+ }
349
+ const cacheKey = this.getCacheUrlKey(optsKey.doId, tag);
350
+ debugCache("Putting to regional cache", { cacheKey, lastRevalidated });
351
+ await cache.put(cacheKey, new Response(lastRevalidated.toString(), {
352
+ status: 200,
353
+ headers: {
354
+ "cache-control": `max-age=${this.opts.regionalCacheTtlSec ?? 5}`,
355
+ ...(tags.length > 0
356
+ ? {
357
+ "cache-tag": tags.join(","),
358
+ }
359
+ : {}),
360
+ },
361
+ }));
329
362
  }));
330
363
  }
364
+ /**
365
+ * Deletes the regional cache for the given tags
366
+ * This is used to ensure that the cache is cleared when the tags are revalidated
367
+ */
331
368
  async deleteRegionalCache(optsKey) {
332
369
  // We never want to crash because of the cache
333
370
  try {
@@ -336,7 +373,11 @@ class ShardedDOTagCache {
336
373
  const cache = await this.getCacheInstance();
337
374
  if (!cache)
338
375
  return;
339
- await cache.delete(this.getCacheUrlKey(optsKey));
376
+ await Promise.all(optsKey.tags.map(async (tag) => {
377
+ const cacheKey = this.getCacheUrlKey(optsKey.doId, tag);
378
+ debugCache("Deleting from regional cache", { cacheKey });
379
+ await cache.delete(cacheKey);
380
+ }));
340
381
  }
341
382
  catch (e) {
342
383
  debugCache("Error while deleting from regional cache", e);
@@ -154,6 +154,7 @@ async function generateBundle(name, options, fnOptions, codeCustomization) {
154
154
  const isAfter141 = buildHelper.compareSemver(options.nextVersion, ">=", "14.1");
155
155
  const isAfter142 = buildHelper.compareSemver(options.nextVersion, ">=", "14.2");
156
156
  const isAfter152 = buildHelper.compareSemver(options.nextVersion, ">=", "15.2.0");
157
+ const isAfter154 = buildHelper.compareSemver(options.nextVersion, ">=", "15.4.0");
157
158
  const disableRouting = isBefore13413 || config.middleware?.external;
158
159
  const plugins = [
159
160
  openNextReplacementPlugin({
@@ -164,6 +165,7 @@ async function generateBundle(name, options, fnOptions, codeCustomization) {
164
165
  ...(disableRouting ? ["withRouting"] : []),
165
166
  ...(isAfter142 ? ["patchAsyncStorage"] : []),
166
167
  ...(isAfter141 ? ["appendPrefetch"] : []),
168
+ ...(isAfter154 ? [] : ["setInitialURL"]),
167
169
  ],
168
170
  }),
169
171
  openNextReplacementPlugin({
@@ -5,6 +5,8 @@ import logger from "@opennextjs/aws/logger.js";
5
5
  * @param config OpenNext configuration.
6
6
  */
7
7
  export function ensureCloudflareConfig(config) {
8
+ const mwIsMiddlewareExternal = config.middleware?.external === true;
9
+ const mwConfig = mwIsMiddlewareExternal ? config.middleware : undefined;
8
10
  const requirements = {
9
11
  // Check for the default function
10
12
  dftUseCloudflareWrapper: config.default?.override?.wrapper === "cloudflare-node",
@@ -18,10 +20,10 @@ export function ensureCloudflareConfig(config) {
18
20
  config.default?.override?.queue === "direct" ||
19
21
  typeof config.default?.override?.queue === "function",
20
22
  // Check for the middleware function
21
- mwIsMiddlewareExternal: config.middleware?.external === true,
22
- mwUseCloudflareWrapper: config.middleware?.override?.wrapper === "cloudflare-edge",
23
- mwUseEdgeConverter: config.middleware?.override?.converter === "edge",
24
- mwUseFetchProxy: config.middleware?.override?.proxyExternalRequest === "fetch",
23
+ mwIsMiddlewareExternal,
24
+ mwUseCloudflareWrapper: mwConfig?.override?.wrapper === "cloudflare-edge",
25
+ mwUseEdgeConverter: mwConfig?.override?.converter === "edge",
26
+ mwUseFetchProxy: mwConfig?.override?.proxyExternalRequest === "fetch",
25
27
  hasCryptoExternal: config.edgeExternals?.includes("node:crypto"),
26
28
  };
27
29
  if (config.default?.override?.queue === "direct") {
@@ -74,10 +74,14 @@ async function populateR2IncrementalCache(options, config, populateCacheOptions)
74
74
  "r2 object put",
75
75
  quoteShellMeta(normalizePath(path.join(bucket, cacheKey))),
76
76
  `--file ${quoteShellMeta(fullPath)}`,
77
- ],
78
- // NOTE: R2 does not support the environment flag and results in the following error:
79
- // Incorrect type for the 'cacheExpiry' field on 'HttpMetadata': the provided value is not of type 'date'.
80
- { target: populateCacheOptions.target, logging: "error" });
77
+ ], {
78
+ target: populateCacheOptions.target,
79
+ configPath: populateCacheOptions.configPath,
80
+ // R2 does not support the environment flag and results in the following error:
81
+ // Incorrect type for the 'cacheExpiry' field on 'HttpMetadata': the provided value is not of type 'date'.
82
+ environment: undefined,
83
+ logging: "error",
84
+ });
81
85
  }
82
86
  logger.info(`Successfully populated cache with ${assets.length} assets`);
83
87
  }
@@ -107,7 +111,9 @@ async function populateKVIncrementalCache(options, config, populateCacheOptions)
107
111
  }));
108
112
  writeFileSync(chunkPath, JSON.stringify(kvMapping));
109
113
  runWrangler(options, ["kv bulk put", quoteShellMeta(chunkPath), `--binding ${KV_CACHE_BINDING_NAME}`], {
110
- ...populateCacheOptions,
114
+ target: populateCacheOptions.target,
115
+ environment: populateCacheOptions.environment,
116
+ configPath: populateCacheOptions.configPath,
111
117
  logging: "error",
112
118
  });
113
119
  rmSync(chunkPath);
@@ -124,7 +130,12 @@ function populateD1TagCache(options, config, populateCacheOptions) {
124
130
  "d1 execute",
125
131
  D1_TAG_BINDING_NAME,
126
132
  `--command "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);"`,
127
- ], { ...populateCacheOptions, logging: "error" });
133
+ ], {
134
+ target: populateCacheOptions.target,
135
+ environment: populateCacheOptions.environment,
136
+ configPath: populateCacheOptions.configPath,
137
+ logging: "error",
138
+ });
128
139
  logger.info("\nSuccessfully created D1 table");
129
140
  }
130
141
  function populateStaticAssetsIncrementalCache(options) {
@@ -28,10 +28,10 @@ import type { WorkerEnvVar } from "./helpers.js";
28
28
  *
29
29
  * @param options Build options
30
30
  * @param config OpenNext config
31
- * @param envVars Environment variables
31
+ * @param workerEnvVars Worker Environment variables (taken from the wrangler config files)
32
32
  * @returns Deployment mapping or undefined
33
33
  */
34
- export declare function getDeploymentMapping(options: BuildOptions, config: OpenNextConfig, envVars: WorkerEnvVar): Promise<Record<string, string> | undefined>;
34
+ export declare function getDeploymentMapping(options: BuildOptions, config: OpenNextConfig, workerEnvVars: WorkerEnvVar): Promise<Record<string, string> | undefined>;
35
35
  /**
36
36
  * Update an existing deployment mapping:
37
37
  * - Replace the "current" version with the latest deployed version
@@ -36,13 +36,17 @@ const MS_PER_DAY = 24 * 3600 * 1000;
36
36
  *
37
37
  * @param options Build options
38
38
  * @param config OpenNext config
39
- * @param envVars Environment variables
39
+ * @param workerEnvVars Worker Environment variables (taken from the wrangler config files)
40
40
  * @returns Deployment mapping or undefined
41
41
  */
42
- export async function getDeploymentMapping(options, config, envVars) {
42
+ export async function getDeploymentMapping(options, config, workerEnvVars) {
43
43
  if (config.cloudflare?.skewProtection?.enabled !== true) {
44
44
  return undefined;
45
45
  }
46
+ // Note that `process.env` is spread after `workerEnvVars` since we do want
47
+ // system environment variables to take precedence over the variables defined
48
+ // in the wrangler config files
49
+ const envVars = { ...workerEnvVars, ...process.env };
46
50
  const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
47
51
  const deploymentId = nextConfig.deploymentId;
48
52
  if (!deploymentId) {
@@ -3,6 +3,7 @@ export type WranglerTarget = "local" | "remote";
3
3
  type WranglerOptions = {
4
4
  target?: WranglerTarget;
5
5
  environment?: string;
6
+ configPath?: string;
6
7
  logging?: "all" | "error";
7
8
  };
8
9
  export declare function runWrangler(options: BuildOptions, args: string[], wranglerOpts?: WranglerOptions): void;
@@ -43,6 +43,7 @@ export function runWrangler(options, args, wranglerOpts = {}) {
43
43
  ...injectPassthroughFlagForArgs(options, [
44
44
  ...args,
45
45
  wranglerOpts.environment && `--env ${wranglerOpts.environment}`,
46
+ wranglerOpts.configPath && `--config ${wranglerOpts.configPath}`,
46
47
  wranglerOpts.target === "remote" && "--remote",
47
48
  wranglerOpts.target === "local" && "--local",
48
49
  ].filter((v) => !!v)),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opennextjs/cloudflare",
3
3
  "description": "Cloudflare builder for next apps",
4
- "version": "1.6.0",
4
+ "version": "1.6.2",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opennextjs-cloudflare": "dist/cli/index.js"
@@ -43,7 +43,7 @@
43
43
  "homepage": "https://github.com/opennextjs/opennextjs-cloudflare",
44
44
  "dependencies": {
45
45
  "@dotenvx/dotenvx": "1.31.0",
46
- "@opennextjs/aws": "3.7.0",
46
+ "@opennextjs/aws": "3.7.1",
47
47
  "cloudflare": "^4.4.1",
48
48
  "enquirer": "^2.4.1",
49
49
  "glob": "^11.0.0",