@portel/photon-core 2.25.0 → 2.27.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.
Files changed (55) hide show
  1. package/README.md +121 -0
  2. package/dist/asset-discovery.d.ts +8 -2
  3. package/dist/asset-discovery.d.ts.map +1 -1
  4. package/dist/asset-discovery.js +46 -9
  5. package/dist/asset-discovery.js.map +1 -1
  6. package/dist/base.d.ts +76 -0
  7. package/dist/base.d.ts.map +1 -1
  8. package/dist/base.js +80 -0
  9. package/dist/base.js.map +1 -1
  10. package/dist/cf.d.ts +121 -0
  11. package/dist/cf.d.ts.map +1 -0
  12. package/dist/cf.js +65 -0
  13. package/dist/cf.js.map +1 -0
  14. package/dist/cloudflare.d.ts +110 -0
  15. package/dist/cloudflare.d.ts.map +1 -0
  16. package/dist/cloudflare.js +138 -0
  17. package/dist/cloudflare.js.map +1 -0
  18. package/dist/collections/Collection.d.ts +12 -0
  19. package/dist/collections/Collection.d.ts.map +1 -1
  20. package/dist/collections/Collection.js +19 -0
  21. package/dist/collections/Collection.js.map +1 -1
  22. package/dist/env.d.ts +29 -0
  23. package/dist/env.d.ts.map +1 -0
  24. package/dist/env.js +2 -0
  25. package/dist/env.js.map +1 -0
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/mixins.d.ts.map +1 -1
  31. package/dist/mixins.js +10 -0
  32. package/dist/mixins.js.map +1 -1
  33. package/dist/photon-loader-lite.d.ts +17 -0
  34. package/dist/photon-loader-lite.d.ts.map +1 -1
  35. package/dist/photon-loader-lite.js +150 -1
  36. package/dist/photon-loader-lite.js.map +1 -1
  37. package/dist/schema-extractor.d.ts +25 -6
  38. package/dist/schema-extractor.d.ts.map +1 -1
  39. package/dist/schema-extractor.js +86 -7
  40. package/dist/schema-extractor.js.map +1 -1
  41. package/dist/types.d.ts +1 -1
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js.map +1 -1
  44. package/package.json +3 -2
  45. package/src/asset-discovery.ts +44 -9
  46. package/src/base.ts +86 -0
  47. package/src/cf.ts +165 -0
  48. package/src/cloudflare.ts +198 -0
  49. package/src/collections/Collection.ts +19 -0
  50. package/src/env.ts +28 -0
  51. package/src/index.ts +27 -0
  52. package/src/mixins.ts +12 -0
  53. package/src/photon-loader-lite.ts +185 -0
  54. package/src/schema-extractor.ts +119 -9
  55. package/src/types.ts +16 -1
package/src/env.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * CloudflareEnv — raw Cloudflare Worker `env`, surfaced as an explicit
3
+ * constructor injection so the import line itself signals that the
4
+ * importing photon depends on a Cloudflare deploy target.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import type { CloudflareEnv } from "@portel/photon";
9
+ *
10
+ * interface MyBindings {
11
+ * WEATHER_KV: KVNamespace;
12
+ * PHOTOS: R2Bucket;
13
+ * }
14
+ *
15
+ * export class Weather {
16
+ * constructor(private env: CloudflareEnv<MyBindings>) {}
17
+ * async forecast() {
18
+ * await this.env.WEATHER_KV.put("k", "v");
19
+ * }
20
+ * }
21
+ * ```
22
+ *
23
+ * The default loose type (`Record<string, unknown>`) keeps the no-types
24
+ * path working. Authors who want strict binding shapes pass their own
25
+ * type parameter; future codegen from `protected cfBindings` can fill
26
+ * this in automatically.
27
+ */
28
+ export type CloudflareEnv<T = Record<string, unknown>> = T;
package/src/index.ts CHANGED
@@ -154,6 +154,33 @@ export {
154
154
  // Core base class with lifecycle hooks
155
155
  export { Photon, Photon as PhotonMCP } from './base.js';
156
156
 
157
+ // Cloudflare deploy-target injections (constructor-injected, not on Photon)
158
+ export {
159
+ type Cloudflare,
160
+ type ScopedBindingCategory,
161
+ bindingNameFor,
162
+ notConfiguredCloudflare,
163
+ createCloudflareFromEnv,
164
+ SHARED_AI_BINDING,
165
+ SHARED_IMAGES_BINDING,
166
+ SHARED_BROWSER_BINDING,
167
+ } from './cloudflare.js';
168
+ export { type CloudflareEnv } from './env.js';
169
+ // Structural CF binding types — published so hosts can type their own
170
+ // CF adapters against the same shape `Cloudflare` exposes (e.g. unit
171
+ // tests that mock `cf.kv()` returns).
172
+ export {
173
+ type R2BucketLike,
174
+ type KVNamespaceLike,
175
+ type D1DatabaseLike,
176
+ type D1PreparedStatementLike,
177
+ type QueueLike,
178
+ type VectorizeIndexLike,
179
+ type AiLike,
180
+ type ImagesBindingLike,
181
+ type FetcherLike,
182
+ } from './cf.js';
183
+
157
184
  // Mixin for capability injection without requiring inheritance
158
185
  export { withPhotonCapabilities } from './mixins.js';
159
186
 
package/src/mixins.ts CHANGED
@@ -84,6 +84,18 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
84
84
  */
85
85
  private _schedule?: ScheduleProvider;
86
86
 
87
+ /**
88
+ * Cloudflare Worker env when deployed; undefined on local hosts.
89
+ * See Photon.env for the full contract.
90
+ */
91
+ readonly env?: Record<string, unknown>;
92
+
93
+ /**
94
+ * True when the active /mcp request passed the PHOTON_MCP_BEARER
95
+ * gate. See Photon.mcpAuthed for the full contract.
96
+ */
97
+ readonly mcpAuthed?: boolean;
98
+
87
99
  /**
88
100
  * Cross-photon call handler - injected by runtime
89
101
  * @internal
@@ -38,6 +38,12 @@ import { withPhotonCapabilities } from './mixins.js';
38
38
  import { MemoryProvider } from './memory.js';
39
39
  import { ScheduleProvider } from './schedule.js';
40
40
  import { toEnvVarName, parseEnvValue, type MissingParamInfo } from './env-utils.js';
41
+ import { Photon as PhotonBase } from './base.js';
42
+ import {
43
+ type Cloudflare,
44
+ notConfiguredCloudflare,
45
+ createCloudflareFromEnv,
46
+ } from './cloudflare.js';
41
47
  import type { ExtractedSchema } from './types.js';
42
48
  import type { MCPClientFactory } from '@portel/mcp';
43
49
  import { getCacheDir } from './data-paths.js';
@@ -59,6 +65,22 @@ export interface PhotonOptions {
59
65
  sessionId?: string;
60
66
  /** Namespace for data path resolution (marketplace owner). Defaults to 'local'. */
61
67
  namespace?: string;
68
+ /**
69
+ * Build a `Cloudflare` runtime for a given photon. Hosts that bring CF
70
+ * support (local miniflare, deployed Worker) supply a factory; the
71
+ * loader calls it whenever a photon's constructor needs `Cloudflare`
72
+ * (or whenever `this.cf.*` is detected on a plain class). When the
73
+ * factory is absent the loader injects a throwing-Proxy fallback so
74
+ * the diagnostic remains clear.
75
+ */
76
+ cloudflareFactory?: (photonName: string) => Cloudflare;
77
+ /**
78
+ * Raw Cloudflare Worker `env`. Wired through to constructor params
79
+ * typed `CloudflareEnv` / `CloudflareEnv<T>`. Only present when the
80
+ * loader is being driven by the deployed Worker template (or by a
81
+ * test that wants to simulate it).
82
+ */
83
+ cloudflareEnv?: Record<string, unknown>;
62
84
  }
63
85
 
64
86
  export interface PhotonEvent {
@@ -263,6 +285,24 @@ async function loadPhotonInternal(
263
285
  // 9. Instantiate
264
286
  const instance = new EnhancedClass(...constructorArgs) as Record<string, any>;
265
287
 
288
+ // 9a. Forgiving auto-inject: classes that reference `this.cf.*` or
289
+ // `this.cfEnv.*` without declaring the matching constructor param
290
+ // still get the field populated, so authoring stays loose. The
291
+ // explicit-injection path (a typed constructor param) takes
292
+ // precedence; this only fills gaps. Mirrors how the classic loader
293
+ // injects `_callHandler` etc. for plain classes.
294
+ const detected = detectCapabilities(source);
295
+ const hasExplicitCloudflare = injections.some(i => i.injectionType === 'cloudflare');
296
+ const hasExplicitCloudflareEnv = injections.some(i => i.injectionType === 'cloudflareEnv');
297
+ if (detected.has('cloudflare') && !hasExplicitCloudflare && instance.cf === undefined) {
298
+ instance.cf = options.cloudflareFactory
299
+ ? options.cloudflareFactory(photonName)
300
+ : notConfiguredCloudflare();
301
+ }
302
+ if (detected.has('cloudflareEnv') && !hasExplicitCloudflareEnv && instance.cfEnv === undefined) {
303
+ instance.cfEnv = options.cloudflareEnv ?? makeThrowingCloudflareEnv();
304
+ }
305
+
266
306
  // 10. Set photon identity. Namespace mirrors the file's position under
267
307
  // baseDir (same rule the classic loader applies). Falls back to 'local'
268
308
  // when no baseDir context is available. See
@@ -450,6 +490,38 @@ async function resolveConstructorArgs(
450
490
  break;
451
491
  }
452
492
 
493
+ case 'photonRuntime': {
494
+ // `private photon: Photon` — inject a Photon instance configured
495
+ // identically to the host so `this.photon.memory.set(...)`,
496
+ // `this.photon.emit(...)`, etc. resolve to the same scope as
497
+ // the equivalent `extends Photon` calls would. Identity-stable
498
+ // per host load.
499
+ const runtime = createPhotonRuntimeForHost(photonName, currentPath, options);
500
+ values.push(runtime);
501
+ break;
502
+ }
503
+
504
+ case 'cloudflare': {
505
+ // `private cf: Cloudflare` — wrapped, auto-named CF surface.
506
+ const cf = options.cloudflareFactory
507
+ ? options.cloudflareFactory(photonName)
508
+ : notConfiguredCloudflare();
509
+ values.push(cf);
510
+ break;
511
+ }
512
+
513
+ case 'cloudflareEnv': {
514
+ // `private cfEnv: CloudflareEnv` — raw Worker env. On hosts
515
+ // without a CF runtime, hand back a throwing Proxy so the
516
+ // diagnostic names the imported symbol clearly.
517
+ if (options.cloudflareEnv) {
518
+ values.push(options.cloudflareEnv);
519
+ } else {
520
+ values.push(makeThrowingCloudflareEnv());
521
+ }
522
+ break;
523
+ }
524
+
453
525
  default:
454
526
  values.push(undefined);
455
527
  }
@@ -466,6 +538,119 @@ async function resolveConstructorArgs(
466
538
  return values;
467
539
  }
468
540
 
541
+ // ═══════════════════════════════════════════════════════════════════
542
+ // Photon-as-injection helpers
543
+ // ═══════════════════════════════════════════════════════════════════
544
+
545
+ /**
546
+ * Build the `Photon` instance handed to `private photon: Photon` ctor
547
+ * params. We instantiate the real base class and configure it with the
548
+ * host photon's identity so every capability (`memory`, `schedule`,
549
+ * `emit`, `call`, `mcp`, `caller`, `confirm`, `elicit`, `sample`,
550
+ * `photon.use`, ...) resolves to the same scope an equivalent
551
+ * `extends Photon` consumer would see. The result is shape-equivalent
552
+ * to a normal Photon, so authors can swap between `extends Photon` and
553
+ * constructor injection without touching any call site beyond the
554
+ * access path (`this.memory` vs `this.photon.memory`).
555
+ *
556
+ * The `_callHandler` and `setMCPFactory` wiring is deferred to the
557
+ * caller (see step 14 of `loadPhotonInternal`), which assigns the
558
+ * resolver after constructor args are built. Constructor-time access
559
+ * to `photon.memory` works because MemoryProvider is lazy-resolved on
560
+ * first use and the photon name / baseDir are set up here directly.
561
+ */
562
+ function createPhotonRuntimeForHost(
563
+ photonName: string,
564
+ currentPath: string,
565
+ options: PhotonOptions,
566
+ ): InstanceType<typeof PhotonBase> {
567
+ const runtime = new PhotonBase() as PhotonBase & Record<string, unknown>;
568
+ runtime._photonName = photonName;
569
+ runtime._photonNamespace =
570
+ options.namespace ?? deriveNamespace(currentPath, options.baseDir);
571
+ runtime._baseDir = options.baseDir;
572
+ runtime._photonFilePath = currentPath;
573
+ if (options.sessionId) {
574
+ runtime._sessionId = options.sessionId;
575
+ }
576
+ const setMCPFactory = (runtime as { setMCPFactory?: (f: MCPClientFactory) => void }).setMCPFactory;
577
+ if (options.mcpFactory && typeof setMCPFactory === 'function') {
578
+ setMCPFactory.call(runtime, options.mcpFactory);
579
+ }
580
+ // Cross-photon calls flow through the same in-process resolver the
581
+ // host instance uses. `loadPhotonInternal` re-assigns `_callHandler`
582
+ // on the host instance below; the runtime facade gets its own copy
583
+ // pointed at the same target resolver so injected callers and
584
+ // extends-Photon callers see identical behaviour.
585
+ runtime._callHandler = async (
586
+ targetPhotonName: string,
587
+ method: string,
588
+ params: Record<string, unknown>,
589
+ ) => {
590
+ const targetPath = resolvePhotonPath(targetPhotonName, currentPath, options.baseDir);
591
+ const target = await photon(targetPath, {
592
+ baseDir: options.baseDir,
593
+ mcpFactory: options.mcpFactory,
594
+ sessionId: options.sessionId,
595
+ });
596
+ return (target as Record<string, (p: Record<string, unknown>) => Promise<unknown>>)[method](params);
597
+ };
598
+ // `this.photon.photon.use()` parity: `extends Photon` instances get
599
+ // `_photonResolver` set by the host loader; the injected runtime
600
+ // needs the same resolver so dynamic photon access works identically
601
+ // through both consumption modes.
602
+ runtime._photonResolver = async (name: string, instanceName?: string) => {
603
+ const targetPath = resolvePhotonPath(name, currentPath, options.baseDir);
604
+ return photon(targetPath, {
605
+ baseDir: options.baseDir,
606
+ mcpFactory: options.mcpFactory,
607
+ sessionId: options.sessionId,
608
+ instanceName,
609
+ });
610
+ };
611
+ return runtime;
612
+ }
613
+
614
+ const CFENV_HINT =
615
+ 'This photon imports `CloudflareEnv` from "@portel/photon" but no CF ' +
616
+ 'env was attached. Run via `photon host run` (miniflare-backed) or ' +
617
+ 'deploy with `photon host deploy cloudflare`. Outside CF, this ' +
618
+ 'photon\'s CF-dependent methods cannot execute.';
619
+
620
+ function makeThrowingCloudflareEnv(): Record<string, unknown> {
621
+ // Engine-internal property accesses (constructor lookup, async-iterator
622
+ // probing, `await` thenable check, JSON.stringify) must not throw —
623
+ // otherwise, harmless reflection from runtime utilities like
624
+ // `wireReactiveCollections` blows up the photon load before any user
625
+ // code runs. We satisfy those safely and only throw when actual
626
+ // user code reads a binding name.
627
+ const safePassthrough = new Set<string | symbol>([
628
+ 'constructor',
629
+ 'then',
630
+ 'toJSON',
631
+ 'toString',
632
+ 'valueOf',
633
+ Symbol.toPrimitive,
634
+ Symbol.toStringTag,
635
+ Symbol.iterator,
636
+ Symbol.asyncIterator,
637
+ ]);
638
+ return new Proxy(Object.create(null), {
639
+ get(_target, prop) {
640
+ if (safePassthrough.has(prop)) {
641
+ if (prop === 'toString' || prop === Symbol.toPrimitive) {
642
+ return () => '[unconfigured CloudflareEnv]';
643
+ }
644
+ return undefined;
645
+ }
646
+ throw new Error(`CloudflareEnv.${String(prop)} accessed but ${CFENV_HINT}`);
647
+ },
648
+ has(_target, prop) {
649
+ return !safePassthrough.has(prop);
650
+ },
651
+ });
652
+ }
653
+
469
654
  // ═══════════════════════════════════════════════════════════════════
470
655
  // Reactive collection wiring
471
656
  // ═══════════════════════════════════════════════════════════════════
@@ -33,6 +33,12 @@ function warnHandlePrefixOnce(methodName: string): void {
33
33
  );
34
34
  }
35
35
 
36
+ export interface HttpRoute {
37
+ method: 'GET' | 'POST';
38
+ path: string;
39
+ handler: string;
40
+ }
41
+
36
42
  export interface ExtractedMetadata {
37
43
  tools: ExtractedSchema[];
38
44
  templates: TemplateInfo[];
@@ -48,8 +54,11 @@ export interface ExtractedMetadata {
48
54
  * - 'required': all methods require authenticated caller
49
55
  * - 'optional': caller populated if token present, anonymous allowed
50
56
  * - string URL: OIDC provider URL (implies required)
57
+ * - 'cf-access': use Cloudflare Access JWT to identify caller
51
58
  */
52
- auth?: 'required' | 'optional' | string;
59
+ auth?: 'required' | 'optional' | 'cf-access' | string;
60
+ /** HTTP routes declared with @get or @post on individual methods */
61
+ httpRoutes?: HttpRoute[];
53
62
  }
54
63
 
55
64
  /**
@@ -93,6 +102,9 @@ export class SchemaExtractor {
93
102
  // MCP OAuth auth requirement (from @auth tag)
94
103
  let auth: 'required' | 'optional' | string | undefined;
95
104
 
105
+ // HTTP routes from @get / @post method-level tags
106
+ const httpRoutes: HttpRoute[] = [];
107
+
96
108
  try {
97
109
  // If source doesn't contain a class declaration, wrap it in one
98
110
  let sourceToParse = source;
@@ -156,6 +168,18 @@ export class SchemaExtractor {
156
168
  return;
157
169
  }
158
170
 
171
+ // @get /path or @post /path — HTTP-only route, not an MCP tool
172
+ const getMatch = jsdoc.match(/@get\s+(\/\S*)/i);
173
+ const postMatch = jsdoc.match(/@post\s+(\/\S*)/i);
174
+ if (getMatch || postMatch) {
175
+ httpRoutes.push({
176
+ method: getMatch ? 'GET' : 'POST',
177
+ path: (getMatch ?? postMatch)![1],
178
+ handler: methodName,
179
+ });
180
+ return;
181
+ }
182
+
159
183
  // Check if this is an async generator method (has asterisk token)
160
184
  const isGenerator = member.asteriskToken !== undefined;
161
185
 
@@ -562,6 +586,11 @@ export class SchemaExtractor {
562
586
  result.auth = auth;
563
587
  }
564
588
 
589
+ // Include HTTP routes if any
590
+ if (httpRoutes.length > 0) {
591
+ result.httpRoutes = httpRoutes;
592
+ }
593
+
565
594
  return result;
566
595
  }
567
596
 
@@ -1980,17 +2009,24 @@ export class SchemaExtractor {
1980
2009
  }
1981
2010
 
1982
2011
  /**
1983
- * Check if JSDoc contains @Template tag
2012
+ * Check if a method's JSDoc marks it as an MCP prompt template.
2013
+ * Canonical form: `@prompt`. Legacy form `@Template` still accepted
2014
+ * for backward compatibility with photons authored before the rename.
1984
2015
  */
1985
2016
  private hasTemplateTag(jsdocContent: string): boolean {
1986
- return /@Template/i.test(jsdocContent);
2017
+ return /@(?:Template|prompt)\b/i.test(jsdocContent);
1987
2018
  }
1988
2019
 
1989
2020
  /**
1990
- * Check if JSDoc contains @Static tag
2021
+ * Check if a method's JSDoc marks it as a dynamic MCP resource resolver.
2022
+ * Canonical form: `@resource <uri-template>`. Legacy form `@Static`
2023
+ * still accepted. Disambiguation from the class-level static-file form
2024
+ * (`@resource <id> <path>`) is by argument shape: the class-level form
2025
+ * requires `<id>` followed by a path starting with `./` or `/`, which
2026
+ * the URI-template form never matches.
1991
2027
  */
1992
2028
  private hasStaticTag(jsdocContent: string): boolean {
1993
- return /@Static/i.test(jsdocContent);
2029
+ return /@(?:Static|resource)\b/i.test(jsdocContent);
1994
2030
  }
1995
2031
 
1996
2032
  /**
@@ -2448,11 +2484,12 @@ export class SchemaExtractor {
2448
2484
  }
2449
2485
 
2450
2486
  /**
2451
- * Extract URI pattern from @Static tag
2452
- * Example: @Static github://repos/{owner}/{repo}/readme
2487
+ * Extract URI pattern from a method's resource annotation.
2488
+ * Canonical: `@resource github://repos/{owner}/{repo}/readme`
2489
+ * Legacy: `@Static github://repos/{owner}/{repo}/readme`
2453
2490
  */
2454
2491
  private extractStaticURI(jsdocContent: string): string | null {
2455
- const match = jsdocContent.match(/@Static\s+([\w:\/\{\}\-_.]+)/i);
2492
+ const match = jsdocContent.match(/@(?:Static|resource)\s+([\w:\/\{\}\-_.]+)/i);
2456
2493
  return match ? match[1].trim() : null;
2457
2494
  }
2458
2495
 
@@ -2851,6 +2888,30 @@ export class SchemaExtractor {
2851
2888
  const photonMap = new Map(photonDeps.map(d => [d.name, d]));
2852
2889
 
2853
2890
  return params.map(param => {
2891
+ // Framework-recognized typed injections (matched on the parameter
2892
+ // type, not the parameter name). Win ahead of every other rule
2893
+ // because they are unambiguous: a user importing `Photon` /
2894
+ // `Cloudflare` / `CloudflareEnv` and typing a constructor param
2895
+ // with one of those names is asking the loader to wire it.
2896
+ if (isPhotonRuntimeType(param.type)) {
2897
+ return {
2898
+ param,
2899
+ injectionType: 'photonRuntime' as const,
2900
+ };
2901
+ }
2902
+ if (isCloudflareType(param.type)) {
2903
+ return {
2904
+ param,
2905
+ injectionType: 'cloudflare' as const,
2906
+ };
2907
+ }
2908
+ if (isCloudflareEnvType(param.type)) {
2909
+ return {
2910
+ param,
2911
+ injectionType: 'cloudflareEnv' as const,
2912
+ };
2913
+ }
2914
+
2854
2915
  // Primitives → env var
2855
2916
  if (param.isPrimitive) {
2856
2917
  const envVarName = this.toEnvVarName(mcpName, param.name);
@@ -3127,7 +3188,23 @@ export class SchemaExtractor {
3127
3188
  /**
3128
3189
  * Capability types that can be auto-detected from source code
3129
3190
  */
3130
- export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances' | 'caller';
3191
+ export type PhotonCapability =
3192
+ | 'emit'
3193
+ | 'memory'
3194
+ | 'call'
3195
+ | 'mcp'
3196
+ | 'lock'
3197
+ | 'instanceMeta'
3198
+ | 'allInstances'
3199
+ | 'caller'
3200
+ // Forgiving auto-inject: when these are detected on a class that
3201
+ // didn't declare a constructor param of the matching type, the loader
3202
+ // assigns the matching field on the instance after construction so
3203
+ // `this.cf.kv()` / `this.cfEnv.MY_KV.put(...)` work without the user
3204
+ // having to wire the import + ctor param themselves. The diagnostic
3205
+ // remains clear when the matching CF runtime isn't actually attached.
3206
+ | 'cloudflare'
3207
+ | 'cloudflareEnv';
3131
3208
 
3132
3209
  /**
3133
3210
  * Match a `this`-like base in source code. Covers:
@@ -3157,6 +3234,34 @@ function memberAccess(name: string, trailing: '\\(' | '\\b'): RegExp {
3157
3234
  return new RegExp(`${THIS_BASE}\\s*\\.\\s*${name}\\s*${trailing}`);
3158
3235
  }
3159
3236
 
3237
+ /**
3238
+ * Constructor-param type matchers for the framework-injected types.
3239
+ * The schema extractor sees parameter types as raw text (e.g. `"Photon"`,
3240
+ * `"Cloudflare"`, `"CloudflareEnv<MyBindings>"`), so we match by string.
3241
+ *
3242
+ * Optional modifiers / unions like `Photon | undefined` are accepted so
3243
+ * authors who write `private photon?: Photon` get the same injection.
3244
+ */
3245
+ function stripNullish(type: string): string {
3246
+ return type
3247
+ .trim()
3248
+ .replace(/\|\s*undefined\b/g, '')
3249
+ .replace(/\|\s*null\b/g, '')
3250
+ .trim();
3251
+ }
3252
+
3253
+ export function isPhotonRuntimeType(type: string): boolean {
3254
+ return /^Photon$/.test(stripNullish(type));
3255
+ }
3256
+
3257
+ export function isCloudflareType(type: string): boolean {
3258
+ return /^Cloudflare$/.test(stripNullish(type));
3259
+ }
3260
+
3261
+ export function isCloudflareEnvType(type: string): boolean {
3262
+ return /^CloudflareEnv(\s*<[\s\S]+>)?$/.test(stripNullish(type));
3263
+ }
3264
+
3160
3265
  /**
3161
3266
  * Detect capabilities used by a Photon from its source code.
3162
3267
  *
@@ -3178,5 +3283,10 @@ export function detectCapabilities(source: string): Set<PhotonCapability> {
3178
3283
  if (memberAccess('instanceMeta', '\\b').test(source)) caps.add('instanceMeta');
3179
3284
  if (memberAccess('allInstances', '\\(').test(source)) caps.add('allInstances');
3180
3285
  if (memberAccess('caller', '\\b').test(source)) caps.add('caller');
3286
+ // Forgiving auto-inject signals — surface as separate capabilities so
3287
+ // the loader can populate `instance.cf` / `instance.cfEnv` for plain
3288
+ // classes that reference them without the explicit constructor param.
3289
+ if (memberAccess('cf', '\\b').test(source)) caps.add('cloudflare');
3290
+ if (memberAccess('cfEnv', '\\b').test(source)) caps.add('cloudflareEnv');
3181
3291
  return caps;
3182
3292
  }
package/src/types.ts CHANGED
@@ -313,7 +313,22 @@ export interface ConstructorParam {
313
313
  /**
314
314
  * Injection type for constructor parameters
315
315
  */
316
- export type InjectionType = 'env' | 'mcp' | 'photon' | 'state';
316
+ export type InjectionType =
317
+ | 'env'
318
+ | 'mcp'
319
+ | 'photon'
320
+ | 'state'
321
+ // Constructor param typed `Photon` from @portel/photon — loader injects
322
+ // a Photon instance configured identically to the host photon.
323
+ | 'photonRuntime'
324
+ // Constructor param typed `Cloudflare` — loader injects a wrapped CF
325
+ // surface (auto-named bindings) backed by miniflare locally or by the
326
+ // deployed Worker `env`.
327
+ | 'cloudflare'
328
+ // Constructor param typed `CloudflareEnv` / `CloudflareEnv<T>` — loader
329
+ // injects the raw Worker `env`. Escape hatch for service bindings
330
+ // and exotic features the wrapped surface doesn't cover.
331
+ | 'cloudflareEnv';
317
332
 
318
333
  /**
319
334
  * Resolved injection info for a constructor parameter