import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context'; import type { ManifestComponentDeletion, ManifestScope } from './manifest-builder'; import { optionalStringComponentField, requireObjectComponentField, requireStringComponentField, } from './_existing-component-fields'; import { Group } from './group'; import type { SavedSuperGroup } from './saved-super-group'; /** * Initialization properties for {@link ManifestFunction}. */ export interface ManifestFunctionProps { /** * Unique identifier for this function (e.g. `'gym_today_schedule_fn'`). * * Required. */ apiName: string; /** * Display name shown in the Rippling UI. * * Required. */ name: string; /** * TypeScript handler source code. Must export an `onRipplingEvent` function. * * Optional. @default `''` */ code?: string; /** * Human-readable description of what this function does. * * Required. */ description: string; /** * NPM package dependencies as a `{ packageName: versionRange }` map * (e.g. `{ '@rippling/rippling-sdk': '^0.2.0' }`). * * Optional. @default null */ dependencies?: Record; /** * Raw deployment options object. * * Optional. @default null */ deploymentOptions?: Record; /** * Outbound domain allowlist. Requests to domains not in this list will be blocked. * * Optional. @default null */ allowedDomains?: string[]; /** * Whether this function runs asynchronously (fire-and-forget). * * Optional. @default `false` */ isAsync?: boolean; /** Embedded group controlling who can run this function. */ runnerGroup?: Group | null; /** Embedded group controlling who can view this function. */ viewerGroup?: Group | null; /** Embedded group controlling who can edit this function. */ editorGroup?: Group | null; } function inlineGroup(group: Group | null | undefined): Group | null | undefined { if (group != null && typeof group._toDict !== 'function') { throw new TypeError('Inline function groups must be created with defineGroup or Group.'); } return group; } function groupFromExistingComponent( component: Record, key: 'runner_group' | 'viewer_group' | 'editor_group', ): Group | null | undefined { const value = component[key]; if (value == null) return value; const apiName = requireStringComponentField(component, 'api_name', ManifestFunction.componentType); const slot = key === 'runner_group' ? 'runner' : key === 'viewer_group' ? 'viewer' : 'editor'; return Group._fromExistingConfig( requireObjectComponentField(component, key, ManifestFunction.componentType), { meObjId: `FUNCTION:${apiName}:${slot}`, spokeOwnerHandle: 'DUMMY_FUNCTIONS' }, ); } /** * Defines a serverless function and registers it with the manifest. * * A `ManifestFunction` packages TypeScript handler code that runs on Rippling's * serverless infrastructure. The handler must export `onRipplingEvent`. * * Functions have two primary uses: * 1. **Rendering SDUI for Custom Apps** — the function returns an SDUI spec * payload that renders as a page tab. Wrap the function in an {@link SduiPage} * and reference it from an {@link App}. * 2. **Workflow / event handlers** — functions invoked from rules, automations, * or callbacks that do not render UI. * * This class is named `ManifestFunction` to avoid collision with the JavaScript * built-in `Function`. It is also re-exported as `Function` from the barrel for * convenience. * * @example * ```ts * const todayScheduleFn = new ManifestFunction(manifest, { * apiName: 'gym_today_schedule_fn', * name: 'Today\'s schedule (SDUI)', * description: 'Searchable grid of class sessions', * code: handlerSource, * dependencies: { * '@rippling/rippling-sdk': '^0.2.0-alpha.33', * }, * isAsync: false, * }); * ``` * * @see {@link SduiPage} — wraps a `ManifestFunction` as a navigable UI surface. */ export class ManifestFunction { static readonly componentType = 'FUNCTION' as const; static toDeletionIdentifier(apiName: string): ManifestComponentDeletion { return { type: ManifestFunction.componentType, api_name: apiName, }; } private readonly _apiName: string; private readonly _name: string; private _code: string; private readonly _description: string; private readonly _dependencies: Record | undefined; private readonly _deploymentOptions: Record | undefined; private readonly _allowedDomains: string[] | undefined; private readonly _isAsync: boolean; private readonly _runnerGroup: Group | null | undefined; private readonly _viewerGroup: Group | null | undefined; private readonly _editorGroup: Group | null | undefined; /** * @param scope - The manifest to register this function with. * @param props - Initialization properties. */ constructor(scope: ManifestScope, props: ManifestFunctionProps) { this._apiName = props.apiName; this._name = props.name; this._code = props.code ?? ''; this._description = props.description; this._dependencies = props.dependencies; this._deploymentOptions = props.deploymentOptions; this._allowedDomains = props.allowedDomains; this._isAsync = props.isAsync ?? false; this._runnerGroup = inlineGroup(props.runnerGroup); this._viewerGroup = inlineGroup(props.viewerGroup); this._editorGroup = inlineGroup(props.editorGroup); scope._register(this); } /** * Loads this function from the active existing-manifest JSON context. */ static loadFromExisting(apiName: string, options: ExistingManifestContextOptions = {}): ManifestFunction { return getExistingManifestContext(options).loadFunction(apiName); } /** @internal Hydrates a function from existing manifest wire JSON. */ static _fromExistingComponent(scope: ManifestScope, component: Record): ManifestFunction { const props: ManifestFunctionProps = { apiName: requireStringComponentField(component, 'api_name', ManifestFunction.componentType), name: requireStringComponentField(component, 'name', ManifestFunction.componentType), description: optionalStringComponentField(component, 'description'), }; const runnerGroup = groupFromExistingComponent(component, 'runner_group'); const viewerGroup = groupFromExistingComponent(component, 'viewer_group'); const editorGroup = groupFromExistingComponent(component, 'editor_group'); if (runnerGroup !== undefined) props.runnerGroup = runnerGroup; if (viewerGroup !== undefined) props.viewerGroup = viewerGroup; if (editorGroup !== undefined) props.editorGroup = editorGroup; if (component['code_draft'] !== undefined) props.code = component['code_draft']; if (component['dependencies'] !== undefined) props.dependencies = component['dependencies']; if (component['deployment_options'] !== undefined) { props.deploymentOptions = component['deployment_options']; } if (component['allowed_domains'] !== undefined) props.allowedDomains = component['allowed_domains']; if (component['is_async'] !== undefined) props.isAsync = component['is_async']; return new ManifestFunction(scope, props); } /** * Returns the api_name of this function (e.g. `'gym_today_schedule_fn'`). */ getApiName(): string { return this._apiName; } getRunnerGroup(): Group | null | undefined { return this._runnerGroup; } getViewerGroup(): Group | null | undefined { return this._viewerGroup; } getEditorGroup(): Group | null | undefined { return this._editorGroup; } /** Only saved groups referenced by embedded rules become manifest dependencies. */ getReferencedGroups(): SavedSuperGroup[] { return [ ...(this._runnerGroup?.getReferencedGroups() ?? []), ...(this._viewerGroup?.getReferencedGroups() ?? []), ...(this._editorGroup?.getReferencedGroups() ?? []), ]; } /** * Replaces the TypeScript handler source code for this function. */ setCode(code: string): this { this._code = code; return this; } /** * Serializes this function to the wire format consumed by the manifest install endpoint. * * @returns A plain object with `type: 'FUNCTION'`. Empty or unset optional fields are omitted. */ toDict(): Record { const component: Record = { type: ManifestFunction.componentType, name: this._name, api_name: this._apiName, description: this._description, }; if (this._code) component['code_draft'] = this._code; if (this._dependencies != null) component['dependencies'] = this._dependencies; if (this._deploymentOptions != null) component['deployment_options'] = this._deploymentOptions; if (this._allowedDomains != null) component['allowed_domains'] = this._allowedDomains; if (this._isAsync) component['is_async'] = true; if (this._runnerGroup !== undefined) { component['runner_group'] = this._runnerGroup?._toDict({ meObjId: `FUNCTION:${this._apiName}:runner`, spokeOwnerHandle: 'DUMMY_FUNCTIONS', }) ?? null; } if (this._viewerGroup !== undefined) { component['viewer_group'] = this._viewerGroup?._toDict({ meObjId: `FUNCTION:${this._apiName}:viewer`, spokeOwnerHandle: 'DUMMY_FUNCTIONS', }) ?? null; } if (this._editorGroup !== undefined) { component['editor_group'] = this._editorGroup?._toDict({ meObjId: `FUNCTION:${this._apiName}:editor`, spokeOwnerHandle: 'DUMMY_FUNCTIONS', }) ?? null; } return component; } }