@bleedingdev/modern-js-app-tools-extensions 3.9.0-ultramodern.22 → 3.9.0-ultramodern.24

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.
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import type {
3
3
  CloudflareWorkerD1DatabaseConfig,
4
4
  CloudflareWorkerServiceBindingConfig,
5
+ CloudflareWorkerVpcServiceConfig,
5
6
  JsonValue,
6
7
  } from '../config';
7
8
  import {
@@ -108,7 +109,9 @@ const createWranglerCompatibilityFlags = (
108
109
  // `wrangler deploy --env <name>` uses an environment's own compatibility_date
109
110
  // and compatibility_flags instead of the top-level values, so each override
110
111
  // is held to the same verified date floor and required flags.
111
- const createWranglerEnvironments = (wranglerEnv: JsonValue | undefined) => {
112
+ const createWranglerEnvironments = (
113
+ wranglerEnv: JsonValue | undefined,
114
+ ): Record<string, Record<string, JsonValue>> | undefined => {
112
115
  if (wranglerEnv === undefined) {
113
116
  return undefined;
114
117
  }
@@ -313,6 +316,232 @@ export const createWorkerServiceBindings = (
313
316
  return services.map(normalizeServiceBinding);
314
317
  };
315
318
 
319
+ const normalizeVpcService = (
320
+ vpcService: CloudflareWorkerVpcServiceConfig,
321
+ index: number,
322
+ ) => {
323
+ const binding = assertNonEmptyString(
324
+ vpcService.binding,
325
+ `deploy.worker.vpcServices[${index}].binding`,
326
+ );
327
+ const serviceId = assertNonEmptyString(
328
+ vpcService.serviceId,
329
+ `deploy.worker.vpcServices[${index}].serviceId`,
330
+ );
331
+ const prefix =
332
+ vpcService.prefix === undefined
333
+ ? undefined
334
+ : assertNonEmptyString(
335
+ vpcService.prefix,
336
+ `deploy.worker.vpcServices[${index}].prefix`,
337
+ );
338
+
339
+ return { binding, serviceId, ...(prefix === undefined ? {} : { prefix }) };
340
+ };
341
+
342
+ // The worker dispatcher percent-decodes a request path (up to four rounds, as
343
+ // `resolvePrefixPathname` does), matches a prefix on path-segment boundaries
344
+ // and takes the first matching binding, so overlapping prefixes, including
345
+ // encoded aliases of one another, route ambiguously.
346
+ const decodeRoutePrefix = (prefix: string) => {
347
+ let decoded = prefix;
348
+ for (let round = 0; round < 4; round += 1) {
349
+ let next: string;
350
+ try {
351
+ next = decodeURIComponent(decoded);
352
+ } catch {
353
+ return decoded;
354
+ }
355
+ if (next === decoded) {
356
+ return decoded;
357
+ }
358
+ decoded = next;
359
+ }
360
+ return decoded;
361
+ };
362
+
363
+ const normalizeRoutePrefix = (prefix: string) => {
364
+ const decoded = decodeRoutePrefix(prefix);
365
+ return decoded === '/' ? decoded : decoded.replace(/\/+$/u, '');
366
+ };
367
+
368
+ // The dispatcher matches `new URL(request.url).pathname`, so a prefix is
369
+ // routable only if URL parsing keeps it unchanged: no query or fragment, no
370
+ // `.`/`..` segments, nothing URL parsing would re-encode, and no percent-encoding
371
+ // (the dispatcher decodes request paths before matching).
372
+ const isCanonicalRoutePrefix = (prefix: string) =>
373
+ prefix.startsWith('/') &&
374
+ !prefix.includes('\\') &&
375
+ decodeRoutePrefix(prefix) === prefix &&
376
+ new URL(prefix, 'https://route.invalid').pathname === prefix;
377
+
378
+ /**
379
+ * Names the generated Worker may already expose on `env`, collected
380
+ * conservatively so no Wrangler binding shape is missed: every `binding` field
381
+ * at any depth (assets, D1, KV, Hyperdrive, queue producers, …), the `name` of
382
+ * every array entry (Durable Objects, `send_email`, `ratelimits`, `unsafe` and
383
+ * `logfwdr` bindings, …), and `vars` keys. A non-binding array `name` can only
384
+ * make a VPC name collide, never let a real collision through. Service and VPC
385
+ * bindings are checked separately; per-environment overrides live under `env`.
386
+ */
387
+ const collectWorkerBindingNames = (config: Record<string, JsonValue>) => {
388
+ const names = new Set<string>();
389
+ const visit = (value: JsonValue, inArray: boolean) => {
390
+ if (Array.isArray(value)) {
391
+ for (const item of value) {
392
+ visit(item, true);
393
+ }
394
+ return;
395
+ }
396
+ if (!isJsonRecord(value)) {
397
+ return;
398
+ }
399
+ for (const field of inArray ? ['binding', 'name'] : ['binding']) {
400
+ const name = value[field];
401
+ if (typeof name === 'string') {
402
+ names.add(name.trim());
403
+ }
404
+ }
405
+ for (const child of Object.values(value)) {
406
+ visit(child, false);
407
+ }
408
+ };
409
+ for (const [key, value] of Object.entries(config)) {
410
+ if (key === 'env' || key === 'services' || key === 'vpc_services') {
411
+ continue;
412
+ }
413
+ if (key === 'vars' && isJsonRecord(value)) {
414
+ for (const name of Object.keys(value)) {
415
+ names.add(name);
416
+ }
417
+ continue;
418
+ }
419
+ visit(value, false);
420
+ }
421
+ return names;
422
+ };
423
+
424
+ const prefixesOverlap = (left: string, right: string) => {
425
+ const [first, second] = [
426
+ normalizeRoutePrefix(left),
427
+ normalizeRoutePrefix(right),
428
+ ];
429
+ return (
430
+ first === '/' ||
431
+ second === '/' ||
432
+ first === second ||
433
+ first.startsWith(`${second}/`) ||
434
+ second.startsWith(`${first}/`)
435
+ );
436
+ };
437
+
438
+ /**
439
+ * The prefix the worker's Effect BFF dispatcher owns. It runs before any
440
+ * service binding, so it takes part in route-overlap checks.
441
+ */
442
+ export const getWorkerEffectBffPrefix = (
443
+ modernConfig: CloudflareModernConfig,
444
+ ) => {
445
+ const bffPrefix = modernConfig.bff?.prefix;
446
+ const primaryBffPrefix = Array.isArray(bffPrefix) ? bffPrefix[0] : bffPrefix;
447
+ const isEffectApi =
448
+ Boolean(modernConfig.bff) && modernConfig.bff?.runtimeFramework !== 'hono';
449
+ return isEffectApi && primaryBffPrefix ? primaryBffPrefix : undefined;
450
+ };
451
+
452
+ const effectiveServiceBindings = (
453
+ serviceBindings: ReturnType<typeof createWorkerServiceBindings>,
454
+ ) => {
455
+ const bindings: unknown[] = Array.isArray(serviceBindings)
456
+ ? serviceBindings
457
+ : [];
458
+ return bindings.flatMap(service =>
459
+ isJsonRecord(service) && typeof service.binding === 'string'
460
+ ? [
461
+ {
462
+ binding: service.binding.trim(),
463
+ prefix:
464
+ typeof service.prefix === 'string' ? service.prefix : undefined,
465
+ },
466
+ ]
467
+ : [],
468
+ );
469
+ };
470
+
471
+ export const createWorkerVpcServiceBindings = (
472
+ modernConfig: CloudflareModernConfig,
473
+ configuredWranglerVpcServices: JsonValue | undefined,
474
+ serviceBindings: ReturnType<typeof createWorkerServiceBindings>,
475
+ workerBindingNames: ReadonlySet<string>,
476
+ ) => {
477
+ const vpcServices = modernConfig.deploy?.worker?.vpcServices;
478
+
479
+ if (vpcServices === undefined) {
480
+ return undefined;
481
+ }
482
+
483
+ if (configuredWranglerVpcServices !== undefined) {
484
+ throw new Error(
485
+ 'Use deploy.worker.vpcServices or deploy.worker.wrangler.vpc_services, not both.',
486
+ );
487
+ }
488
+
489
+ if (!Array.isArray(vpcServices)) {
490
+ throw new Error('deploy.worker.vpcServices must be an array.');
491
+ }
492
+
493
+ const services = effectiveServiceBindings(serviceBindings);
494
+ const serviceBindingNames = new Set(services.map(({ binding }) => binding));
495
+ const effectBffPrefix = getWorkerEffectBffPrefix(modernConfig);
496
+ const routes = [
497
+ ...(effectBffPrefix === undefined
498
+ ? []
499
+ : [{ owner: 'the Effect BFF', prefix: effectBffPrefix }]),
500
+ ...services.flatMap(({ binding, prefix }) =>
501
+ prefix === undefined ? [] : [{ owner: `binding "${binding}"`, prefix }],
502
+ ),
503
+ ];
504
+ const vpcBindingNames = new Set<string>();
505
+
506
+ return vpcServices.map((vpcService, index) => {
507
+ const normalized = normalizeVpcService(vpcService, index);
508
+ if (serviceBindingNames.has(normalized.binding)) {
509
+ throw new Error(
510
+ `deploy.worker.vpcServices[${index}].binding "${normalized.binding}" is already declared by deploy.worker.services.`,
511
+ );
512
+ }
513
+ if (workerBindingNames.has(normalized.binding)) {
514
+ throw new Error(
515
+ `deploy.worker.vpcServices[${index}].binding "${normalized.binding}" is already a binding of this Worker.`,
516
+ );
517
+ }
518
+ if (vpcBindingNames.has(normalized.binding)) {
519
+ throw new Error(
520
+ `deploy.worker.vpcServices[${index}].binding "${normalized.binding}" is declared more than once.`,
521
+ );
522
+ }
523
+ vpcBindingNames.add(normalized.binding);
524
+ if (normalized.prefix !== undefined) {
525
+ const { prefix } = normalized;
526
+ if (!isCanonicalRoutePrefix(prefix)) {
527
+ throw new Error(
528
+ `deploy.worker.vpcServices[${index}].prefix "${prefix}" must be a decoded URL path that starts with "/" (no query, fragment, "." or ".." segments).`,
529
+ );
530
+ }
531
+ const shadow = routes.find(route =>
532
+ prefixesOverlap(route.prefix, prefix),
533
+ );
534
+ if (shadow !== undefined) {
535
+ throw new Error(
536
+ `deploy.worker.vpcServices[${index}].prefix "${prefix}" overlaps the prefix "${shadow.prefix}" of ${shadow.owner}; the worker dispatcher would route one of them ambiguously.`,
537
+ );
538
+ }
539
+ routes.push({ owner: `binding "${normalized.binding}"`, prefix });
540
+ }
541
+ return normalized;
542
+ });
543
+ };
544
+
316
545
  const createWranglerServices = (
317
546
  serviceBindings: ReturnType<typeof createWorkerServiceBindings>,
318
547
  ) => {
@@ -338,9 +567,24 @@ const createWranglerServices = (
338
567
 
339
568
  export const createWorkerManifestServiceBindings = (
340
569
  serviceBindings: ReturnType<typeof createWorkerServiceBindings>,
570
+ vpcServiceBindings: ReturnType<typeof createWorkerVpcServiceBindings>,
341
571
  ) => {
572
+ const vpcManifestBindings = (vpcServiceBindings ?? []).flatMap(
573
+ ({ binding, prefix, serviceId }) =>
574
+ prefix === undefined
575
+ ? []
576
+ : [
577
+ {
578
+ binding,
579
+ interface: 'fetch',
580
+ prefix,
581
+ vpcServiceId: serviceId,
582
+ },
583
+ ],
584
+ );
585
+
342
586
  if (!Array.isArray(serviceBindings)) {
343
- return undefined;
587
+ return vpcManifestBindings.length > 0 ? vpcManifestBindings : undefined;
344
588
  }
345
589
 
346
590
  const bindings: unknown[] = serviceBindings;
@@ -375,7 +619,9 @@ export const createWorkerManifestServiceBindings = (
375
619
  : { fragments: service.fragments }),
376
620
  }));
377
621
 
378
- return manifestBindings.length > 0 ? manifestBindings : undefined;
622
+ const allBindings = [...manifestBindings, ...vpcManifestBindings];
623
+
624
+ return allBindings.length > 0 ? allBindings : undefined;
379
625
  };
380
626
 
381
627
  export const createWranglerConfig = (
@@ -393,8 +639,7 @@ export const createWranglerConfig = (
393
639
  wrangler.services,
394
640
  );
395
641
  const wranglerServices = createWranglerServices(serviceBindings);
396
-
397
- return {
642
+ const workerConfig = {
398
643
  $schema: 'node_modules/wrangler/config-schema.json',
399
644
  name: getConfiguredWorkerName(appDirectory, modernConfig),
400
645
  ...wrangler,
@@ -411,4 +656,59 @@ export const createWranglerConfig = (
411
656
  ...(d1Databases === undefined ? {} : { d1_databases: d1Databases }),
412
657
  ...(wranglerServices === undefined ? {} : { services: wranglerServices }),
413
658
  };
659
+ const vpcServiceBindings = createWorkerVpcServiceBindings(
660
+ modernConfig,
661
+ wrangler.vpc_services,
662
+ serviceBindings,
663
+ collectWorkerBindingNames(workerConfig),
664
+ );
665
+
666
+ if (vpcServiceBindings === undefined) {
667
+ return workerConfig;
668
+ }
669
+ const vpcServicesConfig = vpcServiceBindings.map(
670
+ ({ binding, serviceId }) => ({
671
+ binding,
672
+ service_id: serviceId,
673
+ }),
674
+ );
675
+
676
+ return {
677
+ ...workerConfig,
678
+ // Wrangler bindings are not inherited by named environments, so every
679
+ // configured environment receives the typed VPC bindings as well.
680
+ ...(environments === undefined
681
+ ? {}
682
+ : {
683
+ env: Object.fromEntries(
684
+ Object.entries(environments).map(([name, environment]) => {
685
+ if (environment.vpc_services !== undefined) {
686
+ throw new Error(
687
+ `Use deploy.worker.vpcServices or deploy.worker.wrangler.env.${name}.vpc_services, not both.`,
688
+ );
689
+ }
690
+ // A named environment declares its own (non-inherited) bindings.
691
+ const environmentBindingNames = new Set([
692
+ ...collectWorkerBindingNames(environment),
693
+ ...effectiveServiceBindings(environment.services).map(
694
+ ({ binding }) => binding,
695
+ ),
696
+ ]);
697
+ const collision = vpcServiceBindings.find(({ binding }) =>
698
+ environmentBindingNames.has(binding),
699
+ );
700
+ if (collision !== undefined) {
701
+ throw new Error(
702
+ `deploy.worker.vpcServices binding "${collision.binding}" is already a binding of deploy.worker.wrangler.env.${name}.`,
703
+ );
704
+ }
705
+ return [
706
+ name,
707
+ { ...environment, vpc_services: vpcServicesConfig },
708
+ ];
709
+ }),
710
+ ),
711
+ }),
712
+ vpc_services: vpcServicesConfig,
713
+ };
414
714
  };
@@ -91,8 +91,11 @@ export const CLOUDFLARE_WORKER_NODE_BUILTINS = [
91
91
  ] as const;
92
92
  // Platform imports available with the default 2026-06-02 nodejs_compat target.
93
93
  // Import availability does not imply support for every Node API operation.
94
+ // `cloudflare:workers` exposes the Worker `env` at module scope, which is how
95
+ // clients built once per isolate reach Hyperdrive, VPC and service bindings.
94
96
  export const CLOUDFLARE_WORKER_PLATFORM_MODULES = [
95
97
  'cloudflare:sockets',
98
+ 'cloudflare:workers',
96
99
  ] as const;
97
100
  export const CLOUDFLARE_REQUIRED_COMPATIBILITY_FLAGS = [
98
101
  'nodejs_compat',
package/src/config.ts CHANGED
@@ -99,6 +99,19 @@ export interface CloudflareWorkerServiceBindingConfig {
99
99
  fragments?: CloudflareWorkerServiceBindingFragmentConfig[];
100
100
  }
101
101
 
102
+ export interface CloudflareWorkerVpcServiceConfig {
103
+ /** Worker binding name exposed on the module worker `env` object. */
104
+ binding: string;
105
+ /** Workers VPC service id (`wrangler vpc service create`). */
106
+ serviceId: string;
107
+ /**
108
+ * Optional application path prefix that Modern.js should dispatch to this
109
+ * private origin with `env[binding].fetch(request)`, exactly like a
110
+ * prefixed Worker service binding.
111
+ */
112
+ prefix?: string;
113
+ }
114
+
102
115
  export interface CloudflareWorkerServiceBindingFragmentConfig {
103
116
  /** Stable remote id used by the shell's composition contract. */
104
117
  remote: string;
@@ -194,19 +207,65 @@ export interface CloudflareWorkerSecurityConfig {
194
207
  reason?: string;
195
208
  }
196
209
 
210
+ export interface CloudflareWorkerDeployConfig {
211
+ name?: string;
212
+ /**
213
+ * Cloudflare Workers compatibility date for generated wrangler config.
214
+ * Use YYYY-MM-DD. Defaults to the date validated against the bundled
215
+ * Wrangler version used by UltraModern generated workspaces.
216
+ */
217
+ compatibilityDate?: string;
218
+ ssr?: boolean;
219
+ security?: CloudflareWorkerSecurityConfig;
220
+ /**
221
+ * Raw Wrangler-compatible config merged into `.output/wrangler.json`.
222
+ * Framework-owned worker invariants still win for `main`, the assets
223
+ * binding/directory/run mode, and required compatibility flags.
224
+ */
225
+ wrangler?: Record<string, JsonValue>;
226
+ /**
227
+ * Additional app-root files or directories to stage under `.output`.
228
+ * Use this for provider resources such as migrations or generated config.
229
+ */
230
+ artifacts?: CloudflareWorkerArtifactConfig[];
231
+ /**
232
+ * Additional app-root files or directories to serve as Cloudflare Worker
233
+ * Static Assets under `.output/public`. Use `to: '.'` to copy a
234
+ * source directory's contents into the public asset root.
235
+ */
236
+ publicAssets?: CloudflareWorkerPublicAssetConfig[];
237
+ /**
238
+ * First-class Cloudflare D1 bindings. Modern.js writes these to
239
+ * `wrangler.json` as `d1_databases` and stages configured migration
240
+ * directories into `.output`.
241
+ */
242
+ d1Databases?: CloudflareWorkerD1DatabaseConfig[];
243
+ /**
244
+ * First-class Cloudflare service bindings. Modern.js writes these to
245
+ * `wrangler.json` as `services`; when a binding also has `prefix`,
246
+ * the generated Worker dispatches matching requests through
247
+ * `env[binding].fetch(request)`.
248
+ */
249
+ services?: CloudflareWorkerServiceBindingConfig[];
250
+ /**
251
+ * First-class Workers VPC service bindings to private origins reached
252
+ * through a Cloudflare Tunnel. Modern.js writes these to `wrangler.json`
253
+ * as `vpc_services`; a binding with `prefix` joins the prefix dispatch of
254
+ * `services`.
255
+ */
256
+ vpcServices?: CloudflareWorkerVpcServiceConfig[];
257
+ /**
258
+ * Dist output paths that must not be copied into Cloudflare public assets.
259
+ * Entries are slash-normalized path prefixes relative to the app dist root.
260
+ * Top-level `api` and `shared` dist directories are excluded when matching
261
+ * source directories exist in the app root because they conventionally
262
+ * contain server-only implementation code.
263
+ */
264
+ publicAssetExcludes?: string[];
265
+ }
266
+
197
267
  export interface CloudflareDeployConfig {
198
- worker?: {
199
- name?: string;
200
- compatibilityDate?: string;
201
- ssr?: boolean;
202
- security?: CloudflareWorkerSecurityConfig;
203
- wrangler?: Record<string, JsonValue>;
204
- artifacts?: CloudflareWorkerArtifactConfig[];
205
- publicAssets?: CloudflareWorkerPublicAssetConfig[];
206
- d1Databases?: CloudflareWorkerD1DatabaseConfig[];
207
- services?: CloudflareWorkerServiceBindingConfig[];
208
- publicAssetExcludes?: string[];
209
- };
268
+ worker?: CloudflareWorkerDeployConfig;
210
269
  }
211
270
 
212
271
  export interface NodeDeployConfig {