|
| 1 | +# Cloud Code Adapter — Design Specification |
| 2 | + |
| 3 | +**Status:** Approved |
| 4 | +**Target:** Parse Server 10.x |
| 5 | +**Date:** 2026-03-16 |
| 6 | +**Related:** [ParseCloud/1.0 Protocol](../../../parse-lite-sdks/docs/cloud-code-protocol.md), [Adapter Proposal](../../../parse-lite-sdks/docs/cloud-code-adapter-proposal.md) |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## 1. Problem Statement |
| 11 | + |
| 12 | +Parse Server's cloud code system (`Parse.Cloud.define`, `Parse.Cloud.beforeSave`, etc.) has fundamental limitations: |
| 13 | + |
| 14 | +1. **JavaScript only** — No support for cloud code in Swift, C#, Go, or other languages. |
| 15 | +2. **Global singleton** — All cloud code shares `Parse.Cloud` namespace. No composition, difficult testing. |
| 16 | +3. **In-process only** — No supported mechanism for cloud code as a separate process or service. |
| 17 | +4. **No adapter pattern** — Hard-wired implementation with no pluggable interface. |
| 18 | +5. **Manual webhook registration** — External webhooks require manual REST API calls. |
| 19 | + |
| 20 | +## 2. Design Decisions |
| 21 | + |
| 22 | +| Decision | Choice | Rationale | |
| 23 | +|----------|--------|-----------| |
| 24 | +| Adapter composition | Multiple adapters coexist | Users can run legacy JS + external Swift + custom adapters simultaneously | |
| 25 | +| Hook conflicts | Error on conflict at startup | Fail fast, no ambiguity about which adapter handles a hook | |
| 26 | +| Hot reload | Startup-only for v1 | Simpler implementation; can be added later | |
| 27 | +| Registry API | Adapters only (no public registry) | Clean boundary, single integration point | |
| 28 | +| Implementation location | In parse-server directly | Core server functionality | |
| 29 | +| Webhook key | Explicitly configured (required) | No auto-generation, no persistence question | |
| 30 | +| Language | TypeScript | Type safety throughout | |
| 31 | +| Architecture | Replace triggers.js entirely | CloudCodeManager becomes single source of truth | |
| 32 | + |
| 33 | +## 3. Architecture |
| 34 | + |
| 35 | +### 3.1 CloudCodeManager — The New Core |
| 36 | + |
| 37 | +`CloudCodeManager` replaces `triggers.js` as the single source of truth for all hook registration, lookup, and execution. |
| 38 | + |
| 39 | +```typescript |
| 40 | +class CloudCodeManager { |
| 41 | + private adapters: Map<string, CloudCodeAdapter>; |
| 42 | + private store: HookStore; |
| 43 | + |
| 44 | + // Lifecycle |
| 45 | + async initialize(adapterConfigs: AdapterConfig[], serverConfig: ParseServerConfig): Promise<void>; |
| 46 | + async shutdown(): Promise<void>; |
| 47 | + async healthCheck(): Promise<Map<string, boolean>>; |
| 48 | + |
| 49 | + // Registration (called by adapters via CloudCodeRegistry) |
| 50 | + defineFunction(source: string, name: string, handler: CloudFunctionHandler, validator?: ValidatorHandler): void; |
| 51 | + defineTrigger(source: string, className: string, triggerName: TriggerName, handler: CloudTriggerHandler, validator?: ValidatorHandler): void; |
| 52 | + defineJob(source: string, name: string, handler: CloudJobHandler): void; |
| 53 | + unregisterAll(source: string): void; |
| 54 | + |
| 55 | + // Lookup (consumed by routers, rest of Parse Server) |
| 56 | + getFunction(name: string, applicationId: string): CloudFunctionHandler | undefined; |
| 57 | + getTrigger(className: string, triggerType: string, applicationId: string): CloudTriggerHandler | undefined; |
| 58 | + getJob(name: string, applicationId: string): CloudJobHandler | undefined; |
| 59 | + getFunctionNames(applicationId: string): string[]; |
| 60 | + getValidator(functionName: string, applicationId: string): ValidatorHandler | undefined; |
| 61 | + |
| 62 | + // Execution (replaces maybeRunTrigger, maybeRunValidator) |
| 63 | + async runTrigger(triggerType: string, auth: Auth, parseObject: ParseObject, ...): Promise<any>; |
| 64 | + async runValidator(request: any, functionName: string, auth: Auth): Promise<void>; |
| 65 | +} |
| 66 | +``` |
| 67 | + |
| 68 | +### 3.2 HookStore |
| 69 | + |
| 70 | +Typed internal structure replacing `Object.create(null)` pattern: |
| 71 | + |
| 72 | +```typescript |
| 73 | +interface HookStore { |
| 74 | + functions: Map<string, { handler: CloudFunctionHandler; source: string; validator?: ValidatorHandler }>; |
| 75 | + triggers: Map<string, { handler: CloudTriggerHandler; source: string; validator?: ValidatorHandler }>; |
| 76 | + // key format: `${triggerType}.${className}` |
| 77 | + jobs: Map<string, { handler: CloudJobHandler; source: string }>; |
| 78 | + liveQueryHandlers: Array<{ handler: LiveQueryHandler; source: string }>; |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +### 3.3 CloudCodeAdapter Interface |
| 83 | + |
| 84 | +```typescript |
| 85 | +interface CloudCodeAdapter { |
| 86 | + /** Unique identifier for this adapter instance */ |
| 87 | + readonly name: string; |
| 88 | + |
| 89 | + /** Register all hooks with the registry. Called once at startup. */ |
| 90 | + initialize(registry: CloudCodeRegistry, config: ParseServerConfig): Promise<void>; |
| 91 | + |
| 92 | + /** Return true if adapter is healthy and ready. */ |
| 93 | + isHealthy(): Promise<boolean>; |
| 94 | + |
| 95 | + /** Clean up resources. Called during Parse Server shutdown. */ |
| 96 | + shutdown(): Promise<void>; |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +### 3.4 CloudCodeRegistry |
| 101 | + |
| 102 | +Scoped per-adapter. Created by `CloudCodeManager` with the adapter's `name` bound as `source`: |
| 103 | + |
| 104 | +```typescript |
| 105 | +interface CloudCodeRegistry { |
| 106 | + defineFunction(name: string, handler: CloudFunctionHandler, validator?: ValidatorHandler): void; |
| 107 | + defineTrigger(className: string, triggerName: TriggerName, handler: CloudTriggerHandler, validator?: ValidatorHandler): void; |
| 108 | + defineJob(name: string, handler: CloudJobHandler): void; |
| 109 | +} |
| 110 | + |
| 111 | +type TriggerName = |
| 112 | + | 'beforeSave' | 'afterSave' |
| 113 | + | 'beforeDelete' | 'afterDelete' |
| 114 | + | 'beforeFind' | 'afterFind' |
| 115 | + | 'beforeLogin' | 'afterLogin' | 'afterLogout' |
| 116 | + | 'beforeConnect' | 'beforeSubscribe' | 'afterEvent' |
| 117 | + | 'beforeSaveFile' | 'afterSaveFile' |
| 118 | + | 'beforeDeleteFile' | 'afterDeleteFile'; |
| 119 | +``` |
| 120 | + |
| 121 | +## 4. Built-in Adapter Implementations |
| 122 | + |
| 123 | +### 4.1 LegacyAdapter |
| 124 | + |
| 125 | +Wraps `cloud: './main.js'` or `cloud: (parse) => {}`. Zero breaking changes. |
| 126 | + |
| 127 | +- `initialize()` temporarily patches `Parse.Cloud.*` methods to route through the registry, loads the user's cloud code file, then restores originals. |
| 128 | +- `isHealthy()` always returns `true` (in-process). |
| 129 | +- `shutdown()` is a no-op. |
| 130 | + |
| 131 | +### 4.2 InProcessAdapter |
| 132 | + |
| 133 | +Wraps `cloud: cloudInstance` where `cloudInstance` has a `getRouter()` method (duck-typed). |
| 134 | + |
| 135 | +- `initialize()` calls `getRouter().getManifest()`, creates bridge handlers for each hook that convert Parse Server requests to webhook body format and call `dispatchFunction`/`dispatchTrigger`/`dispatchJob`. |
| 136 | +- `isHealthy()` always returns `true` (in-process). |
| 137 | +- `shutdown()` is a no-op. |
| 138 | + |
| 139 | +**Duck-typed interface:** |
| 140 | + |
| 141 | +```typescript |
| 142 | +interface InProcessCloudCode { |
| 143 | + getRouter(): { |
| 144 | + getManifest(): CloudManifest; |
| 145 | + dispatchFunction(name: string, body: Record<string, unknown>): Promise<WebhookResponse>; |
| 146 | + dispatchTrigger(className: string, triggerName: string, body: Record<string, unknown>): Promise<WebhookResponse>; |
| 147 | + dispatchJob(name: string, body: Record<string, unknown>): Promise<WebhookResponse>; |
| 148 | + }; |
| 149 | +} |
| 150 | + |
| 151 | +interface CloudManifest { |
| 152 | + protocol: string; |
| 153 | + hooks: { |
| 154 | + functions: Array<{ name: string }>; |
| 155 | + triggers: Array<{ className: string; triggerName: string }>; |
| 156 | + jobs: Array<{ name: string }>; |
| 157 | + }; |
| 158 | +} |
| 159 | + |
| 160 | +type WebhookResponse = |
| 161 | + | { success: unknown } |
| 162 | + | { error: { code: number; message: string } }; |
| 163 | +``` |
| 164 | + |
| 165 | +### 4.3 ExternalProcessAdapter |
| 166 | + |
| 167 | +Wraps `cloudCodeCommand: 'swift run CloudCode'`. |
| 168 | + |
| 169 | +- `initialize()` spawns child process with environment variables, waits for `PARSE_CLOUD_READY:<port>` on stdout, fetches manifest via `GET http://localhost:<port>/`, registers bridge handlers. |
| 170 | +- `isHealthy()` calls `GET http://localhost:<port>/health`. |
| 171 | +- `shutdown()` sends `SIGTERM`, waits `shutdownTimeout`, then `SIGKILL`. |
| 172 | +- Crash recovery: unregisters hooks, restarts with exponential backoff (1s, 2s, 4s, 8s, capped at `maxRestartDelay`). |
| 173 | + |
| 174 | +**Environment variables passed to child process:** |
| 175 | + |
| 176 | +| Variable | Source | |
| 177 | +|----------|--------| |
| 178 | +| `PARSE_SERVER_URL` | Parse Server's own URL | |
| 179 | +| `PARSE_APPLICATION_ID` | `appId` from config | |
| 180 | +| `PARSE_MASTER_KEY` | `masterKey` from config | |
| 181 | +| `PARSE_WEBHOOK_KEY` | `webhookKey` from config (required) | |
| 182 | +| `PARSE_CLOUD_PORT` | `0` (OS-assigned) | |
| 183 | + |
| 184 | +## 5. Configuration |
| 185 | + |
| 186 | +### 5.1 ParseServerOptions Extension |
| 187 | + |
| 188 | +```typescript |
| 189 | +interface ParseServerOptions { |
| 190 | + // Existing (unchanged, routes through LegacyAdapter): |
| 191 | + cloud?: string | ((parse: any) => void) | InProcessCloudCode; |
| 192 | + |
| 193 | + // New — external process: |
| 194 | + cloudCodeCommand?: string; |
| 195 | + webhookKey?: string; // Required when cloudCodeCommand is set |
| 196 | + cloudCodeOptions?: { |
| 197 | + startupTimeout?: number; // default 30000ms |
| 198 | + healthCheckInterval?: number; // default 30000ms |
| 199 | + shutdownTimeout?: number; // default 5000ms |
| 200 | + maxRestartDelay?: number; // default 30000ms |
| 201 | + }; |
| 202 | + |
| 203 | + // New — explicit BYO adapter(s): |
| 204 | + cloudCodeAdapters?: CloudCodeAdapter[]; |
| 205 | +} |
| 206 | +``` |
| 207 | + |
| 208 | +### 5.2 Resolution Order |
| 209 | + |
| 210 | +All sources compose. Any hook collision throws at startup. |
| 211 | + |
| 212 | +```typescript |
| 213 | +function resolveAdapters(options: ParseServerOptions): CloudCodeAdapter[] { |
| 214 | + const adapters: CloudCodeAdapter[] = []; |
| 215 | + |
| 216 | + if (options.cloudCodeAdapters) { |
| 217 | + adapters.push(...options.cloudCodeAdapters); |
| 218 | + } |
| 219 | + |
| 220 | + if (options.cloud) { |
| 221 | + if (typeof options.cloud === 'object' && typeof options.cloud.getRouter === 'function') { |
| 222 | + adapters.push(new InProcessAdapter(options.cloud)); |
| 223 | + } else { |
| 224 | + adapters.push(new LegacyAdapter(options.cloud)); |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + if (options.cloudCodeCommand) { |
| 229 | + if (!options.webhookKey) { |
| 230 | + throw new Error('webhookKey is required when using cloudCodeCommand'); |
| 231 | + } |
| 232 | + adapters.push(new ExternalProcessAdapter( |
| 233 | + options.cloudCodeCommand, |
| 234 | + options.webhookKey, |
| 235 | + options.cloudCodeOptions |
| 236 | + )); |
| 237 | + } |
| 238 | + |
| 239 | + return adapters; |
| 240 | +} |
| 241 | +``` |
| 242 | + |
| 243 | +### 5.3 Startup Sequence |
| 244 | + |
| 245 | +1. `ParseServer` constructor |
| 246 | +2. `resolveAdapters(options)` → `CloudCodeAdapter[]` |
| 247 | +3. `CloudCodeManager.initialize(adapters, config)` |
| 248 | + - For each adapter: create scoped `CloudCodeRegistry`, call `adapter.initialize(registry, config)` |
| 249 | + - Registry calls flow into `HookStore` with conflict checks |
| 250 | +4. If any conflict → throw, server does not start |
| 251 | +5. All routers use `CloudCodeManager` for lookups |
| 252 | + |
| 253 | +### 5.4 Conflict Error Format |
| 254 | + |
| 255 | +``` |
| 256 | +"Cloud code conflict: beforeSave on 'Todo' registered by both 'legacy' and 'external-process'" |
| 257 | +``` |
| 258 | + |
| 259 | +## 6. Migration Strategy — Replacing triggers.js |
| 260 | + |
| 261 | +### 6.1 Current Consumers |
| 262 | + |
| 263 | +| Consumer | triggers.js Usage | Migration | |
| 264 | +|----------|-------------------|-----------| |
| 265 | +| `Parse.Cloud.js` | `addFunction`, `addTrigger`, `addJob`, `addConnectTrigger`, `addLiveQueryEventHandler` | LegacyAdapter delegates to `CloudCodeRegistry` | |
| 266 | +| `FunctionsRouter.js` | `getFunction`, `getJob`, `getFunctionNames`, `maybeRunValidator` | Import from `CloudCodeManager` | |
| 267 | +| `CloudCodeRouter.js` | `getJob` (scheduled jobs) | Import from `CloudCodeManager` | |
| 268 | +| `RestWrite.js` | `getTrigger`, `maybeRunTrigger`, `getRequestObject` | Import from `CloudCodeManager` | |
| 269 | +| `RestQuery.js` | `getTrigger`, `maybeRunTrigger` | Import from `CloudCodeManager` | |
| 270 | +| `UsersRouter.js` | `getTrigger` (login/logout) | Import from `CloudCodeManager` | |
| 271 | +| `FilesRouter.js` | `getTrigger` (file triggers) | Import from `CloudCodeManager` | |
| 272 | +| `LiveQuery/` | `getTrigger`, `maybeRunTrigger`, connect/subscribe | Import from `CloudCodeManager` | |
| 273 | +| `Config.js` | Validates cloud config | Updated for new options | |
| 274 | + |
| 275 | +### 6.2 Migration Approach |
| 276 | + |
| 277 | +1. **`triggers.ts` becomes a thin re-export facade** — all exports delegate to `CloudCodeManager` on the current app's `Config`. Existing import sites work without immediate changes. |
| 278 | +2. **Incremental consumer migration** — update consumers one file at a time from `triggers.*` to `config.cloud.*` (the `CloudCodeManager` instance on `Config`). |
| 279 | +3. **Facade removal** — once all consumers are migrated, delete `triggers.ts`. |
| 280 | + |
| 281 | +### 6.3 Parse.Cloud.js Transformation |
| 282 | + |
| 283 | +`LegacyAdapter` temporarily patches `Parse.Cloud.*` during `initialize()`: |
| 284 | + |
| 285 | +```typescript |
| 286 | +class LegacyAdapter implements CloudCodeAdapter { |
| 287 | + readonly name = 'legacy'; |
| 288 | + |
| 289 | + async initialize(registry: CloudCodeRegistry, config: ParseServerConfig): Promise<void> { |
| 290 | + const originalDefine = Parse.Cloud.define; |
| 291 | + Parse.Cloud.define = (name, handler, validator) => { |
| 292 | + registry.defineFunction(name, handler, validator); |
| 293 | + }; |
| 294 | + // ... same for beforeSave, afterSave, etc. |
| 295 | + |
| 296 | + if (typeof this.cloud === 'string') { |
| 297 | + require(this.cloud); |
| 298 | + } else if (typeof this.cloud === 'function') { |
| 299 | + this.cloud(Parse); |
| 300 | + } |
| 301 | + |
| 302 | + Parse.Cloud.define = originalDefine; |
| 303 | + // ... |
| 304 | + } |
| 305 | +} |
| 306 | +``` |
| 307 | + |
| 308 | +### 6.4 Utility Functions |
| 309 | + |
| 310 | +Pure data transformation helpers from `triggers.js` (`getRequestObject()`, `getResponseObject()`, `resolveError()`, `toJSONwithObjects()`) move to `src/cloud-code/request-utils.ts`. They have no dependency on the hook store. |
| 311 | + |
| 312 | +## 7. Request/Response Bridge |
| 313 | + |
| 314 | +For `InProcessAdapter` and `ExternalProcessAdapter`, a bridge converts between Parse Server's internal request objects and the webhook body format. |
| 315 | + |
| 316 | +### Parse Request → Webhook Body |
| 317 | + |
| 318 | +Converts `Parse.Object` instances to JSON, maps all trigger-specific fields (object, original, query, file, context, etc.). |
| 319 | + |
| 320 | +### Webhook Response → Parse Result |
| 321 | + |
| 322 | +- `{ success: <value> }` → return value |
| 323 | +- `{ error: { code, message } }` → throw `Parse.Error` |
| 324 | + |
| 325 | +### beforeSave Special Case |
| 326 | + |
| 327 | +- Empty object `{}` → accept original (no changes) |
| 328 | +- Object with fields → apply field changes to `request.object` |
| 329 | +- Error → reject save |
| 330 | + |
| 331 | +## 8. Non-Goals (v1) |
| 332 | + |
| 333 | +- **Hot reload** — hooks registered once at startup |
| 334 | +- **Public CloudCodeRegistry API** — all registration through adapters |
| 335 | +- **Multi-process orchestration** — one external process per adapter |
| 336 | +- **Auto-generated webhook key** — must be explicitly configured |
0 commit comments