@jundle/cloudflare 0.0.0-stage → 0.0.1-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/src/build.js ADDED
@@ -0,0 +1,327 @@
1
+ // @jundle/cloudflare — the Cloudflare Workers BUILD plugin
2
+ // (SPEC §16.13). The runtime plugin (src/index.js) is dev-only;
3
+ // this is its build half, auto-registered by jundle core when a
4
+ // build entry's runtime is "@jundle/cloudflare" (runtime
5
+ // implies it, SPEC §16.13): core appends it to config.plugins
6
+ // before the inventory is built, with options { configDir,
7
+ // wranglerConfig }, and merges its externals into every
8
+ // graph's external list in buildEngineConfig. It can also be
9
+ // declared explicitly in config.plugins — core then augments
10
+ // its options with configDir/wranglerConfig instead of
11
+ // registering it twice.
12
+ //
13
+ // Two hooks, both wired in the all-native bridge:
14
+ // - resolveId: the five cloudflare:* builtins from
15
+ // @cloudflare/vite-plugin (exact match, not a prefix) are
16
+ // returned as external. Belt-and-braces — the graph-list
17
+ // merge already externalizes them before any resolveId
18
+ // runs, including for dep bundling (which never yields
19
+ // resolveId). The hook also covers a plugin declared
20
+ // without the runtime, where no merge happens.
21
+ // - writeBundle: after the files are on disk, emit the
22
+ // deployment config mirroring @cloudflare/vite-plugin
23
+ // v1.64 (output-config.ts + deploy-config.ts): the
24
+ // generated <outDir>/wrangler.json, <outDir>/.dev.vars
25
+ // when local dev vars exist, and
26
+ // <configDir>/.wrangler/deploy/config.json pointing at
27
+ // the generated wrangler.json. Files are written with
28
+ // fs directly: writeBundle's this.emitFile drops assets
29
+ // with a warning (late emitState), so they never appear
30
+ // in the build's written-file list — that is expected.
31
+ // generateBundle is deferred in the native bridge, so
32
+ // writeBundle is the v1 hook; a native generateBundle
33
+ // yield is the clean long-term fix.
34
+ //
35
+ // The wrangler file is the single source of truth for the
36
+ // deployment config (SPEC §16.3): it is read with the
37
+ // package's existing readWranglerConfig (wrangler's
38
+ // unstable_readConfig), never duplicated in jundle config.
39
+ // Top-level/default env only (named env.* is out of scope,
40
+ // SPEC decisions O10).
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+ import { createUnplugin } from "unplugin";
44
+ import { readWranglerConfig } from "./config.js";
45
+
46
+ // The Workers runtime builtins, exactly
47
+ // @cloudflare/vite-plugin's cloudflareBuiltInModules
48
+ // (v1.64.0, src/plugins/output-config.ts). Exact match
49
+ // only: "cloudflare:*" as a pattern would never match
50
+ // the engine's exact-or-pat/ external check, and a
51
+ // prefix match here would externalize future unknown
52
+ // subpaths. Core merges this same list into every
53
+ // graph's external list (build.js buildEngineConfig);
54
+ // keep the two in sync — the test asserts they match.
55
+ export const CLOUDFLARE_BUILTINS = [
56
+ "cloudflare:email",
57
+ "cloudflare:node",
58
+ "cloudflare:sockets",
59
+ "cloudflare:workers",
60
+ "cloudflare:workflows",
61
+ ];
62
+ const BUILTIN_RE = /^cloudflare:(email|node|sockets|workers|workflows)$/;
63
+
64
+ // Quote a value so wrangler reads it back unchanged when
65
+ // it parses the emitted .dev.vars (upstream
66
+ // quoteForDotenv, output-config.ts): pick the first quote
67
+ // character that does not appear in the value (single,
68
+ // then backtick, then double). Throw rather than silently
69
+ // corrupt a value that cannot be losslessly serialized.
70
+ export function quoteForDotenv(value) {
71
+ if (!value.includes("'")) return `'${value}'`;
72
+ if (!value.includes("`")) return `\`${value}\``;
73
+ if (!value.includes('"') && !/[\\\n\r]/.test(value)) return `"${value}"`;
74
+ throw new Error(
75
+ "CF plugin: unable to serialize .dev.vars value: contains every supported quote character or unsafe escape sequence",
76
+ );
77
+ }
78
+
79
+ export function serializeDevVars(devVars) {
80
+ return Object.entries(devVars || {})
81
+ .map(([key, value]) => `${key}=${quoteForDotenv(String(value ?? ""))}\n`)
82
+ .join("");
83
+ }
84
+
85
+ // Rebase a source-relative path (resolved against the
86
+ // wrangler file's directory) to be relative to the
87
+ // output directory (upstream rebasePathToOutput). "." when
88
+ // the two directories coincide. Always posix separators:
89
+ // wrangler.json paths are posix.
90
+ function rebasePathToOutput(sourcePath, sourceConfigDir, outputDir) {
91
+ const rel = path.relative(outputDir, path.resolve(sourceConfigDir, sourcePath));
92
+ return rel.split(path.sep).join("/") || ".";
93
+ }
94
+
95
+ // Upstream rewriteMigrationsPattern (output-config.ts),
96
+ // ported verbatim: strip the source migrations dir prefix
97
+ // from the pattern and re-root it under the output
98
+ // migrations dir. Undefined stays undefined (the key is
99
+ // then omitted from the emitted config).
100
+ export function rewriteMigrationsPattern(migrationsPattern, fromMigrationsDir, toMigrationsDir) {
101
+ if (migrationsPattern === undefined) return undefined;
102
+ const normalizedDir = path.posix.normalize(fromMigrationsDir.split(path.sep).join("/"));
103
+ const normalizedPattern = path.posix.normalize(migrationsPattern.split(path.sep).join("/"));
104
+ const suffix =
105
+ normalizedDir === "."
106
+ ? normalizedPattern
107
+ : path.posix.relative(normalizedDir, normalizedPattern);
108
+ return path.posix.normalize(toMigrationsDir === "." ? suffix : `${toMigrationsDir}/${suffix}`);
109
+ }
110
+
111
+ // The generated <outDir>/wrangler.json (upstream
112
+ // getOutputConfig, output-config.ts): the user's wrangler
113
+ // config, spread first, then the build overrides. Pure
114
+ // function, exported for tests.
115
+ // - main: the server entry chunk, outDir-relative (e.g.
116
+ // "server/server.js").
117
+ // - no_bundle: true (the chunk is already bundled).
118
+ // - rules: ESModule for all JS/MJS.
119
+ // - assets: only when the build has client output — the
120
+ // built client directory is always "client" under the
121
+ // outDir (emit.rs maps react-client -> client), so the
122
+ // directory is "client" regardless of what the user's
123
+ // assets said (a string assets value is normalised to
124
+ // object form first, as bindings.js does for dev).
125
+ // - containers: dockerfile/build_context rebased to the
126
+ // output dir, build_vars passed through. (Containers are
127
+ // out of scope for the dev runner, SPEC §16.3, but a
128
+ // verbatim passthrough would leave stale source-relative
129
+ // paths in a deployed config, so they are rebased.)
130
+ // - d1_databases: migrations_dir defaults to "migrations"
131
+ // and is rebased; migrations_pattern is rewritten.
132
+ // - upload_source_maps: true only when the user did not
133
+ // set it and the entry chunk has a .map in the bundle
134
+ // (builds always emit sourcemaps, build.js hardcodes
135
+ // sourcemap: true — the bundle-list check is the
136
+ // evidence, not the assumption).
137
+ // - configPath (the source file pointer, if
138
+ // unstable_readConfig returned one) is stripped: it
139
+ // describes the source file, never the output.
140
+ // - An empty unsafe: {} is deleted (upstream).
141
+ // Every other field (name, compatibility_date/flags,
142
+ // vars, bindings) passes through verbatim.
143
+ export function getOutputConfig({ userConfig, main, includeAssets, outDir, configDir, sourcemaps }) {
144
+ // unstable_readConfig returns an absolute configPath;
145
+ // resolve a relative one against configDir so the
146
+ // rebase base is never accidentally process.cwd().
147
+ const sourceConfigDir = userConfig.configPath
148
+ ? path.dirname(path.resolve(configDir, userConfig.configPath))
149
+ : configDir;
150
+ const rebase = (p) => rebasePathToOutput(p, sourceConfigDir, outDir);
151
+ const out = {
152
+ ...userConfig,
153
+ main,
154
+ no_bundle: true,
155
+ rules: [{ type: "ESModule", globs: ["**/*.js", "**/*.mjs"] }],
156
+ };
157
+ delete out.configPath;
158
+ if (includeAssets) {
159
+ const assets =
160
+ userConfig.assets && typeof userConfig.assets === "object" ? userConfig.assets : {};
161
+ out.assets = { ...assets, directory: "client" };
162
+ } else {
163
+ delete out.assets;
164
+ }
165
+ if (Array.isArray(userConfig.containers)) {
166
+ out.containers = userConfig.containers.map((container) => {
167
+ if (container.images === undefined) return container;
168
+ return {
169
+ ...container,
170
+ images: Object.fromEntries(
171
+ Object.entries(container.images).map(([name, image]) => {
172
+ if (image.dockerfile === undefined) return [name, image];
173
+ return [
174
+ name,
175
+ {
176
+ ...image,
177
+ dockerfile: rebase(image.dockerfile),
178
+ ...(image.build_context === undefined
179
+ ? {}
180
+ : { build_context: rebase(image.build_context) }),
181
+ ...(image.build_vars === undefined ? {} : { build_vars: image.build_vars }),
182
+ },
183
+ ];
184
+ }),
185
+ ),
186
+ };
187
+ });
188
+ }
189
+ if (Array.isArray(userConfig.d1_databases)) {
190
+ out.d1_databases = userConfig.d1_databases.map((database) => {
191
+ const sourceMigrationsDir = database.migrations_dir ?? "migrations";
192
+ const outputMigrationsDir = rebase(sourceMigrationsDir);
193
+ const pattern = rewriteMigrationsPattern(
194
+ database.migrations_pattern,
195
+ sourceMigrationsDir,
196
+ outputMigrationsDir,
197
+ );
198
+ return {
199
+ ...database,
200
+ migrations_dir: outputMigrationsDir,
201
+ ...(pattern === undefined ? {} : { migrations_pattern: pattern }),
202
+ };
203
+ });
204
+ }
205
+ if (userConfig.upload_source_maps === undefined && sourcemaps) {
206
+ out.upload_source_maps = true;
207
+ }
208
+ if (out.unsafe && typeof out.unsafe === "object" && Object.keys(out.unsafe).length === 0) {
209
+ delete out.unsafe;
210
+ }
211
+ return out;
212
+ }
213
+
214
+ // The deploy redirect (upstream writeDeployConfig,
215
+ // deploy-config.ts): <configDir>/.wrangler/deploy/
216
+ // config.json, whose configPath is relative from the
217
+ // deploy dir to the generated wrangler.json. wrangler
218
+ // deploy reads the GENERATED config, not the source
219
+ // wrangler file. Single-worker jundle has no auxiliary
220
+ // or prerender workers, so those are [] / omitted.
221
+ export function buildDeployConfig({ configDir, outDir }) {
222
+ const deployDir = path.join(configDir, ".wrangler", "deploy");
223
+ return {
224
+ configPath: path
225
+ .relative(deployDir, path.join(outDir, "wrangler.json"))
226
+ .split(path.sep)
227
+ .join("/"),
228
+ auxiliaryWorkers: [],
229
+ };
230
+ }
231
+
232
+ // Find the Worker entry in server-manifest.json: the
233
+ // entry carrying runtime "@jundle/cloudflare". Fallback
234
+ // is the first entry (the process entry) — for a plugin
235
+ // declared explicitly without a CF runtime entry, the
236
+ // main is still the entry chunk; only a manifest with no
237
+ // entries at all is a hard error.
238
+ function getCloudflareEntry(manifest) {
239
+ const entries = Array.isArray(manifest?.entries) ? manifest.entries : [];
240
+ if (!entries.length) {
241
+ throw new Error(
242
+ "CF plugin: server-manifest.json has no entries; cannot determine the Worker entry chunk for wrangler.json",
243
+ );
244
+ }
245
+ return entries.find((e) => e.runtime === "@jundle/cloudflare") || entries[0];
246
+ }
247
+
248
+ // The build plugin (unplugin factory, as the demo plugin
249
+ // in examples/rsc-app/plugins/demo-plugin.js). Options
250
+ // are core-managed when auto-registered: { configDir,
251
+ // wranglerConfig? }. A user declaring the plugin
252
+ // explicitly passes the same options; configDir is
253
+ // required for the wrangler output (the writeBundle
254
+ // handler only receives { dir } + the file list, never
255
+ // configDir — so it must be captured here, in the
256
+ // closure, at construction time).
257
+ const cloudflareBuildPlugin = createUnplugin((options = {}) => {
258
+ const configDir = options.configDir;
259
+ const wranglerConfig = options.wranglerConfig ?? null;
260
+ return {
261
+ name: "@jundle/cloudflare",
262
+ enforce: "pre",
263
+ resolveId(source) {
264
+ if (BUILTIN_RE.test(source)) return { id: source, external: true };
265
+ return null;
266
+ },
267
+ async writeBundle(writeOptions, bundle) {
268
+ if (!configDir || typeof configDir !== "string") {
269
+ throw new Error(
270
+ "CF plugin: writeBundle needs the project directory (configDir); it is passed by jundle core when the plugin is auto-registered for a @jundle/cloudflare runtime entry, or as the plugin option { configDir } when declared explicitly",
271
+ );
272
+ }
273
+ const outDir = writeOptions?.dir ? path.resolve(writeOptions.dir) : null;
274
+ if (!outDir) {
275
+ throw new Error("CF plugin: writeBundle received no output directory (options.dir)");
276
+ }
277
+ const manifestPath = path.join(outDir, "server-manifest.json");
278
+ let manifest;
279
+ try {
280
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
281
+ } catch (e) {
282
+ throw new Error(
283
+ `CF plugin: cannot read ${manifestPath} for the wrangler output (load error: ${e.message})`,
284
+ );
285
+ }
286
+ const entry = getCloudflareEntry(manifest);
287
+ const main = entry.chunk;
288
+ if (!main || typeof main !== "string") {
289
+ throw new Error(
290
+ `CF plugin: server-manifest.json entry ${JSON.stringify(entry.id ?? entry.file)} has no chunk path; cannot set wrangler.json main`,
291
+ );
292
+ }
293
+ const { config: userConfig, devVars } = await readWranglerConfig({
294
+ configDir,
295
+ wranglerConfig,
296
+ });
297
+ const files = Array.isArray(bundle) ? bundle : [];
298
+ const includeAssets =
299
+ files.some((f) => f === "client" || f.startsWith("client/")) ||
300
+ fs.existsSync(path.join(outDir, "client"));
301
+ const sourcemaps = files.includes(`${main}.map`);
302
+ const outputConfig = getOutputConfig({
303
+ userConfig,
304
+ main,
305
+ includeAssets,
306
+ outDir,
307
+ configDir,
308
+ sourcemaps,
309
+ });
310
+ fs.writeFileSync(
311
+ path.join(outDir, "wrangler.json"),
312
+ `${JSON.stringify(outputConfig, null, 2)}\n`,
313
+ );
314
+ if (devVars && Object.keys(devVars).length > 0) {
315
+ fs.writeFileSync(path.join(outDir, ".dev.vars"), serializeDevVars(devVars));
316
+ }
317
+ const deployDir = path.join(configDir, ".wrangler", "deploy");
318
+ fs.mkdirSync(deployDir, { recursive: true });
319
+ fs.writeFileSync(
320
+ path.join(deployDir, "config.json"),
321
+ `${JSON.stringify(buildDeployConfig({ configDir, outDir }), null, 2)}\n`,
322
+ );
323
+ },
324
+ };
325
+ });
326
+
327
+ export default cloudflareBuildPlugin;
package/src/config.js ADDED
@@ -0,0 +1,82 @@
1
+ // Wrangler config reading for the CF plugin (SPEC §16.3).
2
+ // The single jundle-side setting is which wrangler config file
3
+ // to use (dev.environments.<env>.wranglerConfig, relative to
4
+ // the jundle config dir). Everything else comes from the
5
+ // wrangler file on disk — nothing is duplicated in jundle
6
+ // config. If wranglerConfig is omitted, auto-discover from the
7
+ // project root (the jundle config dir) in order wrangler.jsonc,
8
+ // wrangler.json, wrangler.toml. If workerd is selected and no
9
+ // wrangler file is found, a clear error names the missing file.
10
+ // Config is read via wrangler's own utilities
11
+ // (unstable_readConfig); the wrangler version is pinned in
12
+ // this package's dependencies because the `unstable_` prefix
13
+ // means the API can change across versions.
14
+ // Secrets: wrangler `vars` + `.dev.vars` (read from disk
15
+ // alongside the wrangler file, falling back to the project
16
+ // root). `.dev.vars` is parsed as a dotenv subset (KEY=VALUE
17
+ // lines, # comments, surrounding quotes stripped); wrangler's
18
+ // own reader is not exported, so this is jundle's parser.
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { unstable_readConfig } from "wrangler";
22
+
23
+ const DISCOVER_ORDER = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
24
+
25
+ export function resolveWranglerFile({ configDir, wranglerConfig }) {
26
+ if (wranglerConfig) {
27
+ const file = path.resolve(configDir, wranglerConfig);
28
+ if (!fs.existsSync(file)) {
29
+ throw new Error(
30
+ `CF plugin: wrangler config file not found: ${file} (dev.environments.<env>.wranglerConfig)`,
31
+ );
32
+ }
33
+ return file;
34
+ }
35
+ for (const name of DISCOVER_ORDER) {
36
+ const file = path.join(configDir, name);
37
+ if (fs.existsSync(file)) return file;
38
+ }
39
+ throw new Error(
40
+ `CF plugin: no wrangler config file found (looked for ${DISCOVER_ORDER.join(", ")} in ${configDir}). ` +
41
+ `Set dev.environments.<env>.wranglerConfig to the wrangler config file to use.`,
42
+ );
43
+ }
44
+
45
+ export function parseDevVars(text) {
46
+ const vars = {};
47
+ for (const line of text.split("\n")) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed || trimmed.startsWith("#")) continue;
50
+ const eq = trimmed.indexOf("=");
51
+ if (eq === -1) continue;
52
+ const key = trimmed.slice(0, eq).trim();
53
+ let value = trimmed.slice(eq + 1).trim();
54
+ if (
55
+ (value.startsWith('"') && value.endsWith('"')) ||
56
+ (value.startsWith("'") && value.endsWith("'"))
57
+ ) {
58
+ value = value.slice(1, -1);
59
+ }
60
+ if (key) vars[key] = value;
61
+ }
62
+ return vars;
63
+ }
64
+
65
+ export function readDevVars({ wranglerFile, configDir }) {
66
+ // Alongside the wrangler file first, then the project root.
67
+ const candidates = [
68
+ path.join(path.dirname(wranglerFile), ".dev.vars"),
69
+ path.join(configDir, ".dev.vars"),
70
+ ];
71
+ for (const file of candidates) {
72
+ if (fs.existsSync(file)) return parseDevVars(fs.readFileSync(file, "utf8"));
73
+ }
74
+ return {};
75
+ }
76
+
77
+ export async function readWranglerConfig({ configDir, wranglerConfig }) {
78
+ const file = resolveWranglerFile({ configDir, wranglerConfig });
79
+ const config = await unstable_readConfig({ config: file });
80
+ const devVars = readDevVars({ wranglerFile: file, configDir });
81
+ return { file, config, devVars };
82
+ }