-
Notifications
You must be signed in to change notification settings - Fork 118
Expand file tree
/
Copy pathcloudflare-context.ts
More file actions
284 lines (255 loc) · 11.6 KB
/
cloudflare-context.ts
File metadata and controls
284 lines (255 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
import type { Context, RunningCodeOptions } from "node:vm";
declare global {
interface CloudflareEnv {
NEXT_CACHE_WORKERS_KV?: KVNamespace;
NEXT_CACHE_D1?: D1Database;
NEXT_CACHE_D1_TAGS_TABLE?: string;
NEXT_CACHE_D1_REVALIDATIONS_TABLE?: string;
NEXT_CACHE_REVALIDATION_WORKER?: Service;
// R2 bucket used for the incremental cache
NEXT_CACHE_R2_BUCKET?: R2Bucket;
// Prefix used for the R2 incremental cache bucket
NEXT_CACHE_R2_PREFIX?: string;
ASSETS?: Fetcher;
}
}
export type CloudflareContext<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
> = {
/**
* the worker's [bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/)
*/
env: CloudflareEnv;
/**
* the request's [cf properties](https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties)
*/
cf: CfProperties | undefined;
/**
* the current [execution context](https://developers.cloudflare.com/workers/runtime-apis/context)
*/
ctx: Context;
};
/**
* Symbol used as an index in the global scope to set and retrieve the Cloudflare context
*
* This is used both in production (in the actual built worker) and in development (`next dev`)
*
* Note: this symbol needs to be kept in sync with the one used in `src/cli/templates/worker.ts`
*/
const cloudflareContextSymbol = Symbol.for("__cloudflare-context__");
/**
* `globalThis` override for internal usage
*/
type InternalGlobalThis<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
> = typeof globalThis & {
[cloudflareContextSymbol]: CloudflareContext<CfProperties, Context> | undefined;
__NEXT_DATA__: Record<string, unknown>;
};
type GetCloudflareContextOptions = {
/**
* When `true`, `getCloudflareContext` returns a promise of the cloudflare context instead of the context,
* this is needed to access the context from statically generated routes.
*/
async: boolean;
};
/**
* Utility to get the current Cloudflare context
*
* @returns the cloudflare context
*/
export function getCloudflareContext<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(options: { async: true }): Promise<CloudflareContext<CfProperties, Context>>;
export function getCloudflareContext<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(options?: { async: false }): CloudflareContext<CfProperties, Context>;
export function getCloudflareContext<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(
options: GetCloudflareContextOptions = { async: false }
): CloudflareContext<CfProperties, Context> | Promise<CloudflareContext<CfProperties, Context>> {
return options.async ? getCloudflareContextAsync() : getCloudflareContextSync();
}
/**
* Get the cloudflare context from the current global scope
*/
function getCloudflareContextFromGlobalScope<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(): CloudflareContext<CfProperties, Context> | undefined {
const global = globalThis as InternalGlobalThis<CfProperties, Context>;
return global[cloudflareContextSymbol];
}
/**
* Detects whether the current code is being evaluated in a statically generated route
*/
function inSSG<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(): boolean {
const global = globalThis as InternalGlobalThis<CfProperties, Context>;
// Note: Next.js sets globalThis.__NEXT_DATA__.nextExport to true for SSG routes
// source: https://github.com/vercel/next.js/blob/4e394608423/packages/next/src/export/worker.ts#L55-L57)
return global.__NEXT_DATA__?.nextExport === true;
}
/**
* Utility to get the current Cloudflare context in sync mode
*/
function getCloudflareContextSync<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(): CloudflareContext<CfProperties, Context> {
const cloudflareContext = getCloudflareContextFromGlobalScope<CfProperties, Context>();
if (cloudflareContext) {
return cloudflareContext;
}
// The sync mode of `getCloudflareContext`, relies on the context being set on the global state
// by either the worker entrypoint (in prod) or by `initOpenNextCloudflareForDev` (in dev), neither
// can work during SSG since for SSG Next.js creates (jest) workers that don't get access to the
// normal global state so we throw with a helpful error message.
if (inSSG()) {
throw new Error(
`\n\nERROR: \`getCloudflareContext\` has been called in sync mode in either a static route or at the top level of a non-static one,` +
` both cases are not allowed but can be solved by either:\n` +
` - make sure that the call is not at the top level and that the route is not static\n` +
` - call \`getCloudflareContext({async: true})\` to use the \`async\` mode\n` +
` - avoid calling \`getCloudflareContext\` in the route\n`
);
}
throw new Error(initOpenNextCloudflareForDevErrorMsg);
}
/**
* Utility to get the current Cloudflare context in async mode
*/
async function getCloudflareContextAsync<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(): Promise<CloudflareContext<CfProperties, Context>> {
const cloudflareContext = getCloudflareContextFromGlobalScope<CfProperties, Context>();
if (cloudflareContext) {
return cloudflareContext;
}
// Note: Next.js sets process.env.NEXT_RUNTIME to 'nodejs' when the runtime in use is the node.js one
// We want to detect when the runtime is the node.js one so that during development (`next dev`) we know wether
// we are or not in a node.js process and that access to wrangler's node.js apis
const inNodejsRuntime = process.env.NEXT_RUNTIME === "nodejs";
if (inNodejsRuntime || inSSG()) {
// we're in a node.js process and also in "async mode" so we can use wrangler to asynchronously get the context
const cloudflareContext = await getCloudflareContextFromWrangler<CfProperties, Context>();
addCloudflareContextToNodejsGlobal(cloudflareContext);
return cloudflareContext;
}
throw new Error(initOpenNextCloudflareForDevErrorMsg);
}
/**
* Performs some initial setup to integrate as best as possible the local Next.js dev server (run via `next dev`)
* with the open-next Cloudflare adapter
*
* Note: this function should only be called inside the Next.js config file, and although async it doesn't need to be `await`ed
*/
export async function initOpenNextCloudflareForDev() {
const shouldInitializationRun = shouldContextInitializationRun();
if (!shouldInitializationRun) return;
const context = await getCloudflareContextFromWrangler();
addCloudflareContextToNodejsGlobal(context);
await monkeyPatchVmModuleEdgeContext(context);
}
/**
* Next dev server imports the config file twice (in two different processes, making it hard to track),
* this causes the initialization to run twice as well, to keep things clean, not allocate extra
* resources (i.e. instantiate two miniflare instances) and avoid extra potential logs, it would be best
* to run the initialization only once, this function is used to try to make it so that it does, it returns
* a flag which indicates if the initialization should run in the current process or not.
*
* @returns boolean indicating if the initialization should run
*/
function shouldContextInitializationRun(): boolean {
// via debugging we've seen that AsyncLocalStorage is only set in one of the
// two processes so we're using it as the differentiator between the two
const AsyncLocalStorage = (globalThis as unknown as { AsyncLocalStorage?: unknown })["AsyncLocalStorage"];
return !!AsyncLocalStorage;
}
/**
* Adds the cloudflare context to the global scope of the current node.js process, enabling
* future calls to `getCloudflareContext` to retrieve and return such context
*
* @param cloudflareContext the cloudflare context to add to the node.sj global scope
*/
function addCloudflareContextToNodejsGlobal<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(cloudflareContext: CloudflareContext<CfProperties, Context>) {
const global = globalThis as InternalGlobalThis<CfProperties, Context>;
global[cloudflareContextSymbol] = cloudflareContext;
}
/**
* Next.js uses the Node.js vm module's `runInContext()` function to evaluate edge functions
* in a runtime context that tries to simulate as accurately as possible the actual production runtime
* behavior, see: https://github.com/vercel/next.js/blob/9a1cd3/packages/next/src/server/web/sandbox/context.ts#L525-L527
*
* This function monkey-patches the Node.js `vm` module to override the `runInContext()` function so that the
* cloudflare context is added to the runtime context's global scope before edge functions are evaluated
*
* @param cloudflareContext the cloudflare context to patch onto the "edge" runtime context global scope
*/
async function monkeyPatchVmModuleEdgeContext(cloudflareContext: CloudflareContext<CfProperties, Context>) {
const require = (
await import(/* webpackIgnore: true */ `${"__module".replaceAll("_", "")}`)
).default.createRequire(import.meta.url);
// eslint-disable-next-line unicorn/prefer-node-protocol -- the `next dev` compiler doesn't accept the node prefix
const vmModule = require("vm");
const originalRunInContext = vmModule.runInContext.bind(vmModule);
vmModule.runInContext = (
code: string,
contextifiedObject: Context,
options?: RunningCodeOptions | string
) => {
type RuntimeContext = Record<string, unknown> & {
[cloudflareContextSymbol]?: CloudflareContext<CfProperties, Context>;
};
const runtimeContext = contextifiedObject as RuntimeContext;
runtimeContext[cloudflareContextSymbol] ??= cloudflareContext;
return originalRunInContext(code, contextifiedObject, options);
};
}
/**
* Gets a cloudflare context object from wrangler
*
* @returns the cloudflare context ready for use
*/
async function getCloudflareContextFromWrangler<
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
Context = ExecutionContext,
>(): Promise<CloudflareContext<CfProperties, Context>> {
// Note: we never want wrangler to be bundled in the Next.js app, that's why the import below looks like it does
const { getPlatformProxy } = await import(/* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}`);
const { env, cf, ctx } = await getPlatformProxy({
// This allows the selection of a wrangler environment while running in next dev mode
environment: process.env.NEXT_DEV_WRANGLER_ENV,
});
return {
env,
cf: cf as unknown as CfProperties,
ctx: ctx as Context,
};
}
// In production the cloudflare context is initialized by the worker so it is always available.
// During local development (`next dev`) it might be missing only if the developers hasn't called
// the `initOpenNextCloudflareForDev` function in their Next.js config file
const initOpenNextCloudflareForDevErrorMsg =
`\n\nERROR: \`getCloudflareContext\` has been called without having called` +
` \`initOpenNextCloudflareForDev\` from the Next.js config file.\n` +
`You should update your Next.js config file as shown below:\n\n` +
" ```\n // next.config.mjs\n\n" +
` import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";\n\n` +
` initOpenNextCloudflareForDev();\n\n` +
" const nextConfig = { ... };\n" +
" export default nextConfig;\n" +
" ```\n" +
"\n";