devflare 0.0.0 → 1.0.0-next.0
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/README.md +737 -1
- package/bin/devflare.js +14 -0
- package/dist/account-rvrj687w.js +397 -0
- package/dist/ai-dx4fr9jh.js +107 -0
- package/dist/bridge/client.d.ts +82 -0
- package/dist/bridge/client.d.ts.map +1 -0
- package/dist/bridge/index.d.ts +7 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/miniflare.d.ts +70 -0
- package/dist/bridge/miniflare.d.ts.map +1 -0
- package/dist/bridge/protocol.d.ts +146 -0
- package/dist/bridge/protocol.d.ts.map +1 -0
- package/dist/bridge/proxy.d.ts +49 -0
- package/dist/bridge/proxy.d.ts.map +1 -0
- package/dist/bridge/serialization.d.ts +83 -0
- package/dist/bridge/serialization.d.ts.map +1 -0
- package/dist/bridge/server.d.ts +8 -0
- package/dist/bridge/server.d.ts.map +1 -0
- package/dist/browser-shim/binding-worker.d.ts +7 -0
- package/dist/browser-shim/binding-worker.d.ts.map +1 -0
- package/dist/browser-shim/handler.d.ts +21 -0
- package/dist/browser-shim/handler.d.ts.map +1 -0
- package/dist/browser-shim/index.d.ts +3 -0
- package/dist/browser-shim/index.d.ts.map +1 -0
- package/dist/browser-shim/server.d.ts +25 -0
- package/dist/browser-shim/server.d.ts.map +1 -0
- package/dist/browser-shim/worker.d.ts +14 -0
- package/dist/browser-shim/worker.d.ts.map +1 -0
- package/dist/build-mnf6v8gd.js +53 -0
- package/dist/bundler/do-bundler.d.ts +42 -0
- package/dist/bundler/do-bundler.d.ts.map +1 -0
- package/dist/bundler/index.d.ts +2 -0
- package/dist/bundler/index.d.ts.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/colors.d.ts +11 -0
- package/dist/cli/colors.d.ts.map +1 -0
- package/dist/cli/commands/account.d.ts +4 -0
- package/dist/cli/commands/account.d.ts.map +1 -0
- package/dist/cli/commands/ai.d.ts +3 -0
- package/dist/cli/commands/ai.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts +4 -0
- package/dist/cli/commands/build.d.ts.map +1 -0
- package/dist/cli/commands/deploy.d.ts +4 -0
- package/dist/cli/commands/deploy.d.ts.map +1 -0
- package/dist/cli/commands/dev.d.ts +4 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/init.d.ts +4 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/remote.d.ts +4 -0
- package/dist/cli/commands/remote.d.ts.map +1 -0
- package/dist/cli/commands/types.d.ts +4 -0
- package/dist/cli/commands/types.d.ts.map +1 -0
- package/dist/cli/dependencies.d.ts +90 -0
- package/dist/cli/dependencies.d.ts.map +1 -0
- package/dist/cli/index.d.ts +23 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/wrangler-auth.d.ts +36 -0
- package/dist/cli/wrangler-auth.d.ts.map +1 -0
- package/dist/cloudflare/account.d.ts +65 -0
- package/dist/cloudflare/account.d.ts.map +1 -0
- package/dist/cloudflare/api.d.ts +51 -0
- package/dist/cloudflare/api.d.ts.map +1 -0
- package/dist/cloudflare/auth.d.ts +35 -0
- package/dist/cloudflare/auth.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +107 -0
- package/dist/cloudflare/index.d.ts.map +1 -0
- package/dist/cloudflare/index.js +13 -0
- package/dist/cloudflare/preferences.d.ts +46 -0
- package/dist/cloudflare/preferences.d.ts.map +1 -0
- package/dist/cloudflare/pricing.d.ts +15 -0
- package/dist/cloudflare/pricing.d.ts.map +1 -0
- package/dist/cloudflare/remote-config.d.ts +37 -0
- package/dist/cloudflare/remote-config.d.ts.map +1 -0
- package/dist/cloudflare/types.d.ts +161 -0
- package/dist/cloudflare/types.d.ts.map +1 -0
- package/dist/cloudflare/usage.d.ts +77 -0
- package/dist/cloudflare/usage.d.ts.map +1 -0
- package/dist/config/compiler.d.ts +146 -0
- package/dist/config/compiler.d.ts.map +1 -0
- package/dist/config/define.d.ts +44 -0
- package/dist/config/define.d.ts.map +1 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/loader.d.ts +52 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/ref.d.ts +160 -0
- package/dist/config/ref.d.ts.map +1 -0
- package/dist/config/schema.d.ts +3318 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/decorators/durable-object.d.ts +59 -0
- package/dist/decorators/durable-object.d.ts.map +1 -0
- package/dist/decorators/index.d.ts +3 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/index.js +9 -0
- package/dist/deploy-nhceck39.js +70 -0
- package/dist/dev-qnxet3j9.js +2096 -0
- package/dist/dev-server/index.d.ts +2 -0
- package/dist/dev-server/index.d.ts.map +1 -0
- package/dist/dev-server/server.d.ts +30 -0
- package/dist/dev-server/server.d.ts.map +1 -0
- package/dist/doctor-e8fy6fj5.js +186 -0
- package/dist/durable-object-t4kbb0yt.js +13 -0
- package/dist/env.d.ts +48 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/index-07q6yxyc.js +168 -0
- package/dist/index-1xpj0m4r.js +57 -0
- package/dist/index-37x76zdn.js +4 -0
- package/dist/index-3t6rypgc.js +13 -0
- package/dist/index-67qcae0f.js +183 -0
- package/dist/index-a855bdsx.js +18 -0
- package/dist/index-d8bdkx2h.js +109 -0
- package/dist/index-ep3445yc.js +2225 -0
- package/dist/index-gz1gndna.js +307 -0
- package/dist/index-hcex3rgh.js +266 -0
- package/dist/index-m2q41jwa.js +462 -0
- package/dist/index-n7rs26ft.js +77 -0
- package/dist/index-pf5s73n9.js +1413 -0
- package/dist/index-rbht7m9r.js +36 -0
- package/dist/index-tfyxa77h.js +850 -0
- package/dist/index-tk6ej9dj.js +94 -0
- package/dist/index-z14anrqp.js +226 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +298 -0
- package/dist/init-f9mgmew3.js +186 -0
- package/dist/remote-q59qk463.js +97 -0
- package/dist/runtime/context.d.ts +46 -0
- package/dist/runtime/context.d.ts.map +1 -0
- package/dist/runtime/exports.d.ts +118 -0
- package/dist/runtime/exports.d.ts.map +1 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +111 -0
- package/dist/runtime/middleware.d.ts +82 -0
- package/dist/runtime/middleware.d.ts.map +1 -0
- package/dist/runtime/validation.d.ts +37 -0
- package/dist/runtime/validation.d.ts.map +1 -0
- package/dist/sveltekit/index.d.ts +2 -0
- package/dist/sveltekit/index.d.ts.map +1 -0
- package/dist/sveltekit/index.js +182 -0
- package/dist/sveltekit/platform.d.ts +141 -0
- package/dist/sveltekit/platform.d.ts.map +1 -0
- package/dist/test/bridge-context.d.ts +73 -0
- package/dist/test/bridge-context.d.ts.map +1 -0
- package/dist/test/cf.d.ts +130 -0
- package/dist/test/cf.d.ts.map +1 -0
- package/dist/test/email.d.ts +75 -0
- package/dist/test/email.d.ts.map +1 -0
- package/dist/test/index.d.ts +22 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +71 -0
- package/dist/test/multi-worker-context.d.ts +114 -0
- package/dist/test/multi-worker-context.d.ts.map +1 -0
- package/dist/test/queue.d.ts +74 -0
- package/dist/test/queue.d.ts.map +1 -0
- package/dist/test/remote-ai.d.ts +6 -0
- package/dist/test/remote-ai.d.ts.map +1 -0
- package/dist/test/remote-vectorize.d.ts +6 -0
- package/dist/test/remote-vectorize.d.ts.map +1 -0
- package/dist/test/resolve-service-bindings.d.ts +68 -0
- package/dist/test/resolve-service-bindings.d.ts.map +1 -0
- package/dist/test/scheduled.d.ts +58 -0
- package/dist/test/scheduled.d.ts.map +1 -0
- package/dist/test/should-skip.d.ts +50 -0
- package/dist/test/should-skip.d.ts.map +1 -0
- package/dist/test/simple-context.d.ts +43 -0
- package/dist/test/simple-context.d.ts.map +1 -0
- package/dist/test/tail.d.ts +86 -0
- package/dist/test/tail.d.ts.map +1 -0
- package/dist/test/utilities.d.ts +99 -0
- package/dist/test/utilities.d.ts.map +1 -0
- package/dist/test/worker.d.ts +76 -0
- package/dist/test/worker.d.ts.map +1 -0
- package/dist/transform/durable-object.d.ts +46 -0
- package/dist/transform/durable-object.d.ts.map +1 -0
- package/dist/transform/index.d.ts +3 -0
- package/dist/transform/index.d.ts.map +1 -0
- package/dist/transform/worker-entrypoint.d.ts +66 -0
- package/dist/transform/worker-entrypoint.d.ts.map +1 -0
- package/dist/types-5nyrz1sz.js +454 -0
- package/dist/utils/entrypoint-discovery.d.ts +29 -0
- package/dist/utils/entrypoint-discovery.d.ts.map +1 -0
- package/dist/utils/glob.d.ts +33 -0
- package/dist/utils/glob.d.ts.map +1 -0
- package/dist/utils/resolve-package.d.ts +10 -0
- package/dist/utils/resolve-package.d.ts.map +1 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +339 -0
- package/dist/vite/plugin.d.ts +138 -0
- package/dist/vite/plugin.d.ts.map +1 -0
- package/dist/worker-entrypoint-m9th0rg0.js +13 -0
- package/dist/workerName.d.ts +17 -0
- package/dist/workerName.d.ts.map +1 -0
- package/package.json +111 -1
|
@@ -0,0 +1,2096 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findFiles
|
|
3
|
+
} from "./index-rbht7m9r.js";
|
|
4
|
+
import {
|
|
5
|
+
findDurableObjectClasses
|
|
6
|
+
} from "./index-gz1gndna.js";
|
|
7
|
+
import {
|
|
8
|
+
loadConfig
|
|
9
|
+
} from "./index-hcex3rgh.js";
|
|
10
|
+
import {
|
|
11
|
+
__require
|
|
12
|
+
} from "./index-37x76zdn.js";
|
|
13
|
+
|
|
14
|
+
// src/cli/commands/dev.ts
|
|
15
|
+
import { createConsola } from "consola";
|
|
16
|
+
import { resolve as resolve3 } from "pathe";
|
|
17
|
+
|
|
18
|
+
// src/dev-server/server.ts
|
|
19
|
+
import { resolve as resolve2 } from "pathe";
|
|
20
|
+
|
|
21
|
+
// src/bundler/do-bundler.ts
|
|
22
|
+
import { resolve, dirname, relative } from "pathe";
|
|
23
|
+
import picomatch from "picomatch";
|
|
24
|
+
function classToBindingName(className) {
|
|
25
|
+
return className.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").toUpperCase();
|
|
26
|
+
}
|
|
27
|
+
async function discoverDOs(cwd, pattern) {
|
|
28
|
+
const fs = await import("node:fs/promises");
|
|
29
|
+
const discovered = [];
|
|
30
|
+
const files = await findFiles(pattern, { cwd });
|
|
31
|
+
for (const filePath of files) {
|
|
32
|
+
try {
|
|
33
|
+
const code = await fs.readFile(filePath, "utf-8");
|
|
34
|
+
const classNames = findDurableObjectClasses(code);
|
|
35
|
+
for (const className of classNames) {
|
|
36
|
+
discovered.push({
|
|
37
|
+
filePath,
|
|
38
|
+
className,
|
|
39
|
+
bindingName: classToBindingName(className)
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
return discovered;
|
|
45
|
+
}
|
|
46
|
+
function stripDecoratorSyntax(code) {
|
|
47
|
+
let result = code;
|
|
48
|
+
result = result.replace(/@durableObject\s*\([^)]*\)\s*\n?\s*(?=export\s+class)/g, "");
|
|
49
|
+
result = result.replace(/import\s*\{([^}]*)\bdurableObject\b[^}]*\}\s*from\s*['"]devflare\/runtime['"]\s*;?/g, (match, imports) => {
|
|
50
|
+
const cleanedImports = imports.split(",").map((s) => s.trim()).filter((s) => !s.startsWith("durableObject")).join(", ");
|
|
51
|
+
if (cleanedImports.trim() === "") {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
return `import { ${cleanedImports} } from 'devflare/runtime'`;
|
|
55
|
+
});
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
async function bundleDOFile(sourcePath, className, outDir, cwd) {
|
|
59
|
+
const { rolldown } = await import("rolldown");
|
|
60
|
+
const fs = await import("node:fs/promises");
|
|
61
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
62
|
+
const sourceCode = await fs.readFile(sourcePath, "utf-8");
|
|
63
|
+
const cleanedCode = stripDecoratorSyntax(sourceCode);
|
|
64
|
+
const entryCode = `${cleanedCode}
|
|
65
|
+
|
|
66
|
+
// Default export for worker (required by Miniflare)
|
|
67
|
+
export default {
|
|
68
|
+
async fetch(request) {
|
|
69
|
+
return new Response('DO Worker for ${className}', { status: 200 });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
`;
|
|
73
|
+
const tempFilePath = resolve(outDir, `_temp_${className}.ts`);
|
|
74
|
+
await fs.writeFile(tempFilePath, entryCode, "utf-8");
|
|
75
|
+
const classOutDir = resolve(outDir, className);
|
|
76
|
+
try {
|
|
77
|
+
await fs.rm(classOutDir, { recursive: true, force: true });
|
|
78
|
+
} catch {}
|
|
79
|
+
await fs.mkdir(classOutDir, { recursive: true });
|
|
80
|
+
const debugShimCode = `
|
|
81
|
+
// Debug module shim for local development
|
|
82
|
+
const createDebug = (namespace) => {
|
|
83
|
+
const logger = (...args) => {
|
|
84
|
+
if (createDebug.enabled) console.debug(\`[\${namespace}]\`, ...args)
|
|
85
|
+
}
|
|
86
|
+
logger.enabled = false
|
|
87
|
+
logger.namespace = namespace
|
|
88
|
+
logger.extend = (sub) => createDebug(\`\${namespace}:\${sub}\`)
|
|
89
|
+
return logger
|
|
90
|
+
}
|
|
91
|
+
createDebug.enabled = false
|
|
92
|
+
createDebug.formatters = {}
|
|
93
|
+
export default createDebug
|
|
94
|
+
`;
|
|
95
|
+
const debugShimPath = resolve(outDir, "_debug_shim.js");
|
|
96
|
+
await fs.writeFile(debugShimPath, debugShimCode, "utf-8");
|
|
97
|
+
const bundle = await rolldown({
|
|
98
|
+
input: tempFilePath,
|
|
99
|
+
platform: "neutral",
|
|
100
|
+
tsconfig: resolve(cwd, "tsconfig.json"),
|
|
101
|
+
external: [
|
|
102
|
+
/^cloudflare:/,
|
|
103
|
+
/^node:/,
|
|
104
|
+
"buffer",
|
|
105
|
+
"crypto",
|
|
106
|
+
"events",
|
|
107
|
+
"http",
|
|
108
|
+
"https",
|
|
109
|
+
"net",
|
|
110
|
+
"os",
|
|
111
|
+
"path",
|
|
112
|
+
"stream",
|
|
113
|
+
"tls",
|
|
114
|
+
"url",
|
|
115
|
+
"util",
|
|
116
|
+
"zlib",
|
|
117
|
+
"fs",
|
|
118
|
+
"child_process",
|
|
119
|
+
"async_hooks",
|
|
120
|
+
"querystring",
|
|
121
|
+
"string_decoder",
|
|
122
|
+
"assert",
|
|
123
|
+
"dns"
|
|
124
|
+
],
|
|
125
|
+
resolve: {
|
|
126
|
+
alias: {
|
|
127
|
+
debug: debugShimPath
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const outFile = resolve(classOutDir, "index.js");
|
|
132
|
+
await bundle.write({
|
|
133
|
+
file: outFile,
|
|
134
|
+
format: "esm",
|
|
135
|
+
sourcemap: false,
|
|
136
|
+
inlineDynamicImports: true
|
|
137
|
+
});
|
|
138
|
+
await bundle.close();
|
|
139
|
+
try {
|
|
140
|
+
await fs.unlink(tempFilePath);
|
|
141
|
+
} catch {}
|
|
142
|
+
return resolve(classOutDir, "index.js");
|
|
143
|
+
}
|
|
144
|
+
async function bundleAllDOs(discovered, outDir, cwd, logger) {
|
|
145
|
+
const fs = await import("node:fs/promises");
|
|
146
|
+
const bundles = new Map;
|
|
147
|
+
const classes = new Map;
|
|
148
|
+
const sourceFiles = new Map;
|
|
149
|
+
const errors = [];
|
|
150
|
+
for (const do_ of discovered) {
|
|
151
|
+
const existing = sourceFiles.get(do_.filePath) || [];
|
|
152
|
+
existing.push(do_.className);
|
|
153
|
+
sourceFiles.set(do_.filePath, existing);
|
|
154
|
+
}
|
|
155
|
+
for (const do_ of discovered) {
|
|
156
|
+
try {
|
|
157
|
+
logger?.debug(`Bundling ${do_.className} from ${do_.filePath}`);
|
|
158
|
+
const outFile = await bundleDOFile(do_.filePath, do_.className, outDir, cwd);
|
|
159
|
+
bundles.set(do_.bindingName, outFile);
|
|
160
|
+
classes.set(do_.bindingName, do_.className);
|
|
161
|
+
logger?.debug(` → ${outFile}`);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
164
|
+
errors.push(err);
|
|
165
|
+
logger?.error(`Failed to bundle ${do_.className}:`, err.message);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { bundles, classes, sourceFiles, errors };
|
|
169
|
+
}
|
|
170
|
+
function createDOBundler(options) {
|
|
171
|
+
const { cwd, pattern, outDir, logger, onRebuild } = options;
|
|
172
|
+
let result = {
|
|
173
|
+
bundles: new Map,
|
|
174
|
+
classes: new Map,
|
|
175
|
+
sourceFiles: new Map,
|
|
176
|
+
errors: []
|
|
177
|
+
};
|
|
178
|
+
let watcher = null;
|
|
179
|
+
let chokidarWatcher = null;
|
|
180
|
+
async function build() {
|
|
181
|
+
const discovered = await discoverDOs(cwd, pattern);
|
|
182
|
+
if (discovered.length === 0) {
|
|
183
|
+
logger?.debug("No DOs found matching pattern:", pattern);
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
logger?.info(`Found ${discovered.length} Durable Object(s)`);
|
|
187
|
+
for (const do_ of discovered) {
|
|
188
|
+
logger?.info(` • ${do_.className} → ${do_.bindingName}`);
|
|
189
|
+
}
|
|
190
|
+
result = await bundleAllDOs(discovered, outDir, cwd, logger);
|
|
191
|
+
if (result.errors.length === 0) {
|
|
192
|
+
logger?.success(`Bundled ${result.bundles.size} DO(s) to ${outDir}`);
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
async function watch() {
|
|
197
|
+
const chokidar = await import("chokidar");
|
|
198
|
+
const files = await findFiles(pattern, { cwd });
|
|
199
|
+
let dirsToWatch;
|
|
200
|
+
if (files.length > 0) {
|
|
201
|
+
dirsToWatch = [...new Set(files.map((f) => dirname(f)))];
|
|
202
|
+
} else {
|
|
203
|
+
const patternDir = dirname(pattern);
|
|
204
|
+
const absolutePatternDir = resolve(cwd, patternDir === "." ? "" : patternDir) || cwd;
|
|
205
|
+
dirsToWatch = [absolutePatternDir];
|
|
206
|
+
logger?.debug(`No DO files yet, watching pattern directory: ${absolutePatternDir}`);
|
|
207
|
+
}
|
|
208
|
+
logger?.info(`Watching ${files.length} DO file(s) in ${dirsToWatch.length} director(ies)...`);
|
|
209
|
+
const isWindows = process.platform === "win32";
|
|
210
|
+
chokidarWatcher = chokidar.watch(dirsToWatch, {
|
|
211
|
+
ignoreInitial: true,
|
|
212
|
+
usePolling: isWindows,
|
|
213
|
+
interval: isWindows ? 300 : undefined,
|
|
214
|
+
awaitWriteFinish: {
|
|
215
|
+
stabilityThreshold: 100,
|
|
216
|
+
pollInterval: 50
|
|
217
|
+
},
|
|
218
|
+
depth: 0
|
|
219
|
+
});
|
|
220
|
+
const normalizePath = (p) => {
|
|
221
|
+
let normalized = p.replace(/\\/g, "/");
|
|
222
|
+
if (isWindows && /^[a-zA-Z]:/.test(normalized)) {
|
|
223
|
+
normalized = normalized[0].toLowerCase() + normalized.slice(1);
|
|
224
|
+
}
|
|
225
|
+
return normalized;
|
|
226
|
+
};
|
|
227
|
+
const isMatch = picomatch(pattern, {
|
|
228
|
+
cwd,
|
|
229
|
+
dot: true,
|
|
230
|
+
matchBase: false
|
|
231
|
+
});
|
|
232
|
+
const matchesPattern = (filePath) => {
|
|
233
|
+
const normalizedPath = normalizePath(filePath);
|
|
234
|
+
const relativePath = relative(normalizePath(cwd), normalizedPath);
|
|
235
|
+
return isMatch(relativePath);
|
|
236
|
+
};
|
|
237
|
+
let isRebuilding = false;
|
|
238
|
+
let pendingRebuild = null;
|
|
239
|
+
let rebuildTimeout = null;
|
|
240
|
+
const scheduleRebuild = (changedPath) => {
|
|
241
|
+
if (rebuildTimeout) {
|
|
242
|
+
clearTimeout(rebuildTimeout);
|
|
243
|
+
}
|
|
244
|
+
rebuildTimeout = setTimeout(() => {
|
|
245
|
+
triggerRebuild(changedPath);
|
|
246
|
+
}, 150);
|
|
247
|
+
};
|
|
248
|
+
const triggerRebuild = async (changedPath) => {
|
|
249
|
+
if (isRebuilding) {
|
|
250
|
+
pendingRebuild = changedPath;
|
|
251
|
+
logger?.debug(`Rebuild already in progress, queuing: ${changedPath}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
isRebuilding = true;
|
|
255
|
+
try {
|
|
256
|
+
logger?.info(`DO file changed: ${changedPath}`);
|
|
257
|
+
logger?.info("Rebuilding DOs...");
|
|
258
|
+
const startTime = Date.now();
|
|
259
|
+
result = await build();
|
|
260
|
+
const elapsed = Date.now() - startTime;
|
|
261
|
+
logger?.success(`DO rebuild complete (${elapsed}ms)`);
|
|
262
|
+
await onRebuild?.(result);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
logger?.error("DO rebuild failed:", error);
|
|
265
|
+
} finally {
|
|
266
|
+
isRebuilding = false;
|
|
267
|
+
if (pendingRebuild) {
|
|
268
|
+
const nextPath = pendingRebuild;
|
|
269
|
+
pendingRebuild = null;
|
|
270
|
+
triggerRebuild(nextPath);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
chokidarWatcher.on("change", (filePath) => {
|
|
275
|
+
if (matchesPattern(filePath)) {
|
|
276
|
+
logger?.debug(`File changed: ${filePath}`);
|
|
277
|
+
scheduleRebuild(filePath);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
chokidarWatcher.on("add", (filePath) => {
|
|
281
|
+
if (matchesPattern(filePath)) {
|
|
282
|
+
logger?.debug(`File added: ${filePath}`);
|
|
283
|
+
scheduleRebuild(filePath);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
chokidarWatcher.on("unlink", (filePath) => {
|
|
287
|
+
if (matchesPattern(filePath)) {
|
|
288
|
+
logger?.debug(`File removed: ${filePath}`);
|
|
289
|
+
scheduleRebuild(filePath);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
chokidarWatcher.on("ready", () => {
|
|
293
|
+
logger?.info("DO file watcher ready");
|
|
294
|
+
});
|
|
295
|
+
chokidarWatcher.on("error", (error) => {
|
|
296
|
+
logger?.error("DO file watcher error:", error);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
async function close() {
|
|
300
|
+
if (watcher) {
|
|
301
|
+
await watcher.close();
|
|
302
|
+
watcher = null;
|
|
303
|
+
}
|
|
304
|
+
if (chokidarWatcher) {
|
|
305
|
+
await chokidarWatcher.close();
|
|
306
|
+
chokidarWatcher = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function getResult() {
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
build,
|
|
314
|
+
watch,
|
|
315
|
+
close,
|
|
316
|
+
getResult
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// src/browser-shim/server.ts
|
|
320
|
+
import { homedir } from "node:os";
|
|
321
|
+
import { join } from "node:path";
|
|
322
|
+
import { existsSync } from "node:fs";
|
|
323
|
+
import { createServer } from "node:http";
|
|
324
|
+
import puppeteerCore from "puppeteer-core";
|
|
325
|
+
import {
|
|
326
|
+
install,
|
|
327
|
+
resolveBuildId,
|
|
328
|
+
detectBrowserPlatform,
|
|
329
|
+
Browser as BrowserType
|
|
330
|
+
} from "@puppeteer/browsers";
|
|
331
|
+
var sessions = new Map;
|
|
332
|
+
var history = [];
|
|
333
|
+
var cachedExecutablePath = null;
|
|
334
|
+
async function ensureChrome(cacheDir, logger) {
|
|
335
|
+
if (cachedExecutablePath && existsSync(cachedExecutablePath)) {
|
|
336
|
+
return cachedExecutablePath;
|
|
337
|
+
}
|
|
338
|
+
const platform = detectBrowserPlatform();
|
|
339
|
+
if (!platform) {
|
|
340
|
+
throw new Error("Could not detect browser platform");
|
|
341
|
+
}
|
|
342
|
+
const buildId = await resolveBuildId(BrowserType.CHROMEHEADLESSSHELL, platform, "stable");
|
|
343
|
+
logger?.debug(`[BrowserShim] Resolved Chrome Headless Shell build: ${buildId}`);
|
|
344
|
+
const installedBrowser = await install({
|
|
345
|
+
browser: BrowserType.CHROMEHEADLESSSHELL,
|
|
346
|
+
buildId,
|
|
347
|
+
cacheDir,
|
|
348
|
+
downloadProgressCallback: (downloadedBytes, totalBytes) => {
|
|
349
|
+
if (totalBytes > 0) {
|
|
350
|
+
const percent = Math.round(downloadedBytes / totalBytes * 100);
|
|
351
|
+
if (percent % 20 === 0) {
|
|
352
|
+
logger?.info(`[BrowserShim] Downloading Chrome... ${percent}%`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
cachedExecutablePath = installedBrowser.executablePath;
|
|
358
|
+
logger?.success(`[BrowserShim] Chrome ready: ${installedBrowser.executablePath}`);
|
|
359
|
+
return installedBrowser.executablePath;
|
|
360
|
+
}
|
|
361
|
+
function createBrowserShim(options = {}) {
|
|
362
|
+
const {
|
|
363
|
+
port = 8788,
|
|
364
|
+
host = "127.0.0.1",
|
|
365
|
+
logger,
|
|
366
|
+
verbose = false,
|
|
367
|
+
keepAlive = 60000,
|
|
368
|
+
cacheDir = join(homedir(), ".devflare", "chrome")
|
|
369
|
+
} = options;
|
|
370
|
+
let server = null;
|
|
371
|
+
let executablePath = null;
|
|
372
|
+
let WebSocketServerClass = null;
|
|
373
|
+
let WebSocketClass = null;
|
|
374
|
+
async function acquireSession(acquireOptions) {
|
|
375
|
+
if (!executablePath) {
|
|
376
|
+
throw new Error("Chrome not initialized");
|
|
377
|
+
}
|
|
378
|
+
const browser = await puppeteerCore.launch({
|
|
379
|
+
executablePath,
|
|
380
|
+
headless: true,
|
|
381
|
+
protocolTimeout: 120000,
|
|
382
|
+
args: [
|
|
383
|
+
"--no-sandbox",
|
|
384
|
+
"--disable-setuid-sandbox",
|
|
385
|
+
"--disable-dev-shm-usage",
|
|
386
|
+
"--disable-gpu",
|
|
387
|
+
"--disable-software-rasterizer",
|
|
388
|
+
"--disable-extensions",
|
|
389
|
+
"--disable-background-networking",
|
|
390
|
+
"--disable-background-timer-throttling",
|
|
391
|
+
"--disable-backgrounding-occluded-windows",
|
|
392
|
+
"--disable-renderer-backgrounding",
|
|
393
|
+
"--disable-features=TranslateUI",
|
|
394
|
+
"--disable-ipc-flooding-protection",
|
|
395
|
+
"--disable-default-apps",
|
|
396
|
+
"--mute-audio",
|
|
397
|
+
"--js-flags=--max-old-space-size=4096"
|
|
398
|
+
]
|
|
399
|
+
});
|
|
400
|
+
const wsEndpoint = browser.wsEndpoint();
|
|
401
|
+
const sessionId = crypto.randomUUID();
|
|
402
|
+
const session = {
|
|
403
|
+
sessionId,
|
|
404
|
+
browser,
|
|
405
|
+
wsEndpoint,
|
|
406
|
+
startTime: Date.now()
|
|
407
|
+
};
|
|
408
|
+
sessions.set(sessionId, session);
|
|
409
|
+
const timeout = acquireOptions?.keep_alive ?? keepAlive;
|
|
410
|
+
if (timeout > 0) {
|
|
411
|
+
session.idleTimeout = setTimeout(async () => {
|
|
412
|
+
const s = sessions.get(sessionId);
|
|
413
|
+
if (s && !s.connectionId) {
|
|
414
|
+
await closeSession(sessionId, 2, "BrowserIdle");
|
|
415
|
+
}
|
|
416
|
+
}, timeout);
|
|
417
|
+
}
|
|
418
|
+
if (verbose) {
|
|
419
|
+
logger?.debug(`[BrowserShim] Acquired session ${sessionId}`);
|
|
420
|
+
}
|
|
421
|
+
return { sessionId };
|
|
422
|
+
}
|
|
423
|
+
async function closeSession(sessionId, closeReason = 1, closeReasonText = "NormalClosure") {
|
|
424
|
+
const session = sessions.get(sessionId);
|
|
425
|
+
if (!session)
|
|
426
|
+
return;
|
|
427
|
+
if (session.idleTimeout) {
|
|
428
|
+
clearTimeout(session.idleTimeout);
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
await session.browser.close();
|
|
432
|
+
} catch {}
|
|
433
|
+
sessions.delete(sessionId);
|
|
434
|
+
history.unshift({
|
|
435
|
+
sessionId,
|
|
436
|
+
startTime: session.startTime,
|
|
437
|
+
endTime: Date.now(),
|
|
438
|
+
closeReason,
|
|
439
|
+
closeReasonText
|
|
440
|
+
});
|
|
441
|
+
if (history.length > 100) {
|
|
442
|
+
history.pop();
|
|
443
|
+
}
|
|
444
|
+
if (verbose) {
|
|
445
|
+
logger?.debug(`[BrowserShim] Closed session ${sessionId}: ${closeReasonText}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async function handleRequest(req, res) {
|
|
449
|
+
const url = new URL(req.url || "/", `http://${host}:${port}`);
|
|
450
|
+
const method = req.method || "GET";
|
|
451
|
+
logger?.debug(`[BrowserShim] ${method} ${url.pathname}${url.search ? url.search : ""}`);
|
|
452
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
453
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
454
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
455
|
+
if (method === "OPTIONS") {
|
|
456
|
+
res.writeHead(204);
|
|
457
|
+
res.end();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (url.pathname === "/v1/acquire" && (method === "POST" || method === "GET")) {
|
|
461
|
+
try {
|
|
462
|
+
let acquireOptions = {};
|
|
463
|
+
if (method === "GET") {
|
|
464
|
+
const keepAlive2 = url.searchParams.get("keep_alive");
|
|
465
|
+
if (keepAlive2) {
|
|
466
|
+
acquireOptions.keep_alive = parseInt(keepAlive2, 10);
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
try {
|
|
470
|
+
const body = await readBody(req);
|
|
471
|
+
acquireOptions = JSON.parse(body);
|
|
472
|
+
} catch {}
|
|
473
|
+
}
|
|
474
|
+
const result = await acquireSession(acquireOptions);
|
|
475
|
+
sendJson(res, 200, result);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
const msg = error instanceof Error ? error.message : "Failed to acquire browser";
|
|
478
|
+
logger?.error(`[BrowserShim] Acquire failed: ${msg}`);
|
|
479
|
+
sendJson(res, 500, { error: msg });
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (url.pathname === "/v1/sessions" && method === "GET") {
|
|
484
|
+
const activeSessions = Array.from(sessions.values()).map((s) => ({
|
|
485
|
+
sessionId: s.sessionId,
|
|
486
|
+
startTime: s.startTime,
|
|
487
|
+
connectionId: s.connectionId,
|
|
488
|
+
connectionStartTime: s.connectionStartTime
|
|
489
|
+
}));
|
|
490
|
+
sendJson(res, 200, activeSessions);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (url.pathname === "/v1/history" && method === "GET") {
|
|
494
|
+
sendJson(res, 200, history.slice(0, 50));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (url.pathname === "/v1/limits" && method === "GET") {
|
|
498
|
+
sendJson(res, 200, {
|
|
499
|
+
activeSessions: Array.from(sessions.keys()).map((id) => ({ id })),
|
|
500
|
+
allowedBrowserAcquisitions: 10,
|
|
501
|
+
maxConcurrentSessions: 10,
|
|
502
|
+
timeUntilNextAllowedBrowserAcquisition: 0
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (url.pathname.startsWith("/v1/session/") && method === "GET") {
|
|
507
|
+
const sessionId = url.pathname.slice("/v1/session/".length);
|
|
508
|
+
const session = sessions.get(sessionId);
|
|
509
|
+
if (!session) {
|
|
510
|
+
sendJson(res, 404, { error: "Session not found" });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
sendJson(res, 200, {
|
|
514
|
+
sessionId: session.sessionId,
|
|
515
|
+
wsEndpoint: session.wsEndpoint,
|
|
516
|
+
startTime: session.startTime,
|
|
517
|
+
connectionId: session.connectionId,
|
|
518
|
+
connectionStartTime: session.connectionStartTime
|
|
519
|
+
});
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (url.pathname === "/_devflare/browser/health") {
|
|
523
|
+
sendJson(res, 200, {
|
|
524
|
+
ok: true,
|
|
525
|
+
activeSessions: sessions.size,
|
|
526
|
+
historySize: history.length,
|
|
527
|
+
executablePath
|
|
528
|
+
});
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (url.pathname === "/v1/connectDevtools") {
|
|
532
|
+
res.writeHead(426, { "Content-Type": "text/plain" });
|
|
533
|
+
res.end("WebSocket upgrade required");
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
537
|
+
res.end("Not found");
|
|
538
|
+
}
|
|
539
|
+
function readBody(req) {
|
|
540
|
+
return new Promise((resolve2, reject) => {
|
|
541
|
+
const chunks = [];
|
|
542
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
543
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString()));
|
|
544
|
+
req.on("error", reject);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function sendJson(res, status, data) {
|
|
548
|
+
const body = JSON.stringify(data);
|
|
549
|
+
res.writeHead(status, {
|
|
550
|
+
"Content-Type": "application/json",
|
|
551
|
+
"Content-Length": Buffer.byteLength(body)
|
|
552
|
+
});
|
|
553
|
+
res.end(body);
|
|
554
|
+
}
|
|
555
|
+
async function start() {
|
|
556
|
+
logger?.info("[BrowserShim] Ensuring Chrome Headless Shell is available...");
|
|
557
|
+
executablePath = await ensureChrome(cacheDir, logger);
|
|
558
|
+
try {
|
|
559
|
+
const wsModule = await import("ws");
|
|
560
|
+
WebSocketServerClass = wsModule.WebSocketServer || wsModule.default?.WebSocketServer;
|
|
561
|
+
WebSocketClass = wsModule.WebSocket || wsModule.default?.WebSocket || wsModule.default;
|
|
562
|
+
} catch {
|
|
563
|
+
logger?.warn("[BrowserShim] ws package not found, WebSocket proxy disabled");
|
|
564
|
+
logger?.warn("[BrowserShim] Install with: npm install ws");
|
|
565
|
+
}
|
|
566
|
+
server = createServer((req, res) => {
|
|
567
|
+
handleRequest(req, res).catch((error) => {
|
|
568
|
+
logger?.error("[BrowserShim] Request error:", error);
|
|
569
|
+
res.writeHead(500);
|
|
570
|
+
res.end("Internal server error");
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
if (WebSocketServerClass) {
|
|
574
|
+
const wss = new WebSocketServerClass({ noServer: true });
|
|
575
|
+
server.on("upgrade", (request, socket, head) => {
|
|
576
|
+
const url = new URL(request.url || "/", `http://${host}:${port}`);
|
|
577
|
+
if (url.pathname !== "/v1/connectDevtools") {
|
|
578
|
+
socket.destroy();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const sessionId = url.searchParams.get("browser_session");
|
|
582
|
+
if (!sessionId) {
|
|
583
|
+
socket.write(`HTTP/1.1 400 Bad Request\r
|
|
584
|
+
\r
|
|
585
|
+
`);
|
|
586
|
+
socket.destroy();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const session = sessions.get(sessionId);
|
|
590
|
+
if (!session) {
|
|
591
|
+
socket.write(`HTTP/1.1 404 Not Found\r
|
|
592
|
+
\r
|
|
593
|
+
`);
|
|
594
|
+
socket.destroy();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const connectionId = crypto.randomUUID();
|
|
598
|
+
session.connectionId = connectionId;
|
|
599
|
+
session.connectionStartTime = Date.now();
|
|
600
|
+
if (session.idleTimeout) {
|
|
601
|
+
clearTimeout(session.idleTimeout);
|
|
602
|
+
session.idleTimeout = undefined;
|
|
603
|
+
}
|
|
604
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
605
|
+
if (verbose) {
|
|
606
|
+
logger?.debug(`[BrowserShim] WebSocket connected for session ${sessionId}`);
|
|
607
|
+
}
|
|
608
|
+
const chromeWs = new WebSocketClass(session.wsEndpoint);
|
|
609
|
+
let chromeConnected = false;
|
|
610
|
+
const connectTimeout = setTimeout(() => {
|
|
611
|
+
if (!chromeConnected) {
|
|
612
|
+
logger?.error("[BrowserShim] Chrome connection timeout");
|
|
613
|
+
try {
|
|
614
|
+
ws.close(1011, "Chrome connection timeout");
|
|
615
|
+
chromeWs.close();
|
|
616
|
+
} catch {}
|
|
617
|
+
closeSession(sessionId, 5, "ChromeConnectionTimeout").catch(() => {});
|
|
618
|
+
}
|
|
619
|
+
}, 1e4);
|
|
620
|
+
chromeWs.on("open", () => {
|
|
621
|
+
chromeConnected = true;
|
|
622
|
+
clearTimeout(connectTimeout);
|
|
623
|
+
if (verbose) {
|
|
624
|
+
logger?.debug("[BrowserShim] Connected to Chrome DevTools");
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
chromeWs.on("message", (data) => {
|
|
628
|
+
if (ws.readyState === 1) {
|
|
629
|
+
ws.send(data);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
chromeWs.on("close", (code, reason) => {
|
|
633
|
+
if (verbose) {
|
|
634
|
+
logger?.debug(`[BrowserShim] Chrome WS closed: ${code}`);
|
|
635
|
+
}
|
|
636
|
+
const validCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
637
|
+
try {
|
|
638
|
+
ws.close(validCode, reason?.toString?.() || "");
|
|
639
|
+
} catch {}
|
|
640
|
+
closeSession(sessionId, 2, "ChromeDisconnected").catch((err) => {
|
|
641
|
+
logger?.error("[BrowserShim] Error closing session after Chrome disconnect:", err);
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
chromeWs.on("error", (error) => {
|
|
645
|
+
logger?.error("[BrowserShim] Chrome WS error:", error.message);
|
|
646
|
+
try {
|
|
647
|
+
ws.close(1011, "Chrome WebSocket error");
|
|
648
|
+
} catch {}
|
|
649
|
+
closeSession(sessionId, 4, "ChromeError").catch((err) => {
|
|
650
|
+
logger?.error("[BrowserShim] Error closing session after Chrome error:", err);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
ws.on("message", (data) => {
|
|
654
|
+
if (chromeWs.readyState === 1) {
|
|
655
|
+
chromeWs.send(data);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
ws.on("close", (code, reason) => {
|
|
659
|
+
if (verbose) {
|
|
660
|
+
logger?.debug(`[BrowserShim] Client WS closed for session ${sessionId}`);
|
|
661
|
+
}
|
|
662
|
+
const validCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
663
|
+
try {
|
|
664
|
+
chromeWs.close(validCode, reason?.toString?.() || "");
|
|
665
|
+
} catch {}
|
|
666
|
+
const s = sessions.get(sessionId);
|
|
667
|
+
if (s && s.connectionId === connectionId) {
|
|
668
|
+
s.connectionId = undefined;
|
|
669
|
+
s.connectionStartTime = undefined;
|
|
670
|
+
closeSession(sessionId, 1, "ClientDisconnected").catch((err) => {
|
|
671
|
+
logger?.error("[BrowserShim] Error closing session after disconnect:", err);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
ws.on("error", (error) => {
|
|
676
|
+
logger?.error("[BrowserShim] Client WS error:", error.message);
|
|
677
|
+
try {
|
|
678
|
+
chromeWs.close();
|
|
679
|
+
} catch {}
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
await new Promise((resolve2, reject) => {
|
|
685
|
+
server.on("error", reject);
|
|
686
|
+
server.listen(port, host, () => {
|
|
687
|
+
resolve2();
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
logger?.success(`Browser shim server ready on http://${host}:${port}`);
|
|
691
|
+
}
|
|
692
|
+
async function stop() {
|
|
693
|
+
for (const sessionId of sessions.keys()) {
|
|
694
|
+
await closeSession(sessionId, 3, "ServerShutdown");
|
|
695
|
+
}
|
|
696
|
+
if (server) {
|
|
697
|
+
await new Promise((resolve2) => {
|
|
698
|
+
server.close(() => resolve2());
|
|
699
|
+
});
|
|
700
|
+
server = null;
|
|
701
|
+
}
|
|
702
|
+
logger?.info("Browser shim server stopped");
|
|
703
|
+
}
|
|
704
|
+
function getUrl() {
|
|
705
|
+
return `http://${host}:${port}`;
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
start,
|
|
709
|
+
stop,
|
|
710
|
+
getUrl
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
// src/browser-shim/binding-worker.ts
|
|
714
|
+
var MAX_CHUNK_SIZE = 1048575;
|
|
715
|
+
function getBrowserBindingScript(browserShimUrl, debug = false) {
|
|
716
|
+
const safeUrl = JSON.stringify(browserShimUrl);
|
|
717
|
+
return `
|
|
718
|
+
// Browser Binding Worker — Proxies puppeteer requests to external browser shim
|
|
719
|
+
// Handles WebSocket upgrades using WebSocketPair for @cloudflare/puppeteer compatibility
|
|
720
|
+
|
|
721
|
+
const BROWSER_SHIM_URL = ${safeUrl}
|
|
722
|
+
const MAX_CHUNK_SIZE = ${MAX_CHUNK_SIZE}
|
|
723
|
+
const DEBUG = ${debug}
|
|
724
|
+
const log = (...args) => DEBUG && console.log('[BrowserBinding]', ...args)
|
|
725
|
+
|
|
726
|
+
export default {
|
|
727
|
+
async fetch(request, env, ctx) {
|
|
728
|
+
const url = new URL(request.url)
|
|
729
|
+
const upgradeHeader = request.headers.get('Upgrade')
|
|
730
|
+
const isWebSocket = upgradeHeader && upgradeHeader.toLowerCase() === 'websocket'
|
|
731
|
+
|
|
732
|
+
log('Request:', url.pathname, isWebSocket ? '(WebSocket)' : '(HTTP)')
|
|
733
|
+
|
|
734
|
+
// Handle WebSocket upgrade for DevTools connection
|
|
735
|
+
if (url.pathname === '/v1/connectDevtools' && isWebSocket) {
|
|
736
|
+
return handleDevToolsWebSocket(request, url)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Proxy all other requests to the browser shim server
|
|
740
|
+
return proxyToBrowserShim(request, url)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Proxy HTTP requests to the external browser shim server
|
|
745
|
+
async function proxyToBrowserShim(request, url) {
|
|
746
|
+
const shimUrl = new URL(url.pathname + url.search, BROWSER_SHIM_URL)
|
|
747
|
+
|
|
748
|
+
log('Proxying to:', shimUrl.toString())
|
|
749
|
+
|
|
750
|
+
const response = await fetch(shimUrl.toString(), {
|
|
751
|
+
method: request.method,
|
|
752
|
+
headers: request.headers,
|
|
753
|
+
body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
log('Response:', response.status)
|
|
757
|
+
|
|
758
|
+
// Return the response as-is
|
|
759
|
+
return new Response(response.body, {
|
|
760
|
+
status: response.status,
|
|
761
|
+
statusText: response.statusText,
|
|
762
|
+
headers: response.headers
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Validate WebSocket close code to be in valid range
|
|
767
|
+
function validateCloseCode(code) {
|
|
768
|
+
if (typeof code !== 'number' || isNaN(code)) return 1000
|
|
769
|
+
if (code < 1000 || code > 4999) return 1000
|
|
770
|
+
return code
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Split a message into chunks following @cloudflare/puppeteer protocol
|
|
774
|
+
// First chunk has 4-byte LE length header, subsequent chunks are raw payload
|
|
775
|
+
function messageToChunks(message) {
|
|
776
|
+
const data = typeof message === 'string'
|
|
777
|
+
? new TextEncoder().encode(message)
|
|
778
|
+
: new Uint8Array(message)
|
|
779
|
+
|
|
780
|
+
const chunks = []
|
|
781
|
+
const totalLength = data.length
|
|
782
|
+
let offset = 0
|
|
783
|
+
let isFirst = true
|
|
784
|
+
|
|
785
|
+
while (offset < totalLength) {
|
|
786
|
+
const remaining = totalLength - offset
|
|
787
|
+
let chunkSize
|
|
788
|
+
|
|
789
|
+
if (isFirst) {
|
|
790
|
+
// First chunk: 4-byte header + payload
|
|
791
|
+
chunkSize = Math.min(remaining, MAX_CHUNK_SIZE - 4)
|
|
792
|
+
const chunk = new Uint8Array(chunkSize + 4)
|
|
793
|
+
new DataView(chunk.buffer).setUint32(0, totalLength, true) // little-endian
|
|
794
|
+
chunk.set(data.subarray(offset, offset + chunkSize), 4)
|
|
795
|
+
chunks.push(chunk)
|
|
796
|
+
isFirst = false
|
|
797
|
+
} else {
|
|
798
|
+
// Subsequent chunks: raw payload only
|
|
799
|
+
chunkSize = Math.min(remaining, MAX_CHUNK_SIZE)
|
|
800
|
+
const chunk = data.subarray(offset, offset + chunkSize)
|
|
801
|
+
chunks.push(chunk)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
offset += chunkSize
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return chunks
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Reassemble chunks back into a complete message
|
|
811
|
+
// Returns null if more chunks are needed
|
|
812
|
+
function chunksToMessage(chunks) {
|
|
813
|
+
if (chunks.length === 0) return null
|
|
814
|
+
|
|
815
|
+
// First chunk must have 4-byte header
|
|
816
|
+
const firstChunk = chunks[0]
|
|
817
|
+
if (firstChunk.length < 4) return null
|
|
818
|
+
|
|
819
|
+
const expectedLength = new DataView(firstChunk.buffer, firstChunk.byteOffset).getUint32(0, true)
|
|
820
|
+
|
|
821
|
+
// Calculate total received payload
|
|
822
|
+
let totalReceived = firstChunk.length - 4 // first chunk payload (minus header)
|
|
823
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
824
|
+
totalReceived += chunks[i].length
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (totalReceived < expectedLength) {
|
|
828
|
+
return null // Need more chunks
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Reassemble the message
|
|
832
|
+
const assembled = new Uint8Array(expectedLength)
|
|
833
|
+
let offset = 0
|
|
834
|
+
|
|
835
|
+
// Copy first chunk payload (skip 4-byte header)
|
|
836
|
+
const firstPayload = firstChunk.subarray(4)
|
|
837
|
+
assembled.set(firstPayload, offset)
|
|
838
|
+
offset += firstPayload.length
|
|
839
|
+
|
|
840
|
+
// Copy remaining chunks
|
|
841
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
842
|
+
const chunk = chunks[i]
|
|
843
|
+
const toCopy = Math.min(chunk.length, expectedLength - offset)
|
|
844
|
+
assembled.set(chunk.subarray(0, toCopy), offset)
|
|
845
|
+
offset += toCopy
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return new TextDecoder().decode(assembled)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Handle WebSocket upgrade for DevTools connection
|
|
852
|
+
// Creates a WebSocketPair and proxies to Chrome's DevTools WebSocket
|
|
853
|
+
async function handleDevToolsWebSocket(request, url) {
|
|
854
|
+
const sessionId = url.searchParams.get('browser_session')
|
|
855
|
+
if (!sessionId) {
|
|
856
|
+
return new Response('browser_session parameter required', { status: 400 })
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
log('DevTools WebSocket request for session:', sessionId)
|
|
860
|
+
|
|
861
|
+
// Get session info from browser shim (includes Chrome's wsEndpoint)
|
|
862
|
+
const sessionUrl = new URL('/v1/session/' + sessionId, BROWSER_SHIM_URL)
|
|
863
|
+
|
|
864
|
+
// Add timeout for session fetch
|
|
865
|
+
const controller = new AbortController()
|
|
866
|
+
const timeout = setTimeout(() => controller.abort(), 5000)
|
|
867
|
+
|
|
868
|
+
let sessionRes
|
|
869
|
+
try {
|
|
870
|
+
sessionRes = await fetch(sessionUrl.toString(), { signal: controller.signal })
|
|
871
|
+
} catch (e) {
|
|
872
|
+
DEBUG && console.error('[BrowserBinding] Session fetch timeout or error:', e.message)
|
|
873
|
+
return new Response('Session fetch timeout', { status: 504 })
|
|
874
|
+
} finally {
|
|
875
|
+
clearTimeout(timeout)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (!sessionRes.ok) {
|
|
879
|
+
DEBUG && console.error('[BrowserBinding] Session not found:', sessionId)
|
|
880
|
+
return new Response('Session not found', { status: 404 })
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const sessionInfo = await sessionRes.json()
|
|
884
|
+
const wsEndpoint = sessionInfo.wsEndpoint
|
|
885
|
+
|
|
886
|
+
if (!wsEndpoint) {
|
|
887
|
+
DEBUG && console.error('[BrowserBinding] No wsEndpoint in session info')
|
|
888
|
+
return new Response('No wsEndpoint for session', { status: 500 })
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
log('Connecting to Chrome DevTools:', wsEndpoint)
|
|
892
|
+
|
|
893
|
+
// Connect to Chrome's DevTools WebSocket
|
|
894
|
+
// Chrome uses ws:// but fetch expects http:// for WebSocket upgrade
|
|
895
|
+
const chromeUrl = wsEndpoint.replace('ws://', 'http://').replace('wss://', 'https://')
|
|
896
|
+
|
|
897
|
+
const chromeRes = await fetch(chromeUrl, {
|
|
898
|
+
headers: { Upgrade: 'websocket' }
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
if (!chromeRes.webSocket) {
|
|
902
|
+
DEBUG && console.error('[BrowserBinding] Failed to connect to Chrome DevTools')
|
|
903
|
+
return new Response('Failed to connect to Chrome DevTools', { status: 502 })
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const chromeWs = chromeRes.webSocket
|
|
907
|
+
chromeWs.accept()
|
|
908
|
+
|
|
909
|
+
log('Connected to Chrome DevTools')
|
|
910
|
+
|
|
911
|
+
// Create WebSocketPair for client connection
|
|
912
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
913
|
+
server.accept()
|
|
914
|
+
|
|
915
|
+
// Chunk buffer for reassembling multi-chunk messages from puppeteer
|
|
916
|
+
let chunks = []
|
|
917
|
+
const MAX_BUFFER_SIZE = 50 * 1024 * 1024 // 50MB max buffer
|
|
918
|
+
let bufferSize = 0
|
|
919
|
+
|
|
920
|
+
// Proxy messages from client (puppeteer) to Chrome
|
|
921
|
+
// Handle multi-chunk framing protocol
|
|
922
|
+
server.addEventListener('message', (event) => {
|
|
923
|
+
// Keep-alive ping from puppeteer
|
|
924
|
+
if (event.data === 'ping') {
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Handle binary data (chunked protocol)
|
|
929
|
+
if (event.data instanceof ArrayBuffer) {
|
|
930
|
+
const chunk = new Uint8Array(event.data)
|
|
931
|
+
bufferSize += chunk.length
|
|
932
|
+
|
|
933
|
+
// Prevent unbounded buffering
|
|
934
|
+
if (bufferSize > MAX_BUFFER_SIZE) {
|
|
935
|
+
DEBUG && console.error('[BrowserBinding] Buffer overflow, closing connection')
|
|
936
|
+
server.close(1009, 'Message too big')
|
|
937
|
+
chromeWs.close(1009, 'Message too big')
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
chunks.push(chunk)
|
|
942
|
+
|
|
943
|
+
// Try to reassemble complete message
|
|
944
|
+
const message = chunksToMessage(chunks)
|
|
945
|
+
if (message !== null) {
|
|
946
|
+
// Send complete message to Chrome
|
|
947
|
+
if (chromeWs.readyState === 1) { // OPEN
|
|
948
|
+
chromeWs.send(message)
|
|
949
|
+
}
|
|
950
|
+
// Clear buffer
|
|
951
|
+
chunks = []
|
|
952
|
+
bufferSize = 0
|
|
953
|
+
}
|
|
954
|
+
} else if (typeof event.data === 'string') {
|
|
955
|
+
// Shouldn't happen in normal protocol, but handle it
|
|
956
|
+
if (chromeWs.readyState === 1) {
|
|
957
|
+
chromeWs.send(event.data)
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
// Proxy messages from Chrome to client (puppeteer)
|
|
963
|
+
// Split into chunks following the multi-chunk protocol
|
|
964
|
+
chromeWs.addEventListener('message', (event) => {
|
|
965
|
+
if (server.readyState !== 1) return // Not OPEN
|
|
966
|
+
|
|
967
|
+
// Split message into chunks
|
|
968
|
+
const outChunks = messageToChunks(event.data)
|
|
969
|
+
for (const chunk of outChunks) {
|
|
970
|
+
server.send(chunk)
|
|
971
|
+
}
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
// Handle close events with validated codes
|
|
975
|
+
server.addEventListener('close', (event) => {
|
|
976
|
+
log('Client WebSocket closed:', event.code)
|
|
977
|
+
const code = validateCloseCode(event.code)
|
|
978
|
+
try {
|
|
979
|
+
if (chromeWs.readyState === 1 || chromeWs.readyState === 0) {
|
|
980
|
+
chromeWs.close(code, event.reason || '')
|
|
981
|
+
}
|
|
982
|
+
} catch {}
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
chromeWs.addEventListener('close', (event) => {
|
|
986
|
+
log('Chrome WebSocket closed:', event.code)
|
|
987
|
+
const code = validateCloseCode(event.code)
|
|
988
|
+
try {
|
|
989
|
+
if (server.readyState === 1 || server.readyState === 0) {
|
|
990
|
+
server.close(code, event.reason || '')
|
|
991
|
+
}
|
|
992
|
+
} catch {}
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
// Handle errors
|
|
996
|
+
server.addEventListener('error', (event) => {
|
|
997
|
+
DEBUG && console.error('[BrowserBinding] Client WebSocket error')
|
|
998
|
+
try { chromeWs.close(1011, 'Client error') } catch {}
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
chromeWs.addEventListener('error', (event) => {
|
|
1002
|
+
DEBUG && console.error('[BrowserBinding] Chrome WebSocket error')
|
|
1003
|
+
try { server.close(1011, 'Chrome error') } catch {}
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
log('WebSocket proxy established')
|
|
1007
|
+
|
|
1008
|
+
// Return Cloudflare-style WebSocket response
|
|
1009
|
+
return new Response(null, {
|
|
1010
|
+
status: 101,
|
|
1011
|
+
webSocket: client
|
|
1012
|
+
})
|
|
1013
|
+
}
|
|
1014
|
+
`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/cli/wrangler-auth.ts
|
|
1018
|
+
import { exec } from "node:child_process";
|
|
1019
|
+
import { promisify } from "node:util";
|
|
1020
|
+
var execAsync = promisify(exec);
|
|
1021
|
+
function detectRemoteBindings(config) {
|
|
1022
|
+
const remoteBindings = [];
|
|
1023
|
+
const bindings = config.bindings;
|
|
1024
|
+
if (!bindings)
|
|
1025
|
+
return remoteBindings;
|
|
1026
|
+
if (bindings.ai) {
|
|
1027
|
+
remoteBindings.push(`AI (binding: ${bindings.ai.binding})`);
|
|
1028
|
+
}
|
|
1029
|
+
if (bindings.vectorize) {
|
|
1030
|
+
for (const [name] of Object.entries(bindings.vectorize)) {
|
|
1031
|
+
remoteBindings.push(`Vectorize (binding: ${name})`);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return remoteBindings;
|
|
1035
|
+
}
|
|
1036
|
+
async function checkWranglerAuth() {
|
|
1037
|
+
try {
|
|
1038
|
+
const { stdout, stderr } = await execAsync("bunx wrangler whoami", {
|
|
1039
|
+
timeout: 15000
|
|
1040
|
+
});
|
|
1041
|
+
const output = stdout + stderr;
|
|
1042
|
+
if (output.includes("not authenticated") || output.includes("Not logged in") || output.includes("wrangler login")) {
|
|
1043
|
+
return {
|
|
1044
|
+
loggedIn: false,
|
|
1045
|
+
error: "Not logged in to Wrangler"
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
const emailMatch = output.match(/email[:\s]+([^\s!]+)/i);
|
|
1049
|
+
const accountMatch = output.match(/Account\s+ID[:\s]+([a-f0-9]+)/i);
|
|
1050
|
+
return {
|
|
1051
|
+
loggedIn: true,
|
|
1052
|
+
email: emailMatch?.[1],
|
|
1053
|
+
accountId: accountMatch?.[1]
|
|
1054
|
+
};
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1057
|
+
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
1058
|
+
return {
|
|
1059
|
+
loggedIn: false,
|
|
1060
|
+
error: "Wrangler not installed. Run: npm install -g wrangler"
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
loggedIn: false,
|
|
1065
|
+
error: msg
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async function checkRemoteBindingRequirements(config) {
|
|
1070
|
+
const remoteBindings = detectRemoteBindings(config);
|
|
1071
|
+
if (remoteBindings.length === 0) {
|
|
1072
|
+
return {
|
|
1073
|
+
hasRemoteBindings: false,
|
|
1074
|
+
remoteBindings: [],
|
|
1075
|
+
missingAccountId: false,
|
|
1076
|
+
notLoggedIn: false
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
const missingAccountId = !config.accountId;
|
|
1080
|
+
const authStatus = await checkWranglerAuth();
|
|
1081
|
+
const notLoggedIn = !authStatus.loggedIn;
|
|
1082
|
+
return {
|
|
1083
|
+
hasRemoteBindings: true,
|
|
1084
|
+
remoteBindings,
|
|
1085
|
+
missingAccountId,
|
|
1086
|
+
notLoggedIn
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// src/dev-server/server.ts
|
|
1091
|
+
function getGatewayScript(wsRoutes = [], debug = false) {
|
|
1092
|
+
const wsRoutesJson = JSON.stringify(wsRoutes);
|
|
1093
|
+
return `
|
|
1094
|
+
// Bridge Gateway Worker — RPC Handler
|
|
1095
|
+
// Handles all binding operations via WebSocket RPC
|
|
1096
|
+
// Also handles WebSocket proxying to Durable Objects
|
|
1097
|
+
|
|
1098
|
+
const DEBUG = ${debug}
|
|
1099
|
+
const log = (...args) => DEBUG && console.log('[Gateway]', ...args)
|
|
1100
|
+
|
|
1101
|
+
const activeStreams = new Map()
|
|
1102
|
+
const wsProxies = new Map()
|
|
1103
|
+
const incomingStreams = new Map()
|
|
1104
|
+
|
|
1105
|
+
// WebSocket routes configuration (injected at build time)
|
|
1106
|
+
const WS_ROUTES = ${wsRoutesJson}
|
|
1107
|
+
|
|
1108
|
+
export default {
|
|
1109
|
+
async fetch(request, env, ctx) {
|
|
1110
|
+
const url = new URL(request.url)
|
|
1111
|
+
const isWebSocket = request.headers.get('Upgrade') === 'websocket'
|
|
1112
|
+
|
|
1113
|
+
// Check if this is a WebSocket request matching a DO route
|
|
1114
|
+
if (isWebSocket) {
|
|
1115
|
+
const matchedRoute = matchWsRoute(url.pathname)
|
|
1116
|
+
if (matchedRoute) {
|
|
1117
|
+
return handleDoWebSocket(request, env, url, matchedRoute)
|
|
1118
|
+
}
|
|
1119
|
+
// Otherwise handle as bridge RPC WebSocket
|
|
1120
|
+
return handleBridgeWebSocket(request, env, ctx)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// HTTP endpoint for large file transfers
|
|
1124
|
+
if (url.pathname.startsWith('/_devflare/transfer/')) {
|
|
1125
|
+
return handleHttpTransfer(request, env, url)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// D1 migration endpoint
|
|
1129
|
+
if (url.pathname === '/_devflare/migrate' && request.method === 'POST') {
|
|
1130
|
+
return handleMigration(request, env)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Email handler endpoint (simulates incoming email)
|
|
1134
|
+
if (url.pathname === '/cdn-cgi/handler/email' && request.method === 'POST') {
|
|
1135
|
+
return handleEmailIncoming(request, env, ctx, url)
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Health check
|
|
1139
|
+
if (url.pathname === '/_devflare/health') {
|
|
1140
|
+
return new Response(JSON.stringify({
|
|
1141
|
+
ok: true,
|
|
1142
|
+
bindings: Object.keys(env),
|
|
1143
|
+
wsRoutes: WS_ROUTES
|
|
1144
|
+
}), { headers: { 'Content-Type': 'application/json' } })
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
return new Response('Devflare Bridge Gateway', { status: 200 })
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Handle D1 migrations
|
|
1152
|
+
async function handleMigration(request, env) {
|
|
1153
|
+
try {
|
|
1154
|
+
const { bindingName, statements } = await request.json()
|
|
1155
|
+
log('Migration request for binding:', bindingName, 'statements count:', statements?.length, 'bindings:', Object.keys(env))
|
|
1156
|
+
const db = env[bindingName]
|
|
1157
|
+
if (!db) {
|
|
1158
|
+
return Response.json({ error: 'Binding not found: ' + bindingName }, { status: 404 })
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const results = []
|
|
1162
|
+
for (const sql of statements) {
|
|
1163
|
+
try {
|
|
1164
|
+
log('Running migration SQL:', sql.slice(0, 80))
|
|
1165
|
+
await db.prepare(sql).run()
|
|
1166
|
+
results.push({ sql: sql.slice(0, 50), success: true })
|
|
1167
|
+
log('Migration SQL succeeded')
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
const msg = error?.message || String(error)
|
|
1170
|
+
log('Migration SQL error:', msg)
|
|
1171
|
+
if (msg.includes('already exists')) {
|
|
1172
|
+
results.push({ sql: sql.slice(0, 50), success: true, skipped: true })
|
|
1173
|
+
} else {
|
|
1174
|
+
results.push({ sql: sql.slice(0, 50), success: false, error: msg })
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Verify table exists after migration
|
|
1180
|
+
try {
|
|
1181
|
+
const tables = await db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
|
1182
|
+
log('Tables after migration:', JSON.stringify(tables))
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
log('Error listing tables:', e.message)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return Response.json({ success: true, results })
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
return Response.json({ error: error?.message || String(error) }, { status: 500 })
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Handle incoming email (simulates email() handler)
|
|
1194
|
+
async function handleEmailIncoming(request, env, ctx, url) {
|
|
1195
|
+
try {
|
|
1196
|
+
const from = url.searchParams.get('from') || 'unknown@example.com'
|
|
1197
|
+
const to = url.searchParams.get('to') || 'worker@example.com'
|
|
1198
|
+
const rawBody = await request.text()
|
|
1199
|
+
|
|
1200
|
+
log('Email incoming:', { from, to, bodyLength: rawBody.length })
|
|
1201
|
+
|
|
1202
|
+
// Parse headers from raw email for the Headers object
|
|
1203
|
+
const headerLines = []
|
|
1204
|
+
const lines = rawBody.split(/\\r?\\n/)
|
|
1205
|
+
let bodyStart = 0
|
|
1206
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1207
|
+
if (lines[i].trim() === '') {
|
|
1208
|
+
bodyStart = i + 1
|
|
1209
|
+
break
|
|
1210
|
+
}
|
|
1211
|
+
headerLines.push(lines[i])
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const headers = new Headers()
|
|
1215
|
+
for (const line of headerLines) {
|
|
1216
|
+
const colonIdx = line.indexOf(':')
|
|
1217
|
+
if (colonIdx > 0) {
|
|
1218
|
+
const key = line.slice(0, colonIdx).trim()
|
|
1219
|
+
const value = line.slice(colonIdx + 1).trim()
|
|
1220
|
+
headers.append(key, value)
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Create ReadableStream from raw email
|
|
1225
|
+
const rawStream = new ReadableStream({
|
|
1226
|
+
start(controller) {
|
|
1227
|
+
controller.enqueue(new TextEncoder().encode(rawBody))
|
|
1228
|
+
controller.close()
|
|
1229
|
+
}
|
|
1230
|
+
})
|
|
1231
|
+
|
|
1232
|
+
// Create ForwardableEmailMessage-like object
|
|
1233
|
+
const emailMessage = {
|
|
1234
|
+
from,
|
|
1235
|
+
to,
|
|
1236
|
+
headers,
|
|
1237
|
+
raw: rawStream,
|
|
1238
|
+
rawSize: rawBody.length,
|
|
1239
|
+
|
|
1240
|
+
setReject(reason) {
|
|
1241
|
+
log('Email rejected:', reason)
|
|
1242
|
+
},
|
|
1243
|
+
|
|
1244
|
+
async forward(rcptTo, extraHeaders) {
|
|
1245
|
+
log('Email forwarded to:', rcptTo)
|
|
1246
|
+
return Promise.resolve()
|
|
1247
|
+
},
|
|
1248
|
+
|
|
1249
|
+
async reply(message) {
|
|
1250
|
+
log('Email reply sent to:', message.from)
|
|
1251
|
+
return Promise.resolve()
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Look for email handler in the worker module
|
|
1256
|
+
// For now, we call via a special RPC method that DO workers can implement
|
|
1257
|
+
// The email binding should be configured in the worker
|
|
1258
|
+
|
|
1259
|
+
// Check if there's an EMAIL_HANDLER binding (special DO for email handling)
|
|
1260
|
+
if (env.__emailHandler && typeof env.__emailHandler.email === 'function') {
|
|
1261
|
+
await env.__emailHandler.email(emailMessage, env, ctx)
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
return new Response(JSON.stringify({ ok: true, from, to }), {
|
|
1265
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1266
|
+
})
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
console.error('[Gateway] Email handler error:', error)
|
|
1269
|
+
return Response.json({ error: error?.message || String(error) }, { status: 500 })
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Match URL path against configured WS routes
|
|
1274
|
+
function matchWsRoute(pathname) {
|
|
1275
|
+
for (const route of WS_ROUTES) {
|
|
1276
|
+
// Simple exact match for now (could add glob/regex later)
|
|
1277
|
+
if (pathname === route.pattern || pathname.startsWith(route.pattern + '?')) {
|
|
1278
|
+
return route
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return null
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Handle WebSocket upgrade that should go to a Durable Object
|
|
1285
|
+
async function handleDoWebSocket(request, env, url, route) {
|
|
1286
|
+
try {
|
|
1287
|
+
// Get the DO namespace
|
|
1288
|
+
const namespace = env[route.doNamespace]
|
|
1289
|
+
if (!namespace) {
|
|
1290
|
+
console.error('[Gateway] DO namespace not found:', route.doNamespace)
|
|
1291
|
+
return new Response('DO namespace not found: ' + route.doNamespace, { status: 500 })
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Get the instance ID from query params
|
|
1295
|
+
const idValue = url.searchParams.get(route.idParam) || 'default'
|
|
1296
|
+
|
|
1297
|
+
// Get or create DO instance
|
|
1298
|
+
const doId = namespace.idFromName(idValue)
|
|
1299
|
+
const stub = namespace.get(doId)
|
|
1300
|
+
|
|
1301
|
+
// Construct the forward URL for the DO
|
|
1302
|
+
const forwardUrl = new URL(route.forwardPath, url.origin)
|
|
1303
|
+
// Forward all query params
|
|
1304
|
+
url.searchParams.forEach((v, k) => forwardUrl.searchParams.set(k, v))
|
|
1305
|
+
|
|
1306
|
+
log('Forwarding WebSocket to DO:', route.doNamespace, 'id:', idValue, 'path:', forwardUrl.pathname)
|
|
1307
|
+
|
|
1308
|
+
// Forward the request to the DO
|
|
1309
|
+
return stub.fetch(forwardUrl.toString(), {
|
|
1310
|
+
method: request.method,
|
|
1311
|
+
headers: request.headers
|
|
1312
|
+
})
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
console.error('[Gateway] Error forwarding to DO:', error)
|
|
1315
|
+
return new Response('Error forwarding to DO: ' + error.message, { status: 500 })
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Handle bridge RPC WebSocket (for Node.js Vite server communication)
|
|
1320
|
+
function handleBridgeWebSocket(request, env, ctx) {
|
|
1321
|
+
const { 0: client, 1: server } = new WebSocketPair()
|
|
1322
|
+
server.accept()
|
|
1323
|
+
|
|
1324
|
+
server.addEventListener('message', async (event) => {
|
|
1325
|
+
try {
|
|
1326
|
+
if (typeof event.data === 'string') {
|
|
1327
|
+
await handleJsonMessage(event.data, server, env, ctx)
|
|
1328
|
+
}
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
console.error('[Gateway] Error:', error)
|
|
1331
|
+
}
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
server.addEventListener('close', () => {
|
|
1335
|
+
activeStreams.clear()
|
|
1336
|
+
wsProxies.clear()
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
return new Response(null, { status: 101, webSocket: client })
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function handleJsonMessage(data, ws, env, ctx) {
|
|
1343
|
+
const msg = JSON.parse(data)
|
|
1344
|
+
|
|
1345
|
+
switch (msg.t) {
|
|
1346
|
+
case 'rpc.call':
|
|
1347
|
+
await handleRpcCall(msg, ws, env, ctx)
|
|
1348
|
+
break
|
|
1349
|
+
case 'ws.open':
|
|
1350
|
+
await handleWsOpen(msg, ws, env)
|
|
1351
|
+
break
|
|
1352
|
+
case 'ws.close':
|
|
1353
|
+
handleWsClose(msg)
|
|
1354
|
+
break
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
async function handleRpcCall(msg, ws, env, ctx) {
|
|
1359
|
+
try {
|
|
1360
|
+
const result = await executeRpcMethod(msg.method, msg.params, env, ctx)
|
|
1361
|
+
ws.send(JSON.stringify({ t: 'rpc.ok', id: msg.id, result }))
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
ws.send(JSON.stringify({
|
|
1364
|
+
t: 'rpc.err',
|
|
1365
|
+
id: msg.id,
|
|
1366
|
+
error: { code: error.code || 'INTERNAL_ERROR', message: error.message }
|
|
1367
|
+
}))
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
async function executeRpcMethod(method, params, env, ctx) {
|
|
1372
|
+
const parts = method.split('.')
|
|
1373
|
+
const bindingName = parts[0]
|
|
1374
|
+
const operation = parts.slice(1).join('.')
|
|
1375
|
+
const binding = env[bindingName]
|
|
1376
|
+
|
|
1377
|
+
if (!binding) throw new Error('Binding not found: ' + bindingName)
|
|
1378
|
+
|
|
1379
|
+
// KV operations
|
|
1380
|
+
if (operation === 'get') return binding.get(params[0], params[1])
|
|
1381
|
+
if (operation === 'put') return binding.put(params[0], params[1], params[2])
|
|
1382
|
+
if (operation === 'delete') return binding.delete(params[0])
|
|
1383
|
+
if (operation === 'list') return binding.list(params[0])
|
|
1384
|
+
if (operation === 'getWithMetadata') return binding.getWithMetadata(params[0], params[1])
|
|
1385
|
+
|
|
1386
|
+
// R2 operations
|
|
1387
|
+
if (operation === 'head') return serializeR2Object(await binding.head(params[0]))
|
|
1388
|
+
if (operation === 'r2.get') {
|
|
1389
|
+
const obj = await binding.get(params[0], params[1])
|
|
1390
|
+
if (!obj) return null
|
|
1391
|
+
const body = await obj.arrayBuffer()
|
|
1392
|
+
return serializeR2ObjectBody(obj, arrayBufferToBase64(body))
|
|
1393
|
+
}
|
|
1394
|
+
if (operation === 'r2.put') {
|
|
1395
|
+
// Deserialize the value if it's a serialized ArrayBuffer/Uint8Array
|
|
1396
|
+
let value = params[1]
|
|
1397
|
+
if (value && typeof value === 'object') {
|
|
1398
|
+
if (value.__type === 'ArrayBuffer') {
|
|
1399
|
+
value = base64ToArrayBuffer(value.data)
|
|
1400
|
+
} else if (value.__type === 'Uint8Array') {
|
|
1401
|
+
value = base64ToArrayBuffer(value.data)
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
return serializeR2Object(await binding.put(params[0], value, params[2]))
|
|
1405
|
+
}
|
|
1406
|
+
if (operation === 'r2.delete') return binding.delete(params[0])
|
|
1407
|
+
if (operation === 'r2.list') return serializeR2Objects(await binding.list(params[0]))
|
|
1408
|
+
|
|
1409
|
+
// D1 operations
|
|
1410
|
+
if (operation === 'exec') return binding.exec(params[0])
|
|
1411
|
+
if (operation.startsWith('stmt.')) {
|
|
1412
|
+
log('D1 RPC:', bindingName, operation, 'sql:', String(params[0]).slice(0, 60))
|
|
1413
|
+
const mode = operation.split('.')[1]
|
|
1414
|
+
const [sql, ...rest] = params
|
|
1415
|
+
|
|
1416
|
+
// For first/raw, the last element is the column/options parameter (may be undefined)
|
|
1417
|
+
// For all/run, rest contains only bindings
|
|
1418
|
+
let bindings = rest
|
|
1419
|
+
let extraParam = undefined
|
|
1420
|
+
|
|
1421
|
+
if (mode === 'first' || mode === 'raw') {
|
|
1422
|
+
// Last element is the column/options (may be undefined)
|
|
1423
|
+
extraParam = rest[rest.length - 1]
|
|
1424
|
+
bindings = rest.slice(0, -1)
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
let stmt = binding.prepare(sql)
|
|
1428
|
+
if (bindings.length > 0) stmt = stmt.bind(...bindings)
|
|
1429
|
+
|
|
1430
|
+
if (mode === 'first') {
|
|
1431
|
+
// Only pass column if it's a non-empty string
|
|
1432
|
+
if (typeof extraParam === 'string' && extraParam.length > 0) {
|
|
1433
|
+
return stmt.first(extraParam)
|
|
1434
|
+
}
|
|
1435
|
+
return stmt.first()
|
|
1436
|
+
}
|
|
1437
|
+
if (mode === 'all') return stmt.all()
|
|
1438
|
+
if (mode === 'run') return stmt.run()
|
|
1439
|
+
if (mode === 'raw') return stmt.raw(extraParam)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// DO operations
|
|
1443
|
+
if (operation === 'idFromName') {
|
|
1444
|
+
const id = binding.idFromName(params[0])
|
|
1445
|
+
return { __type: 'DOId', hex: id.toString() }
|
|
1446
|
+
}
|
|
1447
|
+
if (operation === 'idFromString') {
|
|
1448
|
+
const id = binding.idFromString(params[0])
|
|
1449
|
+
return { __type: 'DOId', hex: id.toString() }
|
|
1450
|
+
}
|
|
1451
|
+
if (operation === 'newUniqueId') {
|
|
1452
|
+
const id = binding.newUniqueId(params[0])
|
|
1453
|
+
return { __type: 'DOId', hex: id.toString() }
|
|
1454
|
+
}
|
|
1455
|
+
if (operation === 'stub.fetch') {
|
|
1456
|
+
const [, serializedId, serializedReq] = params
|
|
1457
|
+
log('stub.fetch request:', {
|
|
1458
|
+
url: serializedReq.url,
|
|
1459
|
+
method: serializedReq.method,
|
|
1460
|
+
headers: serializedReq.headers,
|
|
1461
|
+
hasBody: !!serializedReq.body
|
|
1462
|
+
})
|
|
1463
|
+
const id = binding.idFromString(serializedId.hex)
|
|
1464
|
+
const stub = binding.get(id)
|
|
1465
|
+
try {
|
|
1466
|
+
const response = await stub.fetch(new Request(serializedReq.url, {
|
|
1467
|
+
method: serializedReq.method,
|
|
1468
|
+
headers: serializedReq.headers,
|
|
1469
|
+
body: serializedReq.body?.type === 'bytes' ? base64ToArrayBuffer(serializedReq.body.data) : undefined
|
|
1470
|
+
}))
|
|
1471
|
+
// Clone to read body for logging if there's an error
|
|
1472
|
+
const cloned = response.clone()
|
|
1473
|
+
const serialized = await serializeResponse(response)
|
|
1474
|
+
log('stub.fetch response:', {
|
|
1475
|
+
status: serialized.status,
|
|
1476
|
+
headers: serialized.headers,
|
|
1477
|
+
bodyLength: serialized.body?.data?.length || 0
|
|
1478
|
+
})
|
|
1479
|
+
// If 500, log the body content
|
|
1480
|
+
if (response.status >= 400) {
|
|
1481
|
+
const errBody = await cloned.text()
|
|
1482
|
+
log('Error response body:', errBody)
|
|
1483
|
+
}
|
|
1484
|
+
return serialized
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
console.error('[Gateway] stub.fetch error:', err)
|
|
1487
|
+
throw err
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (operation === 'stub.rpc') {
|
|
1491
|
+
const [, serializedId, methodName, args] = params
|
|
1492
|
+
const id = binding.idFromString(serializedId.hex)
|
|
1493
|
+
const stub = binding.get(id)
|
|
1494
|
+
const response = await stub.fetch(new Request('http://do/_rpc', {
|
|
1495
|
+
method: 'POST',
|
|
1496
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1497
|
+
body: JSON.stringify({ method: methodName, params: args })
|
|
1498
|
+
}))
|
|
1499
|
+
const result = await response.json()
|
|
1500
|
+
if (!result.ok) throw new Error(result.error?.message || 'RPC failed')
|
|
1501
|
+
return result.result
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Queue operations
|
|
1505
|
+
if (operation === 'send') return binding.send(params[0], params[1])
|
|
1506
|
+
if (operation === 'sendBatch') return binding.sendBatch(params[0], params[1])
|
|
1507
|
+
|
|
1508
|
+
// Email send operations (send_email binding)
|
|
1509
|
+
if (operation === 'email.send') {
|
|
1510
|
+
log('Email send:', { from: params[0]?.from, to: params[0]?.to })
|
|
1511
|
+
// In local dev, we just log the email - Miniflare handles writing to file
|
|
1512
|
+
if (binding && typeof binding.send === 'function') {
|
|
1513
|
+
return binding.send(params[0])
|
|
1514
|
+
}
|
|
1515
|
+
// Return success even if no real binding (simulated)
|
|
1516
|
+
return { ok: true, simulated: true }
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
throw new Error('Unknown operation: ' + method)
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
async function handleWsOpen(msg, ws, env) {
|
|
1523
|
+
try {
|
|
1524
|
+
const binding = env[msg.target.binding]
|
|
1525
|
+
const id = binding.idFromString(msg.target.id)
|
|
1526
|
+
const stub = binding.get(id)
|
|
1527
|
+
|
|
1528
|
+
const headers = new Headers(msg.target.headers || [])
|
|
1529
|
+
headers.set('Upgrade', 'websocket')
|
|
1530
|
+
|
|
1531
|
+
const response = await stub.fetch(new Request(msg.target.url, { method: 'GET', headers }))
|
|
1532
|
+
const doWs = response.webSocket
|
|
1533
|
+
|
|
1534
|
+
if (!doWs) {
|
|
1535
|
+
ws.send(JSON.stringify({ t: 'rpc.err', id: 'ws_' + msg.wid, error: { code: 'WS_FAILED', message: 'No WebSocket returned' } }))
|
|
1536
|
+
return
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
doWs.accept()
|
|
1540
|
+
wsProxies.set(msg.wid, { doWs })
|
|
1541
|
+
|
|
1542
|
+
doWs.addEventListener('message', (event) => {
|
|
1543
|
+
const isText = typeof event.data === 'string'
|
|
1544
|
+
const data = isText ? event.data : arrayBufferToBase64(event.data)
|
|
1545
|
+
ws.send(JSON.stringify({ t: 'ws.data', wid: msg.wid, data, isText }))
|
|
1546
|
+
})
|
|
1547
|
+
|
|
1548
|
+
doWs.addEventListener('close', (event) => {
|
|
1549
|
+
ws.send(JSON.stringify({ t: 'ws.close', wid: msg.wid, code: event.code, reason: event.reason }))
|
|
1550
|
+
wsProxies.delete(msg.wid)
|
|
1551
|
+
})
|
|
1552
|
+
|
|
1553
|
+
ws.send(JSON.stringify({ t: 'ws.opened', wid: msg.wid }))
|
|
1554
|
+
} catch (error) {
|
|
1555
|
+
ws.send(JSON.stringify({ t: 'rpc.err', id: 'ws_' + msg.wid, error: { code: 'WS_FAILED', message: error.message } }))
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function handleWsClose(msg) {
|
|
1560
|
+
const proxy = wsProxies.get(msg.wid)
|
|
1561
|
+
if (proxy) {
|
|
1562
|
+
proxy.doWs.close(msg.code, msg.reason)
|
|
1563
|
+
wsProxies.delete(msg.wid)
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
async function handleHttpTransfer(request, env, url) {
|
|
1568
|
+
const transferIdEncoded = url.pathname.split('/').pop()
|
|
1569
|
+
const transferId = decodeURIComponent(transferIdEncoded || '')
|
|
1570
|
+
const [binding, ...keyParts] = transferId.split(':')
|
|
1571
|
+
const key = keyParts.join(':')
|
|
1572
|
+
const bucket = env[binding]
|
|
1573
|
+
|
|
1574
|
+
if (!bucket) return new Response('Bucket not found: ' + binding, { status: 404 })
|
|
1575
|
+
|
|
1576
|
+
if (request.method === 'PUT' || request.method === 'POST') {
|
|
1577
|
+
const result = await bucket.put(key, request.body)
|
|
1578
|
+
return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } })
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (request.method === 'GET') {
|
|
1582
|
+
const object = await bucket.get(key)
|
|
1583
|
+
if (!object) return new Response('Not found', { status: 404 })
|
|
1584
|
+
return new Response(object.body, {
|
|
1585
|
+
headers: {
|
|
1586
|
+
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
|
|
1587
|
+
'Content-Length': String(object.size)
|
|
1588
|
+
}
|
|
1589
|
+
})
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
return new Response('Method not allowed', { status: 405 })
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Helpers
|
|
1596
|
+
function serializeR2Object(obj) {
|
|
1597
|
+
if (!obj) return null
|
|
1598
|
+
return {
|
|
1599
|
+
__type: 'R2Object',
|
|
1600
|
+
key: obj.key,
|
|
1601
|
+
version: obj.version,
|
|
1602
|
+
size: obj.size,
|
|
1603
|
+
etag: obj.etag,
|
|
1604
|
+
httpEtag: obj.httpEtag,
|
|
1605
|
+
checksums: obj.checksums,
|
|
1606
|
+
uploaded: obj.uploaded?.toISOString(),
|
|
1607
|
+
httpMetadata: obj.httpMetadata,
|
|
1608
|
+
customMetadata: obj.customMetadata,
|
|
1609
|
+
range: obj.range,
|
|
1610
|
+
storageClass: obj.storageClass
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
function serializeR2ObjectBody(obj, bodyData) {
|
|
1614
|
+
if (!obj) return null
|
|
1615
|
+
return {
|
|
1616
|
+
__type: 'R2ObjectBody',
|
|
1617
|
+
key: obj.key,
|
|
1618
|
+
version: obj.version,
|
|
1619
|
+
size: obj.size,
|
|
1620
|
+
etag: obj.etag,
|
|
1621
|
+
httpEtag: obj.httpEtag,
|
|
1622
|
+
checksums: obj.checksums,
|
|
1623
|
+
uploaded: obj.uploaded?.toISOString(),
|
|
1624
|
+
httpMetadata: obj.httpMetadata,
|
|
1625
|
+
customMetadata: obj.customMetadata,
|
|
1626
|
+
range: obj.range,
|
|
1627
|
+
storageClass: obj.storageClass,
|
|
1628
|
+
bodyData
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
function serializeR2Objects(result) {
|
|
1632
|
+
if (!result) return null
|
|
1633
|
+
return { objects: result.objects.map(serializeR2Object), truncated: result.truncated, cursor: result.cursor }
|
|
1634
|
+
}
|
|
1635
|
+
async function serializeResponse(response) {
|
|
1636
|
+
// Read body as bytes and encode as base64
|
|
1637
|
+
let body = null
|
|
1638
|
+
if (response.body) {
|
|
1639
|
+
const bytes = await response.arrayBuffer()
|
|
1640
|
+
if (bytes.byteLength > 0) {
|
|
1641
|
+
body = { type: 'bytes', data: arrayBufferToBase64(bytes) }
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return { status: response.status, statusText: response.statusText, headers: [...response.headers.entries()], body }
|
|
1645
|
+
}
|
|
1646
|
+
function arrayBufferToBase64(buffer) {
|
|
1647
|
+
const bytes = new Uint8Array(buffer)
|
|
1648
|
+
let binary = ''
|
|
1649
|
+
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
|
1650
|
+
return btoa(binary)
|
|
1651
|
+
}
|
|
1652
|
+
function base64ToArrayBuffer(base64) {
|
|
1653
|
+
const binary = atob(base64)
|
|
1654
|
+
const bytes = new Uint8Array(binary.length)
|
|
1655
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
1656
|
+
return bytes.buffer
|
|
1657
|
+
}
|
|
1658
|
+
`;
|
|
1659
|
+
}
|
|
1660
|
+
function createDevServer(options) {
|
|
1661
|
+
const {
|
|
1662
|
+
cwd,
|
|
1663
|
+
configPath,
|
|
1664
|
+
vitePort = 5173,
|
|
1665
|
+
miniflarePort = 8787,
|
|
1666
|
+
persist = true,
|
|
1667
|
+
logger,
|
|
1668
|
+
verbose = false,
|
|
1669
|
+
debug = process.env.DEVFLARE_DEBUG === "true"
|
|
1670
|
+
} = options;
|
|
1671
|
+
let miniflare = null;
|
|
1672
|
+
let doBundler = null;
|
|
1673
|
+
let viteProcess = null;
|
|
1674
|
+
let config = null;
|
|
1675
|
+
let browserShim = null;
|
|
1676
|
+
let browserShimPort = 8788;
|
|
1677
|
+
function buildMiniflareConfig(doResult) {
|
|
1678
|
+
if (!config)
|
|
1679
|
+
throw new Error("Config not loaded");
|
|
1680
|
+
const bindings = config.bindings ?? {};
|
|
1681
|
+
const persistPath = resolve2(cwd, ".devflare/data");
|
|
1682
|
+
const sharedOptions = {
|
|
1683
|
+
port: miniflarePort,
|
|
1684
|
+
host: "127.0.0.1",
|
|
1685
|
+
kvPersist: persist ? `${persistPath}/kv` : undefined,
|
|
1686
|
+
r2Persist: persist ? `${persistPath}/r2` : undefined,
|
|
1687
|
+
d1Persist: persist ? `${persistPath}/d1` : undefined,
|
|
1688
|
+
durableObjectsPersist: persist ? `${persistPath}/do` : undefined
|
|
1689
|
+
};
|
|
1690
|
+
const gatewayWorker = {
|
|
1691
|
+
name: "gateway",
|
|
1692
|
+
modules: true,
|
|
1693
|
+
script: getGatewayScript(config.wsRoutes, debug),
|
|
1694
|
+
compatibilityDate: config.compatibilityDate,
|
|
1695
|
+
compatibilityFlags: config.compatibilityFlags ?? [],
|
|
1696
|
+
routes: ["*"],
|
|
1697
|
+
kvNamespaces: bindings.kv ? bindings.kv : undefined,
|
|
1698
|
+
r2Buckets: bindings.r2 ? bindings.r2 : undefined,
|
|
1699
|
+
d1Databases: bindings.d1 ? bindings.d1 : undefined,
|
|
1700
|
+
bindings: config.vars
|
|
1701
|
+
};
|
|
1702
|
+
if (!doResult || doResult.bundles.size === 0) {
|
|
1703
|
+
return {
|
|
1704
|
+
...sharedOptions,
|
|
1705
|
+
...gatewayWorker
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
const workers = [];
|
|
1709
|
+
const durableObjects = {};
|
|
1710
|
+
const browserShimUrl = `http://127.0.0.1:${browserShimPort}`;
|
|
1711
|
+
const browserBindingName = bindings.browser?.binding;
|
|
1712
|
+
const browserWorkerName = "browser-binding";
|
|
1713
|
+
for (const [bindingName, bundlePath] of doResult.bundles) {
|
|
1714
|
+
const className = doResult.classes.get(bindingName);
|
|
1715
|
+
if (!className)
|
|
1716
|
+
continue;
|
|
1717
|
+
const workerName = `do-${bindingName.toLowerCase()}`;
|
|
1718
|
+
const baseFlags = config.compatibilityFlags ?? [];
|
|
1719
|
+
const compatFlags = baseFlags.includes("nodejs_compat") ? baseFlags : [...baseFlags, "nodejs_compat"];
|
|
1720
|
+
const workerConfig = {
|
|
1721
|
+
name: workerName,
|
|
1722
|
+
modules: true,
|
|
1723
|
+
modulesRoot: cwd,
|
|
1724
|
+
modulesRules: [
|
|
1725
|
+
{ type: "CommonJS", include: ["**/*.js", "**/*.cjs"] },
|
|
1726
|
+
{ type: "ESModule", include: ["**/*.mjs"] }
|
|
1727
|
+
],
|
|
1728
|
+
scriptPath: bundlePath,
|
|
1729
|
+
compatibilityDate: config.compatibilityDate,
|
|
1730
|
+
compatibilityFlags: compatFlags,
|
|
1731
|
+
durableObjects: {
|
|
1732
|
+
[bindingName]: className
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
if (browserBindingName) {
|
|
1736
|
+
workerConfig.serviceBindings = {
|
|
1737
|
+
...workerConfig.serviceBindings,
|
|
1738
|
+
[browserBindingName]: browserWorkerName
|
|
1739
|
+
};
|
|
1740
|
+
logger?.debug(`DO ${workerName} has browser service binding: ${browserBindingName} → ${browserWorkerName}`);
|
|
1741
|
+
}
|
|
1742
|
+
logger?.debug(`DO ${workerName} config:`, JSON.stringify(workerConfig, null, 2));
|
|
1743
|
+
workers.push(workerConfig);
|
|
1744
|
+
durableObjects[bindingName] = {
|
|
1745
|
+
className,
|
|
1746
|
+
scriptName: workerName
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
if (browserBindingName) {
|
|
1750
|
+
const browserWorker = {
|
|
1751
|
+
name: browserWorkerName,
|
|
1752
|
+
modules: true,
|
|
1753
|
+
script: getBrowserBindingScript(browserShimUrl, debug),
|
|
1754
|
+
compatibilityDate: config.compatibilityDate,
|
|
1755
|
+
compatibilityFlags: config.compatibilityFlags ?? []
|
|
1756
|
+
};
|
|
1757
|
+
workers.push(browserWorker);
|
|
1758
|
+
logger?.info(`Browser binding worker configured: ${browserBindingName} → ${browserShimUrl}`);
|
|
1759
|
+
}
|
|
1760
|
+
gatewayWorker.durableObjects = durableObjects;
|
|
1761
|
+
return {
|
|
1762
|
+
...sharedOptions,
|
|
1763
|
+
workers: [gatewayWorker, ...workers]
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
async function startMiniflare(doResult) {
|
|
1767
|
+
const { Miniflare, Log, LogLevel } = await import("miniflare");
|
|
1768
|
+
const mfConfig = buildMiniflareConfig(doResult);
|
|
1769
|
+
mfConfig.log = new Log(LogLevel.DEBUG);
|
|
1770
|
+
logger?.info("=== MINIFLARE CONFIG DEBUG ===");
|
|
1771
|
+
logger?.info("Full config:", JSON.stringify(mfConfig, (key, value) => {
|
|
1772
|
+
if (key === "script" && typeof value === "string" && value.length > 200) {
|
|
1773
|
+
return value.substring(0, 200) + "...[truncated]";
|
|
1774
|
+
}
|
|
1775
|
+
return value;
|
|
1776
|
+
}, 2));
|
|
1777
|
+
if (mfConfig.workers) {
|
|
1778
|
+
logger?.info("Workers order:");
|
|
1779
|
+
for (const w of mfConfig.workers) {
|
|
1780
|
+
logger?.info(` → ${w.name}:`);
|
|
1781
|
+
logger?.info(` script: ${w.script ? "inline" : w.scriptPath}`);
|
|
1782
|
+
logger?.info(` browserRendering: ${JSON.stringify(w.browserRendering)}`);
|
|
1783
|
+
logger?.info(` durableObjects: ${JSON.stringify(w.durableObjects)}`);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
miniflare = new Miniflare(mfConfig);
|
|
1787
|
+
await miniflare.ready;
|
|
1788
|
+
logger?.success(`Miniflare ready on http://localhost:${miniflarePort}`);
|
|
1789
|
+
try {
|
|
1790
|
+
const gatewayBindings = await miniflare.getBindings("gateway");
|
|
1791
|
+
logger?.info("Gateway worker bindings:", Object.keys(gatewayBindings));
|
|
1792
|
+
if (mfConfig.workers) {
|
|
1793
|
+
for (const w of mfConfig.workers) {
|
|
1794
|
+
if (w.name !== "gateway") {
|
|
1795
|
+
try {
|
|
1796
|
+
const doBindings = await miniflare.getBindings(w.name);
|
|
1797
|
+
logger?.info(`${w.name} worker bindings:`, Object.keys(doBindings));
|
|
1798
|
+
if ("BROWSER" in doBindings) {
|
|
1799
|
+
logger?.success(`${w.name} has BROWSER binding!`);
|
|
1800
|
+
} else {
|
|
1801
|
+
logger?.warn(`${w.name} is MISSING BROWSER binding`);
|
|
1802
|
+
}
|
|
1803
|
+
} catch (e) {
|
|
1804
|
+
logger?.warn(`Could not get bindings for ${w.name}:`, e);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
} catch (e) {
|
|
1810
|
+
logger?.warn("Error getting bindings:", e);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
async function reloadMiniflare(doResult) {
|
|
1814
|
+
if (!miniflare)
|
|
1815
|
+
return;
|
|
1816
|
+
const { Log, LogLevel } = await import("miniflare");
|
|
1817
|
+
const mfConfig = buildMiniflareConfig(doResult);
|
|
1818
|
+
mfConfig.log = new Log(LogLevel.DEBUG);
|
|
1819
|
+
logger?.info("Reloading Miniflare with updated DOs...");
|
|
1820
|
+
await miniflare.setOptions(mfConfig);
|
|
1821
|
+
logger?.success("Miniflare reloaded");
|
|
1822
|
+
}
|
|
1823
|
+
async function runD1Migrations() {
|
|
1824
|
+
if (!miniflare || !config?.bindings?.d1)
|
|
1825
|
+
return;
|
|
1826
|
+
const { existsSync: existsSync2, readdirSync, readFileSync } = await import("node:fs");
|
|
1827
|
+
const migrationsDir = resolve2(cwd, "migrations");
|
|
1828
|
+
if (!existsSync2(migrationsDir)) {
|
|
1829
|
+
logger?.debug("No migrations/ directory found, skipping D1 migrations");
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
|
1833
|
+
if (files.length === 0) {
|
|
1834
|
+
logger?.debug("No SQL migration files found");
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
logger?.info(`Running ${files.length} D1 migration(s)...`);
|
|
1838
|
+
const allStatements = [];
|
|
1839
|
+
for (const file of files) {
|
|
1840
|
+
const sql = readFileSync(resolve2(migrationsDir, file), "utf-8");
|
|
1841
|
+
const cleanedSql = sql.split(`
|
|
1842
|
+
`).filter((line) => !line.trim().startsWith("--")).join(`
|
|
1843
|
+
`);
|
|
1844
|
+
const statements = cleanedSql.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1845
|
+
allStatements.push(...statements);
|
|
1846
|
+
logger?.debug(`File ${file}: ${statements.length} statement(s)`);
|
|
1847
|
+
}
|
|
1848
|
+
for (const [bindingName] of Object.entries(config.bindings.d1)) {
|
|
1849
|
+
for (let attempt = 0;attempt < 5; attempt++) {
|
|
1850
|
+
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
|
1851
|
+
try {
|
|
1852
|
+
const response = await fetch(`http://127.0.0.1:${miniflarePort}/_devflare/migrate`, {
|
|
1853
|
+
method: "POST",
|
|
1854
|
+
headers: { "Content-Type": "application/json" },
|
|
1855
|
+
body: JSON.stringify({ bindingName, statements: allStatements })
|
|
1856
|
+
});
|
|
1857
|
+
if (!response.ok) {
|
|
1858
|
+
const text = await response.text();
|
|
1859
|
+
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
1860
|
+
}
|
|
1861
|
+
const result = await response.json();
|
|
1862
|
+
if (result.success) {
|
|
1863
|
+
logger?.success(`D1 migrations applied to ${bindingName}`);
|
|
1864
|
+
break;
|
|
1865
|
+
} else {
|
|
1866
|
+
throw new Error(result.error || "Unknown error");
|
|
1867
|
+
}
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
if (attempt === 4) {
|
|
1870
|
+
logger?.warn(`Failed to apply migrations to ${bindingName}: ${error}`);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
async function startVite() {
|
|
1877
|
+
const { spawn } = await import("node:child_process");
|
|
1878
|
+
const args = ["vite", "dev", "--port", String(vitePort)];
|
|
1879
|
+
viteProcess = spawn("bunx", args, {
|
|
1880
|
+
cwd,
|
|
1881
|
+
stdio: "inherit",
|
|
1882
|
+
env: {
|
|
1883
|
+
...process.env,
|
|
1884
|
+
DEVFLARE_DEV: "true",
|
|
1885
|
+
DEVFLARE_BRIDGE_PORT: String(miniflarePort),
|
|
1886
|
+
FORCE_COLOR: "1"
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
logger?.success(`Vite dev server started on http://localhost:${vitePort}`);
|
|
1890
|
+
}
|
|
1891
|
+
async function start() {
|
|
1892
|
+
logger?.info("Starting unified dev server...");
|
|
1893
|
+
config = await loadConfig({ cwd, configFile: configPath });
|
|
1894
|
+
logger?.debug("Loaded config:", config.name);
|
|
1895
|
+
const remoteCheck = await checkRemoteBindingRequirements(config);
|
|
1896
|
+
if (remoteCheck.hasRemoteBindings) {
|
|
1897
|
+
logger?.info("");
|
|
1898
|
+
logger?.warn("⚠️ Remote-only bindings detected:");
|
|
1899
|
+
for (const binding of remoteCheck.remoteBindings) {
|
|
1900
|
+
logger?.warn(` • ${binding}`);
|
|
1901
|
+
}
|
|
1902
|
+
logger?.info("");
|
|
1903
|
+
if (remoteCheck.missingAccountId) {
|
|
1904
|
+
logger?.warn("⚠️ WARN: accountId is not set in devflare.config.ts");
|
|
1905
|
+
logger?.warn(" Remote bindings (AI, Vectorize) require accountId to charge the correct account.");
|
|
1906
|
+
logger?.warn(" Add: accountId: 'your-cloudflare-account-id'");
|
|
1907
|
+
logger?.info("");
|
|
1908
|
+
}
|
|
1909
|
+
if (remoteCheck.notLoggedIn) {
|
|
1910
|
+
logger?.warn("⚠️ WARN: Not logged in to Wrangler");
|
|
1911
|
+
logger?.warn(" Remote bindings require authentication.");
|
|
1912
|
+
logger?.warn(" Run: bunx wrangler login");
|
|
1913
|
+
logger?.info("");
|
|
1914
|
+
}
|
|
1915
|
+
if (!remoteCheck.missingAccountId && !remoteCheck.notLoggedIn) {
|
|
1916
|
+
logger?.success("✓ Remote binding requirements met");
|
|
1917
|
+
logger?.info("");
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const browserBinding = config.bindings?.browser?.binding;
|
|
1921
|
+
if (browserBinding) {
|
|
1922
|
+
logger?.info(`Starting Browser Rendering shim (binding: ${browserBinding})...`);
|
|
1923
|
+
browserShim = createBrowserShim({
|
|
1924
|
+
port: browserShimPort,
|
|
1925
|
+
host: "127.0.0.1",
|
|
1926
|
+
logger,
|
|
1927
|
+
verbose
|
|
1928
|
+
});
|
|
1929
|
+
await browserShim.start();
|
|
1930
|
+
}
|
|
1931
|
+
const doPattern = config.files?.durableObjects;
|
|
1932
|
+
let doResult = null;
|
|
1933
|
+
if (typeof doPattern === "string" && doPattern) {
|
|
1934
|
+
const outDir = resolve2(cwd, ".devflare/do-bundles");
|
|
1935
|
+
doBundler = createDOBundler({
|
|
1936
|
+
cwd,
|
|
1937
|
+
pattern: doPattern,
|
|
1938
|
+
outDir,
|
|
1939
|
+
logger,
|
|
1940
|
+
onRebuild: async (result) => {
|
|
1941
|
+
await reloadMiniflare(result);
|
|
1942
|
+
}
|
|
1943
|
+
});
|
|
1944
|
+
doResult = await doBundler.build();
|
|
1945
|
+
await doBundler.watch();
|
|
1946
|
+
}
|
|
1947
|
+
await startMiniflare(doResult);
|
|
1948
|
+
await startVite();
|
|
1949
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1950
|
+
await runD1Migrations();
|
|
1951
|
+
const cleanup = async () => {
|
|
1952
|
+
logger?.info("Shutting down...");
|
|
1953
|
+
await stop();
|
|
1954
|
+
process.exit(0);
|
|
1955
|
+
};
|
|
1956
|
+
process.on("SIGINT", cleanup);
|
|
1957
|
+
process.on("SIGTERM", cleanup);
|
|
1958
|
+
}
|
|
1959
|
+
async function stop() {
|
|
1960
|
+
if (doBundler) {
|
|
1961
|
+
await doBundler.close();
|
|
1962
|
+
doBundler = null;
|
|
1963
|
+
}
|
|
1964
|
+
if (miniflare) {
|
|
1965
|
+
await miniflare.dispose();
|
|
1966
|
+
miniflare = null;
|
|
1967
|
+
}
|
|
1968
|
+
if (viteProcess) {
|
|
1969
|
+
viteProcess.kill("SIGTERM");
|
|
1970
|
+
viteProcess = null;
|
|
1971
|
+
}
|
|
1972
|
+
if (browserShim) {
|
|
1973
|
+
await browserShim.stop();
|
|
1974
|
+
browserShim = null;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
function getMiniflare() {
|
|
1978
|
+
return miniflare;
|
|
1979
|
+
}
|
|
1980
|
+
return {
|
|
1981
|
+
start,
|
|
1982
|
+
stop,
|
|
1983
|
+
getMiniflare
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
// src/cli/commands/dev.ts
|
|
1987
|
+
async function createLogWriter(cwd, options) {
|
|
1988
|
+
if (!options.log && !options.logTemp) {
|
|
1989
|
+
return null;
|
|
1990
|
+
}
|
|
1991
|
+
const fs = await import("node:fs");
|
|
1992
|
+
let logPath;
|
|
1993
|
+
if (options.logTemp) {
|
|
1994
|
+
logPath = resolve3(cwd, ".log");
|
|
1995
|
+
} else {
|
|
1996
|
+
const now = new Date;
|
|
1997
|
+
const timestamp = now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
|
1998
|
+
logPath = resolve3(cwd, `.log-${timestamp}`);
|
|
1999
|
+
}
|
|
2000
|
+
const fileStream = fs.createWriteStream(logPath, { flags: "w" });
|
|
2001
|
+
const ansiRegex = /\x1b\[[0-9;]*m/g;
|
|
2002
|
+
return {
|
|
2003
|
+
write(data, source) {
|
|
2004
|
+
const str = typeof data === "string" ? data : data.toString();
|
|
2005
|
+
if (!str.trim())
|
|
2006
|
+
return;
|
|
2007
|
+
const timestamp = new Date().toISOString().slice(11, 23);
|
|
2008
|
+
const prefix = source ? `[${timestamp}][${source.toUpperCase()}] ` : `[${timestamp}] `;
|
|
2009
|
+
const cleanStr = str.replace(ansiRegex, "");
|
|
2010
|
+
fileStream.write(prefix + cleanStr + (cleanStr.endsWith(`
|
|
2011
|
+
`) ? "" : `
|
|
2012
|
+
`));
|
|
2013
|
+
},
|
|
2014
|
+
close() {
|
|
2015
|
+
fileStream.end();
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
async function runDevCommand(parsed, logger, options) {
|
|
2020
|
+
const cwd = options.cwd || parsed.options.cwd || process.cwd();
|
|
2021
|
+
const configPath = parsed.options.config;
|
|
2022
|
+
const port = parsed.options.port;
|
|
2023
|
+
const logEnabled = parsed.options.log === true;
|
|
2024
|
+
const logTempEnabled = parsed.options["log-temp"] === true;
|
|
2025
|
+
const persistEnabled = parsed.options.persist === true;
|
|
2026
|
+
const debugEnabled = parsed.options.debug === true || process.env.DEVFLARE_DEBUG === "true";
|
|
2027
|
+
const verbose = parsed.options.verbose === true || debugEnabled;
|
|
2028
|
+
const logWriter = await createLogWriter(cwd, {
|
|
2029
|
+
log: logEnabled,
|
|
2030
|
+
logTemp: logTempEnabled
|
|
2031
|
+
});
|
|
2032
|
+
if (logWriter) {
|
|
2033
|
+
const logFile = logTempEnabled ? ".log" : `.log-{datetime}`;
|
|
2034
|
+
logger.info(`\uD83D\uDCDD Logging enabled → ${logFile}`);
|
|
2035
|
+
}
|
|
2036
|
+
const devLogger = createConsola({
|
|
2037
|
+
level: verbose ? 4 : 3
|
|
2038
|
+
});
|
|
2039
|
+
if (logWriter) {
|
|
2040
|
+
const wrapLog = (original, prefix = "") => {
|
|
2041
|
+
return (message, ...args) => {
|
|
2042
|
+
original(message, ...args);
|
|
2043
|
+
const formatted = prefix ? `${prefix} ${[message, ...args].join(" ")}` : [message, ...args].join(" ");
|
|
2044
|
+
logWriter.write(formatted);
|
|
2045
|
+
};
|
|
2046
|
+
};
|
|
2047
|
+
Object.assign(devLogger.info, wrapLog(devLogger.info.bind(devLogger)));
|
|
2048
|
+
Object.assign(devLogger.error, wrapLog(devLogger.error.bind(devLogger), "[ERROR]"));
|
|
2049
|
+
Object.assign(devLogger.warn, wrapLog(devLogger.warn.bind(devLogger), "[WARN]"));
|
|
2050
|
+
Object.assign(devLogger.success, wrapLog(devLogger.success.bind(devLogger), "[OK]"));
|
|
2051
|
+
Object.assign(devLogger.debug, wrapLog(devLogger.debug.bind(devLogger), "[DEBUG]"));
|
|
2052
|
+
}
|
|
2053
|
+
try {
|
|
2054
|
+
logger.info("");
|
|
2055
|
+
logger.info("\uD83D\uDE80 Devflare Unified Dev Server");
|
|
2056
|
+
logger.info(" ├─ Vite: Full HMR for frontend");
|
|
2057
|
+
logger.info(" ├─ Miniflare: All Cloudflare bindings");
|
|
2058
|
+
logger.info(" ├─ Rolldown: Fast DO bundling with watch");
|
|
2059
|
+
logger.info(" └─ Bridge: WebSocket RPC connection");
|
|
2060
|
+
logger.info("");
|
|
2061
|
+
const devServer = createDevServer({
|
|
2062
|
+
cwd,
|
|
2063
|
+
configPath,
|
|
2064
|
+
vitePort: port ? parseInt(port, 10) : 5173,
|
|
2065
|
+
miniflarePort: 8787,
|
|
2066
|
+
persist: persistEnabled,
|
|
2067
|
+
logger: devLogger,
|
|
2068
|
+
verbose,
|
|
2069
|
+
debug: debugEnabled
|
|
2070
|
+
});
|
|
2071
|
+
const cleanup = async () => {
|
|
2072
|
+
logger.info("");
|
|
2073
|
+
logger.info("Shutting down...");
|
|
2074
|
+
await devServer.stop();
|
|
2075
|
+
logWriter?.close();
|
|
2076
|
+
process.exit(0);
|
|
2077
|
+
};
|
|
2078
|
+
process.on("SIGINT", cleanup);
|
|
2079
|
+
process.on("SIGTERM", cleanup);
|
|
2080
|
+
await devServer.start();
|
|
2081
|
+
await new Promise(() => {});
|
|
2082
|
+
return { exitCode: 0 };
|
|
2083
|
+
} catch (error) {
|
|
2084
|
+
if (error instanceof Error) {
|
|
2085
|
+
logger.error("Dev server failed:", error.message);
|
|
2086
|
+
if (verbose) {
|
|
2087
|
+
logger.error(error.stack);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
logWriter?.close();
|
|
2091
|
+
return { exitCode: 1 };
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
export {
|
|
2095
|
+
runDevCommand
|
|
2096
|
+
};
|