diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 45f3461bc4..55a54c0bce 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -51,7 +51,7 @@ describe('Cloud Code', () => { it('cloud code must be valid type', async () => { spyOn(console, 'error').and.callFake(() => { }); - await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWith( + await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWithError( "argument 'cloud' must either be a string or a function" ); }); diff --git a/spec/CloudCodeAdapter.integration.spec.js b/spec/CloudCodeAdapter.integration.spec.js new file mode 100644 index 0000000000..71c6f499cf --- /dev/null +++ b/spec/CloudCodeAdapter.integration.spec.js @@ -0,0 +1,107 @@ +'use strict'; + +const { CloudCodeManager } = require('../lib/cloud-code/CloudCodeManager'); +const { InProcessAdapter } = require('../lib/cloud-code/adapters/InProcessAdapter'); + +describe('Cloud Code Adapter Integration', () => { + describe('composable adapters', () => { + it('supports multiple adapters registering different hooks', async () => { + const manager = new CloudCodeManager(); + + const inProcessCloud = { + getRouter() { + return { + getManifest() { + return { + protocol: 'ParseCloud/1.0', + hooks: { + functions: [{ name: 'inProcessFn' }], + triggers: [], + jobs: [], + }, + }; + }, + async dispatchFunction() { return { success: 'from-in-process' }; }, + async dispatchTrigger() { return { success: {} }; }, + async dispatchJob() { return { success: null }; }, + }; + }, + }; + + const inProcessAdapter = new InProcessAdapter(inProcessCloud); + const inProcessRegistry = manager.createRegistry(inProcessAdapter.name); + await inProcessAdapter.initialize(inProcessRegistry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + // Simulate legacy registration via manager directly + manager.defineFunction('legacyFn', () => 'from-legacy', 'legacy'); + + const legacyEntry = manager.getFunction('legacyFn'); + expect(legacyEntry).toBeDefined(); + expect(manager.getFunction('inProcessFn')).toBeDefined(); + expect(manager.getFunctionNames().sort()).toEqual(['inProcessFn', 'legacyFn']); + }); + + it('throws on conflict between adapters', async () => { + const manager = new CloudCodeManager(); + + // Register a function from "legacy" source + manager.defineFunction('shared', () => 'from-legacy', 'legacy'); + + // InProcess adapter tries to register same function + const inProcessCloud = { + getRouter() { + return { + getManifest() { + return { + protocol: 'ParseCloud/1.0', + hooks: { functions: [{ name: 'shared' }], triggers: [], jobs: [] }, + }; + }, + async dispatchFunction() { return { success: 'from-in-process' }; }, + async dispatchTrigger() { return { success: {} }; }, + async dispatchJob() { return { success: null }; }, + }; + }, + }; + + const adapter = new InProcessAdapter(inProcessCloud); + const registry = manager.createRegistry(adapter.name); + + await expectAsync( + adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }) + ).toBeRejectedWithError(/already registered/); + }); + }); + + describe('shutdown', () => { + it('shuts down all adapters', async () => { + const manager = new CloudCodeManager(); + let shutdownCalled = false; + + const adapter = { + name: 'test', + async initialize() {}, + async isHealthy() { return true; }, + async shutdown() { shutdownCalled = true; }, + }; + + await manager.initialize([adapter], { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + await manager.shutdown(); + + expect(shutdownCalled).toBe(true); + }); + }); + + describe('unregisterAll', () => { + it('allows re-registration after unregisterAll', () => { + const manager = new CloudCodeManager(); + manager.defineFunction('fn', () => 'first', 'adapter-a'); + manager.unregisterAll('adapter-a'); + + expect(() => { + manager.defineFunction('fn', () => 'second', 'adapter-b'); + }).not.toThrow(); + expect(manager.getFunction('fn')).toBeDefined(); + }); + }); +}); diff --git a/spec/CloudCodeManager.spec.js b/spec/CloudCodeManager.spec.js new file mode 100644 index 0000000000..7e1f1bfcd8 --- /dev/null +++ b/spec/CloudCodeManager.spec.js @@ -0,0 +1,658 @@ +'use strict'; + +const { CloudCodeManager } = require('../lib/cloud-code/CloudCodeManager'); + +describe('CloudCodeManager', () => { + let manager; + + beforeEach(() => { + manager = new CloudCodeManager(); + }); + + // ─── Function Registration ─────────────────────────────────────────────────── + + describe('defineFunction', () => { + it('registers a function', () => { + const handler = () => 'result'; + manager.defineFunction('myFunc', handler, 'source-a'); + const entry = manager.getFunction('myFunc'); + expect(entry).not.toBeNull(); + expect(entry.handler).toBe(handler); + expect(entry.source).toBe('source-a'); + }); + + it('allows overwriting a function from the same source', () => { + const handler1 = () => 'v1'; + const handler2 = () => 'v2'; + manager.defineFunction('myFunc', handler1, 'source-a'); + manager.defineFunction('myFunc', handler2, 'source-a'); + expect(manager.getFunction('myFunc').handler).toBe(handler2); + }); + + it('throws when a different source tries to register the same function', () => { + manager.defineFunction('myFunc', () => {}, 'source-a'); + expect(() => { + manager.defineFunction('myFunc', () => {}, 'source-b'); + }).toThrowError(/already registered/i); + }); + + it('stores a validator with the function', () => { + const handler = () => {}; + const validator = req => {}; + manager.defineFunction('myFunc', handler, 'source-a', validator); + expect(manager.getFunction('myFunc').validator).toBe(validator); + }); + + it('returns null for an unknown function', () => { + expect(manager.getFunction('unknown')).toBeNull(); + }); + }); + + // ─── Trigger Registration ──────────────────────────────────────────────────── + + describe('defineTrigger', () => { + it('registers a trigger', () => { + const handler = () => {}; + manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a'); + const entry = manager.getTrigger('MyClass', 'beforeSave'); + expect(entry).not.toBeNull(); + expect(entry.handler).toBe(handler); + expect(entry.source).toBe('source-a'); + }); + + it('allows overwriting a trigger from the same source', () => { + const handler1 = () => {}; + const handler2 = () => {}; + manager.defineTrigger('MyClass', 'beforeSave', handler1, 'source-a'); + manager.defineTrigger('MyClass', 'beforeSave', handler2, 'source-a'); + expect(manager.getTrigger('MyClass', 'beforeSave').handler).toBe(handler2); + }); + + it('throws when a different source tries to register the same trigger', () => { + manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-a'); + expect(() => { + manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-b'); + }).toThrowError(/already registered/i); + }); + + it('stores a validator with the trigger', () => { + const handler = () => {}; + const validator = { requireUser: true }; + manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a', validator); + expect(manager.getTrigger('MyClass', 'beforeSave').validator).toBe(validator); + }); + + it('returns null for an unknown trigger', () => { + expect(manager.getTrigger('MyClass', 'beforeSave')).toBeNull(); + }); + + it('triggerExists returns true for a registered trigger', () => { + manager.defineTrigger('MyClass', 'afterSave', () => {}, 'source-a'); + expect(manager.triggerExists('MyClass', 'afterSave')).toBe(true); + }); + + it('triggerExists returns false for an unregistered trigger', () => { + expect(manager.triggerExists('MyClass', 'afterSave')).toBe(false); + }); + }); + + // ─── Validation Rules ──────────────────────────────────────────────────────── + + describe('validation rules', () => { + it('rejects beforeSave on _PushStatus', () => { + expect(() => { + manager.defineTrigger('_PushStatus', 'beforeSave', () => {}, 'source-a'); + }).toThrowError(/_PushStatus/); + }); + + it('allows afterSave on _PushStatus', () => { + expect(() => { + manager.defineTrigger('_PushStatus', 'afterSave', () => {}, 'source-a'); + }).not.toThrow(); + }); + + it('rejects beforeDelete on _PushStatus', () => { + expect(() => { + manager.defineTrigger('_PushStatus', 'beforeDelete', () => {}, 'source-a'); + }).toThrowError(/_PushStatus/); + }); + + it('rejects beforeFind on _PushStatus', () => { + expect(() => { + manager.defineTrigger('_PushStatus', 'beforeFind', () => {}, 'source-a'); + }).toThrowError(/_PushStatus/); + }); + + it('rejects beforeSave on _Session', () => { + expect(() => { + manager.defineTrigger('_Session', 'beforeSave', () => {}, 'source-a'); + }).toThrowError(/_Session/); + }); + + it('rejects afterSave on _Session', () => { + expect(() => { + manager.defineTrigger('_Session', 'afterSave', () => {}, 'source-a'); + }).toThrowError(/_Session/); + }); + + it('allows afterLogout on _Session', () => { + expect(() => { + manager.defineTrigger('_Session', 'afterLogout', () => {}, 'source-a'); + }).not.toThrow(); + }); + + it('rejects beforeLogin on a non-_User class', () => { + expect(() => { + manager.defineTrigger('SomeClass', 'beforeLogin', () => {}, 'source-a'); + }).toThrowError(/_User/); + }); + + it('allows beforeLogin on _User', () => { + expect(() => { + manager.defineTrigger('_User', 'beforeLogin', () => {}, 'source-a'); + }).not.toThrow(); + }); + + it('rejects afterLogin on a non-_User class', () => { + expect(() => { + manager.defineTrigger('SomeClass', 'afterLogin', () => {}, 'source-a'); + }).toThrowError(/_User/); + }); + + it('allows afterLogin on _User', () => { + expect(() => { + manager.defineTrigger('_User', 'afterLogin', () => {}, 'source-a'); + }).not.toThrow(); + }); + + it('rejects beforePasswordResetRequest on a non-_User class', () => { + expect(() => { + manager.defineTrigger('SomeClass', 'beforePasswordResetRequest', () => {}, 'source-a'); + }).toThrowError(/_User/); + }); + + it('allows beforePasswordResetRequest on _User', () => { + expect(() => { + manager.defineTrigger('_User', 'beforePasswordResetRequest', () => {}, 'source-a'); + }).not.toThrow(); + }); + + it('rejects afterLogout on a non-_Session class', () => { + expect(() => { + manager.defineTrigger('_User', 'afterLogout', () => {}, 'source-a'); + }).toThrowError(/_Session/); + }); + + it('allows beforeSave on @File virtual className', () => { + expect(() => { + manager.defineTrigger('@File', 'beforeSave', () => {}, 'source-a'); + }).not.toThrow(); + }); + + it('allows beforeConnect on @Connect virtual className', () => { + expect(() => { + manager.defineTrigger('@Connect', 'beforeConnect', () => {}, 'source-a'); + }).not.toThrow(); + }); + }); + + // ─── Job Registration ───────────────────────────────────────────────────────── + + describe('defineJob', () => { + it('registers a job', () => { + const handler = () => {}; + manager.defineJob('myJob', handler, 'source-a'); + const entry = manager.getJob('myJob'); + expect(entry).not.toBeNull(); + expect(entry.handler).toBe(handler); + expect(entry.source).toBe('source-a'); + }); + + it('allows overwriting a job from the same source', () => { + const handler1 = () => {}; + const handler2 = () => {}; + manager.defineJob('myJob', handler1, 'source-a'); + manager.defineJob('myJob', handler2, 'source-a'); + expect(manager.getJob('myJob').handler).toBe(handler2); + }); + + it('throws when a different source tries to register the same job', () => { + manager.defineJob('myJob', () => {}, 'source-a'); + expect(() => { + manager.defineJob('myJob', () => {}, 'source-b'); + }).toThrowError(/already registered/i); + }); + + it('returns null for an unknown job', () => { + expect(manager.getJob('unknown')).toBeNull(); + }); + + it('getJobs returns all jobs as a Map', () => { + manager.defineJob('job1', () => {}, 'source-a'); + manager.defineJob('job2', () => {}, 'source-a'); + const jobs = manager.getJobs(); + expect(jobs).toBeInstanceOf(Map); + expect(jobs.size).toBe(2); + expect(jobs.has('job1')).toBe(true); + expect(jobs.has('job2')).toBe(true); + }); + + it('getJobsObject returns all jobs as a plain object', () => { + manager.defineJob('job1', () => {}, 'source-a'); + manager.defineJob('job2', () => {}, 'source-a'); + const jobs = manager.getJobsObject(); + expect(typeof jobs).toBe('object'); + expect(jobs['job1']).toBeDefined(); + expect(jobs['job2']).toBeDefined(); + }); + }); + + // ─── Live Query Handlers ───────────────────────────────────────────────────── + + describe('defineLiveQueryHandler', () => { + it('registers a live query handler', () => { + const handler = data => {}; + manager.defineLiveQueryHandler(handler, 'source-a'); + // runLiveQueryEventHandlers should call the handler + let called = false; + const h = data => { called = true; }; + manager.defineLiveQueryHandler(h, 'source-a'); + manager.runLiveQueryEventHandlers({ event: 'test' }); + expect(called).toBe(true); + }); + + it('runs all live query handlers when runLiveQueryEventHandlers is called', () => { + const calls = []; + manager.defineLiveQueryHandler(data => calls.push('h1'), 'source-a'); + manager.defineLiveQueryHandler(data => calls.push('h2'), 'source-b'); + manager.runLiveQueryEventHandlers({ event: 'test' }); + expect(calls).toEqual(['h1', 'h2']); + }); + + it('passes data to each live query handler', () => { + let received; + manager.defineLiveQueryHandler(data => { received = data; }, 'source-a'); + manager.runLiveQueryEventHandlers({ event: 'create', objectId: '123' }); + expect(received).toEqual({ event: 'create', objectId: '123' }); + }); + }); + + // ─── Lookup Methods ────────────────────────────────────────────────────────── + + describe('getFunctionNames', () => { + it('returns an empty array when no functions are registered', () => { + expect(manager.getFunctionNames()).toEqual([]); + }); + + it('returns names of all registered functions', () => { + manager.defineFunction('funcA', () => {}, 'source-a'); + manager.defineFunction('funcB', () => {}, 'source-a'); + const names = manager.getFunctionNames(); + expect(names.sort()).toEqual(['funcA', 'funcB']); + }); + }); + + describe('getValidator', () => { + it('returns the validator for a function', () => { + const validator = req => {}; + manager.defineFunction('myFunc', () => {}, 'source-a', validator); + expect(manager.getValidator('myFunc')).toBe(validator); + }); + + it('returns null when function has no validator', () => { + manager.defineFunction('myFunc', () => {}, 'source-a'); + expect(manager.getValidator('myFunc')).toBeNull(); + }); + + it('returns null when function does not exist', () => { + expect(manager.getValidator('unknown')).toBeNull(); + }); + }); + + // ─── Removal ───────────────────────────────────────────────────────────────── + + describe('removeFunction', () => { + it('removes a registered function', () => { + manager.defineFunction('myFunc', () => {}, 'source-a'); + manager.removeFunction('myFunc'); + expect(manager.getFunction('myFunc')).toBeNull(); + }); + + it('does not throw when removing an unknown function', () => { + expect(() => manager.removeFunction('unknown')).not.toThrow(); + }); + }); + + describe('removeTrigger', () => { + it('removes a registered trigger', () => { + manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-a'); + manager.removeTrigger('MyClass', 'beforeSave'); + expect(manager.getTrigger('MyClass', 'beforeSave')).toBeNull(); + }); + + it('does not throw when removing an unknown trigger', () => { + expect(() => manager.removeTrigger('MyClass', 'beforeSave')).not.toThrow(); + }); + }); + + describe('unregisterAll', () => { + it('removes all hooks registered by a given source', () => { + manager.defineFunction('funcA', () => {}, 'source-a'); + manager.defineFunction('funcB', () => {}, 'source-b'); + manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-a'); + manager.defineJob('jobA', () => {}, 'source-a'); + + manager.unregisterAll('source-a'); + + expect(manager.getFunction('funcA')).toBeNull(); + expect(manager.getFunction('funcB')).not.toBeNull(); + expect(manager.getTrigger('MyClass', 'beforeSave')).toBeNull(); + expect(manager.getJob('jobA')).toBeNull(); + }); + + it('removes live query handlers registered by a given source', () => { + const calls = []; + manager.defineLiveQueryHandler(() => calls.push('a'), 'source-a'); + manager.defineLiveQueryHandler(() => calls.push('b'), 'source-b'); + + manager.unregisterAll('source-a'); + manager.runLiveQueryEventHandlers({}); + + expect(calls).toEqual(['b']); + }); + }); + + describe('clearAll', () => { + it('removes all registered hooks regardless of source', () => { + manager.defineFunction('funcA', () => {}, 'source-a'); + manager.defineFunction('funcB', () => {}, 'source-b'); + manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-a'); + manager.defineJob('jobA', () => {}, 'source-a'); + + manager.clearAll(); + + expect(manager.getFunction('funcA')).toBeNull(); + expect(manager.getFunction('funcB')).toBeNull(); + expect(manager.getTrigger('MyClass', 'beforeSave')).toBeNull(); + expect(manager.getJob('jobA')).toBeNull(); + expect(manager.getFunctionNames()).toEqual([]); + }); + + it('clears live query handlers', () => { + const calls = []; + manager.defineLiveQueryHandler(() => calls.push('a'), 'source-a'); + manager.clearAll(); + manager.runLiveQueryEventHandlers({}); + expect(calls).toEqual([]); + }); + }); + + // ─── Registry Scoping ──────────────────────────────────────────────────────── + + describe('createRegistry', () => { + it('returns a registry scoped to the given source', () => { + const registry = manager.createRegistry('source-a'); + const handler = () => {}; + registry.defineFunction('myFunc', handler); + expect(manager.getFunction('myFunc').source).toBe('source-a'); + }); + + it('scoped registry defineFunction stores the handler correctly', () => { + const registry = manager.createRegistry('source-a'); + const handler = () => 'result'; + registry.defineFunction('myFunc', handler); + expect(manager.getFunction('myFunc').handler).toBe(handler); + }); + + it('scoped registry defineTrigger stores the trigger correctly', () => { + const registry = manager.createRegistry('source-a'); + const handler = () => {}; + registry.defineTrigger('MyClass', 'beforeSave', handler); + expect(manager.getTrigger('MyClass', 'beforeSave').source).toBe('source-a'); + }); + + it('scoped registry defineJob stores the job correctly', () => { + const registry = manager.createRegistry('source-a'); + const handler = () => {}; + registry.defineJob('myJob', handler); + expect(manager.getJob('myJob').source).toBe('source-a'); + }); + + it('scoped registry defineLiveQueryHandler registers the handler', () => { + const registry = manager.createRegistry('source-a'); + const calls = []; + registry.defineLiveQueryHandler(data => calls.push(data)); + manager.runLiveQueryEventHandlers({ event: 'test' }); + expect(calls.length).toBe(1); + }); + + it('scoped registry conflict detection uses the scoped source', () => { + const registryA = manager.createRegistry('source-a'); + const registryB = manager.createRegistry('source-b'); + registryA.defineFunction('myFunc', () => {}); + expect(() => { + registryB.defineFunction('myFunc', () => {}); + }).toThrowError(/already registered/i); + }); + }); + + // ─── Lifecycle ─────────────────────────────────────────────────────────────── + + describe('initialize', () => { + it('calls initialize on each adapter', async () => { + const calls = []; + const adapterA = { + name: 'adapter-a', + initialize: async (registry, config) => { calls.push('a'); }, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const adapterB = { + name: 'adapter-b', + initialize: async (registry, config) => { calls.push('b'); }, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await manager.initialize([adapterA, adapterB], config); + expect(calls).toEqual(['a', 'b']); + }); + + it('passes a scoped registry to each adapter', async () => { + let capturedRegistry; + const adapter = { + name: 'test-adapter', + initialize: async (registry, config) => { + capturedRegistry = registry; + registry.defineFunction('adapterFunc', () => {}); + }, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await manager.initialize([adapter], config); + expect(manager.getFunction('adapterFunc')).not.toBeNull(); + expect(manager.getFunction('adapterFunc').source).toBe('test-adapter'); + }); + + it('rolls back failed adapter and previously-initialized adapters', async () => { + const shutdownCalls = []; + const adapterA = { + name: 'adapter-a', + initialize: async (registry) => { + registry.defineFunction('funcFromA', () => {}); + }, + isHealthy: async () => true, + shutdown: async () => { shutdownCalls.push('a'); }, + }; + const adapterB = { + name: 'adapter-b', + initialize: async () => { throw new Error('adapter-b failed'); }, + isHealthy: async () => true, + shutdown: async () => { shutdownCalls.push('b'); }, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await expectAsync(manager.initialize([adapterA, adapterB], config)).toBeRejectedWithError('adapter-b failed'); + expect(manager.getFunction('funcFromA')).toBeNull(); + expect(shutdownCalls).toContain('a'); + expect(shutdownCalls).toContain('b'); + // manager should have no adapters left — healthCheck with no adapters returns true + const healthy = await manager.healthCheck(); + expect(healthy).toBe(true); + }); + + it('rolls back partial registrations from the failing adapter', async () => { + const adapter = { + name: 'partial-adapter', + initialize: async (registry) => { + registry.defineFunction('partialFunc', () => {}); + throw new Error('partial failure'); + }, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await expectAsync(manager.initialize([adapter], config)).toBeRejectedWithError('partial failure'); + expect(manager.getFunction('partialFunc')).toBeNull(); + }); + + it('throws when two adapters have the same name', async () => { + const adapterA = { + name: 'duplicate', + initialize: async () => {}, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const adapterB = { + name: 'duplicate', + initialize: async () => {}, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await expectAsync(manager.initialize([adapterA, adapterB], config)).toBeRejectedWithError(/duplicate/i); + }); + }); + + describe('shutdown', () => { + it('calls shutdown on each initialized adapter', async () => { + const calls = []; + const adapter = { + name: 'adapter-a', + initialize: async () => {}, + isHealthy: async () => true, + shutdown: async () => { calls.push('shutdown'); }, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await manager.initialize([adapter], config); + await manager.shutdown(); + expect(calls).toEqual(['shutdown']); + }); + + it('continues shutting down other adapters when one fails', async () => { + spyOn(console, 'error').and.callFake(() => {}); + const shutdownCalls = []; + const adapterA = { + name: 'adapter-a', + initialize: async () => {}, + isHealthy: async () => true, + shutdown: async () => { + shutdownCalls.push('a'); + throw new Error('shutdown-a failed'); + }, + }; + const adapterB = { + name: 'adapter-b', + initialize: async () => {}, + isHealthy: async () => true, + shutdown: async () => { shutdownCalls.push('b'); }, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await manager.initialize([adapterA, adapterB], config); + await manager.shutdown(); + expect(shutdownCalls).toContain('a'); + expect(shutdownCalls).toContain('b'); + }); + + it('clears all registrations after shutdown', async () => { + const adapter = { + name: 'adapter-a', + initialize: async (registry) => { + registry.defineFunction('myFunc', () => {}); + }, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await manager.initialize([adapter], config); + expect(manager.getFunction('myFunc')).not.toBeNull(); + await manager.shutdown(); + expect(manager.getFunction('myFunc')).toBeNull(); + expect(manager.getFunctionNames()).toEqual([]); + }); + }); + + describe('healthCheck', () => { + it('returns true when all adapters are healthy', async () => { + const adapter = { + name: 'adapter-a', + initialize: async () => {}, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await manager.initialize([adapter], config); + const healthy = await manager.healthCheck(); + expect(healthy).toBe(true); + }); + + it('returns false when any adapter is unhealthy', async () => { + const adapterA = { + name: 'adapter-a', + initialize: async () => {}, + isHealthy: async () => true, + shutdown: async () => {}, + }; + const adapterB = { + name: 'adapter-b', + initialize: async () => {}, + isHealthy: async () => false, + shutdown: async () => {}, + }; + const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' }; + await manager.initialize([adapterA, adapterB], config); + const healthy = await manager.healthCheck(); + expect(healthy).toBe(false); + }); + + it('returns true when no adapters are registered', async () => { + const healthy = await manager.healthCheck(); + expect(healthy).toBe(true); + }); + }); + + // ─── Validators for triggers ───────────────────────────────────────────────── + + describe('trigger validators', () => { + it('stores a function validator on a trigger', () => { + const handler = () => {}; + const validator = req => {}; + manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a', validator); + expect(manager.getTrigger('MyClass', 'beforeSave').validator).toBe(validator); + }); + + it('stores an object validator on a trigger', () => { + const handler = () => {}; + const validator = { requireUser: true }; + manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a', validator); + expect(manager.getTrigger('MyClass', 'beforeSave').validator).toBe(validator); + }); + + it('stores undefined validator when no validator provided', () => { + const handler = () => {}; + manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a'); + const entry = manager.getTrigger('MyClass', 'beforeSave'); + expect(entry.validator).toBeUndefined(); + }); + }); +}); diff --git a/spec/ExternalProcessAdapter.spec.js b/spec/ExternalProcessAdapter.spec.js new file mode 100644 index 0000000000..c10dd0951e --- /dev/null +++ b/spec/ExternalProcessAdapter.spec.js @@ -0,0 +1,252 @@ +// spec/ExternalProcessAdapter.spec.js +const { ExternalProcessAdapter } = require('../lib/cloud-code/adapters/ExternalProcessAdapter'); +const { CloudCodeManager } = require('../lib/cloud-code/CloudCodeManager'); +const http = require('http'); + +function createMockCloudServer(manifest) { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + if (req.url === '/' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(manifest)); + } else if (req.url === '/health' && req.method === 'GET') { + res.writeHead(200); + res.end('OK'); + } else if (req.url.startsWith('/functions/') && req.method === 'POST') { + let body = ''; + req.on('data', d => body += d); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: 'external-result' })); + }); + } else { + res.writeHead(404); + res.end(); + } + }); + server.on('error', (err) => reject(err)); + server.listen(0, () => resolve({ server, port: server.address().port })); + }); +} + +describe('ExternalProcessAdapter', () => { + it('derives name from webhookKey by default', () => { + const adapter = new ExternalProcessAdapter('echo test', 'secret-key'); + expect(adapter.name).toBe('external-process-secret-k'); + }); + + it('accepts a custom name', () => { + const adapter = new ExternalProcessAdapter('echo test', 'secret-key', undefined, 'my-adapter'); + expect(adapter.name).toBe('my-adapter'); + }); + + it('requires webhookKey', () => { + expect(() => new ExternalProcessAdapter('echo test', '')).toThrowError(/webhookKey/); + }); + + it('shutdown resolves cleanly when no process started', async () => { + const adapter = new ExternalProcessAdapter('echo test', 'key'); + await expectAsync(adapter.shutdown()).toBeResolved(); + }); + + it('isHealthy returns true for running server', async () => { + const manager = new CloudCodeManager(); + const { server, port } = await createMockCloudServer( + { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } } + ); + + let adapter; + try { + const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`; + adapter = new ExternalProcessAdapter(cmd, 'test-key', { + startupTimeout: 5000, + healthCheckInterval: 0, + }); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + const healthy = await adapter.isHealthy(); + expect(healthy).toBe(true); + } finally { + if (adapter) { + await adapter.shutdown(); + } + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); + } + }, 10000); + + it('isHealthy returns false when server is down', async () => { + const adapter = new ExternalProcessAdapter('echo test', 'test-key'); + // Port is 0 (default) since we never initialized — any HTTP request will fail + const healthy = await adapter.isHealthy(); + expect(healthy).toBe(false); + }); + + it('cleans up process on manifest fetch failure', async () => { + // Create a server that returns 500 for the manifest endpoint + const server = await new Promise((resolve, reject) => { + const srv = http.createServer((req, res) => { + if (req.url === '/' && req.method === 'GET') { + res.writeHead(500); + res.end('Internal Server Error'); + } else { + res.writeHead(404); + res.end(); + } + }); + srv.on('error', reject); + srv.listen(0, () => resolve(srv)); + }); + const port = server.address().port; + + let adapter; + try { + const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`; + adapter = new ExternalProcessAdapter(cmd, 'test-key', { + startupTimeout: 5000, + healthCheckInterval: 0, + }); + const manager = new CloudCodeManager(); + const registry = manager.createRegistry(adapter.name); + + await expectAsync( + adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }) + ).toBeRejectedWithError(/500/); + + // After failure, the process should have been cleaned up by shutdown() + // Calling shutdown again should resolve cleanly (process already null) + await expectAsync(adapter.shutdown()).toBeResolved(); + } finally { + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); + } + }, 10000); + + it('registers triggers including beforeSave', async () => { + const manager = new CloudCodeManager(); + const { server, port } = await createMockCloudServer({ + protocol: 'ParseCloud/1.0', + hooks: { + functions: [], + triggers: [ + { className: 'GameScore', triggerName: 'beforeSave' }, + { className: 'GameScore', triggerName: 'afterSave' }, + ], + jobs: [], + }, + }); + + let adapter; + try { + const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`; + adapter = new ExternalProcessAdapter(cmd, 'test-key', { + startupTimeout: 5000, + healthCheckInterval: 0, + }); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + expect(manager.getTrigger('GameScore', 'beforeSave')).toBeDefined(); + expect(manager.getTrigger('GameScore', 'afterSave')).toBeDefined(); + } finally { + if (adapter) { + await adapter.shutdown(); + } + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); + } + }, 10000); + + it('registers jobs from manifest', async () => { + const manager = new CloudCodeManager(); + const { server, port } = await createMockCloudServer({ + protocol: 'ParseCloud/1.0', + hooks: { + functions: [], + triggers: [], + jobs: [{ name: 'cleanupJob' }], + }, + }); + + let adapter; + try { + const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`; + adapter = new ExternalProcessAdapter(cmd, 'test-key', { + startupTimeout: 5000, + healthCheckInterval: 0, + }); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + expect(manager.getJob('cleanupJob')).toBeDefined(); + } finally { + if (adapter) { + await adapter.shutdown(); + } + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); + } + }, 10000); + + it('shutdown terminates a running process', async () => { + const manager = new CloudCodeManager(); + const { server, port } = await createMockCloudServer( + { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } } + ); + + let adapter; + try { + const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`; + adapter = new ExternalProcessAdapter(cmd, 'test-key', { + startupTimeout: 5000, + healthCheckInterval: 0, + shutdownTimeout: 2000, + }); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + // Shutdown should terminate the spawned process + await expectAsync(adapter.shutdown()).toBeResolved(); + + // After shutdown, isHealthy should return false (port no longer served by our process) + // and a second shutdown should be a no-op + await expectAsync(adapter.shutdown()).toBeResolved(); + } finally { + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); + } + }, 10000); + + it('spawns process and reads manifest', async () => { + const manager = new CloudCodeManager(); + const { server, port } = await createMockCloudServer( + { protocol: 'ParseCloud/1.0', hooks: { functions: [{ name: 'ext-fn' }], triggers: [], jobs: [] } } + ); + + let adapter; + try { + const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`; + adapter = new ExternalProcessAdapter(cmd, 'test-key', { + startupTimeout: 5000, + healthCheckInterval: 0, + }); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + expect(manager.getFunction('ext-fn')).toBeDefined(); + } finally { + if (adapter) { + await adapter.shutdown(); + } + await new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); + } + }, 10000); +}); diff --git a/spec/InProcessAdapter.spec.js b/spec/InProcessAdapter.spec.js new file mode 100644 index 0000000000..1822313493 --- /dev/null +++ b/spec/InProcessAdapter.spec.js @@ -0,0 +1,222 @@ +const { InProcessAdapter } = require('../lib/cloud-code/adapters/InProcessAdapter'); +const { CloudCodeManager } = require('../lib/cloud-code/CloudCodeManager'); + +function createMockCloudCode(manifest, handlers = {}) { + return { + getRouter() { + return { + getManifest() { return manifest; }, + async dispatchFunction(name, body) { + if (handlers[`function:${name}`]) { + return handlers[`function:${name}`](body); + } + return { success: null }; + }, + async dispatchTrigger(className, triggerName, body) { + if (handlers[`trigger:${triggerName}.${className}`]) { + return handlers[`trigger:${triggerName}.${className}`](body); + } + return { success: {} }; + }, + async dispatchJob(name, body) { + if (handlers[`job:${name}`]) { + return handlers[`job:${name}`](body); + } + return { success: null }; + }, + }; + }, + }; +} + +describe('InProcessAdapter', () => { + let manager; + + beforeEach(() => { + manager = new CloudCodeManager(); + }); + + it('has name "in-process"', () => { + const cloud = createMockCloudCode({ protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } }); + const adapter = new InProcessAdapter(cloud); + expect(adapter.name).toBe('in-process'); + }); + + it('registers functions from manifest', async () => { + const cloud = createMockCloudCode({ + protocol: 'ParseCloud/1.0', + hooks: { + functions: [{ name: 'hello' }, { name: 'greet' }], + triggers: [], + jobs: [], + }, + }); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + expect(manager.getFunction('hello')).toBeDefined(); + expect(manager.getFunction('greet')).toBeDefined(); + }); + + it('registers triggers from manifest', async () => { + const cloud = createMockCloudCode({ + protocol: 'ParseCloud/1.0', + hooks: { + functions: [], + triggers: [{ className: 'Todo', triggerName: 'beforeSave' }], + jobs: [], + }, + }); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + expect(manager.getTrigger('Todo', 'beforeSave')).toBeDefined(); + }); + + it('registers jobs from manifest', async () => { + const cloud = createMockCloudCode({ + protocol: 'ParseCloud/1.0', + hooks: { + functions: [], + triggers: [], + jobs: [{ name: 'cleanup' }], + }, + }); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + expect(manager.getJob('cleanup')).toBeDefined(); + }); + + it('bridge handler dispatches function and returns result', async () => { + const cloud = createMockCloudCode( + { protocol: 'ParseCloud/1.0', hooks: { functions: [{ name: 'add' }], triggers: [], jobs: [] } }, + { 'function:add': (body) => ({ success: body.params.a + body.params.b }) } + ); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + const entry = manager.getFunction('add'); + const result = await entry.handler({ params: { a: 2, b: 3 }, master: false, ip: '127.0.0.1', headers: {} }); + expect(result).toBe(5); + }); + + it('bridge handler throws Parse.Error on error response', async () => { + const cloud = createMockCloudCode( + { protocol: 'ParseCloud/1.0', hooks: { functions: [{ name: 'fail' }], triggers: [], jobs: [] } }, + { 'function:fail': () => ({ error: { code: 141, message: 'boom' } }) } + ); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + const entry = manager.getFunction('fail'); + await expectAsync(entry.handler({ params: {}, master: false, ip: '', headers: {} })) + .toBeRejectedWithError(/boom/); + }); + + it('isHealthy returns true', async () => { + const cloud = createMockCloudCode({ protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } }); + const adapter = new InProcessAdapter(cloud); + expect(await adapter.isHealthy()).toBe(true); + }); + + it('beforeSave trigger with request.object calls applyBeforeSaveResponse', async () => { + const cloud = createMockCloudCode( + { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [{ className: 'Todo', triggerName: 'beforeSave' }], jobs: [] } }, + { 'trigger:beforeSave.Todo': () => ({ success: { field1: 'value1' } }) } + ); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + const entry = manager.getTrigger('Todo', 'beforeSave'); + const request = { + object: { set: jasmine.createSpy('set'), toJSON: () => ({}) }, + master: false, + ip: '', + headers: {}, + }; + const result = await entry.handler(request); + expect(result).toBeUndefined(); + expect(request.object.set).toHaveBeenCalledWith('field1', 'value1'); + }); + + it('beforeSave trigger without request.object returns webhookResponseToResult', async () => { + const cloud = createMockCloudCode( + { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [{ className: '@File', triggerName: 'beforeSave' }], jobs: [] } }, + { 'trigger:beforeSave.@File': () => ({ success: { name: 'test.txt' } }) } + ); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + const entry = manager.getTrigger('@File', 'beforeSave'); + const request = { + file: { name: 'test.txt' }, + master: false, + ip: '', + headers: {}, + }; + const result = await entry.handler(request); + expect(result).toEqual({ name: 'test.txt' }); + }); + + it('beforeSave trigger without request.object returning empty object returns undefined', async () => { + const cloud = createMockCloudCode( + { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [{ className: '@File', triggerName: 'beforeSave' }], jobs: [] } }, + { 'trigger:beforeSave.@File': () => ({ success: {} }) } + ); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + const entry = manager.getTrigger('@File', 'beforeSave'); + const request = { + file: { name: 'test.txt' }, + master: false, + ip: '', + headers: {}, + }; + const result = await entry.handler(request); + expect(result).toBeUndefined(); + }); + + it('non-beforeSave trigger returns webhookResponseToResult', async () => { + const cloud = createMockCloudCode( + { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [{ className: 'Todo', triggerName: 'afterSave' }], jobs: [] } }, + { 'trigger:afterSave.Todo': () => ({ success: { saved: true } }) } + ); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + const entry = manager.getTrigger('Todo', 'afterSave'); + const result = await entry.handler({ object: { toJSON: () => ({}) }, master: false, ip: '', headers: {} }); + expect(result).toEqual({ saved: true }); + }); + + it('bridge handler dispatches job and returns result', async () => { + const cloud = createMockCloudCode( + { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [{ name: 'cleanup' }] } }, + { 'job:cleanup': () => ({ success: 'done' }) } + ); + const adapter = new InProcessAdapter(cloud); + const registry = manager.createRegistry(adapter.name); + await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' }); + + const entry = manager.getJob('cleanup'); + const result = await entry.handler({ params: {}, master: true, ip: '', headers: {} }); + expect(result).toBe('done'); + }); + + it('shutdown resolves cleanly', async () => { + const cloud = createMockCloudCode({ protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } }); + const adapter = new InProcessAdapter(cloud); + await expectAsync(adapter.shutdown()).toBeResolved(); + }); +}); diff --git a/spec/LegacyAdapter.spec.js b/spec/LegacyAdapter.spec.js new file mode 100644 index 0000000000..b67d707a7d --- /dev/null +++ b/spec/LegacyAdapter.spec.js @@ -0,0 +1,74 @@ +const { LegacyAdapter } = require('../lib/cloud-code/adapters/LegacyAdapter'); +const path = require('path'); + +const mockRegistry = { + defineFunction: () => {}, + defineTrigger: () => {}, + defineJob: () => {}, + defineLiveQueryHandler: () => {}, +}; +const mockConfig = { + appId: 'test', + masterKey: 'mk', + serverURL: 'http://localhost', +}; + +describe('LegacyAdapter', () => { + it('has name "legacy"', () => { + const adapter = new LegacyAdapter(() => {}); + expect(adapter.name).toBe('legacy'); + }); + + it('initialize calls a function with Parse', async () => { + const cloudFn = jasmine.createSpy('cloudFn'); + const adapter = new LegacyAdapter(cloudFn); + await adapter.initialize(mockRegistry, mockConfig); + expect(cloudFn).toHaveBeenCalledTimes(1); + const Parse = require('parse/node').Parse; + expect(cloudFn).toHaveBeenCalledWith(Parse); + }); + + it('initialize awaits a function that returns a Promise', async () => { + let resolved = false; + const cloudFn = () => + new Promise((resolve) => { + setTimeout(() => { + resolved = true; + resolve(); + }, 10); + }); + const adapter = new LegacyAdapter(cloudFn); + await adapter.initialize(mockRegistry, mockConfig); + expect(resolved).toBe(true); + }); + + it('initialize with a valid cloud code file path loads the file', async () => { + // Use a minimal temp file that doesn't register global cloud functions + const fs = require('fs'); + const tmpFile = path.resolve(__dirname, '_legacyAdapterTestTemp.js'); + fs.writeFileSync(tmpFile, 'module.exports = {};'); + try { + const adapter = new LegacyAdapter(tmpFile); + await expectAsync(adapter.initialize(mockRegistry, mockConfig)).toBeResolved(); + } finally { + fs.unlinkSync(tmpFile); + delete require.cache[require.resolve(tmpFile)]; + } + }); + + it('initialize with a non-existent path throws', async () => { + const adapter = new LegacyAdapter('/non/existent/path/cloud.js'); + await expectAsync(adapter.initialize(mockRegistry, mockConfig)).toBeRejected(); + }); + + it('isHealthy returns true', async () => { + const adapter = new LegacyAdapter(() => {}); + const result = await adapter.isHealthy(); + expect(result).toBe(true); + }); + + it('shutdown resolves cleanly', async () => { + const adapter = new LegacyAdapter(() => {}); + await expectAsync(adapter.shutdown()).toBeResolved(); + }); +}); diff --git a/spec/resolveAdapters.spec.js b/spec/resolveAdapters.spec.js new file mode 100644 index 0000000000..021dd30f28 --- /dev/null +++ b/spec/resolveAdapters.spec.js @@ -0,0 +1,81 @@ +const { resolveAdapters } = require('../lib/cloud-code/resolveAdapters'); +const { LegacyAdapter } = require('../lib/cloud-code/adapters/LegacyAdapter'); +const { InProcessAdapter } = require('../lib/cloud-code/adapters/InProcessAdapter'); +const { ExternalProcessAdapter } = require('../lib/cloud-code/adapters/ExternalProcessAdapter'); + +describe('resolveAdapters', () => { + it('should return an empty array when no relevant options are provided', () => { + const result = resolveAdapters({}); + expect(result).toEqual([]); + }); + + it('should spread cloudCodeAdapters into the result', () => { + const adapter1 = { name: 'adapter1' }; + const adapter2 = { name: 'adapter2' }; + const result = resolveAdapters({ cloudCodeAdapters: [adapter1, adapter2] }); + expect(result.length).toBe(2); + expect(result[0]).toBe(adapter1); + expect(result[1]).toBe(adapter2); + }); + + it('should create a LegacyAdapter when cloud is a string', () => { + const result = resolveAdapters({ cloud: './cloud/main.js' }); + expect(result.length).toBe(1); + expect(result[0]).toBeInstanceOf(LegacyAdapter); + }); + + it('should create a LegacyAdapter when cloud is a function', () => { + const cloudFunction = () => {}; + const result = resolveAdapters({ cloud: cloudFunction }); + expect(result.length).toBe(1); + expect(result[0]).toBeInstanceOf(LegacyAdapter); + }); + + it('should create an InProcessAdapter when cloud is an object with getRouter', () => { + const cloudObject = { getRouter: () => {} }; + const result = resolveAdapters({ cloud: cloudObject }); + expect(result.length).toBe(1); + expect(result[0]).toBeInstanceOf(InProcessAdapter); + }); + + it('should throw when cloud is an invalid type (boolean)', () => { + expect(() => resolveAdapters({ cloud: true })).toThrowError( + "argument 'cloud' must either be a string or a function" + ); + }); + + it('should throw when cloud is an invalid type (number)', () => { + expect(() => resolveAdapters({ cloud: 42 })).toThrowError( + "argument 'cloud' must either be a string or a function" + ); + }); + + it('should throw when cloudCodeCommand is provided without webhookKey', () => { + expect(() => resolveAdapters({ cloudCodeCommand: 'node cloud.js' })).toThrowError( + 'webhookKey is required when using cloudCodeCommand' + ); + }); + + it('should create an ExternalProcessAdapter when cloudCodeCommand and webhookKey are provided', () => { + const result = resolveAdapters({ + cloudCodeCommand: 'node cloud.js', + webhookKey: 'secret-key', + }); + expect(result.length).toBe(1); + expect(result[0]).toBeInstanceOf(ExternalProcessAdapter); + }); + + it('should combine multiple options into a single result array', () => { + const customAdapter = { name: 'custom' }; + const result = resolveAdapters({ + cloudCodeAdapters: [customAdapter], + cloud: './cloud/main.js', + cloudCodeCommand: 'node cloud.js', + webhookKey: 'secret-key', + }); + expect(result.length).toBe(3); + expect(result[0]).toBe(customAdapter); + expect(result[1]).toBeInstanceOf(LegacyAdapter); + expect(result[2]).toBeInstanceOf(ExternalProcessAdapter); + }); +}); diff --git a/spec/webhook-bridge.spec.js b/spec/webhook-bridge.spec.js new file mode 100644 index 0000000000..ef26639686 --- /dev/null +++ b/spec/webhook-bridge.spec.js @@ -0,0 +1,309 @@ +const { + requestToWebhookBody, + webhookResponseToResult, + applyBeforeSaveResponse, +} = require('../lib/cloud-code/adapters/webhook-bridge'); +const Parse = require('parse/node').Parse; + +describe('webhook-bridge', () => { + // ── requestToWebhookBody ────────────────────────────────────────────── + + describe('requestToWebhookBody', () => { + it('should return defaults for a minimal request', () => { + const body = requestToWebhookBody({}); + expect(body).toEqual({ + master: false, + ip: '', + headers: {}, + installationId: undefined, + }); + }); + + it('should pass through master, ip, headers, installationId', () => { + const body = requestToWebhookBody({ + master: true, + ip: '127.0.0.1', + headers: { 'x-custom': 'value' }, + installationId: 'abc-123', + }); + expect(body.master).toBe(true); + expect(body.ip).toBe('127.0.0.1'); + expect(body.headers).toEqual({ 'x-custom': 'value' }); + expect(body.installationId).toBe('abc-123'); + }); + + it('should serialise user via toJSON when available', () => { + const user = { toJSON: () => ({ objectId: 'u1', username: 'alice' }) }; + const body = requestToWebhookBody({ user }); + expect(body.user).toEqual({ objectId: 'u1', username: 'alice' }); + }); + + it('should use plain user object when toJSON is absent', () => { + const user = { objectId: 'u2', username: 'bob' }; + const body = requestToWebhookBody({ user }); + expect(body.user).toEqual({ objectId: 'u2', username: 'bob' }); + }); + + it('should not include user when it is undefined', () => { + const body = requestToWebhookBody({}); + expect(Object.hasOwn(body, 'user')).toBe(false); + }); + + it('should include params when defined', () => { + const body = requestToWebhookBody({ params: { key: 'val' } }); + expect(body.params).toEqual({ key: 'val' }); + }); + + it('should not include params when undefined', () => { + const body = requestToWebhookBody({}); + expect(Object.hasOwn(body, 'params')).toBe(false); + }); + + it('should include jobId when defined', () => { + const body = requestToWebhookBody({ jobId: 'job-42' }); + expect(body.jobId).toBe('job-42'); + }); + + it('should serialise object via toJSON when available', () => { + const obj = { toJSON: () => ({ className: 'Item', objectId: 'o1' }) }; + const body = requestToWebhookBody({ object: obj }); + expect(body.object).toEqual({ className: 'Item', objectId: 'o1' }); + }); + + it('should use plain object when toJSON is absent', () => { + const obj = { className: 'Item', objectId: 'o1' }; + const body = requestToWebhookBody({ object: obj }); + expect(body.object).toEqual({ className: 'Item', objectId: 'o1' }); + }); + + it('should not include object when it is falsy', () => { + const body = requestToWebhookBody({ object: null }); + expect(Object.hasOwn(body, 'object')).toBe(false); + }); + + it('should serialise original via toJSON when available', () => { + const original = { toJSON: () => ({ className: 'Item', objectId: 'o0' }) }; + const body = requestToWebhookBody({ original }); + expect(body.original).toEqual({ className: 'Item', objectId: 'o0' }); + }); + + it('should use plain original when toJSON is absent', () => { + const original = { className: 'Item', objectId: 'o0' }; + const body = requestToWebhookBody({ original }); + expect(body.original).toEqual({ className: 'Item', objectId: 'o0' }); + }); + + it('should include context when defined', () => { + const body = requestToWebhookBody({ context: { source: 'test' } }); + expect(body.context).toEqual({ source: 'test' }); + }); + + it('should not include context when undefined', () => { + const body = requestToWebhookBody({}); + expect(Object.hasOwn(body, 'context')).toBe(false); + }); + + it('should map query fields correctly', () => { + const query = { + className: 'Item', + _where: { score: { $gt: 10 } }, + _limit: 25, + _skip: 5, + _include: ['author', 'comments'], + _keys: ['title', 'score'], + _order: 'score,-createdAt', + }; + const body = requestToWebhookBody({ query }); + expect(body.query).toEqual({ + className: 'Item', + where: { score: { $gt: 10 } }, + limit: 25, + skip: 5, + include: 'author,comments', + keys: 'title,score', + order: 'score,-createdAt', + }); + }); + + it('should handle query with undefined _include and _keys', () => { + const query = { + className: 'Item', + _where: {}, + _limit: 10, + _skip: 0, + }; + const body = requestToWebhookBody({ query }); + expect(body.query.include).toBeUndefined(); + expect(body.query.keys).toBeUndefined(); + }); + + it('should not include query when undefined', () => { + const body = requestToWebhookBody({}); + expect(Object.hasOwn(body, 'query')).toBe(false); + }); + + it('should include count when defined', () => { + const body = requestToWebhookBody({ count: true }); + expect(body.count).toBe(true); + }); + + it('should include isGet when defined', () => { + const body = requestToWebhookBody({ isGet: true }); + expect(body.isGet).toBe(true); + }); + + it('should include file when defined', () => { + const file = { name: 'photo.png', data: 'base64...' }; + const body = requestToWebhookBody({ file }); + expect(body.file).toEqual(file); + }); + + it('should not include file when undefined', () => { + const body = requestToWebhookBody({}); + expect(Object.hasOwn(body, 'file')).toBe(false); + }); + + it('should include fileSize when defined', () => { + const body = requestToWebhookBody({ fileSize: 1024 }); + expect(body.fileSize).toBe(1024); + }); + + it('should include event when defined', () => { + const body = requestToWebhookBody({ event: 'create' }); + expect(body.event).toBe('create'); + }); + + it('should not include event when undefined', () => { + const body = requestToWebhookBody({}); + expect(Object.hasOwn(body, 'event')).toBe(false); + }); + + it('should include requestId when defined', () => { + const body = requestToWebhookBody({ requestId: 'req-99' }); + expect(body.requestId).toBe('req-99'); + }); + + it('should include clients when defined', () => { + const body = requestToWebhookBody({ clients: 5 }); + expect(body.clients).toBe(5); + }); + + it('should include subscriptions when defined', () => { + const body = requestToWebhookBody({ subscriptions: 12 }); + expect(body.subscriptions).toBe(12); + }); + }); + + // ── webhookResponseToResult ─────────────────────────────────────────── + + describe('webhookResponseToResult', () => { + it('should return success value when response is successful', () => { + const result = webhookResponseToResult({ success: { name: 'test' } }); + expect(result).toEqual({ name: 'test' }); + }); + + it('should return success value when it is a primitive', () => { + expect(webhookResponseToResult({ success: 42 })).toBe(42); + }); + + it('should return undefined when success is undefined', () => { + expect(webhookResponseToResult({ success: undefined })).toBeUndefined(); + }); + + it('should throw Parse.Error when response contains error', () => { + const response = { error: { code: 141, message: 'Cloud function failed' } }; + expect(() => webhookResponseToResult(response)).toThrowError(Parse.Error); + try { + webhookResponseToResult(response); + } catch (e) { + expect(e.code).toBe(141); + expect(e.message).toBe('Cloud function failed'); + } + }); + }); + + // ── applyBeforeSaveResponse ─────────────────────────────────────────── + + describe('applyBeforeSaveResponse', () => { + let request; + + beforeEach(() => { + request = { + object: { + set: jasmine.createSpy('set'), + }, + }; + }); + + it('should throw Parse.Error when response contains error', () => { + const response = { error: { code: 101, message: 'Object not found' } }; + expect(() => applyBeforeSaveResponse(request, response)).toThrowError(Parse.Error); + try { + applyBeforeSaveResponse(request, response); + } catch (e) { + expect(e.code).toBe(101); + expect(e.message).toBe('Object not found'); + } + }); + + it('should be a no-op when success is an empty object', () => { + applyBeforeSaveResponse(request, { success: {} }); + expect(request.object.set).not.toHaveBeenCalled(); + }); + + it('should set fields from the success object on request.object', () => { + applyBeforeSaveResponse(request, { success: { title: 'Hello', score: 10 } }); + expect(request.object.set).toHaveBeenCalledWith('title', 'Hello'); + expect(request.object.set).toHaveBeenCalledWith('score', 10); + expect(request.object.set).toHaveBeenCalledTimes(2); + }); + + it('should skip objectId field', () => { + applyBeforeSaveResponse(request, { success: { objectId: 'skip-me', title: 'keep' } }); + expect(request.object.set).not.toHaveBeenCalledWith('objectId', jasmine.anything()); + expect(request.object.set).toHaveBeenCalledWith('title', 'keep'); + }); + + it('should skip createdAt field', () => { + applyBeforeSaveResponse(request, { success: { createdAt: '2025-01-01', name: 'ok' } }); + expect(request.object.set).not.toHaveBeenCalledWith('createdAt', jasmine.anything()); + expect(request.object.set).toHaveBeenCalledWith('name', 'ok'); + }); + + it('should skip updatedAt field', () => { + applyBeforeSaveResponse(request, { success: { updatedAt: '2025-01-02', name: 'ok' } }); + expect(request.object.set).not.toHaveBeenCalledWith('updatedAt', jasmine.anything()); + expect(request.object.set).toHaveBeenCalledWith('name', 'ok'); + }); + + it('should skip className field', () => { + applyBeforeSaveResponse(request, { success: { className: 'Item', name: 'ok' } }); + expect(request.object.set).not.toHaveBeenCalledWith('className', jasmine.anything()); + expect(request.object.set).toHaveBeenCalledWith('name', 'ok'); + }); + + it('should skip all skip fields at once', () => { + applyBeforeSaveResponse(request, { + success: { + objectId: 'x', + createdAt: 'a', + updatedAt: 'b', + className: 'C', + realField: 'yes', + }, + }); + expect(request.object.set).toHaveBeenCalledTimes(1); + expect(request.object.set).toHaveBeenCalledWith('realField', 'yes'); + }); + + it('should not call set when success is null', () => { + applyBeforeSaveResponse(request, { success: null }); + expect(request.object.set).not.toHaveBeenCalled(); + }); + + it('should not call set when success is a primitive', () => { + applyBeforeSaveResponse(request, { success: 'string-value' }); + expect(request.object.set).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index a83b8e1a62..ef019d50a3 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -232,7 +232,7 @@ class ParseLiveQueryServer { installationId: client.installationId, sendEvent: true, }; - const trigger = getTrigger(className, 'afterEvent', Parse.applicationId); + const trigger = getTrigger(className, 'afterEvent', this.config.appId); if (trigger) { const auth = await this.getAuthFromClient(client, requestId); if (auth && auth.user) { @@ -241,7 +241,7 @@ class ParseLiveQueryServer { if (res.object) { res.object = Parse.Object.fromJSON(res.object); } - await runTrigger(trigger, `afterEvent.${className}`, res, auth); + await runTrigger(trigger, `afterEvent.${className}`, res, auth, this.config.appId); } if (!res.sendEvent) { return; @@ -388,7 +388,7 @@ class ParseLiveQueryServer { installationId: client.installationId, sendEvent: true, }; - const trigger = getTrigger(className, 'afterEvent', Parse.applicationId); + const trigger = getTrigger(className, 'afterEvent', this.config.appId); if (trigger) { if (res.object) { res.object = Parse.Object.fromJSON(res.object); @@ -400,7 +400,7 @@ class ParseLiveQueryServer { if (auth && auth.user) { res.user = auth.user; } - await runTrigger(trigger, `afterEvent.${className}`, res, auth); + await runTrigger(trigger, `afterEvent.${className}`, res, auth, this.config.appId); } if (!res.sendEvent) { return; @@ -845,13 +845,13 @@ class ParseLiveQueryServer { installationId: request.installationId, user: undefined, }; - const trigger = getTrigger('@Connect', 'beforeConnect', Parse.applicationId); + const trigger = getTrigger('@Connect', 'beforeConnect', this.config.appId); if (trigger) { const auth = await this.getAuthFromClient(client, request.requestId, req.sessionToken); if (auth && auth.user) { req.user = auth.user; } - await runTrigger(trigger, `beforeConnect.@Connect`, req, auth); + await runTrigger(trigger, `beforeConnect.@Connect`, req, auth, this.config.appId); } parseWebsocket.clientId = clientId; this.clients.set(parseWebsocket.clientId, client); @@ -908,7 +908,7 @@ class ParseLiveQueryServer { const className = request.query.className; let authCalled = false; try { - const trigger = getTrigger(className, 'beforeSubscribe', Parse.applicationId); + const trigger = getTrigger(className, 'beforeSubscribe', this.config.appId); if (trigger) { const auth = await this.getAuthFromClient(client, request.requestId, request.sessionToken); authCalled = true; @@ -919,7 +919,7 @@ class ParseLiveQueryServer { const parseQuery = new Parse.Query(className); parseQuery.withJSON(request.query); request.query = parseQuery; - await runTrigger(trigger, `beforeSubscribe.${className}`, request, auth); + await runTrigger(trigger, `beforeSubscribe.${className}`, request, auth, this.config.appId); const query = request.query.toJSON(); request.query = query; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index e9f8227317..d52f1b52dc 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -128,7 +128,21 @@ module.exports.ParseServerOptions = { }, cloud: { env: 'PARSE_SERVER_CLOUD', - help: 'Full path to your cloud code main.js', + help: 'Full path to your cloud code main.js, a cloud code function, or an object implementing getRouter() for in-process cloud code', + }, + cloudCodeAdapters: { + env: 'PARSE_SERVER_CLOUD_CODE_ADAPTERS', + help: 'Array of CloudCodeAdapter instances for BYO cloud code integration', + action: parsers.arrayParser, + }, + cloudCodeCommand: { + env: 'PARSE_SERVER_CLOUD_CODE_COMMAND', + help: 'Shell command to spawn an external cloud code process (ParseCloud/1.0 protocol)', + }, + cloudCodeOptions: { + env: 'PARSE_SERVER_CLOUD_CODE_OPTIONS', + help: 'Options for the external cloud code process adapter: startupTimeout, healthCheckInterval, shutdownTimeout, maxRestartDelay', + action: parsers.objectParser, }, cluster: { env: 'PARSE_SERVER_CLUSTER', @@ -622,7 +636,7 @@ module.exports.ParseServerOptions = { }, webhookKey: { env: 'PARSE_SERVER_WEBHOOK_KEY', - help: 'Key sent with outgoing webhook calls', + help: 'Key for authenticating external cloud code process requests. Required when cloudCodeCommand is set.', }, }; module.exports.RateLimitOptions = { diff --git a/src/Options/docs.js b/src/Options/docs.js index 5845629022..7ad923309a 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -26,7 +26,10 @@ * @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000 * @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) * @property {String} clientKey Key for iOS, MacOS, tvOS clients - * @property {String} cloud Full path to your cloud code main.js + * @property {Union} cloud Full path to your cloud code main.js, a cloud code function, or an object implementing getRouter() for in-process cloud code + * @property {CloudCodeAdapter[]} cloudCodeAdapters Array of CloudCodeAdapter instances for BYO cloud code integration + * @property {String} cloudCodeCommand Shell command to spawn an external cloud code process (ParseCloud/1.0 protocol) + * @property {CloudCodeOptions} cloudCodeOptions Options for the external cloud code process adapter: startupTimeout, healthCheckInterval, shutdownTimeout, maxRestartDelay * @property {Number|Boolean} cluster Run with cluster, optionally set the number of processes default to os.cpus().length * @property {String} collectionPrefix A collection prefix for the classes * @property {Boolean} convertEmailToLowercase Optional. If set to `true`, the `email` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `email` property is stored as set, without any case modifications. Default is `false`. @@ -113,7 +116,7 @@ * @property {Boolean} verbose Set the logging to verbose * @property {Boolean} verifyServerUrl Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.

⚠️ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Default is `true`. * @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.

The `createdWith` values per scenario:Default is `false`. - * @property {String} webhookKey Key sent with outgoing webhook calls + * @property {String} webhookKey Key for authenticating external cloud code process requests. Required when cloudCodeCommand is set. */ /** diff --git a/src/Options/index.js b/src/Options/index.js index d9f447cf19..e5f9e953ef 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -59,6 +59,18 @@ type SendEmailVerificationRequest = { user: any, master?: boolean, }; +type CloudCodeOptions = { + startupTimeout?: number, + healthCheckInterval?: number, + shutdownTimeout?: number, + maxRestartDelay?: number, +}; +type CloudCodeAdapter = { + name: string, + initialize(registry: any, config: any): Promise, + isHealthy(): Promise, + shutdown(): Promise, +}; export interface ParseServerOptions { /* Your Parse Application ID @@ -139,8 +151,16 @@ export interface ParseServerOptions { /* Optional. If set to `true`, the `username` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `username` property is stored as set, without any case modifications. Default is `false`. :DEFAULT: false */ convertUsernameToLowercase: ?boolean; - /* Full path to your cloud code main.js */ - cloud: ?string; + /* Full path to your cloud code main.js, a cloud code function, or an object implementing getRouter() for in-process cloud code */ + cloud: ?(string | Function | { getRouter: Function }); + /* Shell command to spawn an external cloud code process (ParseCloud/1.0 protocol) */ + cloudCodeCommand: ?string; + /* Key for authenticating external cloud code process requests. Required when cloudCodeCommand is set. */ + webhookKey: ?string; + /* Options for the external cloud code process adapter: startupTimeout, healthCheckInterval, shutdownTimeout, maxRestartDelay */ + cloudCodeOptions: ?CloudCodeOptions; + /* Array of CloudCodeAdapter instances for BYO cloud code integration */ + cloudCodeAdapters: ?(CloudCodeAdapter[]); /* A collection prefix for the classes :DEFAULT: '' */ collectionPrefix: ?string; @@ -158,8 +178,6 @@ export interface ParseServerOptions { restAPIKey: ?string; /* Read-only key, which has the same capabilities as MasterKey without writes */ readOnlyMasterKey: ?string; - /* Key sent with outgoing webhook calls */ - webhookKey: ?string; /* Key for your files */ fileKey: ?string; /* Enable (or disable) the addition of a unique hash to the file names diff --git a/src/ParseServer.ts b/src/ParseServer.ts index b1f03c5863..291f0612f8 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -46,6 +46,9 @@ import Deprecator from './Deprecator/Deprecator'; import { DefinedSchemas } from './SchemaMigrations/DefinedSchemas'; import OptionsDefinitions from './Options/Definitions'; import { resolvingPromise, Connections } from './TestUtils'; +import { CloudCodeManager } from './cloud-code/CloudCodeManager'; +import { resolveAdapters } from './cloud-code/resolveAdapters'; +import { AppCache } from './cache'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -184,24 +187,37 @@ class ParseServer { } startupPromises.push(liveQueryController.connect()); await Promise.all(startupPromises); - if (cloud) { + const adapters = resolveAdapters({ + cloud, + cloudCodeCommand: this.config.cloudCodeCommand, + webhookKey: this.config.webhookKey, + cloudCodeOptions: this.config.cloudCodeOptions, + cloudCodeAdapters: this.config.cloudCodeAdapters, + }); + + if (adapters.length > 0) { + // Re-invoke addParseCloud() so Parse.Cloud methods are available + // before cloud code adapters initialize (the module-level call may + // run before Parse.applicationId is set). addParseCloud(); - if (typeof cloud === 'function') { - await Promise.resolve(cloud(Parse)); - } else if (typeof cloud === 'string') { - let json; - if (process.env.npm_package_json) { - json = require(process.env.npm_package_json); - } - if (process.env.npm_package_type === 'module' || json?.type === 'module') { - await import(path.resolve(process.cwd(), cloud)); - } else { - require(path.resolve(process.cwd(), cloud)); - } - } else { - throw "argument 'cloud' must either be a string or a function"; + const cloudManager = new CloudCodeManager(); + + // CRITICAL: Store on this.config BEFORE adapter initialization. + // this.config flows into AppCache via Config.put() later in start(). + // We must also store it on AppCache NOW so the facade can find it + // during LegacyAdapter.initialize() → Parse.Cloud.define() → triggers.addFunction(). + this.config.cloudCodeManager = cloudManager; + const appId = this.config.appId; + const cached = AppCache.get(appId); + if (cached) { + cached.cloudCodeManager = cloudManager; } - await new Promise(resolve => setTimeout(resolve, 10)); + + await cloudManager.initialize(adapters, { + appId, + masterKey: this.config.masterKey, + serverURL: this.config.serverURL || `http://localhost:${this.config.port}${this.config.mountPath || '/parse'}`, + }); } if (security && security.enableCheck && security.enableCheckLog) { new CheckRunner(security).run(); @@ -273,6 +289,14 @@ class ParseServer { if (this.liveQueryServer) { promises.push(this.liveQueryServer.shutdown()); } + if (this.config.cloudCodeManager) { + promises.push( + this.config.cloudCodeManager.shutdown().catch(error => { + // eslint-disable-next-line no-console + console.error('Error while shutting down CloudCodeManager', error); + }) + ); + } await Promise.all(promises); connections.destroyAll(); await Promise.all([serverClosePromise, liveQueryServerClosePromise]); diff --git a/src/cloud-code/CloudCodeManager.ts b/src/cloud-code/CloudCodeManager.ts new file mode 100644 index 0000000000..d24f47a926 --- /dev/null +++ b/src/cloud-code/CloudCodeManager.ts @@ -0,0 +1,319 @@ +// src/cloud-code/CloudCodeManager.ts + +import type { + CloudCodeAdapter, + CloudCodeRegistry, + CloudFunctionHandler, + CloudJobHandler, + CloudTriggerHandler, + FunctionEntry, + JobEntry, + LiveQueryEntry, + LiveQueryHandler, + ParseServerConfig, + TriggerEntry, + TriggerName, + ValidatorHandler, +} from './types'; + +// Triggers that are restricted to _User class only +const USER_ONLY_TRIGGERS = new Set([ + 'beforeLogin', + 'afterLogin', + 'beforePasswordResetRequest', +]); + +// Triggers blocked on _PushStatus (all except afterSave) +const PUSH_STATUS_ALLOWED_TRIGGERS = new Set(['afterSave']); + +function makeTriggerKey(className: string, triggerName: string): string { + return `${className}:${triggerName}`; +} + +function validateTriggerConstraints(className: string, triggerName: TriggerName): void { + if (className === '_PushStatus' && !PUSH_STATUS_ALLOWED_TRIGGERS.has(triggerName)) { + throw new Error( + `Trigger "${triggerName}" is not allowed on _PushStatus. Only afterSave is permitted.` + ); + } + + if (className === '_Session' && triggerName !== 'afterLogout') { + throw new Error( + `Trigger "${triggerName}" is not allowed on _Session. Only afterLogout is permitted.` + ); + } + + if (USER_ONLY_TRIGGERS.has(triggerName) && className !== '_User') { + throw new Error( + `Trigger "${triggerName}" is only allowed on _User class, not "${className}".` + ); + } + + if (triggerName === 'afterLogout' && className !== '_Session') { + throw new Error( + `Trigger "afterLogout" is only allowed on _Session class, not "${className}".` + ); + } +} + +export class CloudCodeManager { + private readonly functions: Map = new Map(); + private readonly triggers: Map = new Map(); + private readonly jobs: Map = new Map(); + private readonly liveQueryHandlers: LiveQueryEntry[] = []; + private readonly adapters: CloudCodeAdapter[] = []; + + // ─── Function Registration ───────────────────────────────────────────────── + + defineFunction( + name: string, + handler: CloudFunctionHandler, + source: string, + validator?: ValidatorHandler + ): void { + const existing = this.functions.get(name); + if (existing && existing.source !== source) { + throw new Error( + `Cloud function "${name}" is already registered by source "${existing.source}". Cannot register from "${source}".` + ); + } + this.functions.set(name, { handler, source, validator }); + } + + getFunction(name: string): FunctionEntry | null { + return this.functions.get(name) ?? null; + } + + getFunctionNames(): string[] { + return Array.from(this.functions.keys()); + } + + getValidator(key: string): ValidatorHandler | null { + // Check functions first + const fnEntry = this.functions.get(key); + if (fnEntry) { + return fnEntry.validator ?? null; + } + // Check triggers — key format from facade is "triggerType.className" + // Convert to our internal key format "className:triggerName" + const dotIdx = key.indexOf('.'); + if (dotIdx !== -1) { + const triggerName = key.substring(0, dotIdx); + const className = key.substring(dotIdx + 1); + const triggerEntry = this.triggers.get(makeTriggerKey(className, triggerName)); + if (triggerEntry) { + return triggerEntry.validator ?? null; + } + } + return null; + } + + removeFunction(name: string): void { + this.functions.delete(name); + } + + // ─── Trigger Registration ────────────────────────────────────────────────── + + defineTrigger( + className: string, + triggerName: TriggerName, + handler: CloudTriggerHandler, + source: string, + validator?: ValidatorHandler + ): void { + validateTriggerConstraints(className, triggerName); + + const key = makeTriggerKey(className, triggerName); + const existing = this.triggers.get(key); + if (existing && existing.source !== source) { + throw new Error( + `Trigger "${triggerName}" on "${className}" is already registered by source "${existing.source}". Cannot register from "${source}".` + ); + } + this.triggers.set(key, { handler, source, validator }); + } + + getTrigger(className: string, triggerName: string): TriggerEntry | null { + return this.triggers.get(makeTriggerKey(className, triggerName)) ?? null; + } + + triggerExists(className: string, triggerName: string): boolean { + return this.triggers.has(makeTriggerKey(className, triggerName)); + } + + removeTrigger(className: string, triggerName: string): void { + this.triggers.delete(makeTriggerKey(className, triggerName)); + } + + // ─── Job Registration ────────────────────────────────────────────────────── + + defineJob(name: string, handler: CloudJobHandler, source: string): void { + const existing = this.jobs.get(name); + if (existing && existing.source !== source) { + throw new Error( + `Cloud job "${name}" is already registered by source "${existing.source}". Cannot register from "${source}".` + ); + } + this.jobs.set(name, { handler, source }); + } + + getJob(name: string): JobEntry | null { + return this.jobs.get(name) ?? null; + } + + getJobs(): ReadonlyMap { + return this.jobs; + } + + getJobsObject(): Record { + const result: Record = {}; + for (const [name, entry] of this.jobs) { + result[name] = entry.handler; + } + return result; + } + + // ─── Live Query Handlers ─────────────────────────────────────────────────── + + defineLiveQueryHandler(handler: LiveQueryHandler, source: string): void { + this.liveQueryHandlers.push({ handler, source }); + } + + runLiveQueryEventHandlers(data: unknown): void { + for (const entry of this.liveQueryHandlers) { + try { + entry.handler(data); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`LiveQuery handler from "${entry.source}" threw:`, error); + } + } + } + + // ─── Removal ────────────────────────────────────────────────────────────── + + unregisterAll(source: string): void { + for (const [name, entry] of this.functions) { + if (entry.source === source) { + this.functions.delete(name); + } + } + + for (const [key, entry] of this.triggers) { + if (entry.source === source) { + this.triggers.delete(key); + } + } + + for (const [name, entry] of this.jobs) { + if (entry.source === source) { + this.jobs.delete(name); + } + } + + const handlersToRemove = this.liveQueryHandlers.filter(e => e.source === source); + for (const entry of handlersToRemove) { + const idx = this.liveQueryHandlers.indexOf(entry); + if (idx !== -1) { + this.liveQueryHandlers.splice(idx, 1); + } + } + } + + clearAll(): void { + this.functions.clear(); + this.triggers.clear(); + this.jobs.clear(); + this.liveQueryHandlers.length = 0; + } + + // ─── Registry Factory ────────────────────────────────────────────────────── + + createRegistry(source: string): CloudCodeRegistry { + const manager = this; + return { + defineFunction(name: string, handler: CloudFunctionHandler, validator?: ValidatorHandler): void { + manager.defineFunction(name, handler, source, validator); + }, + defineTrigger( + className: string, + triggerName: TriggerName, + handler: CloudTriggerHandler, + validator?: ValidatorHandler + ): void { + manager.defineTrigger(className, triggerName, handler, source, validator); + }, + defineJob(name: string, handler: CloudJobHandler): void { + manager.defineJob(name, handler, source); + }, + defineLiveQueryHandler(handler: LiveQueryHandler): void { + manager.defineLiveQueryHandler(handler, source); + }, + }; + } + + // ─── Lifecycle ───────────────────────────────────────────────────────────── + + async initialize(adapters: CloudCodeAdapter[], config: ParseServerConfig): Promise { + const seen = new Set(); + for (const adapter of adapters) { + if (seen.has(adapter.name)) { + throw new Error( + `Duplicate adapter name "${adapter.name}". Each adapter must have a unique name.` + ); + } + seen.add(adapter.name); + } + + for (const adapter of adapters) { + const registry = this.createRegistry(adapter.name); + try { + await adapter.initialize(registry, config); + } catch (error) { + // Roll back any partial registrations from this adapter + this.unregisterAll(adapter.name); + // Attempt graceful shutdown of the failed adapter + try { + await adapter.shutdown(); + } catch { + // Ignore shutdown errors during initialization rollback + } + // Roll back all previously-initialized adapters + for (const prev of [...this.adapters].reverse()) { + this.unregisterAll(prev.name); + try { + await prev.shutdown(); + } catch { + // Ignore shutdown errors during rollback + } + } + this.adapters.length = 0; + throw error; + } + this.adapters.push(adapter); + } + } + + async shutdown(): Promise { + const errors: Array<{ name: string; error: unknown }> = []; + for (const adapter of this.adapters) { + try { + await adapter.shutdown(); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Error shutting down adapter "${adapter.name}":`, error); + errors.push({ name: adapter.name, error }); + } + } + // Clear all manager state regardless of individual shutdown failures + this.adapters.length = 0; + this.clearAll(); + } + + async healthCheck(): Promise { + const results = await Promise.allSettled( + this.adapters.map(adapter => adapter.isHealthy()) + ); + return results.every(r => r.status === 'fulfilled' && r.value === true); + } +} diff --git a/src/cloud-code/README.md b/src/cloud-code/README.md new file mode 100644 index 0000000000..36ff4572ba --- /dev/null +++ b/src/cloud-code/README.md @@ -0,0 +1,396 @@ +# Cloud Code Adapters + +Parse Server supports pluggable cloud code adapters. You can write cloud code in any language, use any SDK, or bring your own adapter implementation. + +## Quick Start + +### Option A: In-Process (TypeScript/JavaScript) + +Pass any object with a `getRouter()` method as the `cloud` option. Any SDK that implements the router interface works — see [Building a Custom In-Process SDK](#building-a-custom-in-process-sdk) below. + +```typescript +import ParseServer from 'parse-server'; + +const cloud = createMyCloudCode(); // any object with getRouter() + +new ParseServer({ + databaseURI: 'mongodb://localhost:27017/myapp', + appId: 'myapp', + masterKey: 'secret', + cloud: cloud, // detected via getRouter() +}); +``` + +### Option B: External Process (Any Language) + +Spawn a separate process that speaks the ParseCloud/1.0 HTTP protocol: + +```javascript +new ParseServer({ + databaseURI: 'mongodb://localhost:27017/myapp', + appId: 'myapp', + masterKey: 'secret', + cloudCodeCommand: 'swift run CloudCode', + webhookKey: 'your-secret-key', +}); +``` + +The process receives config via environment variables and communicates via HTTP webhooks. + +### Option C: Custom Adapter + +Pass any object implementing the `CloudCodeAdapter` interface: + +```typescript +new ParseServer({ + appId: 'myapp', + masterKey: 'secret', + cloudCodeAdapters: [myCustomAdapter], +}); +``` + +All three options compose — you can use them simultaneously. Hook conflicts (same function/trigger registered by multiple adapters) throw at startup. + +--- + +## Building a Custom In-Process SDK + +To build a JavaScript/TypeScript cloud code SDK that integrates with Parse Server in-process, your library needs to expose a router with three methods. + +### Required Interface + +```typescript +interface InProcessCloudCode { + getRouter(): { + /** Return all registered hooks */ + getManifest(): { + protocol: string; // e.g. "ParseCloud/1.0" + hooks: { + functions: Array<{ name: string }>; + triggers: Array<{ className: string; triggerName: string }>; + jobs: Array<{ name: string }>; + }; + }; + + /** Dispatch a cloud function call */ + dispatchFunction( + name: string, + body: Record + ): Promise<{ success: unknown } | { error: { code: number; message: string } }>; + + /** Dispatch a trigger */ + dispatchTrigger( + className: string, + triggerName: string, + body: Record + ): Promise<{ success: unknown } | { error: { code: number; message: string } }>; + + /** Dispatch a job */ + dispatchJob( + name: string, + body: Record + ): Promise<{ success: unknown } | { error: { code: number; message: string } }>; + }; +} +``` + +Parse Server detects your object via duck typing: if `cloud` has a `getRouter()` method, it's treated as an `InProcessCloudCode` instance. + +### How It Works + +1. Parse Server calls `cloud.getRouter().getManifest()` at startup +2. For each hook in the manifest, a bridge handler is registered +3. When a request comes in, Parse Server serializes it to a webhook body and calls `dispatchFunction`/`dispatchTrigger`/`dispatchJob` +4. Your SDK processes the request and returns `{ success: result }` or `{ error: { code, message } }` + +### Webhook Body Format + +The body passed to `dispatch*` methods contains: + +```typescript +{ + master: boolean, // Was master key used? + ip: string, // Client IP + headers: object, // HTTP headers + installationId: string, // Client installation ID + user?: object, // Authenticated user (JSON) + params?: object, // Function/job parameters + + // Trigger-specific: + object?: object, // The object being saved/deleted (JSON) + original?: object, // Original object before changes (JSON) + context?: object, // Custom context passed between triggers + + // Query triggers: + query?: { + className: string, + where: object, + limit: number, + skip: number, + include: string, + keys: string, + order: string, + }, + + // File triggers: + file?: object, + fileSize?: number, + + // Job-specific: + jobId?: string, +} +``` + +### Response Format + +All dispatch methods return one of: + +```typescript +// Success +{ success: } + +// Error — thrown as Parse.Error on the server side +{ error: { code: number, message: string } } +``` + +For `beforeSave` triggers specifically: +- `{ success: {} }` (empty object) means "accept the original, no changes" +- `{ success: { field: value, ... } }` means "apply these field changes" + +### Minimal Example + +A bare-bones SDK in ~40 lines: + +```typescript +class MyCloudSDK { + private functions = new Map Promise>(); + + define(name: string, handler: (body: any) => Promise) { + this.functions.set(name, handler); + return this; + } + + getRouter() { + const functions = this.functions; + return { + getManifest() { + return { + protocol: 'MySDK/1.0', + hooks: { + functions: Array.from(functions.keys()).map(name => ({ name })), + triggers: [], + jobs: [], + }, + }; + }, + async dispatchFunction(name: string, body: Record) { + const handler = functions.get(name); + if (!handler) return { error: { code: 141, message: `Unknown function: ${name}` } }; + try { + const result = await handler(body); + return { success: result }; + } catch (e: any) { + return { error: { code: e.code || 141, message: e.message } }; + } + }, + async dispatchTrigger() { return { success: {} }; }, + async dispatchJob() { return { success: null }; }, + }; + } +} + +// Usage: +const cloud = new MyCloudSDK(); +cloud.define('hello', async (body) => `Hello, ${body.params.name}!`); + +new ParseServer({ cloud: cloud, ... }); +``` + +--- + +## Building an External Process SDK (Any Language) + +To build a cloud code SDK in Swift, C#, Go, Python, or any language, your process needs to: + +1. Start an HTTP server +2. Print `PARSE_CLOUD_READY:` to stdout +3. Serve a manifest at `GET /` +4. Handle webhook requests at `POST /functions/:name`, `POST /triggers/:className/:triggerName`, `POST /jobs/:name` +5. Respond to health checks at `GET /health` + +### Environment Variables + +Parse Server passes these to your process: + +| Variable | Description | +|----------|-------------| +| `PARSE_SERVER_URL` | Parse Server URL (e.g. `http://localhost:1337/parse`) | +| `PARSE_APPLICATION_ID` | App ID | +| `PARSE_MASTER_KEY` | Master key | +| `PARSE_WEBHOOK_KEY` | Key for authenticating requests (check `X-Parse-Webhook-Key` header) | +| `PARSE_CLOUD_PORT` | Suggested port (`0` = OS-assigned) | + +### Protocol + +**Startup:** Print `PARSE_CLOUD_READY:` to stdout once your HTTP server is listening. + +**Manifest** (`GET /`): + +```json +{ + "protocol": "ParseCloud/1.0", + "hooks": { + "functions": [{ "name": "hello" }], + "triggers": [{ "className": "Todo", "triggerName": "beforeSave" }], + "jobs": [{ "name": "cleanup" }] + } +} +``` + +**Webhook requests** (`POST /functions/:name`, etc.): + +- Request body: same webhook body format described above +- Request header: `X-Parse-Webhook-Key` must match your `PARSE_WEBHOOK_KEY` +- Response: `{ "success": }` or `{ "error": { "code": 142, "message": "..." } }` + +**Health check** (`GET /health`): Return `200 OK`. + +**Shutdown:** Parse Server sends `SIGTERM`. Clean up and exit. After the configured timeout (default 5s), `SIGKILL` is sent. + +### Trigger Names + +| Trigger | className | +|---------|-----------| +| `beforeSave`, `afterSave`, `beforeDelete`, `afterDelete`, `beforeFind`, `afterFind` | Any class name (e.g. `Todo`, `_User`) | +| `beforeSave`, `afterSave`, `beforeDelete`, `afterDelete` on files | `@File` | +| `beforeSave`, `afterSave` on config | `@Config` | +| `beforeLogin`, `afterLogin`, `beforePasswordResetRequest` | `_User` | +| `afterLogout` | `_Session` | +| `beforeConnect` | `@Connect` | +| `beforeSubscribe`, `afterEvent` | Any class name | + +### Example: Go + +```go +package main + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "os" +) + +func main() { + mux := http.NewServeMux() + + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "protocol": "ParseCloud/1.0", + "hooks": map[string]any{ + "functions": []map[string]string{{"name": "hello"}}, + "triggers": []any{}, + "jobs": []any{}, + }, + }) + }) + + mux.HandleFunc("POST /functions/hello", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Parse-Webhook-Key") != os.Getenv("PARSE_WEBHOOK_KEY") { + http.Error(w, "Unauthorized", 401) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + params, _ := body["params"].(map[string]any) + name, _ := params["name"].(string) + json.NewEncoder(w).Encode(map[string]any{ + "success": fmt.Sprintf("Hello, %s!", name), + }) + }) + + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) + }) + + listener, _ := net.Listen("tcp", ":0") + port := listener.Addr().(*net.TCPAddr).Port + fmt.Printf("PARSE_CLOUD_READY:%d\n", port) + http.Serve(listener, mux) +} +``` + +```javascript +// parse-server config +new ParseServer({ + cloudCodeCommand: 'go run ./cloud-code', + webhookKey: 'my-secret-key', + // ... +}); +``` + +--- + +## Building a Fully Custom Adapter + +For complete control, implement the `CloudCodeAdapter` interface directly: + +```typescript +import type { CloudCodeAdapter, CloudCodeRegistry, ParseServerConfig } from 'parse-server/cloud-code/types'; + +class MyAdapter implements CloudCodeAdapter { + readonly name = 'my-adapter'; + + async initialize(registry: CloudCodeRegistry, config: ParseServerConfig): Promise { + // Register hooks using the registry + registry.defineFunction('myFunction', async (request) => { + return { result: 'hello' }; + }); + + registry.defineTrigger('Todo', 'beforeSave', async (request) => { + // request.object, request.user, etc. are Parse Server internal objects + // Return value or throw to reject + }); + + registry.defineJob('myJob', async (request) => { + // Long-running work + }); + + registry.defineLiveQueryHandler((data) => { + // Handle live query events + }); + } + + async isHealthy(): Promise { + return true; + } + + async shutdown(): Promise { + // Clean up resources + } +} + +// Usage: +new ParseServer({ + cloudCodeAdapters: [new MyAdapter()], + // ... +}); +``` + +### Registry API + +| Method | Description | +|--------|-------------| +| `defineFunction(name, handler, validator?)` | Register a cloud function | +| `defineTrigger(className, triggerName, handler, validator?)` | Register a trigger | +| `defineJob(name, handler)` | Register a background job | +| `defineLiveQueryHandler(handler)` | Register a live query event handler | + +### Notes + +- The `handler` receives Parse Server's internal request object (with `Parse.Object` instances, etc.) +- This is a lower-level API than the InProcess router interface — you work with Parse Server internals directly +- Validators (optional) support `{ requireUser: true, requireMaster: true, fields: {...}, rateLimit: {...} }` — same as `Parse.Cloud.define` validators +- Multiple custom adapters can coexist — each gets a unique source name from `adapter.name` +- Hook conflicts between adapters throw at startup diff --git a/src/cloud-code/adapters/ExternalProcessAdapter.ts b/src/cloud-code/adapters/ExternalProcessAdapter.ts new file mode 100644 index 0000000000..ad74518b8a --- /dev/null +++ b/src/cloud-code/adapters/ExternalProcessAdapter.ts @@ -0,0 +1,244 @@ +// src/cloud-code/adapters/ExternalProcessAdapter.ts +import { spawn, ChildProcess } from 'child_process'; +import * as http from 'http'; +import type { + CloudCodeAdapter, + CloudCodeRegistry, + ParseServerConfig, + CloudManifest, + CloudCodeOptions, + WebhookResponse, +} from '../types'; +import { requestToWebhookBody, webhookResponseToResult, applyBeforeSaveResponse } from './webhook-bridge'; + +const DEFAULT_OPTIONS: Required = { + startupTimeout: 30000, + healthCheckInterval: 30000, + shutdownTimeout: 5000, + maxRestartDelay: 30000, +}; + +const HTTP_TIMEOUT = 10000; + +function httpGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + if (res.statusCode === undefined || res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + reject(new Error(`HTTP GET ${url} returned status ${res.statusCode}`)); + return; + } + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => resolve(data)); + }); + req.setTimeout(HTTP_TIMEOUT, () => { + req.destroy(new Error(`HTTP GET ${url} timed out after ${HTTP_TIMEOUT}ms`)); + }); + req.on('error', reject); + }); +} + +function httpPost(url: string, body: Record, webhookKey: string): Promise { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const urlObj = new URL(url); + const req = http.request({ + hostname: urlObj.hostname, + port: urlObj.port, + path: urlObj.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + 'X-Parse-Webhook-Key': webhookKey, + }, + }, (res) => { + if (res.statusCode === undefined || res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + reject(new Error(`HTTP POST ${url} returned status ${res.statusCode}`)); + return; + } + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(new Error(`Invalid JSON from cloud code process: ${data}`)); + } + }); + }); + req.setTimeout(HTTP_TIMEOUT, () => { + req.destroy(new Error(`HTTP POST ${url} timed out after ${HTTP_TIMEOUT}ms`)); + }); + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +export class ExternalProcessAdapter implements CloudCodeAdapter { + readonly name: string; + private command: string; + private webhookKey: string; + private options: Required; + private process: ChildProcess | null = null; + private port: number = 0; + private healthInterval: ReturnType | null = null; + + constructor(command: string, webhookKey: string, options?: CloudCodeOptions, name?: string) { + if (!webhookKey) { + throw new Error('webhookKey is required for ExternalProcessAdapter'); + } + this.command = command; + this.webhookKey = webhookKey; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.name = name || `external-process-${webhookKey.slice(0, 8)}`; + } + + async initialize(registry: CloudCodeRegistry, config: ParseServerConfig): Promise { + this.port = await this.spawnAndWaitForReady(config); + try { + const manifest = await this.fetchManifest(); + this.registerFromManifest(registry, manifest); + } catch (err) { + await this.shutdown(); + throw err; + } + + if (this.options.healthCheckInterval > 0) { + this.healthInterval = setInterval(() => this.checkHealth(), this.options.healthCheckInterval); + } + } + + async isHealthy(): Promise { + try { + const response = await httpGet(`http://localhost:${this.port}/health`); + const trimmed = response.trim(); + return trimmed === 'OK' || trimmed === 'ok'; + } catch { + return false; + } + } + + async shutdown(): Promise { + if (this.healthInterval) { + clearInterval(this.healthInterval); + this.healthInterval = null; + } + if (this.process && !this.process.killed) { + this.process.kill('SIGTERM'); + await Promise.race([ + new Promise((resolve) => this.process!.once('exit', () => resolve())), + new Promise((resolve) => setTimeout(() => { + if (this.process && !this.process.killed) { + this.process.kill('SIGKILL'); + } + resolve(); + }, this.options.shutdownTimeout)), + ]); + } + this.process = null; + } + + private spawnAndWaitForReady(config: ParseServerConfig): Promise { + return new Promise((resolve, reject) => { + const child = spawn(this.command, { + shell: true, + env: { + ...process.env, + PARSE_SERVER_URL: config.serverURL, + PARSE_APPLICATION_ID: config.appId, + PARSE_MASTER_KEY: config.masterKey, + PARSE_WEBHOOK_KEY: this.webhookKey, + PARSE_CLOUD_PORT: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + this.process = child; + + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`Cloud code process did not emit PARSE_CLOUD_READY within ${this.options.startupTimeout}ms`)); + }, this.options.startupTimeout); + + let stdout = ''; + child.stdout!.on('data', (data) => { + stdout += data.toString(); + const match = stdout.match(/PARSE_CLOUD_READY:(\d+)/); + if (match) { + clearTimeout(timeout); + resolve(parseInt(match[1], 10)); + } + }); + + child.stderr!.on('data', (data) => { + process.stderr.write(`[cloud-code] ${data}`); + }); + + child.on('error', (err) => { + clearTimeout(timeout); + reject(new Error(`Failed to spawn cloud code process: ${err.message}`)); + }); + + child.on('exit', (code) => { + clearTimeout(timeout); + if (!this.port) { + reject(new Error(`Cloud code process exited with code ${code} before becoming ready`)); + } + }); + }); + } + + private async fetchManifest(): Promise { + const data = await httpGet(`http://localhost:${this.port}/`); + return JSON.parse(data); + } + + private registerFromManifest(registry: CloudCodeRegistry, manifest: CloudManifest): void { + for (const fn of manifest.hooks.functions) { + registry.defineFunction(fn.name, async (request) => { + const body = requestToWebhookBody(request); + const response = await httpPost(`http://localhost:${this.port}/functions/${fn.name}`, body, this.webhookKey); + return webhookResponseToResult(response); + }); + } + + for (const trigger of manifest.hooks.triggers) { + const { className, triggerName } = trigger; + registry.defineTrigger(className, triggerName as any, async (request) => { + const body = requestToWebhookBody(request); + const response = await httpPost( + `http://localhost:${this.port}/triggers/${className}/${triggerName}`, + body, + this.webhookKey + ); + if (triggerName === 'beforeSave') { + if (request.file || className === 'File') { + return webhookResponseToResult(response); + } + applyBeforeSaveResponse(request, response); + return; + } + return webhookResponseToResult(response); + }); + } + + for (const job of manifest.hooks.jobs) { + registry.defineJob(job.name, async (request) => { + const body = requestToWebhookBody(request); + const response = await httpPost(`http://localhost:${this.port}/jobs/${job.name}`, body, this.webhookKey); + return webhookResponseToResult(response); + }); + } + } + + private async checkHealth(): Promise { + const healthy = await this.isHealthy(); + if (!healthy) { + console.warn('[cloud-code] External process health check failed'); + } + } +} diff --git a/src/cloud-code/adapters/InProcessAdapter.ts b/src/cloud-code/adapters/InProcessAdapter.ts new file mode 100644 index 0000000000..cccf6a94e1 --- /dev/null +++ b/src/cloud-code/adapters/InProcessAdapter.ts @@ -0,0 +1,63 @@ +import type { + CloudCodeAdapter, + CloudCodeRegistry, + ParseServerConfig, + InProcessCloudCode, +} from '../types'; +import { requestToWebhookBody, webhookResponseToResult, applyBeforeSaveResponse } from './webhook-bridge'; + +export class InProcessAdapter implements CloudCodeAdapter { + readonly name = 'in-process'; + private cloudCode: InProcessCloudCode; + + constructor(cloudCode: InProcessCloudCode) { + this.cloudCode = cloudCode; + } + + async initialize(registry: CloudCodeRegistry, _config: ParseServerConfig): Promise { + const router = this.cloudCode.getRouter(); + const manifest = router.getManifest(); + + for (const fn of manifest.hooks.functions) { + registry.defineFunction(fn.name, async (request) => { + const body = requestToWebhookBody(request); + const response = await router.dispatchFunction(fn.name, body); + return webhookResponseToResult(response); + }); + } + + for (const trigger of manifest.hooks.triggers) { + const { className, triggerName } = trigger; + registry.defineTrigger(className, triggerName as any, async (request) => { + const body = requestToWebhookBody(request); + const response = await router.dispatchTrigger(className, triggerName, body); + if (triggerName === 'beforeSave') { + if (request.object) { + applyBeforeSaveResponse(request, response); + return; + } + const result = webhookResponseToResult(response); + if (result && typeof result === 'object' && Object.keys(result).length === 0) { + return; + } + return result; + } + return webhookResponseToResult(response); + }); + } + + for (const job of manifest.hooks.jobs) { + registry.defineJob(job.name, async (request) => { + const body = requestToWebhookBody(request); + const response = await router.dispatchJob(job.name, body); + return webhookResponseToResult(response); + }); + } + } + + async isHealthy(): Promise { + return true; + } + + async shutdown(): Promise {} +} diff --git a/src/cloud-code/adapters/LegacyAdapter.ts b/src/cloud-code/adapters/LegacyAdapter.ts new file mode 100644 index 0000000000..b5de23f39c --- /dev/null +++ b/src/cloud-code/adapters/LegacyAdapter.ts @@ -0,0 +1,43 @@ +// src/cloud-code/adapters/LegacyAdapter.ts + +import type { CloudCodeAdapter, CloudCodeRegistry, ParseServerConfig } from '../types'; + +export class LegacyAdapter implements CloudCodeAdapter { + readonly name = 'legacy'; + private cloud: string | ((parse: any) => void); + + constructor(cloud: string | ((parse: any) => void)) { + this.cloud = cloud; + } + + async initialize(_registry: CloudCodeRegistry, _config: ParseServerConfig): Promise { + // The registry is not used directly by LegacyAdapter. + // Instead, the cloud code file calls Parse.Cloud.define() etc., + // which calls triggers.addFunction() etc., + // which the facade delegates to CloudCodeManager. + const Parse = require('parse/node').Parse; + + if (typeof this.cloud === 'function') { + await Promise.resolve(this.cloud(Parse)); + } else if (typeof this.cloud === 'string') { + const path = require('path'); + const url = require('url'); + const resolved = path.resolve(process.cwd(), this.cloud); + try { + require(resolved); + } catch (err: any) { + if (err?.code === 'ERR_REQUIRE_ESM') { + await import(url.pathToFileURL(resolved).href); + } else { + throw err; + } + } + } + } + + async isHealthy(): Promise { + return true; + } + + async shutdown(): Promise {} +} diff --git a/src/cloud-code/adapters/webhook-bridge.ts b/src/cloud-code/adapters/webhook-bridge.ts new file mode 100644 index 0000000000..20b819a21b --- /dev/null +++ b/src/cloud-code/adapters/webhook-bridge.ts @@ -0,0 +1,70 @@ +import Parse from 'parse/node'; +import type { WebhookResponse } from '../types'; + +export function requestToWebhookBody(request: any): Record { + const body: Record = { + master: request.master ?? false, + ip: request.ip ?? '', + headers: request.headers ?? {}, + installationId: request.installationId, + }; + + if (request.user) { + body.user = typeof request.user.toJSON === 'function' ? request.user.toJSON() : request.user; + } + if (request.params !== undefined) body.params = request.params; + if (request.jobId !== undefined) body.jobId = request.jobId; + if (request.object) { + body.object = typeof request.object.toJSON === 'function' ? request.object.toJSON() : request.object; + } + if (request.original) { + body.original = typeof request.original.toJSON === 'function' ? request.original.toJSON() : request.original; + } + if (request.context !== undefined) body.context = request.context; + if (request.query) { + body.query = { + className: request.query.className, + where: request.query._where, + limit: request.query._limit, + skip: request.query._skip, + include: request.query._include?.join(','), + keys: request.query._keys?.join(','), + order: request.query._order, + }; + } + if (request.count !== undefined) body.count = request.count; + if (request.isGet !== undefined) body.isGet = request.isGet; + if (request.file) body.file = request.file; + if (request.fileSize !== undefined) body.fileSize = request.fileSize; + if (request.event) body.event = request.event; + if (request.requestId !== undefined) body.requestId = request.requestId; + if (request.clients !== undefined) body.clients = request.clients; + if (request.subscriptions !== undefined) body.subscriptions = request.subscriptions; + + return body; +} + +export function webhookResponseToResult(response: WebhookResponse): unknown { + if ('error' in response) { + throw new Parse.Error(response.error.code, response.error.message); + } + return response.success; +} + +export function applyBeforeSaveResponse(request: any, response: WebhookResponse): void { + if ('error' in response) { + throw new Parse.Error(response.error.code, response.error.message); + } + const result = response.success; + if (typeof result === 'object' && result !== null && Object.keys(result).length === 0) { + return; + } + if (typeof result === 'object' && result !== null) { + const skipFields = ['objectId', 'createdAt', 'updatedAt', 'className']; + for (const [key, value] of Object.entries(result)) { + if (!skipFields.includes(key)) { + request.object.set(key, value); + } + } + } +} diff --git a/src/cloud-code/resolveAdapters.ts b/src/cloud-code/resolveAdapters.ts new file mode 100644 index 0000000000..34dc18ea7e --- /dev/null +++ b/src/cloud-code/resolveAdapters.ts @@ -0,0 +1,35 @@ +import { LegacyAdapter } from './adapters/LegacyAdapter'; +import { InProcessAdapter } from './adapters/InProcessAdapter'; +import { ExternalProcessAdapter } from './adapters/ExternalProcessAdapter'; +import type { CloudCodeAdapter } from './types'; + +export function resolveAdapters(options: any): CloudCodeAdapter[] { + const adapters: CloudCodeAdapter[] = []; + + if (options.cloudCodeAdapters) { + adapters.push(...options.cloudCodeAdapters); + } + + if (options.cloud) { + if (typeof options.cloud === 'object' && typeof options.cloud.getRouter === 'function') { + adapters.push(new InProcessAdapter(options.cloud)); + } else if (typeof options.cloud === 'string' || typeof options.cloud === 'function') { + adapters.push(new LegacyAdapter(options.cloud)); + } else { + throw new Error("argument 'cloud' must either be a string or a function"); + } + } + + if (options.cloudCodeCommand) { + if (!options.webhookKey) { + throw new Error('webhookKey is required when using cloudCodeCommand'); + } + adapters.push(new ExternalProcessAdapter( + options.cloudCodeCommand, + options.webhookKey, + options.cloudCodeOptions + )); + } + + return adapters; +} diff --git a/src/cloud-code/types.ts b/src/cloud-code/types.ts new file mode 100644 index 0000000000..01adc84008 --- /dev/null +++ b/src/cloud-code/types.ts @@ -0,0 +1,105 @@ +// src/cloud-code/types.ts + +export const TriggerTypes = Object.freeze({ + beforeLogin: 'beforeLogin', + afterLogin: 'afterLogin', + afterLogout: 'afterLogout', + beforePasswordResetRequest: 'beforePasswordResetRequest', + beforeSave: 'beforeSave', + afterSave: 'afterSave', + beforeDelete: 'beforeDelete', + afterDelete: 'afterDelete', + beforeFind: 'beforeFind', + afterFind: 'afterFind', + beforeConnect: 'beforeConnect', + beforeSubscribe: 'beforeSubscribe', + afterEvent: 'afterEvent', +}); + +export type TriggerName = keyof typeof TriggerTypes; + +export type CloudFunctionHandler = (request: any) => any; +export type CloudTriggerHandler = (request: any) => any; +export type CloudJobHandler = (request: any) => any; +export type LiveQueryHandler = (data: any) => void; +export type ValidatorHandler = Record | ((request: any) => any); + +export interface FunctionEntry { + handler: CloudFunctionHandler; + source: string; + validator?: ValidatorHandler; +} + +export interface TriggerEntry { + handler: CloudTriggerHandler; + source: string; + validator?: ValidatorHandler; +} + +export interface JobEntry { + handler: CloudJobHandler; + source: string; +} + +export interface LiveQueryEntry { + handler: LiveQueryHandler; + source: string; +} + +export interface HookStore { + functions: Map; + triggers: Map; + jobs: Map; + liveQueryHandlers: LiveQueryEntry[]; +} + +export interface ParseServerConfig { + appId: string; + masterKey: string; + serverURL: string; +} + +export interface CloudCodeRegistry { + defineFunction(name: string, handler: CloudFunctionHandler, validator?: ValidatorHandler): void; + defineTrigger(className: string, triggerName: TriggerName, handler: CloudTriggerHandler, validator?: ValidatorHandler): void; + defineJob(name: string, handler: CloudJobHandler): void; + defineLiveQueryHandler(handler: LiveQueryHandler): void; +} + +export interface CloudCodeAdapter { + readonly name: string; + initialize(registry: CloudCodeRegistry, config: ParseServerConfig): Promise; + isHealthy(): Promise; + shutdown(): Promise; +} + +export interface CloudManifest { + protocol: string; + hooks: { + functions: Array<{ name: string }>; + triggers: Array<{ className: string; triggerName: string }>; + jobs: Array<{ name: string }>; + }; +} + +export type WebhookResponse = + | { success: unknown } + | { error: { code: number; message: string } }; + +export interface CloudRouter { + getManifest(): CloudManifest; + dispatchFunction(name: string, body: Record): Promise; + dispatchTrigger(className: string, triggerName: string, body: Record): Promise; + dispatchJob(name: string, body: Record): Promise; +} + +export interface InProcessCloudCode { + getRouter(): CloudRouter; +} + +export interface CloudCodeOptions { + startupTimeout?: number; + healthCheckInterval?: number; + shutdownTimeout?: number; + maxRestartDelay?: number; +} diff --git a/src/triggers.js b/src/triggers.js index 963382a007..e8cc9b43d9 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1,8 +1,14 @@ // triggers.js import Parse from 'parse/node'; import { logger } from './logger'; +import AppCache from './cache'; import Utils from './Utils'; +function getManager(applicationId) { + const cached = AppCache.get(applicationId || Parse.applicationId); + return cached && cached.cloudCodeManager; +} + export const Types = { beforeLogin: 'beforeLogin', afterLogin: 'afterLogin', @@ -148,41 +154,89 @@ function get(category, name, applicationId) { } export function addFunction(functionName, handler, validationHandler, applicationId) { + const manager = getManager(applicationId); + if (manager) { + manager.defineFunction(functionName, handler, 'legacy', validationHandler); + return; + } add(Category.Functions, functionName, handler, applicationId); add(Category.Validators, functionName, validationHandler, applicationId); } export function addJob(jobName, handler, applicationId) { + const manager = getManager(applicationId); + if (manager) { + manager.defineJob(jobName, handler, 'legacy'); + return; + } add(Category.Jobs, jobName, handler, applicationId); } export function addTrigger(type, className, handler, applicationId, validationHandler) { + const manager = getManager(applicationId); + if (manager) { + manager.defineTrigger(className, type, handler, 'legacy', validationHandler); + return; + } validateClassNameForTriggers(className, type); add(Category.Triggers, `${type}.${className}`, handler, applicationId); add(Category.Validators, `${type}.${className}`, validationHandler, applicationId); } export function addConnectTrigger(type, handler, applicationId, validationHandler) { + const manager = getManager(applicationId); + if (manager) { + manager.defineTrigger(ConnectClassName, type, handler, 'legacy', validationHandler); + return; + } add(Category.Triggers, `${type}.${ConnectClassName}`, handler, applicationId); add(Category.Validators, `${type}.${ConnectClassName}`, validationHandler, applicationId); } export function addLiveQueryEventHandler(handler, applicationId) { + const manager = getManager(applicationId); + if (manager) { + manager.defineLiveQueryHandler(handler, 'legacy'); + return; + } applicationId = applicationId || Parse.applicationId; _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); _triggerStore[applicationId].LiveQuery.push(handler); } export function removeFunction(functionName, applicationId) { + const manager = getManager(applicationId); + if (manager) { + manager.removeFunction(functionName); + return; + } remove(Category.Functions, functionName, applicationId); } export function removeTrigger(type, className, applicationId) { + const manager = getManager(applicationId); + if (manager) { + manager.removeTrigger(className, type); + return; + } remove(Category.Triggers, `${type}.${className}`, applicationId); } export function _unregisterAll() { - Object.keys(_triggerStore).forEach(appId => delete _triggerStore[appId]); + // Clear managers from AppCache entries + const appCacheStore = AppCache.cache; + if (appCacheStore) { + Object.keys(appCacheStore).forEach(appId => { + const manager = getManager(appId); + if (manager) { + manager.clearAll(); + } + }); + } + // Clear legacy trigger store + Object.keys(_triggerStore).forEach(appId => { + delete _triggerStore[appId]; + }); } export function toJSONwithObjects(object, className) { @@ -213,14 +267,19 @@ export function getTrigger(className, triggerType, applicationId) { if (!applicationId) { throw 'Missing ApplicationID'; } + const manager = getManager(applicationId); + if (manager) { + const entry = manager.getTrigger(className, triggerType); + return entry ? entry.handler : undefined; + } return get(Category.Triggers, `${triggerType}.${className}`, applicationId); } -export async function runTrigger(trigger, name, request, auth) { +export async function runTrigger(trigger, name, request, auth, applicationId) { if (!trigger) { return; } - await maybeRunValidator(request, name, auth); + await maybeRunValidator(request, name, auth, applicationId); if (request.skipWithMasterKey) { return; } @@ -228,14 +287,27 @@ export async function runTrigger(trigger, name, request, auth) { } export function triggerExists(className: string, type: string, applicationId: string): boolean { + const manager = getManager(applicationId); + if (manager) { + return manager.triggerExists(className, type); + } return getTrigger(className, type, applicationId) != undefined; } export function getFunction(functionName, applicationId) { + const manager = getManager(applicationId); + if (manager) { + const entry = manager.getFunction(functionName); + return entry ? entry.handler : undefined; + } return get(Category.Functions, functionName, applicationId); } export function getFunctionNames(applicationId) { + const manager = getManager(applicationId); + if (manager) { + return manager.getFunctionNames(); + } const store = (_triggerStore[applicationId] && _triggerStore[applicationId][Category.Functions]) || {}; const functionNames = []; @@ -257,10 +329,19 @@ export function getFunctionNames(applicationId) { } export function getJob(jobName, applicationId) { + const manager = getManager(applicationId); + if (manager) { + const entry = manager.getJob(jobName); + return entry ? entry.handler : undefined; + } return get(Category.Jobs, jobName, applicationId); } export function getJobs(applicationId) { + const mgr = getManager(applicationId); + if (mgr) { + return mgr.getJobsObject(); + } var manager = _triggerStore[applicationId]; if (manager && manager.Jobs) { return manager.Jobs; @@ -269,6 +350,10 @@ export function getJobs(applicationId) { } export function getValidator(functionName, applicationId) { + const manager = getManager(applicationId); + if (manager) { + return manager.getValidator(functionName); + } return get(Category.Validators, functionName, applicationId); } @@ -718,8 +803,9 @@ export function resolveError(message, defaultOpts) { } return error; } -export function maybeRunValidator(request, functionName, auth) { - const theValidator = getValidator(functionName, Parse.applicationId); +export function maybeRunValidator(request, functionName, auth, applicationId) { + applicationId = applicationId || (request.config && request.config.applicationId) || Parse.applicationId; + const theValidator = getValidator(functionName, applicationId); if (!theValidator) { return; } @@ -1033,6 +1119,11 @@ export function inflate(data, restObject) { } export function runLiveQueryEventHandlers(data, applicationId = Parse.applicationId) { + const manager = getManager(applicationId); + if (manager) { + manager.runLiveQueryEventHandlers(data); + return; + } if (!_triggerStore || !_triggerStore[applicationId] || !_triggerStore[applicationId].LiveQuery) { return; }