-
Notifications
You must be signed in to change notification settings - Fork 198
/
bindings.ts
352 lines (313 loc) 路 11.3 KB
/
bindings.ts
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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
import assert from "assert";
import fs from "fs/promises";
import path from "path";
import {
Awaitable,
Context,
Mount,
Option,
OptionType,
Plugin,
PluginContext,
RequestContext,
SetupResult,
getRequestContext,
} from "@miniflare/shared";
import dotenv from "dotenv";
import { MiniflareCoreError } from "../error";
import { Request, RequestInfo, RequestInit, Response } from "../standards";
const kWranglerBindings = Symbol("kWranglerBindings");
/** @internal */
export type _CoreMount = Mount<Request, Response>; // yuck :(
// Instead of binding to a service, use this function to handle `fetch`es
// some other custom way (e.g. Cloudflare Pages' `env.PAGES` asset handler)
export type FetcherFetch = (request: Request) => Awaitable<Response>;
export type ServiceBindingsOptions = Record<
string,
| string // Just service name, environment defaults to "production"
| { service: string; environment?: string } // TODO (someday): respect environment, currently ignored
| FetcherFetch
>;
interface ProcessedServiceBinding {
name: string;
service: string | FetcherFetch;
environment: string;
}
export interface BindingsOptions {
envPath?: boolean | string;
envPathDefaultFallback?: boolean;
bindings?: Record<string, any>;
globals?: Record<string, any>;
wasmBindings?: Record<string, string>;
textBlobBindings?: Record<string, string>;
serviceBindings?: ServiceBindingsOptions;
}
export class Fetcher {
readonly #service: string | FetcherFetch;
readonly #getServiceFetch: (name: string) => Promise<FetcherFetch>;
constructor(
service: string | FetcherFetch,
getServiceFetch: (name: string) => Promise<FetcherFetch>
) {
this.#service = service;
this.#getServiceFetch = getServiceFetch;
}
async fetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
// Check we're not too deep, should throw in the caller and NOT return a
// 500 Internal Server Error Response from this function
const parentCtx = getRequestContext();
const requestDepth = parentCtx?.requestDepth ?? 1;
const pipelineDepth = (parentCtx?.pipelineDepth ?? 0) + 1;
// NOTE: `new RequestContext` throws if too deep
const ctx = new RequestContext({ requestDepth, pipelineDepth });
// Always create new Request instance, so clean object passed to services
const req = new Request(input, init);
// If we're using a custom fetch handler, call that or wait for the service
// fetch handler to be available
const fetch =
typeof this.#service === "function"
? this.#service
: await this.#getServiceFetch(this.#service);
// Cloudflare Workers currently don't propagate errors thrown by the service
// when handling the request. Instead a 500 Internal Server Error Response
// is returned with the CF-Worker-Status header set to "exception". We
// could do this, but I think for Miniflare, we get a better developer
// experience if we don't (e.g. the pretty error page will only be shown
// if the error reaches the HTTP request listener). We already do this for
// Durable Objects. If user's want this behaviour, they can explicitly catch
// the error in their service.
// TODO: maybe add (debug/verbose) logging here?
return ctx.runWith(() => fetch(req));
}
}
export class BindingsPlugin
extends Plugin<BindingsOptions>
implements BindingsOptions
{
@Option({
type: OptionType.STRING,
name: "env",
alias: "e",
description: "Path to .env file",
logValue(value: boolean | string) {
if (value === true) return ".env";
if (value === false) return undefined;
return path.relative("", value);
},
fromWrangler: ({ miniflare }) => miniflare?.env_path,
})
envPath?: boolean | string;
// We want custom bindings to override Wrangler bindings, so we can't put
// fromWrangler in `bindings`. Using a symbol, means these low-priority
// bindings can only be loaded from a Wrangler config.
@Option({
type: OptionType.OBJECT,
logName: "Wrangler Variables",
fromWrangler: ({ vars }) => {
if (!vars) return;
// Wrangler stringifies all environment variables
return Object.fromEntries(
Object.entries(vars).map(([key, value]) => [key, String(value)])
);
},
})
[kWranglerBindings]?: Record<string, any>;
// This is another hack. When using the CLI, we'd like to load .env files
// by default if they exist. However, we'd also like to be able to customise
// the .env path in wrangler.toml files. Previously, we just set `envPath` to
// `true` if it wasn't specified via a CLI flag, but API options have a higher
// priority than wrangler.toml's, so `[miniflare] env_path` was always
// ignored. When this option is set to `true`, and `envPath` is undefined,
// we'll treat is as if it were `true`.
//
// See https://discord.com/channels/595317990191398933/891052295410835476/923265884095647844
@Option({ type: OptionType.NONE })
envPathDefaultFallback?: boolean;
@Option({
type: OptionType.OBJECT,
alias: "b",
description: "Binds variable/secret to environment",
logName: "Custom Bindings",
})
bindings?: Record<string, any>;
@Option({
type: OptionType.OBJECT,
description: "Binds variable/secret to global scope",
logName: "Custom Globals",
fromWrangler: ({ miniflare }) => miniflare?.globals,
})
globals?: Record<string, any>;
@Option({
type: OptionType.OBJECT,
typeFormat: "NAME=PATH",
name: "wasm",
description: "WASM module to bind",
logName: "WASM Bindings",
fromWrangler: ({ wasm_modules }) => wasm_modules,
})
wasmBindings?: Record<string, string>;
@Option({
type: OptionType.OBJECT,
typeFormat: "NAME=PATH",
name: "text-blob",
description: "Text blob to bind",
logName: "Text Blob Bindings",
fromWrangler: ({ text_blobs }) => text_blobs,
})
textBlobBindings?: Record<string, string>;
@Option({
type: OptionType.OBJECT,
typeFormat: "NAME=MOUNT[@ENV]",
name: "service",
alias: "S",
description: "Mounted service to bind",
fromEntries: (entries) =>
Object.fromEntries(
// Allow specifying the environment on the CLI, e.g.
// --service AUTH_SERVICE=auth@development
entries.map(([name, serviceEnvironment]) => {
const atIndex = serviceEnvironment.indexOf("@");
if (atIndex === -1) {
return [name, serviceEnvironment];
} else {
const service = serviceEnvironment.substring(0, atIndex);
const environment = serviceEnvironment.substring(atIndex + 1);
return [name, { service, environment }];
}
})
),
fromWrangler: ({ experimental_services }) =>
experimental_services?.reduce(
(services, { name, service, environment }) => {
services[name] = { service, environment };
return services;
},
{} as ServiceBindingsOptions
),
})
serviceBindings?: ServiceBindingsOptions;
readonly #processedServiceBindings: ProcessedServiceBinding[];
#contextPromise?: Promise<void>;
#contextResolve?: () => void;
#mounts?: Map<string, _CoreMount>;
constructor(ctx: PluginContext, options?: BindingsOptions) {
super(ctx);
this.assignOptions(options);
if (this.envPathDefaultFallback && this.envPath === undefined) {
this.envPath = true;
}
this.#processedServiceBindings = Object.entries(
this.serviceBindings ?? {}
).map(([name, options]) => {
const service = typeof options === "object" ? options.service : options;
const environment =
(typeof options === "object" && options.environment) || "production";
return { name, service, environment };
});
if (this.#processedServiceBindings.length) {
ctx.log.warn(
"Service bindings are experimental and primarily meant for internal " +
"testing at the moment. There may be breaking changes in the future."
);
}
}
#getServiceFetch = async (service: string): Promise<FetcherFetch> => {
// Wait for mounts
assert(
this.#contextPromise,
"beforeReload() must be called before #getServiceFetch()"
);
await this.#contextPromise;
// Should've thrown error earlier in reload if service not found and
// dispatchFetch should always be set, it's optional to make testing easier.
const fetch = this.#mounts?.get(service)?.dispatchFetch;
assert(fetch);
return fetch;
};
async setup(): Promise<SetupResult> {
// Bindings should be loaded in this order, from lowest to highest priority:
// 1) Wrangler [vars]
// 2) .env Variables
// 3) WASM Module Bindings
// 4) Text blob Bindings
// 5) Service Bindings
// 6) Custom Bindings
const bindings: Context = {};
const watch: string[] = [];
// 1) Copy Wrangler bindings first
Object.assign(bindings, this[kWranglerBindings]);
// 2) Load bindings from .env file
let envPath = this.envPath === true ? ".env" : this.envPath;
if (envPath) {
envPath = path.resolve(this.ctx.rootPath, envPath);
try {
Object.assign(
bindings,
dotenv.parse(await fs.readFile(envPath, "utf8"))
);
} catch (e: any) {
// Ignore ENOENT (file not found) errors for default path
if (!(e.code === "ENOENT" && this.envPath === true)) throw e;
}
watch.push(envPath);
}
// 3) Load WebAssembly module bindings from files
if (this.wasmBindings) {
// eslint-disable-next-line prefer-const
for (let [name, wasmPath] of Object.entries(this.wasmBindings)) {
wasmPath = path.resolve(this.ctx.rootPath, wasmPath);
bindings[name] = new WebAssembly.Module(await fs.readFile(wasmPath));
watch.push(wasmPath);
}
}
// 4) Load text blobs from files
if (this.textBlobBindings) {
// eslint-disable-next-line prefer-const
for (let [name, textPath] of Object.entries(this.textBlobBindings)) {
textPath = path.resolve(this.ctx.rootPath, textPath);
bindings[name] = await fs.readFile(textPath, "utf-8");
watch.push(textPath);
}
}
// 5) Load service bindings
for (const { name, service } of this.#processedServiceBindings) {
bindings[name] = new Fetcher(service, this.#getServiceFetch);
}
// 6) Copy user's arbitrary bindings
Object.assign(bindings, this.bindings);
return { globals: this.globals, bindings, watch };
}
beforeReload(): void {
// Clear reference to old mounts map, wait for reload() to be called
// before allowing service binding `fetch`es again
this.#mounts = undefined;
this.#contextPromise = new Promise(
(resolve) => (this.#contextResolve = resolve)
);
}
reload(
bindings: Context,
moduleExports: Context,
mounts: Map<string, Mount>
): void {
// Check all services are mounted
for (const { name, service } of this.#processedServiceBindings) {
if (typeof service === "string" && !mounts.has(service)) {
throw new MiniflareCoreError(
"ERR_SERVICE_NOT_MOUNTED",
`Service "${service}" for binding "${name}" not found.
Make sure "${service}" is mounted so Miniflare knows where to find it.`
);
}
}
this.#mounts = mounts;
assert(
this.#contextResolve,
"beforeReload() must be called before reload()"
);
this.#contextResolve();
}
dispose(): void {
return this.beforeReload();
}
}