@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/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@jundle/cloudflare",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1-0",
4
+ "description": "Cloudflare Workers runtime for the jundle dev server (workerd via Miniflare).",
5
+ "license": "ISC",
6
+ "type": "module",
7
+ "main": "src/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./build": {
14
+ "types": "./src/build.d.ts",
15
+ "default": "./src/build.js"
16
+ }
17
+ },
18
+ "dependencies": {
19
+ "miniflare": "4.20260730.0",
20
+ "unplugin": "3.7.0"
21
+ },
22
+ "peerDependencies": {
23
+ "wrangler": "4.156.0"
24
+ }
6
25
  }
@@ -0,0 +1,295 @@
1
+ // Wrangler config -> Miniflare options (SPEC §16.3).
2
+ // Every binding is wired; nothing is silently stubbed or
3
+ // downgraded. Local by default; remote only where the spec
4
+ // says so (AI / Browser Rendering / Vectorize are always
5
+ // remote, and per-binding remote is a dev-server CLI flag,
6
+ // handled by the caller via remoteBindings, not config).
7
+ // Persistence mirrors wrangler dev: .wrangler/state/v3 under
8
+ // the project root (KV, R2, D1, Durable Objects, Workflows
9
+ // each get their own subdir there where Miniflare supports a
10
+ // separate persist path).
11
+ // D1 migrations: Miniflare itself has no migrations option —
12
+ // wrangler dev applies them. The mapping keeps migrations_dir
13
+ // on the database entry so the runner (CF-2) can apply the SQL
14
+ // files after Miniflare starts; it is not dropped.
15
+ // Remote credentials come from CLOUDFLARE_ACCOUNT_ID /
16
+ // CLOUDFLARE_API_TOKEN. Without them, a remote-only binding
17
+ // is a clear error naming what's missing (not a silent local
18
+ // fallback).
19
+ import path from "node:path";
20
+
21
+ function persistDir(configDir, sub) {
22
+ return path.join(configDir, ".wrangler", "state", "v3", sub);
23
+ }
24
+
25
+ export function wranglerToMiniflareOptions({
26
+ config,
27
+ devVars,
28
+ configDir,
29
+ wranglerFile,
30
+ remoteBindings,
31
+ }) {
32
+ const options = {
33
+ modules: true,
34
+ // The main Worker's name (from wrangler config): dispatch
35
+ // routes to it, not to the first worker in `workers`.
36
+ ...(config.name ? { name: config.name } : {}),
37
+ compatibilityDate: config.compatibility_date,
38
+ compatibilityFlags: config.compatibility_flags,
39
+ // Workerd runs with TZ=UTC (workerd does not read the
40
+ // system timezone; Date is always UTC there).
41
+ bindings: { ...config.vars, ...devVars },
42
+ };
43
+
44
+ // Remote bindings (CF-3): AI / Browser Rendering / Vectorize
45
+ // are ALWAYS remote (no local emulation), and any binding
46
+ // named in the entry options' remoteBindings list is remote
47
+ // for this session.
48
+ // Remote requires Cloudflare credentials AND a deployed
49
+ // remote proxy Worker URL (Miniflare's
50
+ // remoteProxyConnectionString is a URL to a proxy Worker —
51
+ // it is what `wrangler dev --remote` deploys). Without
52
+ // either, that is a clear error naming what is missing —
53
+ // never a silent local fallback (SPEC §16.3). Native proxy
54
+ // deployment (deploying the proxy Worker from jundle via
55
+ // the Cloudflare API) is a follow-up; until then, a
56
+ // pre-deployed proxy URL comes from
57
+ // CLOUDFLARE_REMOTE_PROXY_URL (e.g. one wrangler deployed).
58
+ const remoteNames = new Set(remoteBindings || []);
59
+ const alwaysRemote = [];
60
+ if (config.ai?.binding) alwaysRemote.push(["AI", config.ai.binding]);
61
+ if (config.browser?.binding) alwaysRemote.push(["Browser Rendering", config.browser.binding]);
62
+ if (config.vectorize?.length) {
63
+ for (const v of config.vectorize) alwaysRemote.push(["Vectorize", v.binding]);
64
+ }
65
+ for (const [, binding] of alwaysRemote) remoteNames.add(binding);
66
+ // Validate remoteBindings names against the bindings
67
+ // actually in the wrangler file (a typo must not silently
68
+ // do nothing — it must be a clear error).
69
+ if (remoteBindings?.length) {
70
+ const known = new Set([
71
+ ...(config.kv_namespaces || []).map((ns) => ns.binding),
72
+ ...(config.r2_buckets || []).map((b) => b.binding),
73
+ ...(config.d1_databases || []).map((db) => db.binding),
74
+ ...(config.durable_objects?.bindings || []).map((b) => b.name),
75
+ ...(config.queues?.producers || []).map((p) => p.binding),
76
+ ...(config.workflows || []).map((w) => w.binding),
77
+ ...(config.ai?.binding ? [config.ai.binding] : []),
78
+ ...(config.browser?.binding ? [config.browser.binding] : []),
79
+ ...(config.vectorize || []).map((v) => v.binding),
80
+ ...(config.services || []).map((s) => s.binding),
81
+ ]);
82
+ const unknown = remoteBindings.filter((b) => !known.has(b));
83
+ if (unknown.length) {
84
+ throw new Error(
85
+ `CF plugin: remoteBindings (entry options) names unknown binding(s): ${unknown.join(", ")} (not in the wrangler file)`,
86
+ );
87
+ }
88
+ const remoteServices = (config.services || []).filter((s) => remoteNames.has(s.binding));
89
+ if (remoteServices.length) {
90
+ throw new Error(
91
+ `CF plugin: remote service binding(s) ${remoteServices.map((s) => s.binding).join(", ")} are not supported yet (a remote service needs a service URL, not just a proxy — local service bindings via the workers array are supported; see CF-3 multi-worker)`,
92
+ );
93
+ }
94
+ }
95
+ let remoteProxyUrl = null;
96
+ if (remoteNames.size) {
97
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
98
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
99
+ const missing = [
100
+ !accountId && "CLOUDFLARE_ACCOUNT_ID",
101
+ !apiToken && "CLOUDFLARE_API_TOKEN",
102
+ ].filter(Boolean);
103
+ if (missing.length) {
104
+ const names = [
105
+ ...alwaysRemote.map(([kind, binding]) => `${kind} (${binding})`),
106
+ ...[...remoteNames].filter((b) => !alwaysRemote.some(([, ab]) => ab === b)),
107
+ ].join(", ");
108
+ throw new Error(
109
+ `CF plugin: remote binding(s) ${names} require Cloudflare credentials, but missing: ${missing.join(", ")}. ` +
110
+ `Set ${missing.join(" and ")} to use them (remote bindings have no local fallback).`,
111
+ );
112
+ }
113
+ remoteProxyUrl = process.env.CLOUDFLARE_REMOTE_PROXY_URL || null;
114
+ if (!remoteProxyUrl) {
115
+ throw new Error(
116
+ `CF plugin: remote binding(s) ${[...remoteNames].join(", ")} require a deployed remote proxy Worker URL (Miniflare remoteProxyConnectionString), but CLOUDFLARE_REMOTE_PROXY_URL is not set. ` +
117
+ `Deploy a remote proxy (e.g. via \`wrangler dev --remote\`, which deploys one) and set CLOUDFLARE_REMOTE_PROXY_URL to its URL. ` +
118
+ `Native proxy deployment from jundle is a follow-up; until then this is a clear error, not a silent local fallback.`,
119
+ );
120
+ }
121
+ options.cloudflareAccountId = accountId;
122
+ options.cloudflareApiToken = apiToken;
123
+ }
124
+ const isRemote = (binding) => remoteProxyUrl && remoteNames.has(binding);
125
+
126
+ // KV: local by default, persisted. Remote namespaces use
127
+ // the object form ({ id, remoteProxyConnectionString });
128
+ // if any namespace is remote the whole list becomes a
129
+ // Record (local entries keep their id as a plain string).
130
+ if (config.kv_namespaces?.length) {
131
+ if (config.kv_namespaces.some((ns) => isRemote(ns.binding))) {
132
+ options.kvNamespaces = {};
133
+ for (const ns of config.kv_namespaces) {
134
+ options.kvNamespaces[ns.binding] = isRemote(ns.binding)
135
+ ? { id: ns.id, remoteProxyConnectionString: remoteProxyUrl }
136
+ : ns.id;
137
+ }
138
+ } else {
139
+ options.kvNamespaces = config.kv_namespaces.map((ns) => ns.binding);
140
+ }
141
+ options.kvPersist = persistDir(configDir, "kv");
142
+ }
143
+
144
+ // R2: local by default, persisted. Same Record-form
145
+ // conversion as KV when any bucket is remote (id = the
146
+ // binding, matching the string[] form's bucket naming).
147
+ if (config.r2_buckets?.length) {
148
+ if (config.r2_buckets.some((b) => isRemote(b.binding))) {
149
+ options.r2Buckets = {};
150
+ for (const b of config.r2_buckets) {
151
+ options.r2Buckets[b.binding] = isRemote(b.binding)
152
+ ? { id: b.binding, remoteProxyConnectionString: remoteProxyUrl }
153
+ : b.binding;
154
+ }
155
+ } else {
156
+ options.r2Buckets = config.r2_buckets.map((b) => b.binding);
157
+ }
158
+ options.r2Persist = persistDir(configDir, "r2");
159
+ }
160
+
161
+ // D1: local by default, persisted. migrations_dir is kept
162
+ // for the runner to apply (see header).
163
+ if (config.d1_databases?.length) {
164
+ options.d1Databases = {};
165
+ for (const db of config.d1_databases) {
166
+ const id = db.database_id || db.database_name;
167
+ options.d1Databases[db.binding] = isRemote(db.binding)
168
+ ? { id, remoteProxyConnectionString: remoteProxyUrl }
169
+ : id;
170
+ }
171
+ options.d1Persist = persistDir(configDir, "d1");
172
+ options.d1Migrations = config.d1_databases
173
+ .filter((db) => db.migrations_dir)
174
+ .map((db) => ({
175
+ binding: db.binding,
176
+ dir: path.resolve(
177
+ path.dirname(wranglerFile || path.join(configDir, "wrangler.jsonc")),
178
+ db.migrations_dir,
179
+ ),
180
+ }));
181
+ }
182
+
183
+ // Durable Objects: class bindings mapped to classes the app
184
+ // exports; storage persisted; alarms are a runtime feature
185
+ // (no option needed — the DO class's alarm() handler runs).
186
+ if (config.durable_objects?.bindings?.length) {
187
+ options.durableObjects = {};
188
+ for (const b of config.durable_objects.bindings) {
189
+ options.durableObjects[b.name] = isRemote(b.name)
190
+ ? { className: b.class_name, remoteProxyConnectionString: remoteProxyUrl }
191
+ : b.class_name;
192
+ }
193
+ options.durableObjectsPersist = persistDir(configDir, "do");
194
+ }
195
+
196
+ // Queues: producers + consumers, local by default (local
197
+ // queues are in-memory only — Miniflare behavior, not
198
+ // hidden). Consumers dispatch to the app's exported queue()
199
+ // handler (Miniflare does this when the Worker exports it).
200
+ // A remote producer sends to the real Cloudflare queue;
201
+ // the consumer stays local (it is this Worker's handler).
202
+ if (config.queues?.producers?.length) {
203
+ options.queueProducers = {};
204
+ for (const p of config.queues.producers) {
205
+ options.queueProducers[p.binding] = isRemote(p.binding)
206
+ ? { queueName: p.queue, remoteProxyConnectionString: remoteProxyUrl }
207
+ : p.queue;
208
+ }
209
+ }
210
+ if (config.queues?.consumers?.length) {
211
+ options.queueConsumers = {};
212
+ for (const c of config.queues.consumers) {
213
+ options.queueConsumers[c.queue] = {
214
+ ...(c.max_batch_size !== undefined ? { maxBatchSize: c.max_batch_size } : {}),
215
+ ...(c.max_batch_timeout !== undefined ? { maxBatchTimeout: c.max_batch_timeout } : {}),
216
+ ...(c.max_retries !== undefined ? { maxRetries: c.max_retries } : {}),
217
+ ...(c.dead_letter_queue !== undefined ? { deadLetterQueue: c.dead_letter_queue } : {}),
218
+ };
219
+ }
220
+ }
221
+
222
+ // Workflows: mapped to Workflow classes the app exports;
223
+ // state persisted in Durable Objects (Miniflare handles the
224
+ // storage when workflowsPersist is set).
225
+ if (config.workflows?.length) {
226
+ options.workflows = {};
227
+ for (const w of config.workflows) {
228
+ options.workflows[w.binding] = isRemote(w.binding)
229
+ ? { name: w.name, className: w.class_name, remoteProxyConnectionString: remoteProxyUrl }
230
+ : { name: w.name, className: w.class_name };
231
+ }
232
+ options.workflowsPersist = persistDir(configDir, "workflows");
233
+ }
234
+
235
+ // Cron triggers (wrangler `triggers.crons`): Miniflare runs
236
+ // scheduled handlers on this schedule (and
237
+ // dispatchScheduled triggers one on demand). Mapped here
238
+ // so the config is honored, not dropped.
239
+ if (config.triggers?.crons?.length) {
240
+ options.crons = [...config.triggers.crons];
241
+ }
242
+
243
+ // Service bindings: a local Worker (by service name) or a
244
+ // remote service. Local services are resolved by the runner
245
+ // (CF-2) via Miniflare's worker registry; remote services
246
+ // need credentials and are handled in CF-3.
247
+ if (config.services?.length) {
248
+ options.serviceBindings = {};
249
+ for (const s of config.services) {
250
+ if (s.service) options.serviceBindings[s.binding] = s.service;
251
+ }
252
+ }
253
+
254
+ // Assets: bound to a local directory (wrangler `assets`
255
+ // config: { directory } or a plain path string).
256
+ // routerConfig.has_user_worker tells Miniflare's assets
257
+ // router that a user Worker exists to fall through to for
258
+ // non-asset paths; without it, Miniflare serves ONLY static
259
+ // files and every Worker route 404s (assets-only mode).
260
+ // With it, static files win for matching paths and the
261
+ // Worker handles the rest (the Workers Static Assets model).
262
+ if (config.assets) {
263
+ const dir = typeof config.assets === "string" ? config.assets : config.assets.directory;
264
+ if (dir)
265
+ options.assets = {
266
+ directory: path.resolve(configDir, dir),
267
+ routerConfig: { has_user_worker: true },
268
+ };
269
+ }
270
+
271
+ // AI / Browser Rendering / Vectorize: ALWAYS remote (no
272
+ // local emulation). Wired here (not just recorded) when a
273
+ // proxy URL is present; without credentials or a proxy URL
274
+ // the top-of-function checks already threw a clear error.
275
+ if (config.ai?.binding) {
276
+ options.ai = { binding: config.ai.binding, remoteProxyConnectionString: remoteProxyUrl };
277
+ }
278
+ if (config.browser?.binding) {
279
+ options.browserRendering = {
280
+ binding: config.browser.binding,
281
+ remoteProxyConnectionString: remoteProxyUrl,
282
+ };
283
+ }
284
+ if (config.vectorize?.length) {
285
+ options.vectorize = {};
286
+ for (const v of config.vectorize) {
287
+ options.vectorize[v.binding] = {
288
+ index_name: v.index_name,
289
+ remoteProxyConnectionString: remoteProxyUrl,
290
+ };
291
+ }
292
+ }
293
+
294
+ return options;
295
+ }
package/src/build.d.ts ADDED
@@ -0,0 +1,145 @@
1
+ // Type declarations for @jundle/cloudflare/build — the
2
+ // Cloudflare Workers BUILD plugin (SPEC §16.13). See
3
+ // src/build.js for the implementation; src/index.d.ts for
4
+ // the runtime plugin and the WranglerConfig type (imported
5
+ // here, not duplicated).
6
+ import type { WranglerConfig } from "./index.js";
7
+
8
+ /** The five Workers runtime builtins, exactly
9
+ * @cloudflare/vite-plugin's cloudflareBuiltInModules. */
10
+ export declare const CLOUDFLARE_BUILTINS: readonly [
11
+ "cloudflare:email",
12
+ "cloudflare:node",
13
+ "cloudflare:sockets",
14
+ "cloudflare:workers",
15
+ "cloudflare:workflows",
16
+ ];
17
+
18
+ /** Options for the build plugin, core-managed when
19
+ * auto-registered (SPEC §16.13). `configDir` is
20
+ * required: the writeBundle handler only receives
21
+ * { dir } + the file list, so the project directory
22
+ * must be captured at construction time. */
23
+ export interface CloudflareBuildPluginOptions {
24
+ /** Project root: the wrangler file is resolved against this. */
25
+ configDir: string;
26
+ /**
27
+ * Path to the wrangler config file, resolved against
28
+ * configDir. Null/omitted auto-discovers wrangler.jsonc,
29
+ * wrangler.json, then wrangler.toml in the config directory.
30
+ */
31
+ wranglerConfig?: string | null;
32
+ }
33
+
34
+ /** Inputs to {@link getOutputConfig}. */
35
+ export interface OutputConfigArgs {
36
+ /** The user's wrangler config, as read by readWranglerConfig. */
37
+ userConfig: WranglerConfig;
38
+ /** The server entry chunk, outDir-relative (e.g. "server/server.js"). */
39
+ main: string;
40
+ /** Whether the build has client output (drives assets.directory). */
41
+ includeAssets: boolean;
42
+ /** Absolute output directory (the worker outDir). */
43
+ outDir: string;
44
+ /** Project root, for rebasing source-relative paths. */
45
+ configDir: string;
46
+ /** Whether the entry chunk has a .map in the bundle. */
47
+ sourcemaps: boolean;
48
+ }
49
+
50
+ /**
51
+ * The generated <outDir>/wrangler.json (upstream
52
+ * getOutputConfig): the user's config with main,
53
+ * no_bundle, rules, rebased assets/containers/d1, and
54
+ * upload_source_maps applied. Other fields pass through
55
+ * verbatim; the return type is the input config plus the
56
+ * overrides, so it stays open via the index signature.
57
+ */
58
+ export declare function getOutputConfig(args: OutputConfigArgs): WranglerConfig & {
59
+ main: string;
60
+ no_bundle: true;
61
+ rules: Array<{ type: "ESModule"; globs: string[] }>;
62
+ upload_source_maps?: boolean;
63
+ };
64
+
65
+ /** Quote one value for .dev.vars (upstream quoteForDotenv). */
66
+ export declare function quoteForDotenv(value: string): string;
67
+
68
+ /** Serialize local dev vars to .dev.vars file contents. */
69
+ export declare function serializeDevVars(devVars: Record<string, string>): string;
70
+
71
+ /** The <configDir>/.wrangler/deploy/config.json contents. */
72
+ export interface DeployConfig {
73
+ /** Path relative from .wrangler/deploy/ to <outDir>/wrangler.json. */
74
+ configPath: string;
75
+ /** No auxiliary workers in single-worker jundle. */
76
+ auxiliaryWorkers: Array<{ configPath: string }>;
77
+ }
78
+
79
+ export declare function buildDeployConfig(args: {
80
+ configDir: string;
81
+ outDir: string;
82
+ }): DeployConfig;
83
+
84
+ /** Rewrite a D1 migrations_pattern from the source
85
+ * migrations dir to the output migrations dir. */
86
+ export declare function rewriteMigrationsPattern(
87
+ migrationsPattern: string | undefined,
88
+ fromMigrationsDir: string,
89
+ toMigrationsDir: string,
90
+ ): string | undefined;
91
+
92
+ /** The meta object core passes to the factory's `raw`. */
93
+ export interface CloudflareBuildPluginMeta {
94
+ framework: "jundle";
95
+ versions: { unplugin: string; jundle: string };
96
+ }
97
+
98
+ /** The plugin object the factory produces. */
99
+ export interface CloudflareBuildPlugin {
100
+ name: "@jundle/cloudflare";
101
+ enforce: "pre";
102
+ /**
103
+ * Returns `{ id: source, external: true }` for the
104
+ * exact five `cloudflare:*` builtins, `null` otherwise.
105
+ */
106
+ resolveId: (
107
+ source: string,
108
+ importer: string | undefined,
109
+ options: {
110
+ isEntry: boolean;
111
+ attributes: Record<string, string>;
112
+ custom?: unknown;
113
+ kind: "static" | "dynamic";
114
+ },
115
+ ) => { id: string; external: true } | null;
116
+ /** Emits the deployment config after the write phase. */
117
+ writeBundle: (options: { dir: string }, bundle: string[]) => Promise<void>;
118
+ }
119
+
120
+ /**
121
+ * The build plugin factory (unplugin, as the demo plugin
122
+ * in examples/rsc-app/plugins/demo-plugin.js). Core
123
+ * auto-registers it for a @jundle/cloudflare runtime
124
+ * entry; it can also be declared explicitly in
125
+ * config.plugins with the same options.
126
+ *
127
+ * Deliberately NOT typed via `unplugin`'s own types:
128
+ * core only ever calls `.raw(options, meta)` (see
129
+ * `plugin-inventory.js`), and importing unplugin's
130
+ * declarations pulls in its optional peer declarations
131
+ * (`@farmfe/core`, `rollup`, ...) which are not
132
+ * installed, breaking consumers' typechecks. The other
133
+ * framework adapters (`.vite`, `.rollup`, ...) exist at
134
+ * runtime but are not part of jundle's contract, so they
135
+ * are not declared here.
136
+ */
137
+ export interface CloudflareBuildPluginFactory {
138
+ raw: (
139
+ options: CloudflareBuildPluginOptions,
140
+ meta: CloudflareBuildPluginMeta,
141
+ ) => CloudflareBuildPlugin | CloudflareBuildPlugin[];
142
+ }
143
+
144
+ declare const _default: CloudflareBuildPluginFactory;
145
+ export default _default;