alchemy 0.51.0 → 0.51.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.
- package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
- package/lib/cloudflare/compatibility-date.gen.js +1 -1
- package/lib/cloudflare/miniflare/miniflare-worker-options.js +2 -2
- package/lib/cloudflare/miniflare/miniflare-worker-options.js.map +1 -1
- package/lib/cloudflare/miniflare/miniflare-worker-proxy.d.ts +14 -14
- package/lib/cloudflare/miniflare/miniflare-worker-proxy.d.ts.map +1 -1
- package/lib/cloudflare/miniflare/miniflare-worker-proxy.js +123 -66
- package/lib/cloudflare/miniflare/miniflare-worker-proxy.js.map +1 -1
- package/lib/cloudflare/miniflare/miniflare.d.ts +12 -7
- package/lib/cloudflare/miniflare/miniflare.d.ts.map +1 -1
- package/lib/cloudflare/miniflare/miniflare.js +17 -55
- package/lib/cloudflare/miniflare/miniflare.js.map +1 -1
- package/lib/cloudflare/zone.d.ts.map +1 -1
- package/lib/cloudflare/zone.js +20 -8
- package/lib/cloudflare/zone.js.map +1 -1
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +4 -3
- package/lib/scope.js.map +1 -1
- package/lib/state/instrumented-state-store.d.ts +19 -0
- package/lib/state/instrumented-state-store.d.ts.map +1 -0
- package/lib/state/instrumented-state-store.js +59 -0
- package/lib/state/instrumented-state-store.js.map +1 -0
- package/lib/util/telemetry/client.d.ts.map +1 -1
- package/lib/util/telemetry/client.js +14 -12
- package/lib/util/telemetry/client.js.map +1 -1
- package/lib/util/telemetry/types.d.ts +8 -3
- package/lib/util/telemetry/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cloudflare/compatibility-date.gen.ts +1 -1
- package/src/cloudflare/miniflare/miniflare-worker-options.ts +2 -2
- package/src/cloudflare/miniflare/miniflare-worker-proxy.ts +148 -77
- package/src/cloudflare/miniflare/miniflare.ts +23 -79
- package/src/cloudflare/zone.ts +33 -10
- package/src/scope.ts +7 -3
- package/src/state/instrumented-state-store.ts +97 -0
- package/src/util/telemetry/client.ts +14 -12
- package/src/util/telemetry/types.ts +18 -2
|
@@ -1,97 +1,168 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as miniflare from "miniflare";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import type Stream from "node:stream";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
2
6
|
import { WebSocket, WebSocketServer } from "ws";
|
|
3
|
-
import {
|
|
7
|
+
import { logger } from "../../util/logger.ts";
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*/
|
|
10
|
-
getDirectURL: () => Promise<URL>;
|
|
11
|
-
/** Used to proxy HTTP requests to the worker. */
|
|
12
|
-
fetch: (request: Request) => Promise<Response>;
|
|
9
|
+
interface MiniflareWorkerProxyOptions {
|
|
10
|
+
name: string;
|
|
11
|
+
port: number;
|
|
12
|
+
miniflare: miniflare.Miniflare;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export class MiniflareWorkerProxy
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
export class MiniflareWorkerProxy {
|
|
16
|
+
private server = http.createServer();
|
|
17
|
+
private wss = new WebSocketServer({ noServer: true });
|
|
18
18
|
|
|
19
19
|
constructor(private readonly options: MiniflareWorkerProxyOptions) {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
this.server.on("upgrade", async (req, socket, head) => {
|
|
21
|
+
await this.handleUpgrade(req, socket, head);
|
|
22
22
|
});
|
|
23
|
-
this.
|
|
24
|
-
|
|
25
|
-
this.wsServer.handleUpgrade(req, socket, head, async (client) => {
|
|
26
|
-
const id = crypto.randomUUID();
|
|
27
|
-
await this.handleUpgrade(id, client, req);
|
|
28
|
-
});
|
|
23
|
+
this.server.on("request", async (req, res) => {
|
|
24
|
+
await this.handleRequest(req, res);
|
|
29
25
|
});
|
|
26
|
+
this.server.listen(this.options.port);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get ready() {
|
|
30
|
+
if (!this.server.listening) {
|
|
31
|
+
return once(this.server, "listening");
|
|
32
|
+
}
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get url() {
|
|
37
|
+
return `http://localhost:${this.options.port}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async close() {
|
|
41
|
+
await Promise.all([
|
|
42
|
+
new Promise((resolve) => this.wss.close(resolve)),
|
|
43
|
+
new Promise((resolve) => this.server.close(resolve)),
|
|
44
|
+
]);
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
private async handleUpgrade(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
req: http.IncomingMessage,
|
|
49
|
+
socket: Stream.Duplex,
|
|
50
|
+
head: Buffer,
|
|
36
51
|
) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
client.close(1006, "Too many reconnect attempts");
|
|
52
|
+
const server = await this.createServerWebSocket(req);
|
|
53
|
+
if (!server) {
|
|
54
|
+
socket.destroy();
|
|
41
55
|
return;
|
|
42
56
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const target = await this.options.getDirectURL();
|
|
49
|
-
const url = new URL(request.url ?? "/", target);
|
|
50
|
-
const headers = { ...request.headers };
|
|
51
|
-
// All of these headers are set by the WebSocket constructor,
|
|
52
|
-
// so if we don't delete them, the request will fail.
|
|
53
|
-
delete headers.host;
|
|
54
|
-
delete headers.connection;
|
|
55
|
-
delete headers.upgrade;
|
|
56
|
-
delete headers["sec-websocket-version"];
|
|
57
|
-
delete headers["sec-websocket-key"];
|
|
58
|
-
delete headers["sec-websocket-protocol"];
|
|
59
|
-
delete headers["sec-websocket-extensions"];
|
|
60
|
-
delete headers["sec-websocket-accept"];
|
|
61
|
-
const server = new WebSocket(url.toString(), {
|
|
62
|
-
protocol: request.headers["sec-websocket-protocol"],
|
|
63
|
-
key: request.headers["sec-websocket-key"],
|
|
64
|
-
headers,
|
|
65
|
-
});
|
|
66
|
-
server.on("open", () => {
|
|
67
|
-
// Reset the reconnect attempts when the connection is established successfully.
|
|
68
|
-
this.wsReconnectAttempts.set(id, 0);
|
|
69
|
-
});
|
|
70
|
-
server.on("message", (data, binary) => {
|
|
71
|
-
client.send(data, { binary });
|
|
72
|
-
});
|
|
73
|
-
server.on("close", async (code, reason) => {
|
|
74
|
-
if (code === 1006) {
|
|
75
|
-
// When the worker hot reloads, the websocket connection is closed with this code.
|
|
76
|
-
// Reconnecting allows the client to maintain the same connection.
|
|
77
|
-
await this.handleUpgrade(id, client, request);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
client.close(code, reason);
|
|
81
|
-
});
|
|
82
|
-
client.on("message", (data, binary) => {
|
|
83
|
-
server.send(data, { binary });
|
|
84
|
-
});
|
|
85
|
-
client.on("close", (code, reason) => {
|
|
86
|
-
this.wsReconnectAttempts.delete(id);
|
|
87
|
-
if (server.readyState === WebSocket.OPEN) {
|
|
57
|
+
this.wss.handleUpgrade(req, socket, head, (client) => {
|
|
58
|
+
client.on("message", (event, binary) => {
|
|
59
|
+
server.send(event, { binary });
|
|
60
|
+
});
|
|
61
|
+
client.on("close", (code, reason) => {
|
|
88
62
|
server.close(code, reason);
|
|
89
|
-
}
|
|
63
|
+
});
|
|
64
|
+
server.on("message", (event, binary) => {
|
|
65
|
+
client.send(event, { binary });
|
|
66
|
+
});
|
|
67
|
+
server.on("close", (code, reason) => {
|
|
68
|
+
client.close(code, reason);
|
|
69
|
+
});
|
|
70
|
+
this.wss.emit("connection", client, req);
|
|
90
71
|
});
|
|
91
72
|
}
|
|
92
73
|
|
|
93
|
-
async
|
|
94
|
-
this.
|
|
95
|
-
|
|
74
|
+
private async createServerWebSocket(req: http.IncomingMessage) {
|
|
75
|
+
const target = await this.options.miniflare.unsafeGetDirectURL(
|
|
76
|
+
this.options.name,
|
|
77
|
+
);
|
|
78
|
+
if (!target) {
|
|
79
|
+
logger.error(
|
|
80
|
+
`[Alchemy] Websocket connection failed: The worker "${this.options.name}" is not running.`,
|
|
81
|
+
);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const url = new URL(req.url ?? "/", target);
|
|
85
|
+
url.protocol = url.protocol.replace("http", "ws");
|
|
86
|
+
const protocols = req.headers["sec-websocket-protocol"]
|
|
87
|
+
?.split(",")
|
|
88
|
+
.map((p) => p.trim());
|
|
89
|
+
const server = new WebSocket(url, protocols);
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
return await Promise.race([
|
|
92
|
+
once(server, "open", { signal: controller.signal }).then(() => server),
|
|
93
|
+
once(server, "close", { signal: controller.signal }).then((args) => {
|
|
94
|
+
const [code, reason] = args as [number, string];
|
|
95
|
+
logger.error(
|
|
96
|
+
`[Alchemy] Websocket connection failed for worker "${this.options.name}": ${code} ${reason}`,
|
|
97
|
+
);
|
|
98
|
+
return null;
|
|
99
|
+
}),
|
|
100
|
+
]).finally(() => controller.abort());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async handleRequest(
|
|
104
|
+
req: http.IncomingMessage,
|
|
105
|
+
res: http.ServerResponse,
|
|
106
|
+
) {
|
|
107
|
+
const worker = await this.options.miniflare.getWorker(this.options.name);
|
|
108
|
+
if (!worker) {
|
|
109
|
+
res.statusCode = 503;
|
|
110
|
+
res.end(`[Alchemy] The worker "${this.options.name}" is not running.`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const response = await worker.fetch(toMiniflareRequest(req));
|
|
115
|
+
writeMiniflareResponseToNode(response, res);
|
|
116
|
+
} catch (rawError) {
|
|
117
|
+
const message =
|
|
118
|
+
rawError instanceof Error
|
|
119
|
+
? (rawError.stack ?? rawError.message)
|
|
120
|
+
: String(rawError);
|
|
121
|
+
res.statusCode = 500;
|
|
122
|
+
res.end(
|
|
123
|
+
`[Alchemy] The worker "${this.options.name}" threw an error:\n\n${message}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
96
126
|
}
|
|
97
127
|
}
|
|
128
|
+
|
|
129
|
+
const toMiniflareRequest = (req: http.IncomingMessage) => {
|
|
130
|
+
const method = req.method ?? "GET";
|
|
131
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
132
|
+
const headers = new miniflare.Headers();
|
|
133
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
134
|
+
if (Array.isArray(value)) {
|
|
135
|
+
for (const v of value) {
|
|
136
|
+
headers.append(key, v);
|
|
137
|
+
}
|
|
138
|
+
} else if (value) {
|
|
139
|
+
headers.set(key, value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const body =
|
|
143
|
+
["GET", "HEAD", "OPTIONS"].includes(method) || !req.readable
|
|
144
|
+
? undefined
|
|
145
|
+
: Readable.toWeb(req);
|
|
146
|
+
return new miniflare.Request(url, {
|
|
147
|
+
method,
|
|
148
|
+
headers,
|
|
149
|
+
body,
|
|
150
|
+
redirect: "manual",
|
|
151
|
+
duplex: body ? "half" : undefined,
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const writeMiniflareResponseToNode = (
|
|
156
|
+
res: miniflare.Response,
|
|
157
|
+
out: http.ServerResponse,
|
|
158
|
+
) => {
|
|
159
|
+
out.statusCode = res.status;
|
|
160
|
+
res.headers.forEach((value, key) => {
|
|
161
|
+
out.setHeader(key, value);
|
|
162
|
+
});
|
|
163
|
+
if (res.body) {
|
|
164
|
+
Readable.fromWeb(res.body).pipe(out, { end: true });
|
|
165
|
+
} else {
|
|
166
|
+
out.end();
|
|
167
|
+
}
|
|
168
|
+
};
|
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
MiniflareCoreError,
|
|
3
|
-
type Miniflare,
|
|
4
|
-
type MiniflareOptions,
|
|
5
|
-
type RemoteProxyConnectionString,
|
|
6
|
-
type WorkerOptions,
|
|
7
|
-
} from "miniflare";
|
|
1
|
+
import * as miniflare from "miniflare";
|
|
8
2
|
import path from "node:path";
|
|
9
3
|
import { findOpenPort } from "../../util/find-open-port.ts";
|
|
10
|
-
import { logger } from "../../util/logger.ts";
|
|
11
4
|
import {
|
|
12
5
|
promiseWithResolvers,
|
|
13
6
|
type PromiseWithResolvers,
|
|
@@ -24,14 +17,14 @@ import {
|
|
|
24
17
|
} from "./remote-binding-proxy.ts";
|
|
25
18
|
|
|
26
19
|
class MiniflareServer {
|
|
27
|
-
miniflare?: Miniflare;
|
|
28
|
-
workers = new Map<string, WorkerOptions>();
|
|
20
|
+
miniflare?: miniflare.Miniflare;
|
|
21
|
+
workers = new Map<string, miniflare.WorkerOptions>();
|
|
29
22
|
workerProxies = new Map<string, MiniflareWorkerProxy>();
|
|
30
23
|
remoteBindingProxies = new Map<string, RemoteBindingProxy>();
|
|
31
24
|
|
|
32
25
|
stream = new WritableStream<{
|
|
33
26
|
worker: MiniflareWorkerOptions;
|
|
34
|
-
promise: PromiseWithResolvers<
|
|
27
|
+
promise: PromiseWithResolvers<{ url: string }>;
|
|
35
28
|
}>({
|
|
36
29
|
write: async ({ worker, promise }) => {
|
|
37
30
|
try {
|
|
@@ -48,7 +41,7 @@ class MiniflareServer {
|
|
|
48
41
|
writer = this.stream.getWriter();
|
|
49
42
|
|
|
50
43
|
async push(worker: MiniflareWorkerOptions) {
|
|
51
|
-
const promise = promiseWithResolvers<
|
|
44
|
+
const promise = promiseWithResolvers<{ url: string }>();
|
|
52
45
|
const [, server] = await Promise.all([
|
|
53
46
|
this.writer.write({ worker, promise }),
|
|
54
47
|
promise.promise,
|
|
@@ -73,12 +66,6 @@ class MiniflareServer {
|
|
|
73
66
|
this.miniflare.setOptions(await this.miniflareOptions()),
|
|
74
67
|
);
|
|
75
68
|
} else {
|
|
76
|
-
const { Miniflare } = await import("miniflare").catch(() => {
|
|
77
|
-
throw new Error(
|
|
78
|
-
"Miniflare is not installed, but is required in local mode for Workers. Please run `npm install miniflare`.",
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
69
|
// Miniflare intercepts SIGINT and exits with 130, which is not a failure.
|
|
83
70
|
// No one likes to see a non-zero exit code when they Ctrl+C, so here's our workaround.
|
|
84
71
|
process.on("exit", (code) => {
|
|
@@ -86,26 +73,21 @@ class MiniflareServer {
|
|
|
86
73
|
process.exit(0);
|
|
87
74
|
}
|
|
88
75
|
});
|
|
89
|
-
this.miniflare = new Miniflare(await this.miniflareOptions());
|
|
76
|
+
this.miniflare = new miniflare.Miniflare(await this.miniflareOptions());
|
|
90
77
|
await withErrorRewrite(this.miniflare.ready);
|
|
91
78
|
}
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
return
|
|
79
|
+
const existingProxy = this.workerProxies.get(worker.name);
|
|
80
|
+
if (existingProxy) {
|
|
81
|
+
return existingProxy;
|
|
95
82
|
}
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
throw new Error(`Worker "${worker.name}" is not running`);
|
|
101
|
-
}
|
|
102
|
-
return url;
|
|
103
|
-
},
|
|
104
|
-
fetch: this.createRequestHandler(worker.name),
|
|
83
|
+
const newProxy = new MiniflareWorkerProxy({
|
|
84
|
+
name: worker.name,
|
|
85
|
+
port: worker.port ?? (await findOpenPort()),
|
|
86
|
+
miniflare: this.miniflare,
|
|
105
87
|
});
|
|
106
|
-
this.workerProxies.set(worker.name,
|
|
107
|
-
await
|
|
108
|
-
return
|
|
88
|
+
this.workerProxies.set(worker.name, newProxy);
|
|
89
|
+
await newProxy.ready;
|
|
90
|
+
return newProxy;
|
|
109
91
|
}
|
|
110
92
|
|
|
111
93
|
private async dispose() {
|
|
@@ -125,7 +107,7 @@ class MiniflareServer {
|
|
|
125
107
|
|
|
126
108
|
private async maybeCreateRemoteProxy(
|
|
127
109
|
worker: MiniflareWorkerOptions,
|
|
128
|
-
): Promise<RemoteProxyConnectionString | undefined> {
|
|
110
|
+
): Promise<miniflare.RemoteProxyConnectionString | undefined> {
|
|
129
111
|
const bindings = buildRemoteBindings(worker);
|
|
130
112
|
if (bindings.length === 0) {
|
|
131
113
|
return undefined;
|
|
@@ -148,52 +130,11 @@ class MiniflareServer {
|
|
|
148
130
|
return proxy.connectionString;
|
|
149
131
|
}
|
|
150
132
|
|
|
151
|
-
private
|
|
152
|
-
return async (req: Request) => {
|
|
153
|
-
try {
|
|
154
|
-
if (!this.miniflare) {
|
|
155
|
-
return new Response(
|
|
156
|
-
"[Alchemy] Miniflare is not initialized. Please try again.",
|
|
157
|
-
{
|
|
158
|
-
status: 503,
|
|
159
|
-
},
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
const worker = await this.miniflare?.getWorker(name);
|
|
163
|
-
if (!worker) {
|
|
164
|
-
return new Response(
|
|
165
|
-
`[Alchemy] Cannot find worker "${name}". Please try again.`,
|
|
166
|
-
{
|
|
167
|
-
status: 503,
|
|
168
|
-
},
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
const res = await worker.fetch(req.url, {
|
|
172
|
-
method: req.method,
|
|
173
|
-
headers: req.headers as any,
|
|
174
|
-
body: req.body as any,
|
|
175
|
-
duplex: "half",
|
|
176
|
-
redirect: "manual",
|
|
177
|
-
});
|
|
178
|
-
return res as unknown as Response;
|
|
179
|
-
} catch (error) {
|
|
180
|
-
logger.error(error);
|
|
181
|
-
return new Response(
|
|
182
|
-
`[Alchemy] Internal server error: ${String(error)}`,
|
|
183
|
-
{
|
|
184
|
-
status: 500,
|
|
185
|
-
},
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
private async miniflareOptions(): Promise<MiniflareOptions> {
|
|
192
|
-
const { getDefaultDevRegistryPath } = await import("miniflare");
|
|
133
|
+
private async miniflareOptions(): Promise<miniflare.MiniflareOptions> {
|
|
193
134
|
return {
|
|
194
135
|
workers: Array.from(this.workers.values()),
|
|
195
136
|
defaultPersistRoot: path.join(process.cwd(), ".alchemy/miniflare"),
|
|
196
|
-
unsafeDevRegistryPath: getDefaultDevRegistryPath(),
|
|
137
|
+
unsafeDevRegistryPath: miniflare.getDefaultDevRegistryPath(),
|
|
197
138
|
analyticsEngineDatasetsPersist: true,
|
|
198
139
|
cachePersist: true,
|
|
199
140
|
d1Persist: true,
|
|
@@ -202,6 +143,9 @@ class MiniflareServer {
|
|
|
202
143
|
r2Persist: true,
|
|
203
144
|
secretsStorePersist: true,
|
|
204
145
|
workflowsPersist: true,
|
|
146
|
+
log: process.env.DEBUG
|
|
147
|
+
? new miniflare.Log(miniflare.LogLevel.DEBUG)
|
|
148
|
+
: undefined,
|
|
205
149
|
};
|
|
206
150
|
}
|
|
207
151
|
}
|
|
@@ -219,7 +163,7 @@ async function withErrorRewrite<T>(promise: Promise<T>) {
|
|
|
219
163
|
return await promise;
|
|
220
164
|
} catch (error) {
|
|
221
165
|
if (
|
|
222
|
-
error instanceof MiniflareCoreError &&
|
|
166
|
+
error instanceof miniflare.MiniflareCoreError &&
|
|
223
167
|
error.code === "ERR_MODULE_STRING_SCRIPT"
|
|
224
168
|
) {
|
|
225
169
|
throw new ExternalDependencyError();
|
package/src/cloudflare/zone.ts
CHANGED
|
@@ -626,23 +626,46 @@ export async function findZoneForHostname(
|
|
|
626
626
|
// Remove wildcard prefix if present
|
|
627
627
|
const cleanHostname = hostname.replace(/^\*\./, "");
|
|
628
628
|
|
|
629
|
-
//
|
|
630
|
-
const
|
|
629
|
+
// Helper to fetch a page of zones
|
|
630
|
+
const fetchZonePage = async (pageNum: number) => {
|
|
631
|
+
const response = await api.get(`/zones?per_page=50&page=${pageNum}`);
|
|
632
|
+
if (!response.ok) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
`Failed to list zones (page ${pageNum}): ${response.statusText}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return response.json() as Promise<{
|
|
638
|
+
result: Array<{ id: string; name: string }>;
|
|
639
|
+
result_info?: {
|
|
640
|
+
count?: number;
|
|
641
|
+
page?: number;
|
|
642
|
+
per_page?: number;
|
|
643
|
+
total_count?: number;
|
|
644
|
+
total_pages?: number;
|
|
645
|
+
};
|
|
646
|
+
}>;
|
|
647
|
+
};
|
|
631
648
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
649
|
+
// Fetch the first page to get total_pages
|
|
650
|
+
const firstPageData = await fetchZonePage(1);
|
|
651
|
+
const totalPages = firstPageData.result_info?.total_pages ?? 1;
|
|
635
652
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
653
|
+
// Fetch remaining pages concurrently if needed
|
|
654
|
+
const allZones = totalPages > 1
|
|
655
|
+
? await Promise.all([
|
|
656
|
+
Promise.resolve(firstPageData.result),
|
|
657
|
+
...Array.from({ length: totalPages - 1 }, (_, i) =>
|
|
658
|
+
fetchZonePage(i + 2).then(data => data.result)
|
|
659
|
+
),
|
|
660
|
+
]).then(results => results.flat())
|
|
661
|
+
: firstPageData.result;
|
|
639
662
|
|
|
640
663
|
// Find the zone that best matches the hostname
|
|
641
664
|
// We look for the longest matching zone name (most specific)
|
|
642
665
|
let bestMatch: { zoneId: string; zoneName: string } | null = null;
|
|
643
666
|
let longestMatch = 0;
|
|
644
667
|
|
|
645
|
-
for (const zone of
|
|
668
|
+
for (const zone of allZones) {
|
|
646
669
|
if (
|
|
647
670
|
cleanHostname === zone.name ||
|
|
648
671
|
cleanHostname.endsWith(`.${zone.name}`)
|
|
@@ -656,7 +679,7 @@ export async function findZoneForHostname(
|
|
|
656
679
|
|
|
657
680
|
if (!bestMatch) {
|
|
658
681
|
throw new Error(
|
|
659
|
-
`Could not find zone for hostname '${hostname}'. Available zones: ${
|
|
682
|
+
`Could not find zone for hostname '${hostname}'. Available zones: ${allZones.map((z) => z.name).join(", ")}`,
|
|
660
683
|
);
|
|
661
684
|
}
|
|
662
685
|
|
package/src/scope.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import type { State, StateStore, StateStoreType } from "./state.ts";
|
|
16
16
|
import { D1StateStore } from "./state/d1-state-store.ts";
|
|
17
17
|
import { FileSystemStateStore } from "./state/file-system-state-store.ts";
|
|
18
|
+
import { InstrumentedStateStore } from "./state/instrumented-state-store.ts";
|
|
18
19
|
import {
|
|
19
20
|
createDummyLogger,
|
|
20
21
|
createLoggerInstance,
|
|
@@ -201,12 +202,15 @@ export class Scope {
|
|
|
201
202
|
|
|
202
203
|
this.stateStore =
|
|
203
204
|
options.stateStore ?? this.parent?.stateStore ?? defaultStateStore;
|
|
204
|
-
this.
|
|
205
|
+
this.telemetryClient =
|
|
206
|
+
options.telemetryClient ?? this.parent?.telemetryClient!;
|
|
207
|
+
this.state = new InstrumentedStateStore(
|
|
208
|
+
this.stateStore(this),
|
|
209
|
+
this.telemetryClient,
|
|
210
|
+
);
|
|
205
211
|
if (!options.telemetryClient && !this.parent?.telemetryClient) {
|
|
206
212
|
throw new Error("Telemetry client is required");
|
|
207
213
|
}
|
|
208
|
-
this.telemetryClient =
|
|
209
|
-
options.telemetryClient ?? this.parent?.telemetryClient!;
|
|
210
214
|
this.dataMutex = new AsyncMutex();
|
|
211
215
|
}
|
|
212
216
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { State, StateStore } from "../state.ts";
|
|
2
|
+
import type { ITelemetryClient } from "../util/telemetry/client.ts";
|
|
3
|
+
import type { Telemetry } from "../util/telemetry/types.ts";
|
|
4
|
+
|
|
5
|
+
//todo(michael): we should also handle serde here
|
|
6
|
+
export class InstrumentedStateStore<T extends StateStore>
|
|
7
|
+
implements StateStore
|
|
8
|
+
{
|
|
9
|
+
private readonly stateStore: StateStore;
|
|
10
|
+
private readonly telemetryClient: ITelemetryClient;
|
|
11
|
+
private readonly stateStoreClass: string;
|
|
12
|
+
|
|
13
|
+
constructor(stateStore: StateStore, telemetryClient: ITelemetryClient) {
|
|
14
|
+
this.stateStore = stateStore;
|
|
15
|
+
this.telemetryClient = telemetryClient;
|
|
16
|
+
this.stateStoreClass = stateStore.constructor.name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private async callWithTelemetry<T>(
|
|
20
|
+
event: Telemetry.StateStoreEvent["event"],
|
|
21
|
+
fn: () => Promise<T>,
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
const start = performance.now();
|
|
24
|
+
let error: unknown;
|
|
25
|
+
return await fn()
|
|
26
|
+
.catch((err) => (error = err))
|
|
27
|
+
.finally(() => {
|
|
28
|
+
this.telemetryClient.record({
|
|
29
|
+
event,
|
|
30
|
+
stateStoreClass: this.stateStoreClass,
|
|
31
|
+
elapsed: performance.now() - start,
|
|
32
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async init() {
|
|
38
|
+
if (this.stateStore.init == null) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await this.callWithTelemetry(
|
|
42
|
+
"stateStore.init",
|
|
43
|
+
this.stateStore.init.bind(this.stateStore),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
async deinit() {
|
|
47
|
+
if (this.stateStore.deinit == null) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await this.callWithTelemetry(
|
|
51
|
+
"stateStore.deinit",
|
|
52
|
+
this.stateStore.deinit.bind(this.stateStore),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
async list() {
|
|
56
|
+
return await this.callWithTelemetry(
|
|
57
|
+
"stateStore.list",
|
|
58
|
+
this.stateStore.list.bind(this.stateStore),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
async count() {
|
|
62
|
+
return await this.callWithTelemetry(
|
|
63
|
+
"stateStore.count",
|
|
64
|
+
this.stateStore.count.bind(this.stateStore),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
async get(key: string) {
|
|
68
|
+
return await this.callWithTelemetry(
|
|
69
|
+
"stateStore.get",
|
|
70
|
+
this.stateStore.get.bind(this.stateStore, key),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
async getBatch(ids: string[]) {
|
|
74
|
+
return await this.callWithTelemetry(
|
|
75
|
+
"stateStore.getBatch",
|
|
76
|
+
this.stateStore.getBatch.bind(this.stateStore, ids),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
async all() {
|
|
80
|
+
return await this.callWithTelemetry(
|
|
81
|
+
"stateStore.all",
|
|
82
|
+
this.stateStore.all.bind(this.stateStore),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
async set(key: string, value: State) {
|
|
86
|
+
await this.callWithTelemetry(
|
|
87
|
+
"stateStore.set",
|
|
88
|
+
this.stateStore.set.bind(this.stateStore, key, value),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
async delete(key: string) {
|
|
92
|
+
await this.callWithTelemetry(
|
|
93
|
+
"stateStore.delete",
|
|
94
|
+
this.stateStore.delete.bind(this.stateStore, key),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -54,13 +54,9 @@ export class TelemetryClient implements ITelemetryClient {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
record(event: Telemetry.EventInput, timestamp = Date.now()) {
|
|
57
|
-
if (!this.context) {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
57
|
const payload = {
|
|
61
58
|
...event,
|
|
62
59
|
error: this.serializeError(event.error),
|
|
63
|
-
context: this.context,
|
|
64
60
|
timestamp,
|
|
65
61
|
} as Telemetry.Event;
|
|
66
62
|
this.events.push(payload);
|
|
@@ -93,9 +89,10 @@ export class TelemetryClient implements ITelemetryClient {
|
|
|
93
89
|
}
|
|
94
90
|
|
|
95
91
|
private async send(events: Telemetry.Event[]) {
|
|
96
|
-
if (events.length === 0) {
|
|
92
|
+
if (events.length === 0 || this.context) {
|
|
97
93
|
return;
|
|
98
94
|
}
|
|
95
|
+
const { userId, ...data } = this.context!;
|
|
99
96
|
const response = await fetch(`${POSTHOG_CLIENT_API_HOST}/batch`, {
|
|
100
97
|
method: "POST",
|
|
101
98
|
headers: {
|
|
@@ -104,13 +101,18 @@ export class TelemetryClient implements ITelemetryClient {
|
|
|
104
101
|
body: JSON.stringify({
|
|
105
102
|
api_key: POSTHOG_PROJECT_ID,
|
|
106
103
|
historical_migration: false,
|
|
107
|
-
batch: events.map((e) =>
|
|
108
|
-
event
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
104
|
+
batch: events.map((e) => {
|
|
105
|
+
const { event, ...eventData } = e;
|
|
106
|
+
return {
|
|
107
|
+
event: event,
|
|
108
|
+
properties: {
|
|
109
|
+
distinct_id: userId,
|
|
110
|
+
...data,
|
|
111
|
+
...eventData,
|
|
112
|
+
},
|
|
113
|
+
timestamp: new Date(e.timestamp).toISOString(),
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
114
116
|
}),
|
|
115
117
|
});
|
|
116
118
|
if (!response.ok) {
|