@portel/photon-core 2.26.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 (50) hide show
  1. package/README.md +121 -0
  2. package/dist/base.d.ts +27 -0
  3. package/dist/base.d.ts.map +1 -1
  4. package/dist/base.js +27 -0
  5. package/dist/base.js.map +1 -1
  6. package/dist/cf.d.ts +121 -0
  7. package/dist/cf.d.ts.map +1 -0
  8. package/dist/cf.js +65 -0
  9. package/dist/cf.js.map +1 -0
  10. package/dist/cloudflare.d.ts +110 -0
  11. package/dist/cloudflare.d.ts.map +1 -0
  12. package/dist/cloudflare.js +138 -0
  13. package/dist/cloudflare.js.map +1 -0
  14. package/dist/collections/Collection.d.ts +12 -0
  15. package/dist/collections/Collection.d.ts.map +1 -1
  16. package/dist/collections/Collection.js +19 -0
  17. package/dist/collections/Collection.js.map +1 -1
  18. package/dist/env.d.ts +29 -0
  19. package/dist/env.d.ts.map +1 -0
  20. package/dist/env.js +2 -0
  21. package/dist/env.js.map +1 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/mixins.d.ts.map +1 -1
  27. package/dist/mixins.js +10 -0
  28. package/dist/mixins.js.map +1 -1
  29. package/dist/photon-loader-lite.d.ts +17 -0
  30. package/dist/photon-loader-lite.d.ts.map +1 -1
  31. package/dist/photon-loader-lite.js +150 -1
  32. package/dist/photon-loader-lite.js.map +1 -1
  33. package/dist/schema-extractor.d.ts +4 -1
  34. package/dist/schema-extractor.d.ts.map +1 -1
  35. package/dist/schema-extractor.js +54 -0
  36. package/dist/schema-extractor.js.map +1 -1
  37. package/dist/types.d.ts +1 -1
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js.map +1 -1
  40. package/package.json +3 -2
  41. package/src/base.ts +29 -0
  42. package/src/cf.ts +165 -0
  43. package/src/cloudflare.ts +198 -0
  44. package/src/collections/Collection.ts +19 -0
  45. package/src/env.ts +28 -0
  46. package/src/index.ts +27 -0
  47. package/src/mixins.ts +12 -0
  48. package/src/photon-loader-lite.ts +185 -0
  49. package/src/schema-extractor.ts +74 -1
  50. package/src/types.ts +16 -1
@@ -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
  // ═══════════════════════════════════════════════════════════════════
@@ -2888,6 +2888,30 @@ export class SchemaExtractor {
2888
2888
  const photonMap = new Map(photonDeps.map(d => [d.name, d]));
2889
2889
 
2890
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
+
2891
2915
  // Primitives → env var
2892
2916
  if (param.isPrimitive) {
2893
2917
  const envVarName = this.toEnvVarName(mcpName, param.name);
@@ -3164,7 +3188,23 @@ export class SchemaExtractor {
3164
3188
  /**
3165
3189
  * Capability types that can be auto-detected from source code
3166
3190
  */
3167
- 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';
3168
3208
 
3169
3209
  /**
3170
3210
  * Match a `this`-like base in source code. Covers:
@@ -3194,6 +3234,34 @@ function memberAccess(name: string, trailing: '\\(' | '\\b'): RegExp {
3194
3234
  return new RegExp(`${THIS_BASE}\\s*\\.\\s*${name}\\s*${trailing}`);
3195
3235
  }
3196
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
+
3197
3265
  /**
3198
3266
  * Detect capabilities used by a Photon from its source code.
3199
3267
  *
@@ -3215,5 +3283,10 @@ export function detectCapabilities(source: string): Set<PhotonCapability> {
3215
3283
  if (memberAccess('instanceMeta', '\\b').test(source)) caps.add('instanceMeta');
3216
3284
  if (memberAccess('allInstances', '\\(').test(source)) caps.add('allInstances');
3217
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');
3218
3291
  return caps;
3219
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