Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/next-wait-until.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"uploadthing": minor
---

The Next.js App Router adapter passes `ctx.waitUntil` into `middleware`,
`onUploadComplete`, and `onUploadError`. Work scheduled there runs through
Next.js `after`, so the client receives `onUploadComplete`'s return value
without waiting for it. Requires Next.js 15.1 for the task to outlive the
response. Earlier versions start the task and warn once.
27 changes: 27 additions & 0 deletions docs/src/app/(docs)/file-routes/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,31 @@ properties:
An object with info for the file that was uploaded, such as the name, key,
size, url etc.
</Property>
<Property name="ctx" since="7.8" type="RequestContext">
Present on the Next.js App Router adapter (`uploadthing/next`).
`ctx.waitUntil` schedules work with Next.js
[`after`](https://nextjs.org/docs/app/api-reference/functions/after), so the
client receives this function's return value without waiting for that work.
On Next.js 15.1 or later, that work stays alive after the response. Earlier
versions start the task and warn once. In development, callback hooks run
after the response, so those tasks start in-process. The same `ctx` is
passed to `middleware` and `onUploadError`.
</Property>
</Properties>

```ts
import { createUploadthing } from "uploadthing/next";
import { UTApi } from "uploadthing/server";

const f = createUploadthing();
const utapi = new UTApi();

f(["image"])
.middleware(async () => {
return { oldFileKey: "previous-key" };
})
.onUploadComplete(async ({ metadata, ctx }) => {
ctx.waitUntil(utapi.deleteFiles(metadata.oldFileKey));
return { deletionScheduled: true };
});
```
116 changes: 115 additions & 1 deletion packages/uploadthing/src/next.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as NextServer from "next/server";
import type { NextRequest } from "next/server";
import * as Effect from "effect/Effect";

Expand All @@ -18,8 +19,117 @@ export {
UTRegion as experimental_UTRegion,
} from "./_internal/types";

type AfterTask = Promise<unknown> | (() => unknown);

/**
* Request helpers for the Next.js App Router adapter.
* Passed to `middleware`, `onUploadComplete`, and `onUploadError`.
*/
export type RequestContext = {
/**
* Schedule work that should not delay the client callback.
*
* On Next.js 15.1 or later, the task is handed to `after` while the request
* is still open. `onUploadComplete` can return `serverData` without awaiting
* the task, and Next.js keeps the invocation alive until the task settles.
* Earlier versions start the task and warn once. In development, callback
* hooks run after the response, so those tasks start in-process.
*/
waitUntil: (task: AfterTask) => void;
};

let didWarnMissingAfter = false;

const warnTaskMayFreeze = (): void => {
if (didWarnMissingAfter) return;
didWarnMissingAfter = true;
// eslint-disable-next-line no-console
console.warn(
"[uploadthing] ctx.waitUntil could not register with Next.js after. The task was started, but a serverless function may freeze it when the response ends. Requires Next.js 15.1 or later for the task to outlive the response.",
);
};

/** Failures stay off the upload hook, including when `after` runs the task. */
const settleTask = (task: AfterTask): Promise<void> => {
try {
const pending = typeof task === "function" ? task() : task;
return Promise.resolve(pending).then(
() => undefined,
(error: unknown) => {
// eslint-disable-next-line no-console
console.error("[uploadthing] ctx.waitUntil task failed.", error);
},
);
} catch (error) {
// eslint-disable-next-line no-console
console.error("[uploadthing] ctx.waitUntil task failed.", error);
return Promise.resolve();
}
};

/**
* A promise is already running, so its rejection handler is attached now.
* A callback stays deferred until it is run.
*/
const deferTask = (task: AfterTask): (() => Promise<void>) => {
if (typeof task === "function") return () => settleTask(task);
const settled = settleTask(task);
return () => settled;
};

type Scheduler = { enqueue: (task: AfterTask) => void };

type SchedulerState = "queueing" | "flushed" | "unregistered";

const inProcess: Scheduler = {
enqueue: (task) => void deferTask(task)(),
};

/**
* `after` reads the request store at call time. Hooks run later, sometimes
* after that store is gone, so `after` is registered here and hooks enqueue.
*/
const openScheduler = (): Scheduler => {
const queue: Array<() => Promise<void>> = [];
let state: SchedulerState = "queueing";

const flush = (): Promise<void> => {
const batch = queue.splice(0);
if (batch.length === 0) {
state = "flushed";
return Promise.resolve();
}
return Promise.all(batch.map((run) => run())).then(flush);
};

if (typeof NextServer.after === "function") {
try {
NextServer.after(flush);
} catch {
// Thrown before registration, so `flush` never runs.
state = "unregistered";
}
} else {
state = "unregistered";
}

const byState: Record<SchedulerState, (run: () => Promise<void>) => void> = {
queueing: (run) => void queue.push(run),
// Development detaches callback hooks past the response, and the dev
// server still finishes the task.
flushed: (run) => void run(),
unregistered: (run) => {
warnTaskMayFreeze();
void run();
},
};

return { enqueue: (task) => byState[state](deferTask(task)) };
};

type AdapterArgs = {
req: NextRequest;
ctx: RequestContext;
};

export const createUploadthing = <TErrorShape extends Json>(
Expand All @@ -30,7 +140,11 @@ export const createRouteHandler = <TRouter extends FileRouter>(
opts: RouteHandlerOptions<TRouter>,
) => {
const handler = makeAdapterHandler<[NextRequest], AdapterArgs>(
(req) => Effect.succeed({ req }),
(req) => {
// Built eagerly, while the route handler still holds the request scope.
const scheduler = req.method === "POST" ? openScheduler() : inProcess;
return Effect.succeed({ req, ctx: { waitUntil: scheduler.enqueue } });
},
(req) => Effect.succeed(req),
opts,
"nextjs-app",
Expand Down
Loading
Loading