Skip to content

Commit 8f1d2b4

Browse files
authored
Fix: await cache set (#446)
* await cache set * add new lint rules * fix all linting issues * Refactor detached promise handling * fix e2e remove lint from next build * fix example and docs * Create eight-parrots-peel.md
1 parent c50f2c8 commit 8f1d2b4

File tree

23 files changed

+132
-43
lines changed

23 files changed

+132
-43
lines changed

.changeset/eight-parrots-peel.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"open-next-docs": patch
3+
"open-next-benchmark": patch
4+
"app-pages-router": patch
5+
"app-router": patch
6+
"pages-router": patch
7+
"open-next": patch
8+
"tests-e2e": patch
9+
---
10+
11+
Fix: dangling promises

.eslintrc.cjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,24 @@ module.exports = {
1616
"sonarjs/elseif-without-else": "warn",
1717
"sonarjs/no-duplicate-string": "warn",
1818
"sonarjs/cognitive-complexity": "warn",
19+
20+
// We add some typescript rules - The recommended rules breaks too much stuff
21+
// TODO: We should add more rules, especially around typescript types
22+
23+
// Promises related rules
24+
"@typescript-eslint/await-thenable": "error",
25+
"@typescript-eslint/no-floating-promises": "error",
26+
"@typescript-eslint/no-misused-promises": [
27+
"error",
28+
{ checksVoidReturn: false },
29+
],
30+
31+
"@typescript-eslint/unbound-method": "error",
32+
33+
"@typescript-eslint/no-non-null-assertion": "warn",
34+
},
35+
parserOptions: {
36+
project: ["./tsconfig.eslint.json", "./**/tsconfig.json"],
1937
},
38+
ignorePatterns: ["**/node_modules/**", "**/dist/**", "**/out/**"],
2039
};

docs/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const withNextra = require("nextra")({
3434

3535
module.exports = withNextra({
3636
swcMinify: true,
37+
eslint: {
38+
ignoreDuringBuilds: true,
39+
},
3740
images: {
3841
unoptimized: true,
3942
},

example/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ const nextConfig = {
33
reactStrictMode: true,
44
cleanDistDir: true,
55
swcMinify: true,
6+
eslint: {
7+
ignoreDuringBuilds: true,
8+
},
69
images: {
710
remotePatterns: [
811
{

examples/app-pages-router/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ const nextConfig = {
88
experimental: {
99
serverActions: true,
1010
},
11+
eslint: {
12+
ignoreDuringBuilds: true,
13+
},
1114
trailingSlash: true,
1215
skipTrailingSlashRedirect: true,
1316
};

examples/app-router/app/api/sse/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ export async function GET(request: NextRequest) {
1616
});
1717

1818
setTimeout(async () => {
19-
writer.write(
19+
await writer.write(
2020
`data: ${JSON.stringify({
2121
message: "open",
2222
time: new Date().toISOString(),
2323
})}\n\n`,
2424
);
2525
for (let i = 1; i <= 4; i++) {
2626
await wait(2000);
27-
writer.write(
27+
await writer.write(
2828
`data: ${JSON.stringify({
2929
message: "hello:" + i,
3030
time: new Date().toISOString(),
@@ -33,7 +33,7 @@ export async function GET(request: NextRequest) {
3333
}
3434

3535
await wait(2000); // Wait for 4 seconds
36-
writer.write(
36+
await writer.write(
3737
`data: ${JSON.stringify({
3838
message: "close",
3939
time: new Date().toISOString(),

examples/app-router/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ const nextConfig = {
88
experimental: {
99
serverActions: true,
1010
},
11+
eslint: {
12+
ignoreDuringBuilds: true,
13+
},
1114
images: {
1215
remotePatterns: [
1316
{

examples/pages-router/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const nextConfig = {
55
reactStrictMode: true,
66
output: "standalone",
77
outputFileTracing: "../sst",
8+
eslint: {
9+
ignoreDuringBuilds: true,
10+
},
811
rewrites: () => [
912
{ source: "/rewrite", destination: "/" },
1013
{

packages/open-next/src/adapters/cache.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { DetachedPromise } from "utils/promise.js";
2-
31
import { IncrementalCache } from "../cache/incremental/types.js";
42
import { TagCache } from "../cache/tag/types.js";
53
import { isBinaryContentType } from "./binary.js";
@@ -225,8 +223,11 @@ export default class S3Cache {
225223
if (globalThis.disableIncrementalCache) {
226224
return;
227225
}
228-
const detachedPromise = new DetachedPromise<void>();
229-
globalThis.__als.getStore()?.pendingPromises.push(detachedPromise);
226+
// This one might not even be necessary anymore
227+
// Better be safe than sorry
228+
const detachedPromise = globalThis.__als
229+
.getStore()
230+
?.pendingPromiseRunner.withResolvers<void>();
230231
try {
231232
if (data?.kind === "ROUTE") {
232233
const { body, status, headers } = data;
@@ -250,7 +251,7 @@ export default class S3Cache {
250251
const { html, pageData } = data;
251252
const isAppPath = typeof pageData === "string";
252253
if (isAppPath) {
253-
globalThis.incrementalCache.set(
254+
await globalThis.incrementalCache.set(
254255
key,
255256
{
256257
type: "app",
@@ -260,7 +261,7 @@ export default class S3Cache {
260261
false,
261262
);
262263
} else {
263-
globalThis.incrementalCache.set(
264+
await globalThis.incrementalCache.set(
264265
key,
265266
{
266267
type: "page",
@@ -312,7 +313,7 @@ export default class S3Cache {
312313
error("Failed to set cache", e);
313314
} finally {
314315
// We need to resolve the promise even if there was an error
315-
detachedPromise.resolve();
316+
detachedPromise?.resolve();
316317
}
317318
}
318319

packages/open-next/src/build.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export async function build(
7676
// Build Next.js app
7777
printHeader("Building Next.js app");
7878
setStandaloneBuildMode(monorepoRoot);
79-
await buildNextjsApp(packager);
79+
buildNextjsApp(packager);
8080

8181
// Generate deployable bundle
8282
printHeader("Generating bundle");
@@ -280,7 +280,7 @@ async function createRevalidationBundle(config: OpenNextConfig) {
280280
copyOpenNextConfig(options.tempDir, outputPath);
281281

282282
// Build Lambda code
283-
esbuildAsync(
283+
await esbuildAsync(
284284
{
285285
external: ["next", "styled-jsx", "react"],
286286
entryPoints: [path.join(__dirname, "adapters", "revalidate.js")],

packages/open-next/src/build/createServerBundle.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ async function generateBundle(
157157
// Bundle next server if necessary
158158
const isBundled = fnOptions.experimentalBundledNextServer ?? false;
159159
if (isBundled) {
160-
bundleNextServer(path.join(outputPath, packagePath), appPath);
160+
await bundleNextServer(path.join(outputPath, packagePath), appPath);
161161
}
162162

163163
// // Copy middleware
@@ -181,7 +181,7 @@ async function generateBundle(
181181
copyEnvFile(appBuildOutputPath, packagePath, outputPath);
182182

183183
// Copy all necessary traced files
184-
copyTracedFiles(
184+
await copyTracedFiles(
185185
appBuildOutputPath,
186186
packagePath,
187187
outputPath,

packages/open-next/src/core/createMainHandler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { AsyncLocalStorage } from "node:async_hooks";
22

33
import type { OpenNextConfig } from "types/open-next";
4-
import { DetachedPromise } from "utils/promise";
4+
import { DetachedPromiseRunner } from "utils/promise";
55

66
import { debug } from "../adapters/logger";
77
import { generateUniqueId } from "../adapters/util";
@@ -23,7 +23,7 @@ declare global {
2323
var serverId: string;
2424
var __als: AsyncLocalStorage<{
2525
requestId: string;
26-
pendingPromises: DetachedPromise<void>[];
26+
pendingPromiseRunner: DetachedPromiseRunner;
2727
}>;
2828
}
2929

packages/open-next/src/core/requestHandler.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
StreamCreator,
77
} from "http/index.js";
88
import { InternalEvent, InternalResult } from "types/open-next";
9-
import { DetachedPromise } from "utils/promise";
9+
import { DetachedPromiseRunner } from "utils/promise";
1010

1111
import { debug, error, warn } from "../adapters/logger";
1212
import { convertRes, createServerResponse, proxyRequest } from "./routing/util";
@@ -16,7 +16,7 @@ import { requestHandler, setNextjsPrebundledReact } from "./util";
1616
// This is used to identify requests in the cache
1717
globalThis.__als = new AsyncLocalStorage<{
1818
requestId: string;
19-
pendingPromises: DetachedPromise<any>[];
19+
pendingPromiseRunner: DetachedPromiseRunner;
2020
}>();
2121

2222
export async function openNextHandler(
@@ -85,9 +85,10 @@ export async function openNextHandler(
8585
remoteAddress: preprocessedEvent.remoteAddress,
8686
};
8787
const requestId = Math.random().toString(36);
88-
const pendingPromises: DetachedPromise<void>[] = [];
88+
const pendingPromiseRunner: DetachedPromiseRunner =
89+
new DetachedPromiseRunner();
8990
const internalResult = await globalThis.__als.run(
90-
{ requestId, pendingPromises },
91+
{ requestId, pendingPromiseRunner },
9192
async () => {
9293
const preprocessedResult = preprocessResult as MiddlewareOutputEvent;
9394
const req = new IncomingMessage(reqProps);
@@ -117,10 +118,7 @@ export async function openNextHandler(
117118
// reset lastModified. We need to do this to avoid memory leaks
118119
delete globalThis.lastModified[requestId];
119120

120-
// Wait for all promises to resolve
121-
// We are not catching errors here, because they are catched before
122-
// This may need to change in the future
123-
await Promise.all(pendingPromises.map((p) => p.promise));
121+
await pendingPromiseRunner.await();
124122

125123
return internalResult;
126124
},
@@ -161,10 +159,10 @@ async function processRequest(
161159
if (e.constructor.name === "NoFallbackError") {
162160
// Do we need to handle _not-found
163161
// Ideally this should never get triggered and be intercepted by the routing handler
164-
tryRenderError("404", res, internalEvent);
162+
await tryRenderError("404", res, internalEvent);
165163
} else {
166164
error("NextJS request failed.", e);
167-
tryRenderError("500", res, internalEvent);
165+
await tryRenderError("500", res, internalEvent);
168166
}
169167
}
170168
}

packages/open-next/src/core/routing/util.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { OpenNextNodeResponse } from "http/openNextResponse.js";
77
import { parseHeaders } from "http/util.js";
88
import type { MiddlewareManifest } from "types/next-types";
99
import { InternalEvent } from "types/open-next.js";
10-
import { DetachedPromise } from "utils/promise.js";
1110

1211
import { isBinaryContentType } from "../../adapters/binary.js";
1312
import { debug, error } from "../../adapters/logger.js";
@@ -356,11 +355,6 @@ export async function revalidateIfRequired(
356355
: internalMeta?._nextRewroteUrl
357356
: rawPath;
358357

359-
// We want to ensure that the revalidation is done in the background
360-
// But we should still wait for the queue send to be successful
361-
const detachedPromise = new DetachedPromise<void>();
362-
globalThis.__als.getStore()?.pendingPromises.push(detachedPromise);
363-
364358
// We need to pass etag to the revalidation queue to try to bypass the default 5 min deduplication window.
365359
// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/using-messagededuplicationid-property.html
366360
// If you need to have a revalidation happen more frequently than 5 minutes,
@@ -387,9 +381,6 @@ export async function revalidateIfRequired(
387381
});
388382
} catch (e) {
389383
error(`Failed to revalidate stale page ${rawPath}`, e);
390-
} finally {
391-
// We don't care if it fails or not, we don't want to block the request
392-
detachedPromise.resolve();
393384
}
394385
}
395386
}

packages/open-next/src/http/openNextResponse.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ export class OpenNextNodeResponse extends Transform implements ServerResponse {
9191
if (!this.headersSent) {
9292
this.flushHeaders();
9393
}
94-
onEnd(this.headers);
94+
globalThis.__als
95+
.getStore()
96+
?.pendingPromiseRunner.add(onEnd(this.headers));
9597
const bodyLength = this.body.length;
9698
this.streamCreator?.onFinish(bodyLength);
9799
});

packages/open-next/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ if (command !== "build") printHelp();
88
const args = parseArgs();
99
if (Object.keys(args).includes("--help")) printHelp();
1010

11-
build(args["--config-path"], args["--node-externals"]);
11+
await build(args["--config-path"], args["--node-externals"]);
1212

1313
function parseArgs() {
1414
return process.argv.slice(2).reduce(

packages/open-next/src/utils/promise.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { debug, error } from "../adapters/logger";
2+
13
/**
24
* A `Promise.withResolvers` implementation that exposes the `resolve` and
35
* `reject` functions on a `Promise`.
@@ -21,7 +23,38 @@ export class DetachedPromise<T = any> {
2123

2224
// We know that resolvers is defined because the Promise constructor runs
2325
// synchronously.
26+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
2427
this.resolve = resolve!;
28+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
2529
this.reject = reject!;
2630
}
2731
}
32+
33+
export class DetachedPromiseRunner {
34+
private promises: DetachedPromise<any>[] = [];
35+
36+
public withResolvers<T>(): DetachedPromise<T> {
37+
const detachedPromise = new DetachedPromise<T>();
38+
this.promises.push(detachedPromise);
39+
return detachedPromise;
40+
}
41+
42+
public add<T>(promise: Promise<T>): void {
43+
const detachedPromise = new DetachedPromise<T>();
44+
this.promises.push(detachedPromise);
45+
promise.then(detachedPromise.resolve, detachedPromise.reject);
46+
}
47+
48+
public async await(): Promise<void> {
49+
debug(`Awaiting ${this.promises.length} detached promises`);
50+
const results = await Promise.allSettled(
51+
this.promises.map((p) => p.promise),
52+
);
53+
const rejectedPromises = results.filter(
54+
(r) => r.status === "rejected",
55+
) as PromiseRejectedResult[];
56+
rejectedPromises.forEach((r) => {
57+
error(r.reason);
58+
});
59+
}
60+
}

packages/tests-e2e/tests/appPagesRouter/pages_ssr.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ test("Server Side Render", async ({ page }) => {
2121
el = page.getByText("Time:");
2222
newTime = await el.textContent();
2323
await expect(el).toBeVisible();
24-
await expect(time).not.toEqual(newTime);
24+
expect(time).not.toEqual(newTime);
2525
time = newTime;
2626
await wait(250);
2727
}

packages/tests-e2e/tests/appPagesRouter/ssr.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ test("Server Side Render", async ({ page }) => {
2121
el = page.getByText("Time:");
2222
newTime = await el.textContent();
2323
await expect(el).toBeVisible();
24-
await expect(time).not.toEqual(newTime);
24+
expect(time).not.toEqual(newTime);
2525
time = newTime;
2626
await wait(250);
2727
}

packages/tests-e2e/tests/appRouter/headers.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ test("Headers", async ({ page }) => {
1212
const response = await responsePromise;
1313
// Response header should be set
1414
const headers = response.headers();
15-
await expect(headers["response-header"]).toEqual("response-header");
15+
expect(headers["response-header"]).toEqual("response-header");
1616

1717
// The next.config.js headers should be also set in response
18-
await expect(headers["e2e-headers"]).toEqual("next.config.js");
18+
expect(headers["e2e-headers"]).toEqual("next.config.js");
1919

2020
// Request header should be available in RSC
2121
let el = page.getByText(`request-header`);

0 commit comments

Comments
 (0)