@portel/photon-core 2.11.0 → 2.13.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/dist/auto-ui.js.map +1 -1
- package/dist/base.d.ts +39 -0
- package/dist/base.d.ts.map +1 -1
- package/dist/base.js +49 -0
- package/dist/base.js.map +1 -1
- package/dist/generator.d.ts +12 -1
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mixins.d.ts.map +1 -1
- package/dist/mixins.js +20 -0
- package/dist/mixins.js.map +1 -1
- package/dist/photon-loader-lite.d.ts +54 -0
- package/dist/photon-loader-lite.d.ts.map +1 -0
- package/dist/photon-loader-lite.js +436 -0
- package/dist/photon-loader-lite.js.map +1 -0
- package/dist/schedule.d.ts +157 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +283 -0
- package/dist/schedule.js.map +1 -0
- package/dist/schema-extractor.d.ts +10 -6
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +63 -25
- package/dist/schema-extractor.js.map +1 -1
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/auto-ui.ts +2 -2
- package/src/base.ts +51 -0
- package/src/generator.ts +14 -1
- package/src/index.ts +15 -1
- package/src/mixins.ts +22 -0
- package/src/photon-loader-lite.ts +594 -0
- package/src/schedule.ts +365 -0
- package/src/schema-extractor.ts +73 -25
- package/src/types.ts +7 -1
package/src/mixins.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { MCPClient, MCPClientFactory, createMCPProxy } from '@portel/mcp';
|
|
|
26
26
|
import { executionContext } from '@portel/cli';
|
|
27
27
|
import { getBroker } from './channels/index.js';
|
|
28
28
|
import { MemoryProvider } from './memory.js';
|
|
29
|
+
import { ScheduleProvider } from './schedule.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Type for a constructor that may or may not extend Photon base class
|
|
@@ -69,6 +70,12 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
|
|
|
69
70
|
*/
|
|
70
71
|
private _memory?: MemoryProvider;
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Scoped schedule provider - lazy-initialized on first access
|
|
75
|
+
* @internal
|
|
76
|
+
*/
|
|
77
|
+
private _schedule?: ScheduleProvider;
|
|
78
|
+
|
|
72
79
|
/**
|
|
73
80
|
* Cross-photon call handler - injected by runtime
|
|
74
81
|
* @internal
|
|
@@ -102,6 +109,21 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
|
|
|
102
109
|
return this._memory;
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Runtime task scheduling
|
|
114
|
+
*/
|
|
115
|
+
get schedule(): ScheduleProvider {
|
|
116
|
+
if (!this._schedule) {
|
|
117
|
+
const name = this._photonName || this.constructor.name
|
|
118
|
+
.replace(/MCP$/, '')
|
|
119
|
+
.replace(/([A-Z])/g, '-$1')
|
|
120
|
+
.toLowerCase()
|
|
121
|
+
.replace(/^-/, '');
|
|
122
|
+
this._schedule = new ScheduleProvider(name);
|
|
123
|
+
}
|
|
124
|
+
return this._schedule;
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
/**
|
|
106
128
|
* Emit an event/progress update
|
|
107
129
|
*/
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Photon Loader Lite — Direct TypeScript API
|
|
3
|
+
*
|
|
4
|
+
* Load a .photon.ts file and get a fully-enhanced instance with all
|
|
5
|
+
* runtime features: middleware, memory, scheduling, events, __meta.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { photon } from '@portel/photon-core';
|
|
10
|
+
*
|
|
11
|
+
* const todo = await photon('./todo.photon.ts');
|
|
12
|
+
* await todo.add({ title: 'Buy milk' });
|
|
13
|
+
* // ✅ @cached, @throttled, @retry all work
|
|
14
|
+
* // ✅ this.memory, this.schedule work
|
|
15
|
+
* // ✅ @stateful events emitted, __meta attached
|
|
16
|
+
* // ✅ @photon dependencies recursively loaded
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'fs/promises';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import { pathToFileURL } from 'url';
|
|
23
|
+
import { compilePhotonTS } from './compiler.js';
|
|
24
|
+
import { findPhotonClass } from './class-detection.js';
|
|
25
|
+
import { SchemaExtractor, detectCapabilities } from './schema-extractor.js';
|
|
26
|
+
import {
|
|
27
|
+
buildMiddlewareChain,
|
|
28
|
+
builtinRegistry,
|
|
29
|
+
MiddlewareRegistry,
|
|
30
|
+
createStateStore,
|
|
31
|
+
type MiddlewareContext,
|
|
32
|
+
type MiddlewareState,
|
|
33
|
+
type MiddlewareDeclaration,
|
|
34
|
+
} from './middleware.js';
|
|
35
|
+
import { withPhotonCapabilities } from './mixins.js';
|
|
36
|
+
import { MemoryProvider } from './memory.js';
|
|
37
|
+
import { ScheduleProvider } from './schedule.js';
|
|
38
|
+
import { toEnvVarName, parseEnvValue, type MissingParamInfo } from './env-utils.js';
|
|
39
|
+
import type { ExtractedSchema } from './types.js';
|
|
40
|
+
import type { MCPClientFactory } from '@portel/mcp';
|
|
41
|
+
|
|
42
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
43
|
+
// Types
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
45
|
+
|
|
46
|
+
export interface PhotonOptions {
|
|
47
|
+
/** Override the base directory for memory/schedule storage (default: ~/.photon) */
|
|
48
|
+
baseDir?: string;
|
|
49
|
+
/** MCP client factory for this.mcp() support */
|
|
50
|
+
mcpFactory?: MCPClientFactory;
|
|
51
|
+
/** Named instance identifier */
|
|
52
|
+
instanceName?: string;
|
|
53
|
+
/** Receive emitted events from @stateful methods */
|
|
54
|
+
onEvent?: (event: PhotonEvent) => void;
|
|
55
|
+
/** Session ID for session-scoped memory */
|
|
56
|
+
sessionId?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PhotonEvent {
|
|
60
|
+
method: string;
|
|
61
|
+
params: Record<string, any>;
|
|
62
|
+
result: any;
|
|
63
|
+
timestamp: string;
|
|
64
|
+
instance?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
68
|
+
// Loading state (cycle detection, caching)
|
|
69
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
/** Currently-loading photon paths for cycle detection */
|
|
72
|
+
const loadingPaths = new Set<string>();
|
|
73
|
+
|
|
74
|
+
/** Cache of loaded photon instances (keyed by absolutePath::instanceName) */
|
|
75
|
+
const instanceCache = new Map<string, any>();
|
|
76
|
+
|
|
77
|
+
/** Dedup concurrent loads */
|
|
78
|
+
const loadPromises = new Map<string, Promise<any>>();
|
|
79
|
+
|
|
80
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
81
|
+
// Main API
|
|
82
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load a .photon.ts file and return a fully-enhanced instance.
|
|
86
|
+
*
|
|
87
|
+
* The returned object has all methods working with middleware (@cached, @retry, etc.),
|
|
88
|
+
* memory, scheduling, @stateful event emission, and cross-photon calls.
|
|
89
|
+
*
|
|
90
|
+
* @param filePath Path to the .photon.ts file (absolute or relative to cwd)
|
|
91
|
+
* @param options Optional configuration
|
|
92
|
+
* @returns Enhanced photon instance with all runtime features
|
|
93
|
+
*/
|
|
94
|
+
export async function photon<T = any>(
|
|
95
|
+
filePath: string,
|
|
96
|
+
options: PhotonOptions = {},
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
// Resolve to absolute path
|
|
99
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
100
|
+
? filePath
|
|
101
|
+
: path.resolve(process.cwd(), filePath);
|
|
102
|
+
|
|
103
|
+
const instanceName = options.instanceName || '';
|
|
104
|
+
const cacheKey = instanceName ? `${absolutePath}::${instanceName}` : absolutePath;
|
|
105
|
+
|
|
106
|
+
// Cycle detection (must come before dedup check to avoid deadlock)
|
|
107
|
+
if (loadingPaths.has(absolutePath)) {
|
|
108
|
+
const chain = Array.from(loadingPaths).concat(absolutePath).join(' → ');
|
|
109
|
+
throw new Error(`Circular @photon dependency: ${chain}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Return cached instance
|
|
113
|
+
if (instanceCache.has(cacheKey)) {
|
|
114
|
+
return instanceCache.get(cacheKey) as T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Dedup concurrent loads
|
|
118
|
+
if (loadPromises.has(cacheKey)) {
|
|
119
|
+
return loadPromises.get(cacheKey) as Promise<T>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const promise = loadPhotonInternal(absolutePath, cacheKey, options);
|
|
123
|
+
loadPromises.set(cacheKey, promise);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const result = await promise;
|
|
127
|
+
instanceCache.set(cacheKey, result);
|
|
128
|
+
return result as T;
|
|
129
|
+
} finally {
|
|
130
|
+
loadPromises.delete(cacheKey);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Clear the photon instance cache. Useful for testing.
|
|
136
|
+
*/
|
|
137
|
+
export function clearPhotonCache(): void {
|
|
138
|
+
instanceCache.clear();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
142
|
+
// Internal pipeline
|
|
143
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
144
|
+
|
|
145
|
+
async function loadPhotonInternal(
|
|
146
|
+
absolutePath: string,
|
|
147
|
+
cacheKey: string,
|
|
148
|
+
options: PhotonOptions,
|
|
149
|
+
): Promise<any> {
|
|
150
|
+
loadingPaths.add(absolutePath);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
// 1. Read source
|
|
154
|
+
const source = await fs.readFile(absolutePath, 'utf-8');
|
|
155
|
+
|
|
156
|
+
// 2. Compile TypeScript → JavaScript
|
|
157
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
158
|
+
const cacheDir = path.join(homeDir, '.photon', 'cache');
|
|
159
|
+
const compiledPath = await compilePhotonTS(absolutePath, { cacheDir });
|
|
160
|
+
|
|
161
|
+
// 3. Import compiled module
|
|
162
|
+
const moduleUrl = pathToFileURL(compiledPath).href;
|
|
163
|
+
const module = await import(moduleUrl);
|
|
164
|
+
|
|
165
|
+
// 4. Find the Photon class
|
|
166
|
+
const PhotonClass = findPhotonClass(module as Record<string, unknown>);
|
|
167
|
+
if (!PhotonClass) {
|
|
168
|
+
throw new Error(`No Photon class found in ${absolutePath}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 5. Derive photon name from file path
|
|
172
|
+
const photonName = derivePhotonName(absolutePath);
|
|
173
|
+
|
|
174
|
+
// 6. Extract schema for middleware and metadata
|
|
175
|
+
const extractor = new SchemaExtractor();
|
|
176
|
+
const metadata = extractor.extractAllFromSource(source);
|
|
177
|
+
const toolSchemas = metadata.tools;
|
|
178
|
+
|
|
179
|
+
// 7. Resolve constructor injections
|
|
180
|
+
const injections = extractor.resolveInjections(source, photonName);
|
|
181
|
+
const constructorArgs = await resolveConstructorArgs(
|
|
182
|
+
injections,
|
|
183
|
+
photonName,
|
|
184
|
+
absolutePath,
|
|
185
|
+
options,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// 8. Enhance class with capabilities (for plain classes)
|
|
189
|
+
const EnhancedClass = withPhotonCapabilities(PhotonClass);
|
|
190
|
+
|
|
191
|
+
// 9. Instantiate
|
|
192
|
+
const instance = new EnhancedClass(...constructorArgs) as Record<string, any>;
|
|
193
|
+
|
|
194
|
+
// 10. Set photon identity
|
|
195
|
+
instance._photonName = photonName;
|
|
196
|
+
if (options.instanceName) {
|
|
197
|
+
instance.instanceName = options.instanceName;
|
|
198
|
+
}
|
|
199
|
+
if (options.sessionId) {
|
|
200
|
+
instance._sessionId = options.sessionId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 11. Wire reactive collections
|
|
204
|
+
wireReactiveCollections(instance);
|
|
205
|
+
|
|
206
|
+
// 12. Wrap @stateful methods (event emission + __meta)
|
|
207
|
+
wrapStatefulMethods(instance, source, options.onEvent);
|
|
208
|
+
|
|
209
|
+
// 13. Inject MCP factory if provided
|
|
210
|
+
if (options.mcpFactory && typeof instance.setMCPFactory === 'function') {
|
|
211
|
+
instance.setMCPFactory(options.mcpFactory);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 14. Inject cross-photon call handler (in-process resolution)
|
|
215
|
+
instance._callHandler = async (
|
|
216
|
+
targetPhotonName: string,
|
|
217
|
+
method: string,
|
|
218
|
+
params: Record<string, any>,
|
|
219
|
+
) => {
|
|
220
|
+
const targetPath = resolvePhotonPath(targetPhotonName, absolutePath);
|
|
221
|
+
const target = await photon(targetPath, {
|
|
222
|
+
baseDir: options.baseDir,
|
|
223
|
+
mcpFactory: options.mcpFactory,
|
|
224
|
+
sessionId: options.sessionId,
|
|
225
|
+
});
|
|
226
|
+
return (target as any)[method](params);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// 15. Call onInitialize lifecycle hook
|
|
230
|
+
if (typeof instance.onInitialize === 'function') {
|
|
231
|
+
await instance.onInitialize();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 16. Build middleware proxy
|
|
235
|
+
const proxy = buildMiddlewareProxy(instance, photonName, toolSchemas, options);
|
|
236
|
+
|
|
237
|
+
return proxy;
|
|
238
|
+
} finally {
|
|
239
|
+
loadingPaths.delete(absolutePath);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
244
|
+
// Constructor injection
|
|
245
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
async function resolveConstructorArgs(
|
|
248
|
+
injections: Array<{
|
|
249
|
+
param: { name: string; type: string; isOptional: boolean; hasDefault: boolean; defaultValue?: any };
|
|
250
|
+
injectionType: string;
|
|
251
|
+
envVarName?: string;
|
|
252
|
+
photonDependency?: { name: string; source: string; sourceType: string; instanceName?: string };
|
|
253
|
+
mcpDependency?: { name: string; source: string; sourceType: string };
|
|
254
|
+
}>,
|
|
255
|
+
photonName: string,
|
|
256
|
+
currentPath: string,
|
|
257
|
+
options: PhotonOptions,
|
|
258
|
+
): Promise<any[]> {
|
|
259
|
+
const values: any[] = [];
|
|
260
|
+
const missing: MissingParamInfo[] = [];
|
|
261
|
+
|
|
262
|
+
for (const injection of injections) {
|
|
263
|
+
const { param, injectionType } = injection;
|
|
264
|
+
|
|
265
|
+
switch (injectionType) {
|
|
266
|
+
case 'photon': {
|
|
267
|
+
// Recursive photon loading
|
|
268
|
+
const dep = injection.photonDependency!;
|
|
269
|
+
const depPath = resolvePhotonDepPath(dep.source, dep.sourceType, currentPath);
|
|
270
|
+
const depInstance = await photon(depPath, {
|
|
271
|
+
baseDir: options.baseDir,
|
|
272
|
+
mcpFactory: options.mcpFactory,
|
|
273
|
+
instanceName: dep.instanceName,
|
|
274
|
+
sessionId: options.sessionId,
|
|
275
|
+
});
|
|
276
|
+
values.push(depInstance);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case 'mcp': {
|
|
281
|
+
// MCP dependencies require a factory
|
|
282
|
+
if (!options.mcpFactory) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Photon "${photonName}" requires MCP dependency "${param.name}" but no mcpFactory was provided. ` +
|
|
285
|
+
`Pass { mcpFactory } in the options to photon().`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
// Will be resolved by the instance's mcp() method at call time
|
|
289
|
+
values.push(undefined);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
case 'env': {
|
|
294
|
+
const envVarName = injection.envVarName || toEnvVarName(photonName, param.name);
|
|
295
|
+
const envValue = process.env[envVarName];
|
|
296
|
+
|
|
297
|
+
if (envValue !== undefined) {
|
|
298
|
+
values.push(parseEnvValue(envValue, param.type));
|
|
299
|
+
} else if (param.hasDefault || param.isOptional) {
|
|
300
|
+
values.push(undefined);
|
|
301
|
+
} else {
|
|
302
|
+
missing.push({ paramName: param.name, envVarName, type: param.type });
|
|
303
|
+
values.push(undefined);
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case 'state': {
|
|
309
|
+
// State injection — use default value (state is loaded by the class itself)
|
|
310
|
+
values.push(undefined);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
default:
|
|
315
|
+
values.push(undefined);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (missing.length > 0) {
|
|
320
|
+
const envList = missing.map(m => ` ${m.envVarName} (${m.paramName}: ${m.type})`).join('\n');
|
|
321
|
+
console.warn(
|
|
322
|
+
`⚠️ ${photonName}: Missing environment variables:\n${envList}\n` +
|
|
323
|
+
`Some methods may fail until these are set.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return values;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
331
|
+
// Reactive collection wiring
|
|
332
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
333
|
+
|
|
334
|
+
function wireReactiveCollections(instance: Record<string, any>): void {
|
|
335
|
+
const emit = typeof instance.emit === 'function'
|
|
336
|
+
? instance.emit.bind(instance)
|
|
337
|
+
: null;
|
|
338
|
+
|
|
339
|
+
if (!emit) return;
|
|
340
|
+
|
|
341
|
+
for (const key of Object.keys(instance)) {
|
|
342
|
+
const value = instance[key];
|
|
343
|
+
if (!value || typeof value !== 'object') continue;
|
|
344
|
+
|
|
345
|
+
const ctorName = value.constructor?.name;
|
|
346
|
+
if (
|
|
347
|
+
ctorName === 'ReactiveArray' ||
|
|
348
|
+
ctorName === 'ReactiveMap' ||
|
|
349
|
+
ctorName === 'ReactiveSet' ||
|
|
350
|
+
ctorName === 'Collection'
|
|
351
|
+
) {
|
|
352
|
+
value._propertyName = key;
|
|
353
|
+
value._emitter = emit;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
359
|
+
// @stateful method wrapping
|
|
360
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
361
|
+
|
|
362
|
+
function wrapStatefulMethods(
|
|
363
|
+
instance: Record<string, any>,
|
|
364
|
+
source: string,
|
|
365
|
+
onEvent?: (event: PhotonEvent) => void,
|
|
366
|
+
): void {
|
|
367
|
+
if (!/@stateful\b/i.test(source)) return;
|
|
368
|
+
|
|
369
|
+
// Skip framework-injected methods from withPhotonCapabilities
|
|
370
|
+
const frameworkMethods = new Set([
|
|
371
|
+
'emit', 'call', 'mcp', 'setMCPFactory', 'onInitialize', 'onShutdown',
|
|
372
|
+
]);
|
|
373
|
+
|
|
374
|
+
// Walk the prototype chain to find all public methods
|
|
375
|
+
// (withPhotonCapabilities creates a subclass, so methods may be on grandparent prototype)
|
|
376
|
+
const methodNames: string[] = [];
|
|
377
|
+
const seen = new Set<string>();
|
|
378
|
+
let proto = Object.getPrototypeOf(instance);
|
|
379
|
+
while (proto && proto !== Object.prototype) {
|
|
380
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
381
|
+
if (seen.has(name) || name === 'constructor' || name.startsWith('_')) continue;
|
|
382
|
+
if (frameworkMethods.has(name)) continue;
|
|
383
|
+
seen.add(name);
|
|
384
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
|
|
385
|
+
if (descriptor && typeof descriptor.value === 'function') {
|
|
386
|
+
methodNames.push(name);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
proto = Object.getPrototypeOf(proto);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (methodNames.length === 0) return;
|
|
393
|
+
|
|
394
|
+
for (const methodName of methodNames) {
|
|
395
|
+
const original = instance[methodName];
|
|
396
|
+
if (typeof original !== 'function') continue;
|
|
397
|
+
|
|
398
|
+
instance[methodName] = function (this: any, ...args: any[]) {
|
|
399
|
+
const paramNames = extractParamNames(original);
|
|
400
|
+
const params = Object.fromEntries(paramNames.map((name, i) => [name, args[i]]));
|
|
401
|
+
|
|
402
|
+
const result = original.apply(this, args);
|
|
403
|
+
|
|
404
|
+
// Handle both sync and async results
|
|
405
|
+
const attachMeta = (res: any) => {
|
|
406
|
+
if (res && typeof res === 'object' && !Array.isArray(res) && !res.__meta) {
|
|
407
|
+
const timestamp = new Date().toISOString();
|
|
408
|
+
Object.defineProperty(res, '__meta', {
|
|
409
|
+
value: {
|
|
410
|
+
createdAt: timestamp,
|
|
411
|
+
createdBy: methodName,
|
|
412
|
+
modifiedAt: null,
|
|
413
|
+
modifiedBy: null,
|
|
414
|
+
modifications: [],
|
|
415
|
+
},
|
|
416
|
+
enumerable: false,
|
|
417
|
+
writable: true,
|
|
418
|
+
configurable: true,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Emit event
|
|
423
|
+
if (onEvent) {
|
|
424
|
+
const event: PhotonEvent = {
|
|
425
|
+
method: methodName,
|
|
426
|
+
params,
|
|
427
|
+
result: res,
|
|
428
|
+
timestamp: new Date().toISOString(),
|
|
429
|
+
};
|
|
430
|
+
if (this.instanceName) {
|
|
431
|
+
event.instance = this.instanceName;
|
|
432
|
+
}
|
|
433
|
+
onEvent(event);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return res;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Support async methods (most common case)
|
|
440
|
+
if (result && typeof result.then === 'function') {
|
|
441
|
+
return result.then(attachMeta);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return attachMeta(result);
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Extract parameter names from a function signature string
|
|
451
|
+
*/
|
|
452
|
+
function extractParamNames(fn: (...args: any[]) => any): string[] {
|
|
453
|
+
const fnStr = fn.toString();
|
|
454
|
+
const match = fnStr.match(/\(([^)]*)\)/);
|
|
455
|
+
if (!match?.[1]) return [];
|
|
456
|
+
|
|
457
|
+
return match[1]
|
|
458
|
+
.split(',')
|
|
459
|
+
.map(param => {
|
|
460
|
+
const cleaned = param
|
|
461
|
+
.trim()
|
|
462
|
+
.split('=')[0] // Remove default value
|
|
463
|
+
.split(':')[0] // Remove type annotations
|
|
464
|
+
.trim();
|
|
465
|
+
return cleaned;
|
|
466
|
+
})
|
|
467
|
+
.filter(name => name && name !== 'this');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
471
|
+
// Middleware proxy
|
|
472
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
473
|
+
|
|
474
|
+
function buildMiddlewareProxy(
|
|
475
|
+
instance: Record<string, any>,
|
|
476
|
+
photonName: string,
|
|
477
|
+
toolSchemas: ExtractedSchema[],
|
|
478
|
+
options: PhotonOptions,
|
|
479
|
+
): any {
|
|
480
|
+
// Build tool lookup: method name → schema
|
|
481
|
+
const toolMap = new Map<string, ExtractedSchema>();
|
|
482
|
+
for (const schema of toolSchemas) {
|
|
483
|
+
toolMap.set(schema.name, schema);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Middleware state stores (shared across calls)
|
|
487
|
+
const stateStores = new Map<string, MiddlewareState>();
|
|
488
|
+
|
|
489
|
+
// Build combined registry (builtins only for now; custom middleware can be added later)
|
|
490
|
+
const registry = new MiddlewareRegistry();
|
|
491
|
+
for (const name of builtinRegistry.names()) {
|
|
492
|
+
registry.register(builtinRegistry.get(name)!);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return new Proxy(instance, {
|
|
496
|
+
get(target, prop, receiver) {
|
|
497
|
+
const value = Reflect.get(target, prop, receiver);
|
|
498
|
+
if (typeof value !== 'function') return value;
|
|
499
|
+
if (typeof prop !== 'string') return value;
|
|
500
|
+
|
|
501
|
+
// Skip internal/private methods
|
|
502
|
+
if (prop.startsWith('_') || prop === 'constructor') {
|
|
503
|
+
return value.bind(target);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const schema = toolMap.get(prop);
|
|
507
|
+
const declarations: MiddlewareDeclaration[] = schema?.middleware || [];
|
|
508
|
+
|
|
509
|
+
// No middleware — return bound method directly
|
|
510
|
+
if (declarations.length === 0) {
|
|
511
|
+
return value.bind(target);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Return a function that applies middleware on each call
|
|
515
|
+
return (...args: any[]) => {
|
|
516
|
+
const ctx: MiddlewareContext = {
|
|
517
|
+
photon: photonName,
|
|
518
|
+
tool: prop,
|
|
519
|
+
instance: options.instanceName || 'default',
|
|
520
|
+
params: args[0] ?? {},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const execute = () => value.apply(target, args);
|
|
524
|
+
const chain = buildMiddlewareChain(
|
|
525
|
+
execute,
|
|
526
|
+
declarations,
|
|
527
|
+
registry,
|
|
528
|
+
stateStores,
|
|
529
|
+
ctx,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
return chain();
|
|
533
|
+
};
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
539
|
+
// Path utilities
|
|
540
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Derive a photon name from a file path.
|
|
544
|
+
* e.g., '/path/to/todo.photon.ts' → 'todo'
|
|
545
|
+
*/
|
|
546
|
+
function derivePhotonName(filePath: string): string {
|
|
547
|
+
const basename = path.basename(filePath);
|
|
548
|
+
return basename
|
|
549
|
+
.replace(/\.photon\.(ts|js|mjs)$/, '')
|
|
550
|
+
.replace(/\.(ts|js|mjs)$/, '');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Resolve a photon dependency source to an absolute path.
|
|
555
|
+
*/
|
|
556
|
+
function resolvePhotonDepPath(
|
|
557
|
+
source: string,
|
|
558
|
+
sourceType: string,
|
|
559
|
+
currentPhotonPath: string,
|
|
560
|
+
): string {
|
|
561
|
+
if (sourceType === 'local') {
|
|
562
|
+
if (source.startsWith('./') || source.startsWith('../')) {
|
|
563
|
+
return path.resolve(path.dirname(currentPhotonPath), source);
|
|
564
|
+
}
|
|
565
|
+
return source;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// For marketplace photons, look in ~/.photon/photons/<name>/
|
|
569
|
+
if (sourceType === 'marketplace') {
|
|
570
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
571
|
+
return path.join(homeDir, '.photon', 'photons', source, `${source}.photon.ts`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// npm and github sources — for now, throw a helpful error
|
|
575
|
+
throw new Error(
|
|
576
|
+
`Cannot resolve ${sourceType} photon dependency "${source}" in lite loader. ` +
|
|
577
|
+
`Only local paths and marketplace photons are supported. ` +
|
|
578
|
+
`Use the full runtime for npm/github dependencies.`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Resolve a photon name to a path for cross-photon calls.
|
|
584
|
+
* Searches: sibling files, then ~/.photon/photons/
|
|
585
|
+
*/
|
|
586
|
+
function resolvePhotonPath(photonName: string, callerPath: string): string {
|
|
587
|
+
// Try sibling file first
|
|
588
|
+
const dir = path.dirname(callerPath);
|
|
589
|
+
const siblingPath = path.join(dir, `${photonName}.photon.ts`);
|
|
590
|
+
|
|
591
|
+
// We can't do sync fs.existsSync in an async context cleanly,
|
|
592
|
+
// so just return the sibling path — the load will fail with a clear error if not found
|
|
593
|
+
return siblingPath;
|
|
594
|
+
}
|