├── .gitignore ├── src ├── ihttprequest.ts ├── ihttpresponse.ts ├── actions │ ├── iaction.ts │ ├── continueasnewaction.ts │ ├── callhttpaction.ts │ ├── externaleventtype.ts │ ├── callactivityaction.ts │ ├── waitforexternaleventaction.ts │ ├── createtimeraction.ts │ ├── callsuborchestratoraction.ts │ ├── callactivitywithretryaction.ts │ ├── callentityaction.ts │ ├── actiontype.ts │ └── callsuborchestratorwithretryaction.ts ├── entities │ ├── durablelock.ts │ ├── operationresult.ts │ ├── responsemessage.ts │ ├── signal.ts │ ├── entitystateresponse.ts │ ├── entitystate.ts │ ├── lockstate.ts │ ├── requestmessage.ts │ └── entityid.ts ├── ientityfunctioncontext.ts ├── constants.ts ├── iorchestrationfunctioncontext.ts ├── httpcreationpayload.ts ├── iorchestratorstate.ts ├── purgehistoryresult.ts ├── taskset.ts ├── durableentitybindinginfo.ts ├── history │ ├── historyevent.ts │ ├── orchestratorstartedevent.ts │ ├── orchestratorcompletedevent.ts │ ├── historyeventoptions.ts │ ├── timercreatedevent.ts │ ├── taskscheduledevent.ts │ ├── eventraisedevent.ts │ ├── executionstartedevent.ts │ ├── historyeventtype.ts │ ├── taskfailedevent.ts │ ├── timerfiredevent.ts │ ├── taskcompletedevent.ts │ ├── suborchestrationinstancefailedevent.ts │ ├── eventsentevent.ts │ ├── suborchestrationinstancecompletedevent.ts │ └── suborchestrationinstancecreatedevent.ts ├── getstatusoptions.ts ├── durableorchestrationbindinginfo.ts ├── index.ts ├── itaskmethods.ts ├── durablehttpresponse.ts ├── orchestratorstate.ts ├── httpmanagementpayload.ts ├── orchestrationclientinputdata.ts ├── orchestrationruntimestatus.ts ├── durablehttprequest.ts ├── shim.ts ├── guidmanager.ts ├── retryoptions.ts ├── tokensource.ts ├── webhookutils.ts ├── timertask.ts ├── utils.ts ├── durableorchestrationstatus.ts ├── task.ts ├── durableentitycontext.ts ├── classes.ts ├── entity.ts ├── durableorchestrationcontext.ts └── durableorchestrationclient.ts ├── samples ├── E1_SayHello │ ├── index.js │ └── function.json ├── FlakyFunction │ ├── index.js │ └── function.json ├── E3_GetIsClear │ ├── function.json │ └── index.js ├── E3_Monitor │ ├── function.json │ └── index.js ├── cancel-timer │ ├── function.json │ └── index.js ├── E1_HelloSequence │ ├── function.json │ └── index.js ├── E2_GetFileList │ ├── function.json │ └── index.js ├── CounterOrchestration │ ├── function.json │ └── index.js ├── E2_BackupSiteContent │ ├── function.json │ └── index.js ├── CounterEntity │ ├── functions.json │ └── index.js ├── E4_SmsPhoneVerification │ ├── function.json │ └── index.js ├── ThrowsErrorInline │ ├── function.json │ └── index.js ├── CallActivityWithRetry │ ├── function.json │ └── index.js ├── ContinueAsNewCounter │ ├── function.json │ └── index.js ├── SayHelloWithActivity │ ├── function.json │ └── index.js ├── SayHelloWithCustomStatus │ ├── function.json │ └── index.js ├── CallSubOrchestratorWithRetry │ ├── function.json │ └── index.js ├── SayHelloWithSubOrchestrator │ ├── function.json │ └── index.js ├── E3_SendGoodWeatherAlert │ ├── index.js │ └── function.json ├── .gitignore ├── host.json ├── E2_CopyFileToBlob │ ├── function.json │ └── index.js ├── HttpStart │ ├── index.js │ └── function.json ├── E4_SendSmsChallenge │ ├── function.json │ └── index.js ├── package.json ├── functions-extensions │ └── extensions.csproj ├── HttpSyncStart │ ├── function.json │ └── index.js └── extensions.csproj ├── tslint.json ├── test ├── unit │ ├── retryoptions-spec.ts │ ├── timertask-spec.ts │ ├── entityid-spec.ts │ ├── guidmanager-spec.ts │ ├── getclient-spec.ts │ └── utils-spec.ts ├── testobjects │ ├── testentityoperations.ts │ ├── testentities.ts │ ├── testconstants.ts │ ├── testutils.ts │ ├── testentitybatches.ts │ └── TestOrchestrations.ts └── integration │ └── entity-spec.ts ├── tsconfig.json ├── .vscode ├── tasks.json └── launch.json ├── azure-pipelines.yml ├── LICENSE ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | npm-debug.log 4 | lib/ -------------------------------------------------------------------------------- /src/ihttprequest.ts: -------------------------------------------------------------------------------- 1 | export interface IHttpRequest { 2 | http: { 3 | url: string; 4 | }; 5 | } 6 | -------------------------------------------------------------------------------- /samples/E1_SayHello/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function(context) { 2 | context.done(null, `Hello ${context.bindings.name}!`); 3 | }; -------------------------------------------------------------------------------- /src/ihttpresponse.ts: -------------------------------------------------------------------------------- 1 | export interface IHttpResponse { 2 | status: number; 3 | body: unknown; 4 | headers?: object; 5 | } 6 | -------------------------------------------------------------------------------- /src/actions/iaction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from "../classes"; 2 | 3 | export interface IAction { 4 | actionType: ActionType; 5 | } 6 | -------------------------------------------------------------------------------- /src/entities/durablelock.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * For use with locking operations. 3 | * TODO: improve this 4 | */ 5 | export class DurableLock { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /samples/FlakyFunction/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function(context) { 2 | context.log("Flaky Function Flaking!"); 3 | throw Error("FlakyFunction flaked"); 4 | }; -------------------------------------------------------------------------------- /samples/E1_SayHello/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "name", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/E3_GetIsClear/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "location", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/E3_Monitor/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/cancel-timer/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/E1_HelloSequence/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/E2_GetFileList/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "rootDirectory", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/CounterOrchestration/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/E2_BackupSiteContent/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/CounterEntity/functions.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "entityTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/E4_SmsPhoneVerification/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/FlakyFunction/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "name", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /src/ientityfunctioncontext.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "@azure/functions"; 2 | import { DurableEntityContext } from "./classes"; 3 | 4 | export interface IEntityFunctionContext extends Context { 5 | df: DurableEntityContext; 6 | } 7 | -------------------------------------------------------------------------------- /src/entities/operationresult.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export class OperationResult { 3 | constructor( 4 | readonly isError: boolean, 5 | readonly duration: number, 6 | readonly result?: string, 7 | ) { } 8 | } 9 | -------------------------------------------------------------------------------- /samples/ThrowsErrorInline/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export class Constants { 3 | public static readonly DefaultLocalHost: string = "localhost:7071"; 4 | public static readonly DefaultLocalOrigin: string = `http://${Constants.DefaultLocalHost}`; 5 | } 6 | -------------------------------------------------------------------------------- /src/entities/responsemessage.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export class ResponseMessage { 3 | public result: string; // Result 4 | public exceptionType?: string; // ExceptionType 5 | } 6 | 7 | // TODO: error deserialization 8 | -------------------------------------------------------------------------------- /samples/CallActivityWithRetry/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/ContinueAsNewCounter/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/SayHelloWithActivity/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/SayHelloWithCustomStatus/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/CallSubOrchestratorWithRetry/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/SayHelloWithSubOrchestrator/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "context", 5 | "type": "orchestrationTrigger", 6 | "direction": "in" 7 | } 8 | ], 9 | "disabled": false 10 | } -------------------------------------------------------------------------------- /samples/E3_SendGoodWeatherAlert/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function(context, phoneNumber) { 2 | context.bindings.message = { 3 | body: `The weather's clear outside! Go take a walk!`, 4 | to: phoneNumber 5 | }; 6 | 7 | context.done(); 8 | }; -------------------------------------------------------------------------------- /samples/ThrowsErrorInline/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | const input = context.df.getInput(); 5 | 6 | throw Error("ThrowsErrorInline does what it says on the tin."); 7 | }); -------------------------------------------------------------------------------- /src/iorchestrationfunctioncontext.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "@azure/functions"; 2 | import { DurableOrchestrationContext } from "./classes"; 3 | 4 | export interface IOrchestrationFunctionContext extends Context { 5 | df: DurableOrchestrationContext; 6 | } 7 | -------------------------------------------------------------------------------- /src/entities/signal.ts: -------------------------------------------------------------------------------- 1 | import { EntityId } from "../classes"; 2 | 3 | export class Signal { 4 | constructor( 5 | public readonly target: EntityId, 6 | public readonly name: string, 7 | public readonly input: string, 8 | ) { } 9 | } 10 | -------------------------------------------------------------------------------- /src/httpcreationpayload.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export class HttpCreationPayload { 3 | [key: string]: string; 4 | 5 | constructor( 6 | public createNewInstancePostUri: string, 7 | public waitOnNewInstancePostUri: string, 8 | ) { } 9 | } 10 | -------------------------------------------------------------------------------- /src/iorchestratorstate.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from "./classes"; 2 | 3 | /** @hidden */ 4 | export interface IOrchestratorState { 5 | isDone: boolean; 6 | actions: IAction[][]; 7 | output: unknown; 8 | error?: string; 9 | customStatus?: unknown; 10 | } 11 | -------------------------------------------------------------------------------- /samples/SayHelloWithActivity/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | const input = context.df.getInput(); 5 | 6 | const output = yield context.df.callActivity("E1_SayHello", input); 7 | return output; 8 | }); -------------------------------------------------------------------------------- /samples/SayHelloWithSubOrchestrator/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | const input = context.df.getInput(); 5 | 6 | const output = yield context.df.callSubOrchestrator("SayHelloWithActivity", input); 7 | return output; 8 | }); -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:latest", 3 | "exclude": "./node_modules", 4 | "rules": { 5 | "max-line-length": false, 6 | "no-implicit-dependencies": [true, "dev"], 7 | "no-submodule-imports": false, 8 | "object-literal-sort-keys": false, 9 | "interface-name": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/purgehistoryresult.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class to hold statistics about this execution of purge history. 3 | */ 4 | export class PurgeHistoryResult { 5 | constructor( 6 | /** 7 | * The number of deleted instances. 8 | */ 9 | public readonly instancesDeleted: number, 10 | ) { } 11 | } 12 | -------------------------------------------------------------------------------- /samples/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | bin 3 | obj 4 | csx 5 | .vs 6 | edge 7 | Publish 8 | .vscode 9 | 10 | *.user 11 | *.suo 12 | *.cscfg 13 | *.Cache 14 | project.lock.json 15 | 16 | /packages 17 | /TestResults 18 | 19 | /tools/NuGet.exe 20 | /App_Data 21 | /secrets 22 | /data 23 | .secrets 24 | appsettings.json 25 | local.settings.json 26 | -------------------------------------------------------------------------------- /src/actions/continueasnewaction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, IAction } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class ContinueAsNewAction implements IAction { 5 | public readonly actionType: ActionType = ActionType.ContinueAsNew; 6 | 7 | constructor( 8 | public readonly input: unknown, 9 | ) { } 10 | } 11 | -------------------------------------------------------------------------------- /src/taskset.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from "./classes"; 2 | 3 | /** @hidden */ 4 | export class TaskSet { 5 | constructor( 6 | public isCompleted: boolean, 7 | public isFaulted: boolean, 8 | public actions: IAction[], 9 | public result?: unknown, 10 | public exception?: unknown, 11 | ) { } 12 | } 13 | -------------------------------------------------------------------------------- /samples/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "extensions": { 4 | "http": { 5 | "routePrefix": "" 6 | }, 7 | "durableTask": { 8 | "HubName": "SampleHubJs" 9 | } 10 | }, 11 | "logging": { 12 | "applicationInsights": { 13 | "sampling": { 14 | "isEnabled": false 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/actions/callhttpaction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, DurableHttpRequest, IAction, Utils } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class CallHttpAction implements IAction { 5 | public readonly actionType: ActionType = ActionType.CallHttp; 6 | 7 | constructor( 8 | public readonly httpRequest: DurableHttpRequest, 9 | ) { } 10 | } 11 | -------------------------------------------------------------------------------- /src/actions/externaleventtype.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @hidden 3 | * Represents the options that can be provided for the "reason" field of events in 4 | * Durable Functions 2.0. 5 | */ 6 | export enum ExternalEventType { 7 | ExternalEvent = "ExternalEvent", 8 | LockAcquisitionCompleted = "LockAcquisitionCompleted", 9 | EntityResponse = "EntityResponse", 10 | } 11 | -------------------------------------------------------------------------------- /samples/E2_CopyFileToBlob/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "filePath", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | }, 8 | { 9 | "name": "out", 10 | "type": "blob", 11 | "path": "", 12 | "connection": "AzureWebJobsStorage", 13 | "direction": "out" 14 | } 15 | ], 16 | "disabled": false 17 | } -------------------------------------------------------------------------------- /src/durableentitybindinginfo.ts: -------------------------------------------------------------------------------- 1 | import { EntityId, RequestMessage } from "./classes"; 2 | 3 | /** @hidden */ 4 | export class DurableEntityBindingInfo { 5 | constructor( 6 | public readonly self: EntityId, 7 | public readonly exists: boolean, 8 | public readonly state: string | undefined, 9 | public readonly batch: RequestMessage[], 10 | ) { } 11 | } 12 | -------------------------------------------------------------------------------- /src/history/historyevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export abstract class HistoryEvent { 5 | constructor( 6 | public EventType: HistoryEventType, 7 | public EventId: number, 8 | public IsPlayed: boolean, 9 | public Timestamp: Date, 10 | public IsProcessed: boolean = false, 11 | ) { } 12 | } 13 | -------------------------------------------------------------------------------- /samples/CounterOrchestration/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | const entityId = new df.EntityId("CounterEntity", "myCounter"); 5 | 6 | currentValue = yield context.df.callEntity(entityId, "get"); 7 | if (currentValue < 10) { 8 | yield context.df.callEntity(entityId, "add", 1); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /samples/HttpStart/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = async function (context, req) { 4 | const client = df.getClient(context); 5 | const instanceId = await client.startNew(req.params.functionName, undefined, req.body); 6 | 7 | context.log(`Started orchestration with ID = '${instanceId}'.`); 8 | 9 | return client.createCheckStatusResponse(context.bindingData.req, instanceId); 10 | }; -------------------------------------------------------------------------------- /src/entities/entitystateresponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The response returned by [[DurableOrchestrationClient]].[[readEntityState]]. 3 | */ 4 | export class EntityStateResponse { 5 | constructor( 6 | /** Whether this entity exists or not. */ 7 | public entityExists: boolean, 8 | 9 | /** The current state of the entity, if it exists, or default value otherwise. */ 10 | public entityState: T | undefined, 11 | ) {} 12 | } 13 | -------------------------------------------------------------------------------- /src/actions/callactivityaction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, IAction, Utils } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class CallActivityAction implements IAction { 5 | public readonly actionType: ActionType = ActionType.CallActivity; 6 | 7 | constructor( 8 | public readonly functionName: string, 9 | public readonly input?: unknown, 10 | ) { 11 | Utils.throwIfEmpty(functionName, "functionName"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /samples/E4_SendSmsChallenge/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "phoneNumber", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | }, 8 | { 9 | "type": "twilioSms", 10 | "name": "message", 11 | "from": "%TwilioPhoneNumber%", 12 | "accountSidSetting": "TwilioAccountSid", 13 | "authTokenSetting": "TwilioAuthToken", 14 | "direction": "out" 15 | } 16 | ], 17 | "disabled": false 18 | } -------------------------------------------------------------------------------- /samples/E1_HelloSequence/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | context.log("Starting chain sample"); 5 | const output = []; 6 | output.push(yield context.df.callActivity("E1_SayHello", "Tokyo")); 7 | output.push(yield context.df.callActivity("E1_SayHello", "Seattle")); 8 | output.push(yield context.df.callActivity("E1_SayHello", "London")); 9 | 10 | return output; 11 | }); 12 | -------------------------------------------------------------------------------- /src/entities/entitystate.ts: -------------------------------------------------------------------------------- 1 | import { OperationResult, Signal } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class EntityState { 5 | public entityExists: boolean; 6 | public entityState: string | undefined; 7 | public readonly results: OperationResult[]; 8 | public readonly signals: Signal[]; 9 | 10 | constructor(results: OperationResult[], signals: Signal[]) { 11 | this.results = results; 12 | this.signals = signals; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/entities/lockstate.ts: -------------------------------------------------------------------------------- 1 | import { EntityId } from "../classes"; 2 | 3 | /** 4 | * Returned by [DurableOrchestrationContext].[isLocked] and 5 | * [DurableEntityContext].[isLocked]. 6 | */ 7 | export class LockState { 8 | constructor( 9 | /** Whether the context already holds some locks. */ 10 | public readonly isLocked: boolean, 11 | /** The locks held by the context. */ 12 | public readonly ownedLocks: EntityId[], 13 | ) { } 14 | } 15 | -------------------------------------------------------------------------------- /test/unit/retryoptions-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { Constants, RetryOptions } from "../../src/classes"; 4 | 5 | describe("RetryOptions", () => { 6 | it ("throws if firstRetryIntervalInMilliseconds less than or equal to zero", async () => { 7 | expect(() => { 8 | const retryOptions = new RetryOptions(0, 1); 9 | }).to.throw("firstRetryIntervalInMilliseconds value must be greater than 0."); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/getstatusoptions.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:member-access 2 | 3 | import { OrchestrationRuntimeStatus } from "./classes"; 4 | 5 | /** @hidden */ 6 | export class GetStatusOptions { 7 | instanceId?: string; 8 | taskHubName?: string; 9 | connectionName?: string; 10 | showHistory?: boolean; 11 | showHistoryOutput?: boolean; 12 | createdTimeFrom?: Date; 13 | createdTimeTo?: Date; 14 | runtimeStatus?: OrchestrationRuntimeStatus[]; 15 | showInput?: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /src/history/orchestratorstartedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class OrchestratorStartedEvent extends HistoryEvent { 5 | constructor( 6 | options: HistoryEventOptions, 7 | ) { 8 | super( 9 | HistoryEventType.OrchestratorStarted, 10 | options.eventId, 11 | options.isPlayed, 12 | options.timestamp, 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/history/orchestratorcompletedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class OrchestratorCompletedEvent extends HistoryEvent { 5 | constructor( 6 | options: HistoryEventOptions, 7 | ) { 8 | super( 9 | HistoryEventType.OrchestratorCompleted, 10 | options.eventId, 11 | options.isPlayed, 12 | options.timestamp, 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /samples/CallActivityWithRetry/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | const retryOptions = new df.RetryOptions(1000, 2); 5 | let returnValue; 6 | 7 | try { 8 | returnValue = yield context.df.callActivityWithRetry("FlakyFunction", retryOptions); 9 | } catch (e) { 10 | context.log("Orchestrator caught exception. Flaky function is extremely flaky."); 11 | } 12 | 13 | return returnValue; 14 | }); -------------------------------------------------------------------------------- /samples/E3_SendGoodWeatherAlert/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "name": "phoneNumber", 5 | "type": "activityTrigger", 6 | "direction": "in" 7 | }, 8 | { 9 | "type": "twilioSms", 10 | "name": "message", 11 | "from": "%TwilioPhoneNumber%", 12 | "accountSidSetting": "TwilioAccountSid", 13 | "authTokenSetting": "TwilioAuthToken", 14 | "direction": "out" 15 | } 16 | ], 17 | "disabled": false 18 | } -------------------------------------------------------------------------------- /src/actions/waitforexternaleventaction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, ExternalEventType, IAction, Utils } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class WaitForExternalEventAction implements IAction { 5 | public readonly actionType: ActionType = ActionType.WaitForExternalEvent; 6 | 7 | constructor( 8 | public readonly externalEventName: string, 9 | public readonly reason = ExternalEventType.ExternalEvent, 10 | ) { 11 | Utils.throwIfEmpty(externalEventName, "externalEventName"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /samples/cancel-timer/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | const moment = require('moment'); 3 | 4 | module.exports = df.orchestrator(function*(context){ 5 | const expiration = moment.utc(context.df.currentUtcDateTime).add(2, 'm'); 6 | const timeoutTask = context.df.createTimer(expiration.toDate()); 7 | 8 | const hello = yield context.df.callActivity("E1_SayHello", "from the other side"); 9 | 10 | if (!timeoutTask.isCompleted) { 11 | timeoutTask.cancel(); 12 | } 13 | 14 | return hello; 15 | }); -------------------------------------------------------------------------------- /samples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "durable-functions-samples", 3 | "version": "1.2.3", 4 | "description": "Durable Functions sample library for Node.js Azure Functions", 5 | "license": "MIT", 6 | "repository": "", 7 | "author": "katyshimizu", 8 | "keywords": [ 9 | "azure-functions" 10 | ], 11 | "dependencies": { 12 | "azure-storage": "^2.10.2", 13 | "durable-functions": "^1.2.2", 14 | "moment": "^2.22.2", 15 | "readdirp": "^2.2.1", 16 | "request": "^2.87.0", 17 | "seedrandom": "^2.4.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /samples/functions-extensions/extensions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netstandard2.0 4 | 5 | ** 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/history/historyeventoptions.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export class HistoryEventOptions { 3 | public details?: string; 4 | public fireAt?: Date; 5 | public input?: string; 6 | public instanceId?: string; 7 | public name?: string; 8 | public reason?: string; 9 | public result?: string; 10 | public taskScheduledId?: number; 11 | public timerId?: number; 12 | 13 | constructor( 14 | public eventId: number, 15 | public timestamp: Date, 16 | public isPlayed: boolean = false, 17 | ) { } 18 | } 19 | -------------------------------------------------------------------------------- /src/actions/createtimeraction.ts: -------------------------------------------------------------------------------- 1 | import { isDate } from "util"; 2 | import { ActionType, Constants, IAction } from "../classes"; 3 | 4 | /** @hidden */ 5 | export class CreateTimerAction implements IAction { 6 | public readonly actionType: ActionType = ActionType.CreateTimer; 7 | 8 | constructor( 9 | public readonly fireAt: Date, 10 | public isCanceled: boolean = false, 11 | ) { 12 | if (!isDate(fireAt)) { 13 | throw new TypeError(`fireAt: Expected valid Date object but got ${fireAt}`); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true, 4 | "declaration": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "outDir": "./lib", 9 | "preserveConstEnums": true, 10 | "removeComments": true, 11 | "target": "es6", 12 | "sourceMap": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true 15 | }, 16 | "include": [ 17 | "src/**/*", 18 | "test/**/*" 19 | ], 20 | "exclude": [ 21 | "node_modules" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /samples/SayHelloWithCustomStatus/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | const output = []; 5 | 6 | output.push(yield context.df.callActivity("E1_SayHello", "Tokyo")); 7 | context.df.setCustomStatus("Tokyo"); 8 | output.push(yield context.df.callActivity("E1_SayHello", "Seattle")); 9 | context.df.setCustomStatus("Seattle"); 10 | output.push(yield context.df.callActivity("E1_SayHello", "London")); 11 | context.df.setCustomStatus("London"); 12 | 13 | return output; 14 | }); -------------------------------------------------------------------------------- /src/durableorchestrationbindinginfo.ts: -------------------------------------------------------------------------------- 1 | import { EntityId, HistoryEvent } from "./classes"; 2 | 3 | /** @hidden */ 4 | export class DurableOrchestrationBindingInfo { 5 | constructor( 6 | public readonly history: HistoryEvent[] = [], 7 | public readonly input?: unknown, 8 | public readonly instanceId: string = "", 9 | public readonly isReplaying: boolean = false, 10 | public readonly parentInstanceId?: string, 11 | // TODO: Implement entity locking 12 | // public readonly contextLocks?: EntityId[], 13 | ) { } 14 | } 15 | -------------------------------------------------------------------------------- /samples/HttpStart/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "name": "req", 6 | "type": "httpTrigger", 7 | "direction": "in", 8 | "route": "orchestrators/{functionName}", 9 | "methods": ["post"] 10 | }, 11 | { 12 | "name": "$return", 13 | "type": "http", 14 | "direction": "out" 15 | }, 16 | { 17 | "name": "starter", 18 | "type": "orchestrationClient", 19 | "direction": "in" 20 | } 21 | ], 22 | "disabled": false 23 | } -------------------------------------------------------------------------------- /samples/CallSubOrchestratorWithRetry/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | const retryOptions = new df.RetryOptions(10000, 2); 5 | const childId = `${context.df.instanceId}:0`; 6 | 7 | let returnValue; 8 | 9 | try { 10 | returnValue = yield context.df.callSubOrchestratorWithRetry("ThrowsErrorInline", retryOptions, "Matter", childId); 11 | } catch (e) { 12 | context.log("Orchestrator caught exception. Sub-orchestrator failed."); 13 | } 14 | 15 | return returnValue; 16 | }); -------------------------------------------------------------------------------- /samples/HttpSyncStart/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "name": "req", 6 | "type": "httpTrigger", 7 | "direction": "in", 8 | "route": "orchestrators/wait/{functionName}", 9 | "methods": ["post"] 10 | }, 11 | { 12 | "name": "$return", 13 | "type": "http", 14 | "direction": "out" 15 | }, 16 | { 17 | "name": "starter", 18 | "type": "orchestrationClient", 19 | "direction": "in" 20 | } 21 | ], 22 | "disabled": false 23 | } -------------------------------------------------------------------------------- /samples/CounterEntity/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.entity(function(context) { 4 | let currentValue = context.df.getState(() => 0); 5 | 6 | switch (context.df.operationName) { 7 | case "add": 8 | const amount = context.df.getInput(); 9 | currentValue += amount; 10 | break; 11 | case "reset": 12 | currentValue = 0; 13 | break; 14 | case "get": 15 | context.df.return(currentValue); 16 | break; 17 | } 18 | 19 | context.df.setState(currentValue); 20 | }); -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { DurableHttpRequest, DurableHttpResponse, EntityId, EntityStateResponse, 2 | OrchestrationRuntimeStatus, RetryOptions } from "./classes"; 3 | import { getClient } from "./durableorchestrationclient"; 4 | import { entity, orchestrator } from "./shim"; 5 | import { ManagedIdentityTokenSource } from "./tokensource"; 6 | 7 | export { 8 | DurableHttpRequest, 9 | DurableHttpResponse, 10 | entity, 11 | EntityId, 12 | EntityStateResponse, 13 | getClient, 14 | ManagedIdentityTokenSource, 15 | orchestrator, 16 | OrchestrationRuntimeStatus, 17 | RetryOptions, 18 | }; 19 | -------------------------------------------------------------------------------- /samples/ContinueAsNewCounter/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | const moment = require('moment'); 3 | 4 | module.exports = df.orchestrator(function*(context) { 5 | let currentValue = context.df.getInput() || 0; 6 | context.log(`Value is ${currentValue}`); 7 | currentValue++; 8 | 9 | var wait = moment.utc(context.df.currentUtcDateTime).add(30, 's'); 10 | context.log("Counting up at" + wait.toString()); 11 | yield context.df.createTimer(wait.toDate()); 12 | 13 | if (currentValue < 10) { 14 | yield context.df.continueAsNew(currentValue); 15 | } 16 | 17 | return currentValue; 18 | }); -------------------------------------------------------------------------------- /src/actions/callsuborchestratoraction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, IAction, Utils } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class CallSubOrchestratorAction implements IAction { 5 | public readonly actionType: ActionType = ActionType.CallSubOrchestrator; 6 | 7 | constructor( 8 | public readonly functionName: string, 9 | public readonly instanceId?: string, 10 | public readonly input?: unknown, 11 | ) { 12 | Utils.throwIfEmpty(functionName, "functionName"); 13 | 14 | if (instanceId) { 15 | Utils.throwIfEmpty(instanceId, "instanceId"); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /samples/E4_SendSmsChallenge/index.js: -------------------------------------------------------------------------------- 1 | const seedrandom = require("seedrandom"); 2 | const uuidv1 = require("uuid/v1"); 3 | 4 | module.exports = function (context, phoneNumber) { 5 | // Get a random number generator with a random seed (not time-based) 6 | const rand = seedrandom(uuidv1()); 7 | const challengeCode = Math.floor(rand() * 10000); 8 | 9 | context.log(`Sending verification code ${challengeCode} to ${phoneNumber}.`); 10 | 11 | context.bindings.message = { 12 | body: `Your verification code is ${challengeCode.toPrecision(4)}`, 13 | to: phoneNumber 14 | }; 15 | 16 | context.done(null, challengeCode); 17 | }; -------------------------------------------------------------------------------- /src/actions/callactivitywithretryaction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, IAction, RetryOptions, Utils } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class CallActivityWithRetryAction implements IAction { 5 | public readonly actionType: ActionType = ActionType.CallActivityWithRetry; 6 | 7 | constructor( 8 | public readonly functionName: string, 9 | public readonly retryOptions: RetryOptions, 10 | public readonly input?: unknown, 11 | ) { 12 | Utils.throwIfEmpty(functionName, "functionName"); 13 | 14 | Utils.throwIfNotInstanceOf(retryOptions, "retryOptions", new RetryOptions(1, 1), "RetryOptions"); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/history/timercreatedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class TimerCreatedEvent extends HistoryEvent { 5 | public FireAt: Date; 6 | 7 | constructor( 8 | options: HistoryEventOptions, 9 | ) { 10 | super( 11 | HistoryEventType.TimerCreated, 12 | options.eventId, 13 | options.isPlayed, 14 | options.timestamp, 15 | ); 16 | 17 | if (options.fireAt === undefined) { 18 | throw new Error("TimerCreatedEvent needs a fireAt time provided."); 19 | } 20 | 21 | this.FireAt = options.fireAt; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /samples/extensions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netstandard2.0 4 | 5 | ** 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/actions/callentityaction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, EntityId, IAction, Utils } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class CallEntityAction implements IAction { 5 | public readonly actionType: ActionType = ActionType.CallEntity; 6 | public readonly instanceId: string; 7 | 8 | constructor( 9 | entityId: EntityId, 10 | public readonly operation: string, 11 | public readonly input?: unknown, 12 | ) { 13 | if (!entityId) { 14 | throw new Error("Must provide EntityId to CallEntityAction constructor"); 15 | } 16 | Utils.throwIfEmpty(operation, "operation"); 17 | this.instanceId = EntityId.getSchedulerIdFromEntityId(entityId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/itaskmethods.ts: -------------------------------------------------------------------------------- 1 | import { Task, TaskSet } from "./classes"; 2 | 3 | /** 4 | * Methods to handle collections of pending actions represented by [[Task]] 5 | * instances. For use in parallelization operations. 6 | */ 7 | export interface ITaskMethods { 8 | /** 9 | * Similar to Promise.all. When called with `yield` or `return`, returns an 10 | * array containing the results of all [[Task]]s passed to it. It returns 11 | * when all of the [[Task]] instances have completed. 12 | */ 13 | all: (tasks: Task[]) => TaskSet; 14 | 15 | /** 16 | * Similar to Promise.race. When called with `yield` or `return`, returns 17 | * the first [[Task]] instance to complete. 18 | */ 19 | any: (tasks: Task[]) => TaskSet; 20 | } 21 | -------------------------------------------------------------------------------- /samples/E2_GetFileList/index.js: -------------------------------------------------------------------------------- 1 | const readdirp = require("readdirp"); 2 | 3 | module.exports = function (context, rootDirectory) { 4 | context.log(`Searching for files under '${rootDirectory}'...`); 5 | const allFilePaths = []; 6 | 7 | readdirp( 8 | {root: rootDirectory, entryType: 'all'}, 9 | function (fileInfo) { 10 | if (!fileInfo.stat.isDirectory()) { 11 | allFilePaths.push(fileInfo.fullPath); 12 | } 13 | }, 14 | function (err, res) { 15 | if (err) { 16 | throw err; 17 | } 18 | 19 | context.log(`Found ${allFilePaths.length} under ${rootDirectory}.`); 20 | context.done(null, allFilePaths); 21 | } 22 | ); 23 | }; -------------------------------------------------------------------------------- /test/testobjects/testentityoperations.ts: -------------------------------------------------------------------------------- 1 | import { DurableEntityBindingInfo, EntityState } from "../../src/classes"; 2 | 3 | export interface EntityInputsAndOutputs { 4 | input: DurableEntityBindingInfo; 5 | output: EntityState; 6 | } 7 | 8 | export interface Get { 9 | kind: "get"; 10 | } 11 | 12 | export interface Set { 13 | kind: "set"; 14 | value: T; 15 | } 16 | 17 | export interface Increment { 18 | kind: "increment"; 19 | } 20 | 21 | export interface Add { 22 | kind: "add"; 23 | value: T; 24 | } 25 | 26 | export interface Delete { 27 | kind: "delete"; 28 | } 29 | 30 | export type StringStoreOperation = Get | Set; 31 | 32 | export type CounterOperation = Get | Set | Increment | Add | Delete; 33 | -------------------------------------------------------------------------------- /src/history/taskscheduledevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class TaskScheduledEvent extends HistoryEvent { 5 | public Name: string; 6 | public Input: string | undefined; 7 | 8 | constructor( 9 | options: HistoryEventOptions, 10 | ) { 11 | super( 12 | HistoryEventType.TaskScheduled, 13 | options.eventId, 14 | options.isPlayed, 15 | options.timestamp, 16 | ); 17 | 18 | if (options.name === undefined) { 19 | throw new Error("TaskScheduledEvent needs a name provided."); 20 | } 21 | 22 | this.Input = options.input; 23 | this.Name = options.name; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "0.1.0", 5 | "command": "npm", 6 | "isShellCommand": true, 7 | "showOutput": "silent", 8 | "suppressTaskName": true, 9 | "tasks": [ 10 | { 11 | "taskName": "install", 12 | "args": ["install"] 13 | }, 14 | { 15 | "taskName": "update", 16 | "args": ["update"] 17 | }, 18 | { 19 | "taskName": "test", 20 | "args": ["run", "test"] 21 | }, 22 | { 23 | "taskName": "build", 24 | "isBuildCommand": true, 25 | "args": ["run", "build"] 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /src/durablehttpresponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Data structure representing a durable HTTP response. 3 | */ 4 | export class DurableHttpResponse { 5 | /** 6 | * Creates a new instance of DurableHttpResponse with the 7 | * specified parameters. 8 | * 9 | * @param statusCode The HTTP response status code. 10 | * @param content The HTTP response content. 11 | * @param headers The HTTP response headers. 12 | */ 13 | constructor( 14 | /** The HTTP response status code. */ 15 | public statusCode: number, 16 | 17 | /** The HTTP response content. */ 18 | public content?: string, 19 | 20 | /** The HTTP response headers. */ 21 | public headers?: { 22 | [key: string]: string; 23 | }, 24 | ) { } 25 | } 26 | -------------------------------------------------------------------------------- /src/history/eventraisedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class EventRaisedEvent extends HistoryEvent { 5 | public Name: string; 6 | public Input: string | undefined; 7 | 8 | constructor( 9 | options: HistoryEventOptions, 10 | ) { 11 | super( 12 | HistoryEventType.EventRaised, 13 | options.eventId, 14 | options.isPlayed, 15 | options.timestamp, 16 | ); 17 | 18 | if (options.name === undefined) { 19 | throw new Error("EventRaisedEvent needs a name provided."); 20 | } else { 21 | this.Name = options.name; 22 | } 23 | 24 | this.Input = options.input; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/orchestratorstate.ts: -------------------------------------------------------------------------------- 1 | import { IAction, IOrchestratorState } from "./classes"; 2 | 3 | /** @hidden */ 4 | export class OrchestratorState implements IOrchestratorState { 5 | public readonly isDone: boolean; 6 | public readonly actions: IAction[][]; 7 | public readonly output: unknown; 8 | public readonly error?: string; 9 | public readonly customStatus?: unknown; 10 | 11 | constructor(options: IOrchestratorState) { 12 | this.isDone = options.isDone; 13 | this.actions = options.actions; 14 | this.output = options.output; 15 | 16 | if (options.error) { 17 | this.error = options.error; 18 | } 19 | 20 | if (options.customStatus) { 21 | this.customStatus = options.customStatus; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/history/executionstartedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class ExecutionStartedEvent extends HistoryEvent { 5 | public Name: string; 6 | public Input: string | undefined; 7 | 8 | constructor( 9 | options: HistoryEventOptions, 10 | ) { 11 | super( 12 | HistoryEventType.ExecutionStarted, 13 | options.eventId, 14 | options.isPlayed, 15 | options.timestamp, 16 | ); 17 | 18 | if (options.name === undefined) { 19 | throw new Error("ExecutionStartedEvent needs a name provided."); 20 | } else { 21 | this.Name = options.name; 22 | } 23 | 24 | this.Input = options.input; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/history/historyeventtype.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @hidden 3 | * Corresponds to subclasses of HistoryEvent type in [Durable Task framework.](https://github.com/Azure/durabletask) 4 | */ 5 | export enum HistoryEventType { 6 | ExecutionStarted = 0, 7 | ExecutionCompleted = 1, 8 | ExecutionFailed = 2, 9 | ExecutionTerminated = 3, 10 | TaskScheduled = 4, 11 | TaskCompleted = 5, 12 | TaskFailed = 6, 13 | SubOrchestrationInstanceCreated = 7, 14 | SubOrchestrationInstanceCompleted = 8, 15 | SubOrchestrationInstanceFailed = 9, 16 | TimerCreated = 10, 17 | TimerFired = 11, 18 | OrchestratorStarted = 12, 19 | OrchestratorCompleted = 13, 20 | EventSent = 14, 21 | EventRaised = 15, 22 | ContinueAsNew = 16, 23 | GenericEvent = 17, 24 | HistoryState = 18, 25 | } 26 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | variables: { 2 | MODULE_VERSION: '1.2.2', 3 | NODE_LOWER_LTS: '8.x', 4 | NODE_HIGHER_LTS: '10.x' 5 | } 6 | name: $(MODULE_VERSION)-$(Date:yyyyMMdd)$(Rev:.r) 7 | 8 | trigger: 9 | - master 10 | - dev 11 | 12 | jobs: 13 | - job: UnitTests 14 | strategy: 15 | matrix: 16 | WINDOWS_NODE8: 17 | IMAGE_TYPE: 'vs2017-win2016' 18 | NODE_VERSION: $(NODE_LOWER_LTS) 19 | WINDOWS_NODE10: 20 | IMAGE_TYPE: 'vs2017-win2016' 21 | NODE_VERSION: $(NODE_HIGHER_LTS) 22 | pool: 23 | vmImage: $(IMAGE_TYPE) 24 | steps: 25 | - task: NodeTool@0 26 | inputs: 27 | versionSpec: $(NODE_VERSION) 28 | displayName: 'Install Node.js' 29 | - script: npm install 30 | displayName: 'npm install' 31 | - script: npm run test 32 | displayName: 'npm build and test' 33 | -------------------------------------------------------------------------------- /src/actions/actiontype.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @hidden 3 | * 4 | * The type of asynchronous behavior the Durable Functions extension should 5 | * perform on behalf of the language shim. For internal use only as part of the 6 | * [out-of-proc execution schema.](https://github.com/Azure/azure-functions-durable-extension/wiki/Out-of-Proc-Orchestrator-Execution-Schema-Reference) 7 | * 8 | * Corresponds to internal type AsyncActionType in [Durable Functions extension.](https://github.com/Azure/azure-functions-durable-extension) 9 | */ 10 | export enum ActionType { 11 | CallActivity = 0, 12 | CallActivityWithRetry = 1, 13 | CallSubOrchestrator = 2, 14 | CallSubOrchestratorWithRetry = 3, 15 | ContinueAsNew = 4, 16 | CreateTimer = 5, 17 | WaitForExternalEvent = 6, 18 | CallEntity = 7, 19 | CallHttp = 8, 20 | } 21 | -------------------------------------------------------------------------------- /src/actions/callsuborchestratorwithretryaction.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, IAction, RetryOptions, Utils } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class CallSubOrchestratorWithRetryAction implements IAction { 5 | public readonly actionType: ActionType = ActionType.CallSubOrchestratorWithRetry; 6 | 7 | constructor( 8 | public readonly functionName: string, 9 | public readonly retryOptions: RetryOptions, 10 | public readonly input?: unknown, 11 | public readonly instanceId?: string, 12 | ) { 13 | Utils.throwIfEmpty(functionName, "functionName"); 14 | 15 | Utils.throwIfNotInstanceOf(retryOptions, "retryOptions", new RetryOptions(1, 1), "RetryOptions"); 16 | 17 | if (instanceId) { 18 | Utils.throwIfEmpty(instanceId, "instanceId"); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /samples/E2_BackupSiteContent/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | module.exports = df.orchestrator(function*(context){ 4 | const rootDirectory = context.df.getInput(); 5 | if (!rootDirectory) { 6 | throw new Error("A directory path is required as an input."); 7 | } 8 | 9 | const files = yield context.df.callActivity("E2_GetFileList", rootDirectory); 10 | 11 | // Backup Files and save Promises into array 12 | const tasks = []; 13 | for (const file of files) { 14 | tasks.push(context.df.callActivity("E2_CopyFileToBlob", file)); 15 | } 16 | 17 | // wait for all the Backup Files Activities to complete, sum total bytes 18 | const results = yield context.df.Task.all(tasks); 19 | const totalBytes = results.reduce((prev, curr) => prev + curr, 0); 20 | 21 | // return results; 22 | return totalBytes; 23 | }); -------------------------------------------------------------------------------- /src/history/taskfailedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class TaskFailedEvent extends HistoryEvent { 5 | public TaskScheduledId: number; 6 | public Reason: string | undefined; 7 | public Details: string | undefined; 8 | 9 | constructor( 10 | options: HistoryEventOptions, 11 | ) { 12 | super( 13 | HistoryEventType.TaskFailed, 14 | options.eventId, 15 | options.isPlayed, 16 | options.timestamp, 17 | ); 18 | 19 | if (options.taskScheduledId === undefined) { 20 | throw new Error("TaskFailedEvent needs a task scheduled id provided."); 21 | } 22 | 23 | this.TaskScheduledId = options.taskScheduledId; 24 | this.Reason = options.reason; 25 | this.Details = options.details; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/history/timerfiredevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class TimerFiredEvent extends HistoryEvent { 5 | public TimerId: number; 6 | public FireAt: Date; 7 | 8 | constructor( 9 | options: HistoryEventOptions, 10 | ) { 11 | super( 12 | HistoryEventType.TimerFired, 13 | options.eventId, 14 | options.isPlayed, 15 | options.timestamp, 16 | ); 17 | 18 | if (options.timerId === undefined) { 19 | throw new Error("TimerFiredEvent needs a timer id provided."); 20 | } 21 | 22 | if (options.fireAt === undefined) { 23 | throw new Error("TimerFiredEvent needs a fireAt time provided."); 24 | } 25 | 26 | this.TimerId = options.timerId; 27 | this.FireAt = options.fireAt; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/httpmanagementpayload.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Data structure containing instance management HTTP endpoints. 3 | */ 4 | export class HttpManagementPayload { 5 | /** @hidden */ 6 | [key: string]: string; 7 | 8 | /** @hidden */ 9 | constructor( 10 | /** The ID of the orchestration instance. */ 11 | public readonly id: string, 12 | /** The HTTP GET status query endpoint URL. */ 13 | public readonly statusQueryGetUri: string, 14 | /** The HTTP POST external event sending endpoint URL. */ 15 | public readonly sendEventPostUri: string, 16 | /** The HTTP POST instance termination endpoint. */ 17 | public readonly terminatePostUri: string, 18 | /** The HTTP POST instance rewind endpoint. */ 19 | public readonly rewindPostUri: string, 20 | /** The HTTP DELETE purge endpoint. */ 21 | public readonly purgeHistoryDeleteUri: string, 22 | ) { } 23 | } 24 | -------------------------------------------------------------------------------- /src/history/taskcompletedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class TaskCompletedEvent extends HistoryEvent { 5 | public TaskScheduledId: number; 6 | public Result: string; 7 | 8 | constructor( 9 | options: HistoryEventOptions, 10 | ) { 11 | super( 12 | HistoryEventType.TaskCompleted, 13 | options.eventId, 14 | options.isPlayed, 15 | options.timestamp, 16 | ); 17 | 18 | if (options.taskScheduledId === undefined) { 19 | throw new Error("TaskCompletedEvent needs a task scheduled id provided."); 20 | } 21 | 22 | if (options.result === undefined) { 23 | throw new Error("TaskCompletedEvent needs a result provided."); 24 | } 25 | 26 | this.TaskScheduledId = options.taskScheduledId; 27 | this.Result = options.result; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/history/suborchestrationinstancefailedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class SubOrchestrationInstanceFailedEvent extends HistoryEvent { 5 | public TaskScheduledId: number; 6 | public Reason: string | undefined; 7 | public Details: string | undefined; 8 | 9 | constructor( 10 | options: HistoryEventOptions, 11 | ) { 12 | super( 13 | HistoryEventType.SubOrchestrationInstanceFailed, 14 | options.eventId, 15 | options.isPlayed, 16 | options.timestamp, 17 | ); 18 | 19 | if (options.taskScheduledId === undefined) { 20 | throw new Error("SubOrchestrationInstanceFailedEvent needs a task scheduled id provided."); 21 | } 22 | 23 | this.TaskScheduledId = options.taskScheduledId; 24 | this.Reason = options.reason; 25 | this.Details = options.details; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/history/eventsentevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class EventSentEvent extends HistoryEvent { 5 | public Name: string; 6 | public Input: string | undefined; 7 | public InstanceId: string; 8 | 9 | constructor( 10 | options: HistoryEventOptions, 11 | ) { 12 | super( 13 | HistoryEventType.EventSent, 14 | options.eventId, 15 | options.isPlayed, 16 | options.timestamp, 17 | ); 18 | 19 | if (options.name === undefined) { 20 | throw new Error("EventSentEvent needs a name provided."); 21 | } 22 | 23 | if (options.instanceId === undefined) { 24 | throw new Error("EventSentEvent needs an instance id provided."); 25 | } 26 | 27 | this.Input = options.input; 28 | this.Name = options.name; 29 | this.InstanceId = options.instanceId; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/orchestrationclientinputdata.ts: -------------------------------------------------------------------------------- 1 | import { HttpCreationPayload, HttpManagementPayload } from "./classes"; 2 | 3 | /** @hidden */ 4 | export class OrchestrationClientInputData { 5 | public static isOrchestrationClientInputData(obj: unknown): boolean { 6 | const typedInstance = obj as { [index: string]: unknown }; 7 | if (typedInstance) { 8 | // Only check for required fields. 9 | if (typedInstance.taskHubName !== undefined 10 | && typedInstance.creationUrls !== undefined 11 | && typedInstance.managementUrls !== undefined) { 12 | return true; 13 | } 14 | return false; 15 | } 16 | return false; 17 | } 18 | 19 | constructor( 20 | public taskHubName: string, 21 | public creationUrls: HttpCreationPayload, 22 | public managementUrls: HttpManagementPayload, 23 | public baseUrl?: string, 24 | public requiredQueryStringParameters?: string, 25 | ) { } 26 | } 27 | -------------------------------------------------------------------------------- /src/history/suborchestrationinstancecompletedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class SubOrchestrationInstanceCompletedEvent extends HistoryEvent { 5 | public TaskScheduledId: number; 6 | public Result: string; 7 | 8 | constructor( 9 | options: HistoryEventOptions, 10 | ) { 11 | super( 12 | HistoryEventType.SubOrchestrationInstanceCompleted, 13 | options.eventId, 14 | options.isPlayed, 15 | options.timestamp, 16 | ); 17 | 18 | if (options.taskScheduledId === undefined) { 19 | throw new Error("SubOrchestrationInstanceCompletedEvent needs a task scheduled id provided."); 20 | } 21 | 22 | if (options.result === undefined) { 23 | throw new Error("SubOrchestrationInstanceCompletedEvent needs an result provided."); 24 | } 25 | 26 | this.TaskScheduledId = options.taskScheduledId; 27 | this.Result = options.result; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /samples/HttpSyncStart/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | 3 | const timeout = "timeout"; 4 | const retryInterval = "retryInterval"; 5 | 6 | module.exports = async function (context, req) { 7 | const client = df.getClient(context); 8 | const instanceId = await client.startNew(req.params.functionName, undefined, req.body); 9 | 10 | context.log(`Started orchestration with ID = '${instanceId}'.`); 11 | 12 | const timeoutInMilliseconds = getTimeInSeconds(req, timeout) || 30000; 13 | const retryIntervalInMilliseconds = getTimeInSeconds(req, retryInterval) || 1000; 14 | 15 | const response = client.waitForCompletionOrCreateCheckStatusResponse( 16 | context.bindingData.req, 17 | instanceId, 18 | timeoutInMilliseconds, 19 | retryIntervalInMilliseconds); 20 | return response; 21 | }; 22 | 23 | function getTimeInSeconds (req, queryParameterName) { 24 | const queryValue = req.query[queryParameterName]; 25 | return queryValue 26 | ? queryValue // expected to be in seconds 27 | * 1000 : undefined; 28 | } -------------------------------------------------------------------------------- /src/orchestrationruntimestatus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The status of an orchestration instance. 3 | */ 4 | export enum OrchestrationRuntimeStatus { 5 | /** 6 | * The orchestration instance has started running. 7 | */ 8 | Running = "Running", 9 | 10 | /** 11 | * The orchestration instance has completed normally. 12 | */ 13 | Completed = "Completed", 14 | 15 | /** 16 | * The orchestration instance has restarted itself with a new history. 17 | * This is a transient state. 18 | */ 19 | ContinuedAsNew = "ContinuedAsNew", 20 | 21 | /** 22 | * The orchestration instance failed with an error. 23 | */ 24 | Failed = "Failed", 25 | 26 | /** 27 | * The orchestration was canceled gracefully. 28 | */ 29 | Canceled = "Canceled", 30 | 31 | /** 32 | * The orchestration instance was stopped abruptly. 33 | */ 34 | Terminated = "Terminated", 35 | 36 | /** 37 | * The orchestration instance has been scheduled but has not yet started 38 | * running. 39 | */ 40 | Pending = "Pending", 41 | } 42 | -------------------------------------------------------------------------------- /src/history/suborchestrationinstancecreatedevent.ts: -------------------------------------------------------------------------------- 1 | import { HistoryEvent, HistoryEventOptions, HistoryEventType } from "../classes"; 2 | 3 | /** @hidden */ 4 | export class SubOrchestrationInstanceCreatedEvent extends HistoryEvent { 5 | public Name: string; 6 | public InstanceId: string; 7 | public Input: string | undefined; 8 | 9 | constructor( 10 | options: HistoryEventOptions, 11 | ) { 12 | super( 13 | HistoryEventType.SubOrchestrationInstanceCreated, 14 | options.eventId, 15 | options.isPlayed, 16 | options.timestamp, 17 | ); 18 | 19 | if (options.name === undefined) { 20 | throw new Error("SubOrchestrationInstanceCreatedEvent needs a name provided."); 21 | } 22 | 23 | if (options.instanceId === undefined) { 24 | throw new Error("SubOrchestrationInstanceCreatedEvent needs an instance id provided."); 25 | } 26 | 27 | this.Input = options.input; 28 | this.Name = options.name; 29 | this.InstanceId = options.instanceId; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Microsoft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/durablehttprequest.ts: -------------------------------------------------------------------------------- 1 | import { TokenSource } from "./tokensource"; 2 | 3 | /** 4 | * Data structure representing a durable HTTP request. 5 | */ 6 | export class DurableHttpRequest { 7 | 8 | /** 9 | * Creates a new instance of DurableHttpRequest with the 10 | * specified parameters. 11 | * 12 | * @param method The HTTP request method. 13 | * @param uri The HTTP request URL. 14 | * @param content The HTTP request content. 15 | * @param headers The HTTP request headers. 16 | * @param tokenSource The source of OAuth tokens to add to the request. 17 | */ 18 | constructor( 19 | /** The HTTP request method. */ 20 | public readonly method: string, 21 | /** The HTTP request URL. */ 22 | public readonly uri: string, 23 | /** The HTTP request content. */ 24 | public readonly content?: string, 25 | /** The HTTP request headers. */ 26 | public readonly headers?: { 27 | [key: string]: string; 28 | }, 29 | /** The source of OAuth token to add to the request. */ 30 | public readonly tokenSource?: TokenSource, 31 | ) { } 32 | } 33 | -------------------------------------------------------------------------------- /src/shim.ts: -------------------------------------------------------------------------------- 1 | import { Entity, IEntityFunctionContext, IOrchestrationFunctionContext, Orchestrator } from "./classes"; 2 | 3 | /** 4 | * Enables a generator function to act as an orchestrator function. 5 | * 6 | * Orchestration context methods can be acces 7 | * @param fn the generator function that should act as an orchestrator 8 | * @example Initialize an orchestrator 9 | * ```javascript 10 | * const df = require("durable-functions"); 11 | * 12 | * module.exports = df.orchestrator(function*(context) { 13 | * // function body 14 | * }); 15 | * ``` 16 | */ 17 | export function orchestrator(fn: (context: IOrchestrationFunctionContext) => IterableIterator) 18 | : (context: IOrchestrationFunctionContext) => void { 19 | const listener = new Orchestrator(fn).listen(); 20 | return (context: IOrchestrationFunctionContext) => { 21 | listener(context); 22 | }; 23 | } 24 | 25 | export function entity(fn: (context: IEntityFunctionContext) => unknown) 26 | : (context: IEntityFunctionContext) => void { 27 | const listener = new Entity(fn).listen(); 28 | return (context: IEntityFunctionContext) => { 29 | listener(context); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/entities/requestmessage.ts: -------------------------------------------------------------------------------- 1 | import { EntityId } from "./entityid"; 2 | 3 | /** @hidden */ 4 | export class RequestMessage { 5 | /** A unique identifier for this operation. */ 6 | public id: string; // Id 7 | 8 | /** 9 | * The name of the operation being called (if this is an operation message) 10 | * or undefined (if this is a lock request). 11 | */ 12 | public name?: string; // Operation 13 | 14 | /** Whether or not this is a one-way message. */ 15 | public signal?: boolean; // IsSignal 16 | 17 | /** The operation input. */ 18 | public input?: string; // Input 19 | 20 | /** The content the operation was called with. */ 21 | public arg?: unknown; // Content 22 | 23 | /** The parent instance that called this operation. */ 24 | public parent?: string; // ParentInstanceId 25 | 26 | /** 27 | * For lock requests, the set of locks being acquired. Is sorted, 28 | * contains at least one element, and has no repetitions. 29 | */ 30 | public lockset?: EntityId[]; // LockSet 31 | 32 | /** For lock requests involving multiple locks, the message number. */ 33 | public pos?: number; // Position 34 | } 35 | -------------------------------------------------------------------------------- /samples/E2_CopyFileToBlob/index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const storage = require("azure-storage"); 4 | 5 | module.exports = function (context, filePath) { 6 | const container = "backups"; 7 | const root = path.parse(filePath).root; 8 | const blobPath = filePath 9 | .substring(root.length) 10 | .replace("\\", "/"); 11 | const outputLocation = `backups/${blobPath}`; 12 | const blobService = storage.createBlobService(); 13 | 14 | blobService.createContainerIfNotExists(container, (error) => { 15 | if (error) { 16 | throw error; 17 | } 18 | 19 | fs.stat(filePath, function (error, stats) { 20 | if (error) { 21 | throw error; 22 | } 23 | context.log(`Copying '${filePath}' to '${outputLocation}'. Total bytes = ${stats.size}.`); 24 | 25 | const readStream = fs.createReadStream(filePath); 26 | 27 | blobService.createBlockBlobFromStream(container, blobPath, readStream, stats.size, function (error) { 28 | if (error) { 29 | throw error; 30 | } 31 | 32 | context.done(null, stats.size); 33 | }); 34 | }); 35 | }); 36 | }; -------------------------------------------------------------------------------- /test/unit/timertask-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { Constants, CreateTimerAction, TimerTask } from "../../src/classes"; 4 | 5 | describe("TimerTask", () => { 6 | it ("throws cannot cancel a completed task", async () => { 7 | const task = new TimerTask(true, false, new CreateTimerAction(new Date(), false)); 8 | expect(() => { 9 | task.cancel(); 10 | }).to.throw("Cannot cancel a completed task."); 11 | }); 12 | 13 | it ("cancels an incomplete task", async () => { 14 | const task = new TimerTask( 15 | false, 16 | false, 17 | new CreateTimerAction(new Date()), 18 | undefined, 19 | undefined, 20 | undefined); 21 | task.cancel(); 22 | expect(task.action.isCanceled).to.equal(true); 23 | expect(task.isCanceled).to.equal(true); 24 | }); 25 | 26 | it ("is canceled when its action is canceled", async () => { 27 | const task = new TimerTask( 28 | false, 29 | false, 30 | new CreateTimerAction(new Date(), true), 31 | undefined, 32 | undefined, 33 | undefined); 34 | expect(task.isCanceled).to.equal(true); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/guidmanager.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | import uuidv5 = require("uuid/v5"); 3 | import { Utils } from "./classes"; 4 | 5 | /** @hidden */ 6 | export class GuidManager { 7 | // I don't anticipate these changing often. 8 | public static DnsNamespaceValue: string = "9e952958-5e33-4daf-827f-2fa12937b875"; 9 | public static UrlNamespaceValue: string = "9e952958-5e33-4daf-827f-2fa12937b875"; 10 | public static IsoOidNamespaceValue: string = "9e952958-5e33-4daf-827f-2fa12937b875"; 11 | 12 | public static createDeterministicGuid(namespaceValue: string, name: string): string { 13 | return this.createDeterministicGuidCore(namespaceValue, name, DeterministicGuidVersion.V5); 14 | } 15 | 16 | private static createDeterministicGuidCore(namespaceValue: string, name: string, version: DeterministicGuidVersion): string { 17 | Utils.throwIfEmpty(namespaceValue, "namespaceValue"); 18 | Utils.throwIfEmpty(name, "name"); 19 | 20 | const hash = crypto.createHash("sha1"); 21 | hash.update(name); 22 | const bytes: number[] = Array.prototype.slice.call(hash.digest(), 0, 16); 23 | 24 | return uuidv5(namespaceValue, bytes); 25 | } 26 | } 27 | 28 | enum DeterministicGuidVersion { 29 | V3 = 0, 30 | V5 = 1, 31 | } 32 | -------------------------------------------------------------------------------- /samples/E3_GetIsClear/index.js: -------------------------------------------------------------------------------- 1 | const request = require("request"); 2 | 3 | const clearWeatherConditions = ['Overcast', 'Clear', 'Partly Cloudy', 'Mostly Cloudy', 'Scattered Clouds']; 4 | 5 | module.exports = function (context, location) { 6 | getCurrentConditions(location) 7 | .then(function (data) { 8 | const isClear = clearWeatherConditions.includes(data.weather); 9 | context.done(null, isClear); 10 | }) 11 | .catch(function (err) { 12 | context.log(`E3_GetIsClear encountered an error: ${err}`); 13 | context.done(err); 14 | }); 15 | }; 16 | 17 | function getCurrentConditions(location) { 18 | return new Promise(function (resolve, reject) { 19 | const options = { 20 | url: `https://api.wunderground.com/api/${process.env["WeatherUndergroundApiKey"]}/conditions/q/${location.state}/${location.city}.json`, 21 | method: 'GET', 22 | json: true 23 | }; 24 | request(options, function (err, res, body) { 25 | if (err) { 26 | reject(err); 27 | } 28 | if (body.error) { 29 | reject(body.error); 30 | } 31 | if (body.response.error) { 32 | reject(body.response.error); 33 | } 34 | resolve(body.current_observation); 35 | }); 36 | }); 37 | } -------------------------------------------------------------------------------- /src/entities/entityid.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:member-access 2 | 3 | import { Utils } from "../utils"; 4 | 5 | /** 6 | * A unique identifier for an entity, consisting of entity class and entity key. 7 | */ 8 | export class EntityId { 9 | /** @hidden */ 10 | static getEntityIdFromSchedulerId(schedulerId: string): EntityId { 11 | const pos = schedulerId.indexOf("@", 1); 12 | const entityName = schedulerId.substring(1, pos); 13 | const entityKey = schedulerId.substring(pos + 1); 14 | return new EntityId(entityName, entityKey); 15 | } 16 | 17 | /** @hidden */ 18 | static getSchedulerIdFromEntityId(entityId: EntityId): string { 19 | return `@${entityId.name.toLowerCase()}@${entityId.key}`; 20 | } 21 | 22 | /** 23 | * Create an entity id for an entity. 24 | */ 25 | constructor( 26 | // TODO: consider how to name these fields more accurately without interfering with JSON serialization 27 | /** The name of the entity class. */ 28 | public readonly name: string, 29 | /** The entity key. Uniquely identifies an entity among all instances of the same class. */ 30 | public readonly key: string, 31 | ) { 32 | Utils.throwIfEmpty(name, "name"); 33 | Utils.throwIfEmpty(key, "key"); 34 | } 35 | 36 | public toString(): string { 37 | return EntityId.getSchedulerIdFromEntityId(this); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/retryoptions.ts: -------------------------------------------------------------------------------- 1 | import { Utils } from "./utils"; 2 | 3 | /** 4 | * Defines retry policies that can be passed as parameters to various 5 | * operations. 6 | */ 7 | export class RetryOptions { 8 | /** Gets or sets the backoff coefficient. */ 9 | public backoffCoefficient: number; 10 | /** Gets or sets the max retry interval (ms). */ 11 | public maxRetryIntervalInMilliseconds: number; 12 | /** Gets or sets the timeout for retries (ms). */ 13 | public retryTimeoutInMilliseconds: number; 14 | 15 | /** 16 | * Creates a new instance of RetryOptions with the supplied first retry and 17 | * max attempts. 18 | * @param firstRetryIntervalInMilliseconds Must be greater than 0. 19 | */ 20 | constructor( 21 | /** 22 | * Gets or sets the first retry interval (ms). Must be greater than 23 | * 0. 24 | */ 25 | public readonly firstRetryIntervalInMilliseconds: number, 26 | /** Gets or sets the max number of attempts. */ 27 | public readonly maxNumberOfAttempts: number, 28 | ) { 29 | Utils.throwIfNotNumber(firstRetryIntervalInMilliseconds, "firstRetryIntervalInMilliseconds"); 30 | Utils.throwIfNotNumber(maxNumberOfAttempts, "maxNumberOfAttempts"); 31 | 32 | if (firstRetryIntervalInMilliseconds <= 0) { 33 | throw new RangeError("firstRetryIntervalInMilliseconds value must be greater than 0."); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/tokensource.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Token Source implementation for [Azure Managed Identities](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview). 3 | * 4 | * @example Get a list of Azure Subscriptions by calling the Azure Resource Manager HTTP API. 5 | * ```javascript 6 | * const df = require("durable-functions"); 7 | * 8 | * module.exports = df.orchestrator(function*(context) { 9 | * return yield context.df.callHttp( 10 | * "GET", 11 | * "https://management.azure.com/subscriptions?api-version=2019-06-01", 12 | * undefined, 13 | * undefined, 14 | * df.ManagedIdentityTokenSource("https://management.core.windows.net")); 15 | * }); 16 | * ``` 17 | */ 18 | export class ManagedIdentityTokenSource { 19 | /** @hidden */ 20 | public kind: "AzureManagedIdentity"; 21 | 22 | /** 23 | * Returns a `ManagedIdentityTokenSource` object. 24 | * @param resource The Azure Active Directory resource identifier of the web API being invoked. 25 | */ 26 | constructor( 27 | /** 28 | * The Azure Active Directory resource identifier of the web API being invoked. 29 | * For example, `https://management.core.windows.net/` or `https://graph.microsoft.com/`. 30 | */ 31 | public readonly resource: string, 32 | ) { } 33 | } 34 | 35 | // Over time we will likely add more implementations 36 | export type TokenSource = ManagedIdentityTokenSource; 37 | -------------------------------------------------------------------------------- /test/unit/entityid-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { EntityId } from "../../src/classes"; 4 | 5 | describe("EntityId", () => { 6 | const defaultEntityName = "entity"; 7 | const defaultEntityKey = "123"; 8 | const defaultEntityId = new EntityId(defaultEntityName, defaultEntityKey); 9 | 10 | it("returns correct toString", () => { 11 | const expectedToString = `@${defaultEntityName}@${defaultEntityKey}`; 12 | 13 | const result = defaultEntityId.toString(); 14 | 15 | expect(result).to.equal(expectedToString); 16 | }); 17 | 18 | describe("getEntityIdFromSchedulerId", () => { 19 | it ("constructs correct entity ID from scheduler ID", () => { 20 | const schedulerId = `@${defaultEntityName}@${defaultEntityKey}`; 21 | 22 | const expectedEntityId = new EntityId(defaultEntityName, defaultEntityKey); 23 | 24 | const result = EntityId.getEntityIdFromSchedulerId(schedulerId); 25 | 26 | expect(result).to.deep.equal(expectedEntityId); 27 | }); 28 | }); 29 | 30 | describe("getSchedulerIdFromEntityId", () => { 31 | it ("constructs correct scheduler ID from entity ID", () => { 32 | const expectedSchedulerId = `@${defaultEntityName}@${defaultEntityKey}`; 33 | 34 | const result = EntityId.getSchedulerIdFromEntityId(defaultEntityId); 35 | 36 | expect(result).to.equal(expectedSchedulerId); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /samples/E4_SmsPhoneVerification/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | const moment = require('moment'); 3 | 4 | module.exports = df.orchestrator(function*(context) { 5 | const phoneNumber = context.df.getInput(); 6 | if (!phoneNumber) { 7 | throw "A phone number input is required."; 8 | } 9 | 10 | const challengeCode = yield context.df.callActivity("E4_SendSmsChallenge", phoneNumber); 11 | 12 | // The user has 90 seconds to respond with the code they received in the SMS message. 13 | const expiration = moment.utc(context.df.currentUtcDateTime).add(90, 's'); 14 | const timeoutTask = context.df.createTimer(expiration.toDate()); 15 | 16 | let authorized = false; 17 | for (let i = 0; i <= 3; i++) { 18 | const challengeResponseTask = context.df.waitForExternalEvent("SmsChallengeResponse"); 19 | 20 | const winner = yield context.df.Task.any([challengeResponseTask, timeoutTask]); 21 | 22 | if (winner === challengeResponseTask) { 23 | // We got back a response! Compare it to the challenge code. 24 | if (challengeResponseTask.result === challengeCode) { 25 | authorized = true; 26 | break; 27 | } 28 | } else { 29 | // Timeout expired 30 | break; 31 | } 32 | } 33 | 34 | if (!timeoutTask.isCompleted) { 35 | // All pending timers must be complete or canceled before the function exits. 36 | timeoutTask.cancel(); 37 | } 38 | 39 | return authorized; 40 | }); -------------------------------------------------------------------------------- /test/testobjects/testentities.ts: -------------------------------------------------------------------------------- 1 | import * as df from "../../src"; 2 | import { IEntityFunctionContext } from "../../src/classes"; 3 | 4 | export class TestEntities { 5 | public static StringStore: any = df.entity((context: IEntityFunctionContext): void => { 6 | switch (context.df.operationName) { 7 | case "set": 8 | context.df.setState(context.df.getInput()); 9 | break; 10 | case "get": 11 | context.df.return(context.df.getState()); 12 | break; 13 | default: 14 | throw new Error("No such operation exists"); 15 | } 16 | }); 17 | 18 | public static Counter: any = df.entity((context: IEntityFunctionContext): void => { 19 | const input: number = context.df.getInput() as number; 20 | const state: number = context.df.getState() as number; 21 | switch (context.df.operationName) { 22 | case "increment": 23 | context.df.setState(input + 1); 24 | break; 25 | case "add": 26 | context.df.setState(state + input); 27 | break; 28 | case "get": 29 | context.df.return(state); 30 | break; 31 | case "set": 32 | context.df.setState(input); 33 | break; 34 | case "delete": 35 | context.df.destructOnExit(); 36 | break; 37 | default: 38 | throw Error("Invalid operation"); 39 | } 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/webhookutils.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export class WebhookUtils { 3 | public static getReadEntityUrl(baseUrl: string, requiredQueryStrings: string, entityName: string, entityKey: string, taskHubName?: string, connectionName?: string): string { 4 | let requestUrl = baseUrl + "/entities/" 5 | + entityName + "/" 6 | + entityKey + "?"; 7 | 8 | const queryStrings: string[] = []; 9 | if (taskHubName) { 10 | queryStrings.push("taskHub=" + taskHubName); 11 | } 12 | 13 | if (connectionName) { 14 | queryStrings.push("connection=" + connectionName); 15 | } 16 | 17 | queryStrings.push(requiredQueryStrings); 18 | requestUrl += queryStrings.join("&"); 19 | return requestUrl; 20 | } 21 | 22 | public static getSignalEntityUrl(baseUrl: string, requiredQueryStrings: string, entityName: string, entityKey: string, operationName?: string, taskHubName?: string, connectionName?: string) { 23 | let requestUrl = baseUrl + "/entities/" 24 | + entityName + "/" 25 | + entityKey + "?"; 26 | 27 | const queryStrings: string[] = []; 28 | if (operationName) { 29 | queryStrings.push("op=" + operationName); 30 | } 31 | 32 | if (taskHubName) { 33 | queryStrings.push("taskHub=" + taskHubName); 34 | } 35 | 36 | if (connectionName) { 37 | queryStrings.push("connection=" + connectionName); 38 | } 39 | 40 | queryStrings.push(requiredQueryStrings); 41 | requestUrl += queryStrings.join("&"); 42 | return requestUrl; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. Please make an effort to fill in all the sections below; the information will help us investigate your issue. 12 | 13 | **Investigative information** 14 | 15 | - Durable Functions extension version: 16 | - durable-functions npm module version: 17 | - Language (JavaScript/TypeScript) and version: 18 | - Node.js version: 19 | 20 | ***If deployed to Azure App Service*** 21 | 22 | - Timeframe issue observed: 23 | - Function App name: 24 | - Function name(s): 25 | - Region: 26 | - Orchestration instance ID(s): 27 | 28 | > If you don't want to share your Function App name or Functions names on GitHub, please be sure to provide your Invocation ID, Timestamp, and Region - we can use this to look up your Function App/Function. Provide an invocation id per Function. See the [Functions Host wiki](https://github.com/Azure/azure-webjobs-sdk-script/wiki/Sharing-Your-Function-App-name-privately) for more details. 29 | 30 | **To Reproduce** 31 | Steps to reproduce the behavior: 32 | 33 | 1. Go to '...' 34 | 2. Click on '....' 35 | 3. Scroll down to '....' 36 | 4. See error 37 | 38 | > While not required, providing your orchestrator's source code in anonymized form is often very helpful when investigating unexpected orchestrator behavior. 39 | 40 | **Expected behavior** 41 | A clear and concise description of what you expected to happen. 42 | 43 | **Actual behavior** 44 | A clear and concise description of what actually happened. 45 | 46 | **Screenshots** 47 | If applicable, add screenshots to help explain your problem. 48 | 49 | **Known workarounds** 50 | Provide a description of any known workarounds you used. 51 | 52 | **Additional context** 53 | 54 | - Development environment (ex. Visual Studio) 55 | - Links to source 56 | - Additional bindings used 57 | - Function invocation IDs 58 | -------------------------------------------------------------------------------- /test/unit/guidmanager-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import * as crypto from "crypto"; 3 | import "mocha"; 4 | import moment = require("moment"); 5 | import uuidv1 = require("uuid/v1"); 6 | import uuidv5 = require("uuid/v5"); 7 | import { isUUID } from "validator"; 8 | import { GuidManager } from "../../src/classes"; 9 | 10 | describe("GuidManager", () => { 11 | describe("createDeterministicGuid()", async () => { 12 | it("throws if namespaceValue is empty", () => { 13 | expect(() => GuidManager.createDeterministicGuid("", "name")) 14 | .to.throw("namespaceValue: Expected non-empty, non-whitespace string but got empty string"); 15 | }); 16 | 17 | it("throws if name is empty", () => { 18 | expect(() => GuidManager.createDeterministicGuid("namespaceValue", "")) 19 | .to.throw("name: Expected non-empty, non-whitespace string but got empty string"); 20 | }); 21 | 22 | it("returns consistent GUID for namespace and name", () => { 23 | const namespace = GuidManager.UrlNamespaceValue; 24 | const instanceId = uuidv1(); 25 | const currentUtcDateTime = moment.utc().toDate().valueOf(); 26 | 27 | const name1 = `${instanceId}_${currentUtcDateTime}_0`; 28 | const name2 = `${instanceId}_${currentUtcDateTime}_12`; 29 | 30 | const result1a = GuidManager.createDeterministicGuid(namespace, name1); 31 | const result1b = GuidManager.createDeterministicGuid(namespace, name1); 32 | 33 | const result2a = GuidManager.createDeterministicGuid(namespace, name2); 34 | const result2b = GuidManager.createDeterministicGuid(namespace, name2); 35 | 36 | expect(isUUID(result1a, "5")).to.equal(true); 37 | expect(isUUID(result2a, "5")).to.equal(true); 38 | expect(result1a).to.equal(result1b); 39 | expect(result2a).to.equal(result2b); 40 | expect(result1a).to.not.equal(result2a); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "durable-functions", 3 | "version": "1.3.1", 4 | "description": "Durable Functions library for Node.js Azure Functions", 5 | "license": "MIT", 6 | "repository": "github:Azure/azure-functions-durable-js", 7 | "author": "Microsoft Corporation", 8 | "keywords": [ 9 | "azure-functions" 10 | ], 11 | "files": [ 12 | "lib/src" 13 | ], 14 | "main": "lib/src/index.js", 15 | "typings": "lib/src/index.d.ts", 16 | "scripts": { 17 | "clean": "rimraf lib", 18 | "lint": "tslint --project tsconfig.json --config tslint.json --force --format verbose \"src/**/*.ts\"", 19 | "lint:test": "tslint --force --format verbose \"test/**/*.ts\"", 20 | "build": "npm run clean && npm run lint && npm run lint:test && echo Using TypeScript && tsc --version && tsc --pretty && echo Done", 21 | "test": "npm run build && mocha --recursive ./lib/test/**/*-spec.js", 22 | "watch": "npm run build -- --watch", 23 | "watch:test": "npm run test -- --watch", 24 | "docs": "typedoc --excludePrivate --mode file --out ./lib/docs ./src", 25 | "e2etst": "npm run " 26 | }, 27 | "dependencies": { 28 | "@azure/functions": "^1.0.2-beta2", 29 | "@types/lodash": "^4.14.119", 30 | "@types/uuid": "~3.4.4", 31 | "@types/validator": "^9.4.3", 32 | "axios": "^0.19.0", 33 | "commander": "~2.9.0", 34 | "debug": "~2.6.9", 35 | "lodash": "^4.17.15", 36 | "rimraf": "~2.5.4", 37 | "uuid": "~3.3.2", 38 | "validator": "~10.8.0" 39 | }, 40 | "devDependencies": { 41 | "@types/chai": "~4.1.6", 42 | "@types/chai-as-promised": "~7.1.0", 43 | "@types/commander": "~2.3.31", 44 | "@types/debug": "0.0.29", 45 | "@types/mocha": "~5.2.5", 46 | "@types/nock": "^9.3.0", 47 | "@types/node": "~6.14.7", 48 | "@types/rimraf": "0.0.28", 49 | "@types/sinon": "~5.0.5", 50 | "chai": "~4.2.0", 51 | "chai-as-promised": "~7.1.1", 52 | "mocha": "~5.2.0", 53 | "moment": "~2.22.2", 54 | "nock": "^10.0.6", 55 | "sinon": "~7.1.1", 56 | "ts-node": "~1.0.0", 57 | "tslint": "^5.11.0", 58 | "typescript": "~3.1.6" 59 | }, 60 | "engines": { 61 | "node": ">=6.5.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /samples/E3_Monitor/index.js: -------------------------------------------------------------------------------- 1 | const df = require("durable-functions"); 2 | const moment = require('moment'); 3 | 4 | module.exports = df.orchestrator(function*(context) { 5 | const input = context.df.getInput(); 6 | context.log("Received monitor request. location: " + (input ? input.location : undefined) 7 | + ". phone: " + (input ? input.phone : undefined) + "."); 8 | 9 | verifyRequest(input); 10 | 11 | const endTime = moment.utc(context.df.currentUtcDateTime).add(6, 'h'); 12 | context.log("Instantiating monitor for " + input.location.city + ", " + input.location.state 13 | + ". Expires: " + (endTime) + "."); 14 | 15 | while (moment.utc(context.df.currentUtcDateTime).isBefore(endTime)) { 16 | // Check the weather 17 | context.log("Checking current weather conditions for " + input.location.city + ", " 18 | + input.location.state + " at " + context.df.currentUtcDateTime + "."); 19 | const isClear = yield context.df.callActivity("E3_GetIsClear", input.location); 20 | 21 | if (isClear) { 22 | // It's not raining! Or snowing. Or misting. Tell our user to take advantage of it. 23 | context.log("Detected clear weather for " + input.location.city + ", " 24 | + input.location.state + ". Notifying " + input.phone + "."); 25 | 26 | yield context.df.callActivity("E3_SendGoodWeatherAlert", input.phone); 27 | break; 28 | } else { 29 | // Wait for the next checkpoint 30 | var nextCheckpoint = moment.utc(context.df.currentUtcDateTime).add(30, 's'); 31 | context.log("Next check for " + input.location.city + ", " + input.location.state 32 | + " at " + nextCheckpoint.toString()); 33 | 34 | yield context.df.createTimer(nextCheckpoint.toDate()); // accomodate cancellation tokens 35 | } 36 | } 37 | 38 | context.log("Monitor expiring."); 39 | }); 40 | 41 | function verifyRequest(request) { 42 | if (!request) { 43 | throw new Error("An input object is required."); 44 | } 45 | if (!request.location) { 46 | throw new Error("A location input is required."); 47 | } 48 | if (!request.phone) { 49 | throw new Error("A phone number input is required."); 50 | } 51 | } -------------------------------------------------------------------------------- /src/timertask.ts: -------------------------------------------------------------------------------- 1 | import { Constants, CreateTimerAction, Task } from "./classes"; 2 | 3 | /** 4 | * Returned from [[DurableOrchestrationClient]].[[createTimer]] if the call is 5 | * not `yield`-ed. Represents a pending timer. See documentation on [[Task]] 6 | * for more information. 7 | * 8 | * All pending timers must be completed or canceled for an orchestration to 9 | * complete. 10 | * 11 | * @example Cancel a timer 12 | * ```javascript 13 | * // calculate expiration date 14 | * const timeoutTask = context.df.createTimer(expirationDate); 15 | * 16 | * // do some work 17 | * 18 | * if (!timeoutTask.isCompleted) { 19 | * timeoutTask.cancel(); 20 | * } 21 | * ``` 22 | * 23 | * @example Create a timeout 24 | * ```javascript 25 | * const now = Date.now(); 26 | * const expiration = new Date(now.valueOf()).setMinutes(now.getMinutes() + 30); 27 | * 28 | * const timeoutTask = context.df.createTimer(expirationDate); 29 | * const otherTask = context.df.callActivity("DoWork"); 30 | * 31 | * const winner = yield context.df.Task.any([timeoutTask, otherTask]); 32 | * 33 | * if (winner === otherTask) { 34 | * // do some more work 35 | * } 36 | * 37 | * if (!timeoutTask.isCompleted) { 38 | * timeoutTask.cancel(); 39 | * } 40 | * ``` 41 | */ 42 | export class TimerTask extends Task { 43 | /** @hidden */ 44 | constructor( 45 | isCompleted: boolean, 46 | isFaulted: boolean, 47 | /** 48 | * The scheduled action represented by the task. _Internal use only._ 49 | */ 50 | public readonly action: CreateTimerAction, 51 | result?: unknown, 52 | timestamp?: Date, 53 | id?: number, 54 | ) { super(isCompleted, isFaulted, action, result, timestamp, id); } 55 | 56 | /** 57 | * @returns Whether or not the timer has been canceled. 58 | */ 59 | get isCanceled(): boolean { 60 | return this.action && this.action.isCanceled; 61 | } 62 | 63 | /** 64 | * Indicates the timer should be canceled. This request will execute on the 65 | * next `yield` or `return` statement. 66 | */ 67 | public cancel() { 68 | if (!this.isCompleted) { 69 | this.action.isCanceled = true; 70 | } else { 71 | throw new Error("Cannot cancel a completed task."); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export class Utils { 3 | public static getInstancesOf(collection: { [index: string]: unknown }, typeInstance: T): T[] { 4 | return collection && typeInstance 5 | ? Object.keys(collection) 6 | .filter((key: string) => this.hasAllPropertiesOf(collection[key], typeInstance)) 7 | .map((key: string) => collection[key]) as T[] 8 | : []; 9 | } 10 | 11 | public static getHrMilliseconds(times: number[]): number { 12 | return times[0] * 1000 + times[1] / 1e6; 13 | } 14 | 15 | public static hasAllPropertiesOf(obj: unknown, refInstance: T): boolean { 16 | return typeof refInstance === "object" 17 | && typeof obj === "object" 18 | && obj !== null 19 | && Object.keys(refInstance).every((key: string) => { 20 | return obj.hasOwnProperty(key); 21 | }); 22 | } 23 | 24 | public static ensureNonNull(argument: T | undefined, message: string) { 25 | if (argument === undefined) { 26 | throw new TypeError(message); 27 | } 28 | 29 | return argument; 30 | } 31 | 32 | public static sleep(delayInMilliseconds: number): Promise { 33 | return new Promise((resolve) => setTimeout(resolve, delayInMilliseconds)); 34 | } 35 | 36 | public static throwIfNotInstanceOf(value: unknown, name: string, refInstance: T, type: string): void { 37 | if (!this.hasAllPropertiesOf(value, refInstance)) { 38 | throw new TypeError(`${name}: Expected object of type ${type} but got ${typeof value}; are you missing properties?`); 39 | } 40 | } 41 | 42 | public static throwIfEmpty(value: unknown, name: string): void { 43 | if (typeof value !== "string") { 44 | throw new TypeError(`${name}: Expected non-empty, non-whitespace string but got ${typeof value}`); 45 | } else if (value.trim().length < 1) { 46 | throw new Error(`${name}: Expected non-empty, non-whitespace string but got empty string`); 47 | } 48 | } 49 | 50 | public static throwIfNotNumber(value: unknown, name: string): void { 51 | if (typeof value !== "number") { 52 | throw new TypeError(`${name}: Expected number but got ${typeof value}`); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/durableorchestrationstatus.ts: -------------------------------------------------------------------------------- 1 | import { OrchestrationRuntimeStatus } from "./classes"; 2 | 3 | /** 4 | * Represents the status of a durable orchestration instance. 5 | * 6 | * Can be fetched using [[DurableOrchestrationClient]].[[getStatus]]. 7 | */ 8 | export class DurableOrchestrationStatus { 9 | /** @hidden */ 10 | constructor( 11 | /** The orchestrator function name. */ 12 | public readonly name: string, 13 | /** 14 | * The unique ID of the instance. 15 | * 16 | * The instance ID is generated and fixed when the orchestrator 17 | * function is scheduled. It can either auto-generated, in which case 18 | * it is formatted as a GUID, or it can be user-specified with any 19 | * format. 20 | */ 21 | public readonly instanceId: string, 22 | /** 23 | * The time at which the orchestration instance was created. 24 | * 25 | * If the orchestration instance is in the [[Pending]] status, this 26 | * time represents the time at which the orchestration instance was 27 | * scheduled. 28 | */ 29 | public readonly createdTime: Date, 30 | /** 31 | * The time at which the orchestration instance last updated its 32 | * execution history. 33 | */ 34 | public readonly lastUpdatedTime: Date, 35 | /** 36 | * The input of the orchestration instance. 37 | */ 38 | public readonly input: unknown, 39 | /** 40 | * The output of the orchestration instance. 41 | */ 42 | public readonly output: unknown, 43 | /** 44 | * The runtime status of the orchestration instance. 45 | */ 46 | public readonly runtimeStatus: OrchestrationRuntimeStatus, 47 | /** 48 | * The custom status payload (if any) that was set by 49 | * [[DurableOrchestrationClient]].[[setCustomStatus]]. 50 | */ 51 | public readonly customStatus?: unknown, 52 | /** 53 | * The execution history of the orchestration instance. 54 | * 55 | * The history log can be large and is therefore `undefined` by 56 | * default. It is populated only when explicitly requested in the call 57 | * to [[DurableOrchestrationClient]].[[getStatus]]. 58 | */ 59 | public readonly history?: Array, 60 | ) { } 61 | } 62 | -------------------------------------------------------------------------------- /src/task.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from "./classes"; 2 | 3 | /** 4 | * Represents some pending action. Similar to a native JavaScript promise in 5 | * that it acts as a placeholder for outstanding asynchronous work, but has 6 | * a synchronous implementation and is specific to Durable Functions. 7 | * 8 | * Tasks are only returned to an orchestration function when a 9 | * [[DurableOrchestrationContext]] operation is not called with `yield`. They 10 | * are useful for parallelization and timeout operations in conjunction with 11 | * Task.all and Task.any. 12 | * 13 | * @example Wait for all parallel operations to complete 14 | * ```javascript 15 | * const operations = context.df.callActivity("GetOperations"); 16 | * 17 | * const tasks = []; 18 | * for (const operation of operations) { 19 | * tasks.push(context.df.callActivity("DoOperation", operation)); 20 | * } 21 | * 22 | * const results = yield context.df.Task.all(tasks); 23 | * ``` 24 | * 25 | * @example Return the result of the first of two operations to complete 26 | * ```javascript 27 | * const taskA = context.df.callActivity("DoWorkA"); 28 | * const taskB = context.df.callActivity("DoWorkB"); 29 | * 30 | * const firstDone = yield context.df.Task.any([taskA, taskB]); 31 | * 32 | * return firstDone.result; 33 | * ``` 34 | */ 35 | export class Task { 36 | /** @hidden */ 37 | constructor( 38 | /** 39 | * Whether the task has completed. Note that completion is not 40 | * equivalent to success. 41 | */ 42 | public readonly isCompleted: boolean, 43 | /** 44 | * Whether the task faulted in some way due to error. 45 | */ 46 | public readonly isFaulted: boolean, 47 | /** 48 | * The scheduled action represented by the task. _Internal use only._ 49 | */ 50 | public readonly action: IAction, 51 | /** 52 | * The result of the task, if completed. Otherwise `undefined`. 53 | */ 54 | public readonly result?: unknown, 55 | /** 56 | * The timestamp of the task. 57 | */ 58 | public readonly timestamp?: Date, 59 | /** 60 | * The ID number of the task. _Internal use only._ 61 | */ 62 | public readonly id?: number, 63 | /** 64 | * The error thrown when attempting to perform the task's action. If 65 | * the Task has not yet completed or has completed successfully, 66 | * `undefined`. 67 | */ 68 | public readonly exception?: unknown, 69 | ) { } 70 | } 71 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | 9 | { 10 | "type": "node", 11 | "request": "launch", 12 | "name": "Launch Program", 13 | "program": "${workspaceRoot}\\test\\test.ts", 14 | "sourceMaps": true, 15 | "cwd": "${workspaceRoot}\\lib\\test", 16 | "outFiles": [ 17 | "${workspaceRoot}/lib/**/*.js" 18 | ], 19 | "env": { 20 | "DEBUG":"*" 21 | }, 22 | "preLaunchTask": "build" 23 | }, 24 | { 25 | "type": "node", 26 | "request": "attach", 27 | "name": "Attach by Process ID", 28 | "outFiles": [ 29 | "${workspaceRoot}/lib/**/*.js" 30 | ], 31 | "processId": "${command:PickProcess}" 32 | }, 33 | { 34 | "type": "node", 35 | "request": "attach", 36 | "name": "Attach","outFiles": [ 37 | "${workspaceRoot}/lib/**/*.js" 38 | ], 39 | "port": 5858 40 | }, 41 | { 42 | "type": "node", 43 | "request": "launch", 44 | "name": "Mocha Tests", 45 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 46 | "args": [ 47 | "-u", 48 | "tdd", 49 | "--colors", 50 | "${workspaceFolder}/lib/test/**/**-spec.js", 51 | "-g", 52 | ".*" 53 | ], 54 | "internalConsoleOptions": "openOnSessionStart", 55 | "sourceMaps": true, 56 | "outFiles": [ 57 | "${workspaceFolder}/lib/**" 58 | ], 59 | "preLaunchTask": "build" 60 | }, 61 | { 62 | "type": "node", 63 | "request": "launch", 64 | "name": "Mocha Tests Debug", 65 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 66 | "args": [ 67 | "-u", 68 | "tdd", 69 | "--colors", 70 | "${workspaceFolder}/lib/test/**/**-spec.js", 71 | "-g", 72 | ".*", 73 | "--timeout", 74 | "300000" 75 | ], 76 | "internalConsoleOptions": "openOnSessionStart", 77 | "sourceMaps": true, 78 | "outFiles": [ 79 | "${workspaceFolder}/lib/**" 80 | ], 81 | "preLaunchTask": "build" 82 | } 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /test/integration/entity-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { DurableEntityContext, EntityState, OperationResult } from "../../src/classes"; 4 | import { TestEntities } from "../testobjects/testentities"; 5 | import { TestEntityBatches } from "../testobjects/testentitybatches"; 6 | import { StringStoreOperation } from "../testobjects/testentityoperations"; 7 | 8 | describe("Entity", () => { 9 | it("StringStore entity with no initial state.", async () => { 10 | const entity = TestEntities.StringStore; 11 | const operations: StringStoreOperation[] = []; 12 | operations.push({ kind: "set", value: "hello"}); 13 | operations.push({ kind: "get"}); 14 | operations.push({ kind: "set", value: "hello world"}); 15 | operations.push({ kind: "get"}); 16 | 17 | const testData = TestEntityBatches.GetStringStoreBatch(operations, undefined); 18 | const mockContext = new MockContext({ 19 | context: testData.input, 20 | }); 21 | entity(mockContext); 22 | 23 | expect(mockContext.doneValue).to.not.equal(undefined); 24 | 25 | if (mockContext.doneValue) { 26 | entityStateMatchesExpected(mockContext.doneValue, testData.output); 27 | } 28 | }); 29 | 30 | it("StringStore entity with initial state.", async () => { 31 | const entity = TestEntities.StringStore; 32 | const operations: StringStoreOperation[] = []; 33 | operations.push({ kind: "get"}); 34 | 35 | const testData = TestEntityBatches.GetStringStoreBatch(operations, "Hello world"); 36 | const mockContext = new MockContext({ 37 | context: testData.input, 38 | }); 39 | entity(mockContext); 40 | 41 | expect(mockContext.doneValue).to.not.equal(undefined); 42 | 43 | if (mockContext.doneValue) { 44 | entityStateMatchesExpected(mockContext.doneValue, testData.output); 45 | } 46 | }); 47 | }); 48 | 49 | // Have to compare on an element by element basis as elapsed time is not deterministic. 50 | function entityStateMatchesExpected(actual: EntityState, expected: EntityState) { 51 | expect(actual.entityExists).to.be.equal(expected.entityExists); 52 | expect(actual.entityState).to.be.deep.equal(expected.entityState); 53 | expect(actual.signals).to.be.deep.equal(expected.signals); 54 | for (let i = 0; i < actual.results.length; i++) { 55 | expect(actual.results[i].isError).to.be.equal(expected.results[i].isError); 56 | expect(actual.results[i].result).to.be.deep.equal(expected.results[i].result); 57 | } 58 | } 59 | 60 | class MockContext { 61 | constructor( 62 | public bindings: IBindings, 63 | public df?: DurableEntityContext, 64 | public doneValue?: EntityState, 65 | public err?: Error | string | null, 66 | ) { } 67 | 68 | public done(err?: Error | string | null, result?: EntityState) { 69 | this.doneValue = result; 70 | this.err = err; 71 | } 72 | } 73 | 74 | interface IBindings { 75 | [key: string]: unknown; 76 | } 77 | -------------------------------------------------------------------------------- /src/durableentitycontext.ts: -------------------------------------------------------------------------------- 1 | import { EntityId } from "./classes"; 2 | 3 | /** 4 | * Provides functionality for application code implementing an entity 5 | * operation. 6 | */ 7 | export class DurableEntityContext { 8 | /** 9 | * Gets the name of the currently executing entity. 10 | */ 11 | public readonly entityName: string; 12 | 13 | /** 14 | * Gets the key of the currently executing entity. 15 | */ 16 | public readonly entityKey: string; 17 | 18 | /** 19 | * Gets the id of the currently executing entity. 20 | */ 21 | public readonly entityId: EntityId; 22 | 23 | /** 24 | * Gets the name of the operation that was called. 25 | * 26 | * An operation invocation on an entity includes an operation name, which 27 | * states what operation to perform, and optionally an operation input. 28 | */ 29 | public readonly operationName: string | undefined; 30 | 31 | /** 32 | * Whether this entity is freshly constructed, i.e. did not exist prior to 33 | * this operation being called. 34 | */ 35 | public readonly isNewlyConstructed: boolean; 36 | 37 | /** 38 | * Gets the current state of this entity, for reading and/or writing. 39 | * 40 | * @param initializer Provides an initial value to use for the state, 41 | * instead of TState's default. 42 | * @returns The current state of this entity, or undefined if none has been set yet. 43 | */ 44 | public getState(initializer?: () => unknown): unknown | undefined { 45 | throw new Error("This is a placeholder."); 46 | } 47 | 48 | /** 49 | * Sets the current state of this entity. 50 | * 51 | * @param state The state of the entity. 52 | */ 53 | public setState(state: unknown): void { 54 | throw new Error("This is a placeholder."); 55 | } 56 | 57 | /** 58 | * Gets the input for this operation. 59 | * 60 | * An operation invocation on an entity includes an operation name, which 61 | * states what operation to perform, and optionally an operation input. 62 | * 63 | * @returns The operation input, or undefined if none. 64 | */ 65 | public getInput(): unknown | undefined { 66 | throw new Error("This is a placeholder."); 67 | } 68 | 69 | /** 70 | * Returns the given result to the caller of this operation. 71 | * 72 | * @param result The result to return. 73 | */ 74 | public return(result: unknown): void { 75 | throw new Error("This is a placeholder."); 76 | } 77 | 78 | /** 79 | * Deletes this entity after this operation completes. 80 | */ 81 | public destructOnExit(): void { 82 | throw new Error("This is a placeholder."); 83 | } 84 | 85 | /** 86 | * Signals an entity to perform an operation, without waiting for a 87 | * response. Any result or exception is ignored (fire and forget). 88 | * 89 | * @param entity The target entity. 90 | * @param operationName The name of the operation. 91 | * @param operationInput The operation input. 92 | */ 93 | public signalEntity(entity: EntityId, operationName: string, operationInput?: unknown): void { 94 | throw new Error("This is a placeholder."); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test/testobjects/testconstants.ts: -------------------------------------------------------------------------------- 1 | export class TestConstants { 2 | public static readonly connectionPlaceholder: string = "CONNECTION"; 3 | public static readonly taskHubPlaceholder: string = "TASK-HUB"; 4 | public static readonly hostPlaceholder: string = "HOST-PLACEHOLDER"; 5 | 6 | public static readonly testCode: string = "code=base64string"; 7 | 8 | public static readonly entityNamePlaceholder: string = "{entityName}"; 9 | public static readonly entityKeyPlaceholder: string = "{entityKey?}"; 10 | public static readonly eventNamePlaceholder: string = "{eventName}"; 11 | public static readonly functionPlaceholder: string = "{functionName}"; 12 | public static readonly idPlaceholder: string = "[/{instanceId}]"; 13 | public static readonly intervalPlaceholder: string = "{intervalInSeconds}"; 14 | public static readonly operationPlaceholder: string = "{operation}"; 15 | public static readonly reasonPlaceholder: string = "{text}"; 16 | public static readonly timeoutPlaceholder: string = "{timeoutInSeconds}"; 17 | 18 | public static readonly uriSuffix: string = `taskHub=${TestConstants.taskHubPlaceholder}&connection=${TestConstants.connectionPlaceholder}&${TestConstants.testCode}`; // tslint:disable-line max-line-length 19 | public static readonly webhookPath: string = "/runtime/webhooks/durabletask/"; 20 | 21 | public static readonly statusQueryGetUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}instances/${TestConstants.idPlaceholder}?${TestConstants.uriSuffix}`; // tslint:disable-line max-line-length 22 | public static readonly sendEventPostUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}instances/${TestConstants.idPlaceholder}/raiseEvent/${TestConstants.eventNamePlaceholder}?${TestConstants.uriSuffix}`; // tslint:disable-line max-line-length 23 | public static readonly terminatePostUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}instances/${TestConstants.idPlaceholder}/terminate?reason=${TestConstants.reasonPlaceholder}&${TestConstants.uriSuffix}`; // tslint:disable-line max-line-length 24 | public static readonly rewindPostUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}instances/${TestConstants.idPlaceholder}/rewind?reason=${TestConstants.reasonPlaceholder}&${TestConstants.uriSuffix}`; // tslint:disable-line max-line-length 25 | public static readonly purgeDeleteUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}instances/${TestConstants.idPlaceholder}?${TestConstants.uriSuffix}`; // tslint:disable-line max-line-length 26 | 27 | public static readonly createPostUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}orchestrators/${TestConstants.functionPlaceholder}${TestConstants.idPlaceholder}?${TestConstants.testCode}`; // tslint:disable-line max-line-length 28 | public static readonly waitOnPostUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}orchestrators/${TestConstants.functionPlaceholder}${TestConstants.idPlaceholder}?timeout=${TestConstants.timeoutPlaceholder}&pollingInterval=${TestConstants.intervalPlaceholder}&${TestConstants.testCode}`; // tslint:disable-line max-line-length 29 | 30 | public static readonly entityGetUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}entities/${TestConstants.entityNamePlaceholder}/${TestConstants.entityKeyPlaceholder}&${TestConstants.uriSuffix}`; // tslint:disable-line max-line-length 31 | public static readonly entityPostUriTemplate: string = `${TestConstants.hostPlaceholder}${TestConstants.webhookPath}entities/${TestConstants.entityNamePlaceholder}/${TestConstants.entityKeyPlaceholder}&op=${TestConstants.operationPlaceholder}&${TestConstants.uriSuffix}`; // tslint:disable-line max-line-length 32 | } 33 | -------------------------------------------------------------------------------- /test/testobjects/testutils.ts: -------------------------------------------------------------------------------- 1 | import { HttpCreationPayload, HttpManagementPayload, 2 | OrchestrationClientInputData } from "../../src/classes"; 3 | import { TestConstants } from "./testconstants"; 4 | 5 | export class TestUtils { 6 | public static createOrchestrationClientInputData( 7 | id: string, 8 | host: string, 9 | taskHub: string = TestConstants.taskHubPlaceholder, 10 | connection: string = TestConstants.connectionPlaceholder) { 11 | return new OrchestrationClientInputData( 12 | taskHub, 13 | TestUtils.createHttpCreationPayload(host, taskHub, connection), 14 | TestUtils.createHttpManagementPayload(id, host, taskHub, connection), 15 | `${host}${TestConstants.webhookPath.replace(/\/$/, "")}`, // Returns baseURL with remaining whitespace trimmed. 16 | TestConstants.testCode, 17 | ); 18 | } 19 | 20 | public static createV1OrchestrationClientInputData( 21 | id: string, 22 | host: string, 23 | taskHub: string = TestConstants.taskHubPlaceholder, 24 | connection: string = TestConstants.connectionPlaceholder) { 25 | return new OrchestrationClientInputData( 26 | taskHub, 27 | TestUtils.createHttpCreationPayload(host, taskHub, connection), 28 | TestUtils.createHttpManagementPayload(id, host, taskHub, connection), 29 | ); 30 | } 31 | 32 | public static createHttpCreationPayload(host: string, taskHub: string, connection: string) { 33 | return new HttpCreationPayload( 34 | TestConstants.createPostUriTemplate 35 | .replace(TestConstants.hostPlaceholder, host) 36 | .replace(TestConstants.taskHubPlaceholder, taskHub) 37 | .replace(TestConstants.connectionPlaceholder, connection), 38 | TestConstants.waitOnPostUriTemplate 39 | .replace(TestConstants.hostPlaceholder, host) 40 | .replace(TestConstants.taskHubPlaceholder, taskHub) 41 | .replace(TestConstants.connectionPlaceholder, connection), 42 | ); 43 | } 44 | 45 | public static createHttpManagementPayload(id: string, host: string, taskHub: string, connection: string) { 46 | return new HttpManagementPayload( 47 | id, 48 | TestConstants.statusQueryGetUriTemplate 49 | .replace(TestConstants.hostPlaceholder, host) 50 | .replace(TestConstants.idPlaceholder, id) 51 | .replace(TestConstants.taskHubPlaceholder, taskHub) 52 | .replace(TestConstants.connectionPlaceholder, connection), 53 | TestConstants.sendEventPostUriTemplate 54 | .replace(TestConstants.hostPlaceholder, host) 55 | .replace(TestConstants.idPlaceholder, id) 56 | .replace(TestConstants.taskHubPlaceholder, taskHub) 57 | .replace(TestConstants.connectionPlaceholder, connection), 58 | TestConstants.terminatePostUriTemplate 59 | .replace(TestConstants.hostPlaceholder, host) 60 | .replace(TestConstants.idPlaceholder, id) 61 | .replace(TestConstants.taskHubPlaceholder, taskHub) 62 | .replace(TestConstants.connectionPlaceholder, connection), 63 | TestConstants.rewindPostUriTemplate 64 | .replace(TestConstants.hostPlaceholder, host) 65 | .replace(TestConstants.idPlaceholder, id) 66 | .replace(TestConstants.taskHubPlaceholder, taskHub) 67 | .replace(TestConstants.connectionPlaceholder, connection), 68 | TestConstants.purgeDeleteUriTemplate 69 | .replace(TestConstants.hostPlaceholder, host) 70 | .replace(TestConstants.idPlaceholder, id) 71 | .replace(TestConstants.taskHubPlaceholder, taskHub) 72 | .replace(TestConstants.connectionPlaceholder, connection), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/classes.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export { Constants } from "./constants"; 3 | export { Utils } from "./utils"; 4 | 5 | export { Orchestrator } from "./orchestrator"; 6 | export { Entity } from "./entity"; 7 | 8 | export { IEntityFunctionContext } from "./ientityfunctioncontext"; 9 | export { IOrchestrationFunctionContext } from "./iorchestrationfunctioncontext"; 10 | export { DurableEntityBindingInfo } from "./durableentitybindinginfo"; 11 | export { DurableEntityContext } from "./durableentitycontext"; 12 | export { DurableOrchestrationBindingInfo } from "./durableorchestrationbindinginfo"; 13 | export { DurableOrchestrationContext } from "./durableorchestrationcontext"; 14 | 15 | export { IAction } from "./actions/iaction"; 16 | export { ActionType } from "./actions/actiontype"; 17 | export { ExternalEventType } from "./actions/externaleventtype"; 18 | export { CallActivityAction } from "./actions/callactivityaction"; 19 | export { CallActivityWithRetryAction } from "./actions/callactivitywithretryaction"; 20 | export { CallEntityAction } from "./actions/callentityaction"; 21 | export { CallSubOrchestratorAction } from "./actions/callsuborchestratoraction"; 22 | export { CallSubOrchestratorWithRetryAction } from "./actions/callsuborchestratorwithretryaction"; 23 | export { CallHttpAction } from "./actions/callhttpaction"; 24 | export { ContinueAsNewAction } from "./actions/continueasnewaction"; 25 | export { CreateTimerAction } from "./actions/createtimeraction"; 26 | export { WaitForExternalEventAction } from "./actions/waitforexternaleventaction"; 27 | 28 | export { HistoryEvent } from "./history/historyevent"; 29 | export { HistoryEventOptions } from "./history/historyeventoptions"; 30 | export { EventRaisedEvent } from "./history/eventraisedevent"; 31 | export { EventSentEvent } from "./history/eventsentevent"; 32 | export { ExecutionStartedEvent } from "./history/executionstartedevent"; 33 | export { HistoryEventType } from "./history/historyeventtype"; 34 | export { OrchestratorStartedEvent } from "./history/orchestratorstartedevent"; 35 | export { OrchestratorCompletedEvent } from "./history/orchestratorcompletedevent"; 36 | export { SubOrchestrationInstanceCompletedEvent } from "./history/suborchestrationinstancecompletedevent"; 37 | export { SubOrchestrationInstanceCreatedEvent } from "./history/suborchestrationinstancecreatedevent"; 38 | export { SubOrchestrationInstanceFailedEvent } from "./history/suborchestrationinstancefailedevent"; 39 | export { TaskCompletedEvent } from "./history/taskcompletedevent"; 40 | export { TaskFailedEvent } from "./history/taskfailedevent"; 41 | export { TaskScheduledEvent } from "./history/taskscheduledevent"; 42 | export { TimerCreatedEvent } from "./history/timercreatedevent"; 43 | export { TimerFiredEvent } from "./history/timerfiredevent"; 44 | 45 | export { ITaskMethods } from "./itaskmethods"; 46 | export { TaskSet } from "./taskset"; 47 | export { Task } from "./task"; 48 | export { TimerTask } from "./timertask"; 49 | 50 | export { OrchestratorState } from "./orchestratorstate"; 51 | export { IOrchestratorState } from "./iorchestratorstate"; 52 | 53 | export { RetryOptions } from "./retryoptions"; 54 | 55 | export { DurableOrchestrationClient } from "./durableorchestrationclient"; 56 | export { OrchestrationClientInputData } from "./orchestrationclientinputdata"; 57 | export { HttpCreationPayload } from "./httpcreationpayload"; 58 | export { HttpManagementPayload } from "./httpmanagementpayload"; 59 | export { GetStatusOptions } from "./getstatusoptions"; 60 | 61 | export { IHttpRequest } from "./ihttprequest"; 62 | export { IHttpResponse } from "./ihttpresponse"; 63 | 64 | export { DurableOrchestrationStatus } from "./durableorchestrationstatus"; 65 | export { OrchestrationRuntimeStatus } from "./orchestrationruntimestatus"; 66 | export { PurgeHistoryResult } from "./purgehistoryresult"; 67 | 68 | export { GuidManager } from "./guidmanager"; 69 | 70 | export { DurableHttpRequest } from "./durablehttprequest"; 71 | export { DurableHttpResponse } from "./durablehttpresponse"; 72 | export { DurableLock } from "./entities/durablelock"; 73 | export { EntityId } from "./entities/entityid"; 74 | export { EntityState } from "./entities/entitystate"; 75 | export { EntityStateResponse } from "./entities/entitystateresponse"; 76 | export { LockState } from "./entities/lockstate"; 77 | export { OperationResult } from "./entities/operationresult"; 78 | export { RequestMessage } from "./entities/requestmessage"; 79 | export { ResponseMessage } from "./entities/responsemessage"; 80 | export { Signal } from "./entities/signal"; 81 | -------------------------------------------------------------------------------- /src/entity.ts: -------------------------------------------------------------------------------- 1 | import * as debug from "debug"; 2 | import { DurableEntityBindingInfo, DurableEntityContext, EntityId, EntityState, IEntityFunctionContext, 3 | OperationResult, RequestMessage, Signal, Utils } from "./classes"; 4 | 5 | /** @hidden */ 6 | const log = debug("orchestrator"); 7 | 8 | /** @hidden */ 9 | export class Entity { 10 | constructor(public fn: (context: IEntityFunctionContext) => unknown) { } 11 | 12 | public listen() { 13 | return this.handle.bind(this); 14 | } 15 | 16 | private async handle(context: IEntityFunctionContext): Promise { 17 | const entityBinding = Utils.getInstancesOf( 18 | context.bindings, new DurableEntityBindingInfo(new EntityId("samplename", "samplekey"), true, "", []))[0]; 19 | 20 | if (entityBinding === undefined) { 21 | throw new Error("Could not find an entityTrigger binding on context."); 22 | } 23 | 24 | // Setup 25 | const returnState: EntityState = new EntityState([], []); 26 | returnState.entityExists = entityBinding.exists; 27 | returnState.entityState = entityBinding.state; 28 | for (let i = 0; i < entityBinding.batch.length; i++) { 29 | const startTime = new Date(); 30 | context.df = this.getCurrentDurableEntityContext(entityBinding, returnState, i, startTime); 31 | 32 | try { 33 | this.fn(context); 34 | if (!returnState.results[i]) { 35 | const elapsedMs = this.computeElapsedMilliseconds(startTime); 36 | returnState.results[i] = new OperationResult(false, elapsedMs); 37 | } 38 | } catch (error) { 39 | const elapsedMs = this.computeElapsedMilliseconds(startTime); 40 | returnState.results[i] = new OperationResult(true, elapsedMs, JSON.stringify(error)); 41 | } 42 | } 43 | 44 | context.done(null, returnState); 45 | } 46 | 47 | private getCurrentDurableEntityContext(bindingInfo: DurableEntityBindingInfo, batchState: EntityState, requestIndex: number, startTime: Date): DurableEntityContext { 48 | const currentRequest = bindingInfo.batch[requestIndex]; 49 | return { 50 | entityName: bindingInfo.self.name, 51 | entityKey: bindingInfo.self.key, 52 | entityId: bindingInfo.self, 53 | operationName: currentRequest.name, 54 | isNewlyConstructed: !batchState.entityExists, 55 | getState: this.getState.bind(this, batchState), 56 | setState: this.setState.bind(this, batchState), 57 | getInput: this.getInput.bind(this, currentRequest), 58 | return: this.return.bind(this, batchState, startTime), 59 | destructOnExit: this.destructOnExit.bind(this, batchState), 60 | signalEntity: this.signalEntity.bind(this, batchState), 61 | }; 62 | } 63 | 64 | private destructOnExit(batchState: EntityState): void { 65 | batchState.entityExists = false; 66 | batchState.entityState = undefined; 67 | } 68 | 69 | private getInput(currentRequest: RequestMessage): unknown | undefined { 70 | if (currentRequest.input) { 71 | return JSON.parse(currentRequest.input); 72 | } 73 | return undefined; 74 | } 75 | 76 | private getState(returnState: EntityState, initializer?: () => unknown): unknown | undefined { 77 | if (returnState.entityState) { 78 | return JSON.parse(returnState.entityState); 79 | } else if (initializer != null) { 80 | return initializer(); 81 | } 82 | return undefined; 83 | } 84 | 85 | private return(returnState: EntityState, startTime: Date, result: unknown): void { 86 | returnState.entityExists = true; 87 | returnState.results.push(new OperationResult(false, this.computeElapsedMilliseconds(startTime), JSON.stringify(result))); 88 | } 89 | 90 | private setState(returnState: EntityState, state: unknown): void { 91 | returnState.entityExists = true; 92 | returnState.entityState = JSON.stringify(state); 93 | } 94 | 95 | private signalEntity(returnState: EntityState, entity: EntityId, operationName: string, operationInput?: unknown): void { 96 | returnState.signals.push(new Signal(entity, operationName, operationInput ? JSON.stringify(operationInput) : "")); 97 | } 98 | 99 | private computeElapsedMilliseconds(startTime: Date): number { 100 | const endTime = new Date(); 101 | return endTime.getTime() - startTime.getTime(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /test/unit/getclient-spec.ts: -------------------------------------------------------------------------------- 1 | import chai = require("chai"); 2 | import chaiAsPromised = require("chai-as-promised"); 3 | import "mocha"; 4 | import "process"; 5 | import uuidv1 = require("uuid/v1"); 6 | import { getClient } from "../../src"; 7 | import { Constants, DurableOrchestrationClient } from "../../src/classes"; 8 | import { TestConstants } from "../testobjects/testconstants"; 9 | import { TestUtils } from "../testobjects/testutils"; 10 | 11 | const expect = chai.expect; 12 | chai.use(chaiAsPromised); 13 | 14 | describe("getClient()", () => { 15 | const defaultTaskHub = "TestTaskHub"; 16 | const defaultConnection = "Storage"; 17 | 18 | const defaultClientInputData = TestUtils.createOrchestrationClientInputData( 19 | TestConstants.idPlaceholder, 20 | Constants.DefaultLocalOrigin, 21 | defaultTaskHub, 22 | defaultConnection); 23 | 24 | const v1ClientInputData = TestUtils.createV1OrchestrationClientInputData( 25 | TestConstants.idPlaceholder, 26 | Constants.DefaultLocalOrigin, 27 | defaultTaskHub, 28 | defaultConnection); 29 | 30 | const defaultContext = { 31 | bindings: { 32 | starter: defaultClientInputData, 33 | }, 34 | }; 35 | 36 | const v1Context = { 37 | bindings: { 38 | starter: v1ClientInputData, 39 | }, 40 | }; 41 | 42 | it("throws if context.bindings is undefined", async () => { 43 | expect(() => { 44 | getClient({}); 45 | }).to.throw("An orchestration client function must have an orchestrationClient input binding. Check your function.json definition."); 46 | }); 47 | 48 | it("throws if context.bindings does not contain valid orchestrationClient input binding", async () => { 49 | expect(() => { 50 | const badContext = { 51 | bindings: { 52 | orchestrationClient: { id: "" }, 53 | }, 54 | }; 55 | getClient(badContext); 56 | }).to.throw("An orchestration client function must have an orchestrationClient input binding. Check your function.json definition."); 57 | }); 58 | 59 | it("returns DurableOrchestrationClient if called with valid context", async () => { 60 | const client = getClient(defaultContext); 61 | expect(client).to.be.instanceOf(DurableOrchestrationClient); 62 | }); 63 | 64 | it("returns DurableOrchestrationClient if called with V1 context", async () => { 65 | const client = getClient(v1Context); 66 | expect(client).to.be.instanceOf(DurableOrchestrationClient); 67 | }); 68 | 69 | describe("Azure/azure-functions-durable-js#28 patch", () => { 70 | beforeEach(() => { 71 | this.WEBSITE_HOSTNAME = process.env.WEBSITE_HOSTNAME; 72 | }); 73 | 74 | afterEach(() => { 75 | process.env.WEBSITE_HOSTNAME = this.WEBSITE_HOSTNAME; 76 | }); 77 | 78 | it("corrects API endpoints if WEBSITE_HOSTNAME environment variable not found", async () => { 79 | delete process.env.WEBSITE_HOSTNAME; 80 | 81 | const badContext = { 82 | bindings: { 83 | starter: TestUtils.createOrchestrationClientInputData( 84 | TestConstants.idPlaceholder, 85 | "http://0.0.0.0:12345", 86 | defaultTaskHub, 87 | defaultConnection, 88 | ), 89 | }, 90 | }; 91 | 92 | const client = getClient(badContext); 93 | 94 | const expectedUniqueWebhookOrigins: string[] = [ Constants.DefaultLocalOrigin ]; 95 | expect(client.uniqueWebhookOrigins).to.deep.equal(expectedUniqueWebhookOrigins); 96 | }); 97 | 98 | it("corrects API endpoints if WEBSITE_HOSTNAME environment variable is 0.0.0.0", async () => { 99 | // Azure Functions Core Tools sets WEBSITE_HOSTNAME to 0.0.0.0:{port} on startup 100 | process.env.WEBSITE_HOSTNAME = "0.0.0.0:12345"; 101 | 102 | const badContext = { 103 | bindings: { 104 | starter: TestUtils.createOrchestrationClientInputData( 105 | TestConstants.idPlaceholder, 106 | `http://${process.env.WEBSITE_HOSTNAME}`, 107 | defaultTaskHub, 108 | defaultConnection, 109 | ), 110 | }, 111 | }; 112 | 113 | const client = getClient(badContext); 114 | 115 | const expectedUniqueWebhookOrigins: string[] = [ Constants.DefaultLocalOrigin ]; 116 | expect(client.uniqueWebhookOrigins).to.deep.equal(expectedUniqueWebhookOrigins); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/unit/utils-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { Utils } from "../../src/classes"; 4 | 5 | describe("Utils", () => { 6 | describe("getInstancesOf()", () => { 7 | it("returns empty array when typeInstance is not object", () => { 8 | const result = Utils.getInstancesOf([], true); 9 | return expect(result).to.be.an("array").that.is.empty; 10 | }); 11 | 12 | it("returns empty array when typeInstance is undefined", async () => { 13 | const result = Utils.getInstancesOf([], undefined); 14 | return expect(result).to.be.an("array").that.is.empty; 15 | }); 16 | 17 | it("returns empty array when collection contains no instances of target type", async () => { 18 | const collection = { 19 | obj1: new Date(), 20 | obj2: true, 21 | obj3: "blueberry", 22 | }; 23 | 24 | const result = Utils.getInstancesOf(collection, new TestType()); 25 | return expect(result).to.be.an("array").that.is.empty; 26 | }); 27 | 28 | it("returns all instances of target type in collection", async () => { 29 | const instance1 = new TestType(true, "apple", 3); 30 | const instance2 = new TestType(false, "orange", "tomato"); 31 | const collection = { 32 | obj1: instance1, 33 | obj2: "banana", 34 | obj3: undefined as unknown, 35 | obj4: instance2, 36 | obj5: 0, 37 | }; 38 | 39 | const result = Utils.getInstancesOf(collection, new TestType()); 40 | expect(result).to.be.deep.equal([ instance1, instance2 ]); 41 | }); 42 | }); 43 | 44 | describe("hasAllPropertiesOf()", () => { 45 | it("returns false when obj is undefined", async () => { 46 | const result = Utils.hasAllPropertiesOf(undefined, new TestType()); 47 | return expect(result).to.be.false; 48 | }); 49 | 50 | it("returns false when obj is null", async () => { 51 | const result = Utils.hasAllPropertiesOf(null, new TestType()); 52 | return expect(result).to.be.false; 53 | }); 54 | 55 | it("returns false when obj matches no properties of refInstance", async () => { 56 | const result = Utils.hasAllPropertiesOf(33, new TestType()); 57 | return expect(result).to.be.false; 58 | }); 59 | 60 | it("returns false when obj matches some properties of refInstance", async () => { 61 | const obj = { 62 | property0: false, 63 | }; 64 | 65 | const result = Utils.hasAllPropertiesOf(obj, new TestType()); 66 | return expect(result).to.be.false; 67 | }); 68 | 69 | it("returns true when obj matches all properties of refInstance", async () => { 70 | const obj = { 71 | property0: false, 72 | property1: "", 73 | property2: 3, 74 | property3: new Date(), 75 | }; 76 | 77 | const result = Utils.hasAllPropertiesOf(obj, new TestType()); 78 | return expect(result).to.be.be.true; 79 | }); 80 | }); 81 | 82 | describe("throwIfNotInstanceOf", () => { 83 | const notObjects = [ undefined, true, 3, "thing", Symbol(), () => 3 ]; 84 | const defaultName = "name"; 85 | 86 | notObjects.forEach((notObject) => { 87 | it(`throws when called with ${typeof notObject}`, async () => { 88 | expect(() => { 89 | Utils.throwIfNotInstanceOf(notObject, defaultName, new TestType(), "TestType"); 90 | }).to.throw( 91 | `${defaultName}: Expected object of type TestType but got ${typeof notObject}; are you missing properties?`, 92 | ); 93 | }); 94 | }); 95 | 96 | it("throws when called with null", async () => { 97 | expect(() => { 98 | Utils.throwIfNotInstanceOf(null, defaultName, new TestType(), "TestType"); 99 | }).to.throw( 100 | `${defaultName}: Expected object of type TestType but got ${typeof null}; are you missing properties?`, 101 | ); 102 | }); 103 | 104 | it("does not throw when called with instance of type", async () => { 105 | expect(() => { 106 | Utils.throwIfNotInstanceOf(new TestType(), defaultName, new TestType(), "TestType"); 107 | }).to.not.throw(); 108 | }); 109 | }); 110 | 111 | describe("throwIfEmpty", () => { 112 | const notStrings = [ undefined, true, 3, Symbol(), () => 3, { key: "value" } ]; 113 | const defaultName = "name"; 114 | 115 | notStrings.forEach((notString) => { 116 | it(`throws when called with ${typeof notString}`, async () => { 117 | expect(() => { 118 | Utils.throwIfEmpty(notString, defaultName); 119 | }).to.throw( 120 | `${defaultName}: Expected non-empty, non-whitespace string but got ${typeof notString}`, 121 | ); 122 | }); 123 | }); 124 | 125 | it("throws when called with null", async () => { 126 | expect(() => { 127 | Utils.throwIfEmpty(null, defaultName); 128 | }).to.throw( 129 | `${defaultName}: Expected non-empty, non-whitespace string but got ${typeof null}`, 130 | ); 131 | }); 132 | 133 | it("throws when called with whitespace", async () => { 134 | expect(() => { 135 | Utils.throwIfEmpty(" ", defaultName); 136 | }).to.throw( 137 | `${defaultName}: Expected non-empty, non-whitespace string but got empty string`, 138 | ); 139 | }); 140 | 141 | it("does not throw when called with non-empty string", async () => { 142 | expect(() => { 143 | Utils.throwIfEmpty("hedgehog", defaultName); 144 | }).to.not.throw(); 145 | }); 146 | }); 147 | }); 148 | 149 | class TestType { 150 | constructor( 151 | public property0: boolean = false, 152 | public property1: string = "", 153 | public property2?: unknown, 154 | public property3?: object, 155 | ) { } 156 | } 157 | -------------------------------------------------------------------------------- /test/testobjects/testentitybatches.ts: -------------------------------------------------------------------------------- 1 | import * as df from "../../src"; 2 | import { DurableEntityBindingInfo, EntityState, OperationResult, RequestMessage } from "../../src/classes"; 3 | import { CounterOperation, EntityInputsAndOutputs, StringStoreOperation } from "../testobjects/testentityoperations"; 4 | 5 | export class TestEntityBatches { 6 | 7 | public static GetStringStoreBatch(operations: StringStoreOperation[], existingState: string | undefined): EntityInputsAndOutputs { 8 | const id = new df.EntityId("stringstore", "stringstorekey"); 9 | 10 | const entityExists = existingState !== undefined; 11 | const output = new EntityState([], []); 12 | if (entityExists) { 13 | output.entityState = JSON.stringify(existingState); 14 | output.entityExists = entityExists; 15 | } 16 | 17 | const batch: RequestMessage[] = []; 18 | let operationCount = 0; 19 | for (const operation of operations) { 20 | batch[operationCount] = new RequestMessage(); 21 | switch (operation.kind) { 22 | case "get": 23 | // Handle inputs 24 | batch[operationCount].id = JSON.stringify(operationCount); 25 | batch[operationCount].name = "get"; 26 | batch[operationCount].signal = false; 27 | 28 | // Handle outputs 29 | output.results[operationCount] = new OperationResult(false, -1, output.entityState); 30 | break; 31 | case "set": 32 | // Handle inputs 33 | const value = JSON.stringify(operation.value); 34 | batch[operationCount].id = JSON.stringify(operationCount); 35 | batch[operationCount].name = "set"; 36 | batch[operationCount].signal = false; 37 | batch[operationCount].input = value; 38 | 39 | // Handle outputs 40 | output.results[operationCount] = new OperationResult(false, -1); 41 | output.entityExists = true; 42 | output.entityState = value; 43 | break; 44 | } 45 | operationCount++; 46 | } 47 | return { 48 | input: new DurableEntityBindingInfo(id, entityExists, JSON.stringify(existingState), batch), 49 | output, 50 | }; 51 | } 52 | 53 | public static GetCounterBatch(operations: CounterOperation[], existingState: number | undefined): EntityInputsAndOutputs { 54 | const id = new df.EntityId("stringstore", "stringstorekey"); 55 | let currentState: number | undefined; 56 | if (existingState) { 57 | currentState = Number(existingState); 58 | } 59 | 60 | const entityExists: boolean = !existingState; 61 | const output = new EntityState([], []); 62 | output.entityExists = entityExists; 63 | const batch: RequestMessage[] = []; 64 | let operationCount = 0; 65 | for (const operation of operations) { 66 | batch[operationCount] = new RequestMessage(); 67 | switch (operation.kind) { 68 | case "get": 69 | // Handle inputs 70 | batch[operationCount].id = operationCount.toString(); 71 | batch[operationCount].name = "get"; 72 | batch[operationCount].signal = false; 73 | 74 | // Handle outputs 75 | output.results[operationCount] = new OperationResult(false, -1, JSON.stringify(currentState)); 76 | break; 77 | case "set": 78 | // Handle inputs 79 | batch[operationCount].id = operationCount.toString(); 80 | batch[operationCount].name = "set"; 81 | batch[operationCount].signal = false; 82 | batch[operationCount].input = operation.value.toString(); 83 | 84 | // Handle outputs 85 | currentState = operation.value; 86 | output.results[operationCount] = new OperationResult(false, -1); 87 | output.entityExists = true; 88 | output.entityState = currentState.toString(); 89 | break; 90 | case "increment": 91 | batch[operationCount].id = operationCount.toString(); 92 | batch[operationCount].name = "increment"; 93 | batch[operationCount].signal = false; 94 | 95 | if (currentState != null) { 96 | currentState = currentState + 1; 97 | output.results[operationCount] = new OperationResult(false, -1); 98 | output.entityExists = true; 99 | output.entityState = currentState.toString(); 100 | } else { 101 | output.results[operationCount] = new OperationResult(true, -1, "dummy error message"); 102 | } 103 | break; 104 | case "add": 105 | batch[operationCount].id = operationCount.toString(); 106 | batch[operationCount].name = "add"; 107 | batch[operationCount].signal = false; 108 | batch[operationCount].input = operation.value.toString(); 109 | 110 | if (currentState != null) { 111 | currentState = currentState + operation.value; 112 | output.results[operationCount] = new OperationResult(false, -1); 113 | output.entityExists = true; 114 | output.entityState = currentState.toString(); 115 | } else { 116 | output.results[operationCount] = new OperationResult(true, -1, "dummy error message"); 117 | } 118 | break; 119 | case "delete": 120 | batch[operationCount].id = operationCount.toString(); 121 | batch[operationCount].name = "add"; 122 | batch[operationCount].signal = false; 123 | 124 | output.entityExists = false; 125 | output.results[operationCount] = new OperationResult(false, -1); 126 | break; 127 | } 128 | operationCount++; 129 | } 130 | return { 131 | input: new DurableEntityBindingInfo(id, entityExists, existingState !== undefined ? existingState.toString() : undefined, batch), 132 | output, 133 | }; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | |Branch|Status| 2 | |---|---| 3 | |master|[![Build Status](https://azfunc.visualstudio.com/Azure%20Functions/_apis/build/status/Azure.azure-functions-durable-js?branchName=master)](https://azfunc.visualstudio.com/Azure%20Functions/_build/latest?definitionId=13&branchName=master)| 4 | |dev|[![Build Status](https://azfunc.visualstudio.com/Azure%20Functions/_apis/build/status/Azure.azure-functions-durable-js?branchName=dev)](https://azfunc.visualstudio.com/Azure%20Functions/_build/latest?definitionId=13&branchName=dev)| 5 | 6 | # Durable Functions for Node.js 7 | 8 | The `durable-functions` npm package allows you to write [Durable Functions](https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-overview) for [Node.js](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-node). Durable Functions is an extension of [Azure Functions](https://docs.microsoft.com/en-us/azure/azure-functions/functions-overview) that lets you write stateful functions and workflows in a serverless environment. The extension manages state, checkpoints, and restarts for you. Durable Functions' advantages include: 9 | 10 | * Define workflows in code. No JSON schemas or designers are needed. 11 | * Call other functions synchronously and asynchronously. Output from called functions can be saved to local variables. 12 | * Automatically checkpoint progress whenever the function schedules async work. Local state is never lost if the process recycles or the VM reboots. 13 | 14 | You can find more information at the following links: 15 | 16 | * [Azure Functions overview](https://docs.microsoft.com/en-us/azure/azure-functions/functions-overview) 17 | * [Azure Functions JavaScript developers guide](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-node) 18 | * [Durable Functions overview](https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-overview) 19 | 20 | A durable function, or _orchestration_, is a solution made up of different types of Azure Functions: 21 | 22 | * **Activity:** the functions and tasks being orchestrated by your workflow. 23 | * **Orchestrator:** a function that describes the way and order actions are executed in code. 24 | * **Client:** the entry point for creating an instance of a durable orchestration. 25 | 26 | Durable Functions' function types and features are documented in-depth [here.](https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-types-features-overview) 27 | 28 | ## Getting Started 29 | 30 | You can follow the [Visual Studio Code quickstart](https://docs.microsoft.com/en-us/azure/azure-functions/durable/quickstart-js-vscode) to get started with a function chaining example, or follow the general checklist below: 31 | 32 | 1. Install prerequisites: 33 | - [Azure Functions Core Tools version 2.x](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local#install-the-azure-functions-core-tools) 34 | - [Azure Storage Emulator](https://docs.microsoft.com/en-us/azure/storage/common/storage-use-emulator) (Windows) or an actual Azure storage account (Mac or Linux) 35 | - Node.js 8.6.0 or later 36 | 37 | 2. [Create an Azure Functions app.](https://docs.microsoft.com/en-us/azure/azure-functions/functions-create-first-function-vs-code) [Visual Studio Code's Azure Functions plugin](https://code.visualstudio.com/tutorials/functions-extension/getting-started) is recommended. 38 | 39 | 3. Install the Durable Functions extension 40 | 41 | Run this command from the root folder of your Azure Functions app: 42 | ```bash 43 | func extensions install -p Microsoft.Azure.WebJobs.Extensions.DurableTask -v 1.8.3 44 | ``` 45 | 46 | **durable-functions requires Microsoft.Azure.WebJobs.Extensions.DurableTask 1.8.3 or greater.** 47 | 48 | 4. Install the `durable-functions` npm package at the root of your function app: 49 | 50 | ```bash 51 | npm install durable-functions 52 | ``` 53 | 54 | 5. Write an activity function ([see sample](./samples/E1_SayHello)): 55 | ```javascript 56 | module.exports = async function(context) { 57 | // your code here 58 | }; 59 | ``` 60 | 61 | 6. Write an orchestrator function ([see sample](./samples/E1_HelloSequence)): 62 | 63 | ```javascript 64 | const df = require('durable-functions'); 65 | module.exports = df.orchestrator(function*(context){ 66 | // your code here 67 | }); 68 | ``` 69 | 70 | **Note:** Orchestrator functions must follow certain [code constraints.](https://docs.microsoft.com/en-us/azure/azure-functions/durable-functions-checkpointing-and-replay#orchestrator-code-constraints) 71 | 72 | 7. Write your client function ([see sample](./samples/HttpStart/)): 73 | ```javascript 74 | module.exports = async function (context, req) { 75 | const client = df.getClient(context); 76 | const instanceId = await client.startNew(req.params.functionName, undefined, req.body); 77 | 78 | context.log(`Started orchestration with ID = '${instanceId}'.`); 79 | 80 | return client.createCheckStatusResponse(context.bindingData.req, instanceId); 81 | }; 82 | ``` 83 | 84 | **Note:** Client functions are started by a trigger binding available in the Azure Functions 2.x major version. [Read more about trigger bindings and 2.x-supported bindings.](https://docs.microsoft.com/en-us/azure/azure-functions/functions-triggers-bindings#overview) 85 | 86 | ## Samples 87 | 88 | The [Durable Functions samples](https://docs.microsoft.com/en-us/azure/azure-functions/durable-functions-install) demonstrate several common use cases. They are located in the [samples directory.](./samples/) Descriptive documentation is also available: 89 | 90 | * [Function Chaining - Hello Sequence](https://docs.microsoft.com/en-us/azure/azure-functions/durable-functions-sequence) 91 | * [Fan-out/Fan-in - Cloud Backup](https://docs.microsoft.com/en-us/azure/azure-functions/durable-functions-cloud-backup) 92 | * [Monitors - Weather Watcher](https://docs.microsoft.com/en-us/azure/azure-functions/durable-functions-monitor) 93 | * [Human Interaction & Timeouts - Phone Verification](https://docs.microsoft.com/en-us/azure/azure-functions/durable-functions-phone-verification) 94 | 95 | ```javascript 96 | const df = require("durable-functions"); 97 | 98 | module.exports = df.orchestrator(function*(context){ 99 | context.log("Starting chain sample"); 100 | const output = []; 101 | output.push(yield context.df.callActivity("E1_SayHello", "Tokyo")); 102 | output.push(yield context.df.callActivity("E1_SayHello", "Seattle")); 103 | output.push(yield context.df.callActivity("E1_SayHello", "London")); 104 | 105 | return output; 106 | }); 107 | ``` 108 | 109 | ## How it works 110 | 111 | ### Durable Functions 112 | One of the key attributes of Durable Functions is reliable execution. Orchestrator functions and activity functions may be running on different VMs within a data center, and those VMs or the underlying networking infrastructure is not 100% reliable. 113 | 114 | In spite of this, Durable Functions ensures reliable execution of orchestrations. It does so by using storage queues to drive function invocation and by periodically checkpointing execution history into storage tables (using a cloud design pattern known as [Event Sourcing](https://docs.microsoft.com/azure/architecture/patterns/event-sourcing)). That history can then be replayed to automatically rebuild the in-memory state of an orchestrator function. 115 | 116 | [Read more about Durable Functions' reliable execution.](https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-checkpointing-and-replay) 117 | 118 | ### Durable Functions JS 119 | 120 | The `durable-functions` shim lets you express a workflow in code as a [generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators) wrapped by a call to the `orchestrator` method. `orchestrator` treats `yield`-ed calls to your function `context`'s `df` object, like `context.df.callActivity`, as points where you want to schedule an asynchronous unit of work and wait for it to complete. 121 | 122 | These calls return a `Task` or `TaskSet` object signifying the outstanding work. The `orchestrator` method appends the action(s) of the `Task` or `TaskSet` object to a list which it passes back to the Functions runtime, plus whether the function is completed, and any output or errors. 123 | 124 | The Azure Functions extension schedules the desired actions. When the actions complete, the extension triggers the orchestrator function to replay up to the next incomplete asynchronous unit of work or its end, whichever comes first. 125 | -------------------------------------------------------------------------------- /src/durableorchestrationcontext.ts: -------------------------------------------------------------------------------- 1 | import { DurableHttpRequest, DurableLock, EntityId, ITaskMethods, LockState, 2 | RetryOptions, Task, TimerTask } from "./classes"; 3 | 4 | /** 5 | * Parameter data for orchestration bindings that can be used to schedule 6 | * function-based activities. 7 | */ 8 | export class DurableOrchestrationContext { 9 | /** 10 | * The ID of the current orchestration instance. 11 | * 12 | * The instance ID is generated and fixed when the orchestrator function is 13 | * scheduled. It can be either auto-generated, in which case it is 14 | * formatted as a GUID, or it can be user-specified with any format. 15 | */ 16 | public readonly instanceId: string; 17 | 18 | /** 19 | * Gets a value indicating whether the orchestrator function is currently 20 | * replaying itself. 21 | * 22 | * This property is useful when there is logic that needs to run only when 23 | * the orchestrator function is _not_ replaying. For example, certain types 24 | * of application logging may become too noisy when duplicated as part of 25 | * orchestrator function replay. The orchestrator code could check to see 26 | * whether the function is being replayed and then issue the log statements 27 | * when this value is `false`. 28 | */ 29 | public readonly isReplaying: boolean; 30 | 31 | /** 32 | * The ID of the parent orchestration of the current sub-orchestration 33 | * instance. The value will be available only in sub-orchestrations. 34 | * 35 | * The parent instance ID is generated and fixed when the parent 36 | * orchestrator function is scheduled. It can be either auto-generated, in 37 | * which case it is formatted as a GUID, or it can be user-specified with 38 | * any format. 39 | */ 40 | public readonly parentInstanceId: string | undefined; 41 | 42 | /** 43 | * Gets the current date/time in a way that is safe for use by orchestrator 44 | * functions. 45 | * 46 | * This date/time value is derived from the orchestration history. It 47 | * always returns the same value at specific points in the orchestrator 48 | * function code, making it deterministic and safe for replay. 49 | */ 50 | public currentUtcDateTime: Date; 51 | 52 | /** 53 | * 54 | */ 55 | public Task: ITaskMethods; 56 | 57 | /** 58 | * Schedules an activity function named `name` for execution. 59 | * 60 | * @param name The name of the activity function to call. 61 | * @param input The JSON-serializable input to pass to the activity 62 | * function. 63 | * @returns A Durable Task that completes when the called activity 64 | * function completes or fails. 65 | */ 66 | public callActivity(name: string, input?: unknown): Task { 67 | throw new Error("This is a placeholder."); 68 | } 69 | 70 | /** 71 | * Schedules an activity function named `name` for execution with 72 | * retry options. 73 | * 74 | * @param name The name of the activity function to call. 75 | * @param retryOptions The retry options for the activity function. 76 | * @param input The JSON-serializable input to pass to the activity 77 | * function. 78 | */ 79 | public callActivityWithRetry(name: string, retryOptions: RetryOptions, input?: unknown): Task { 80 | throw new Error("This is a placeholder."); 81 | } 82 | 83 | /** 84 | * Calls an operation on an entity, passing an argument, and waits for it 85 | * to complete. 86 | * 87 | * @param entityId The target entity. 88 | * @param operationName The name of the operation. 89 | * @param operationInput The input for the operation. 90 | */ 91 | public callEntity(entityId: EntityId, operationName: string, operationInput?: unknown): Task { 92 | throw new Error("This is a placeholder."); 93 | } 94 | 95 | /** 96 | * Schedules an orchestration function named `name` for execution. 97 | * 98 | * @param name The name of the orchestrator function to call. 99 | * @param input The JSON-serializable input to pass to the orchestrator 100 | * function. 101 | * @param instanceId A unique ID to use for the sub-orchestration instance. 102 | * If `instanceId` is not specified, the extension will generate an id in 103 | * the format `:<#>` 104 | */ 105 | public callSubOrchestrator(name: string, input?: unknown, instanceId?: string): Task { 106 | throw new Error("This is a placeholder."); 107 | } 108 | 109 | /** 110 | * Schedules an orchestrator function named `name` for execution with retry 111 | * options. 112 | * 113 | * @param name The name of the orchestrator function to call. 114 | * @param retryOptions The retry options for the orchestrator function. 115 | * @param input The JSON-serializable input to pass to the orchestrator 116 | * function. 117 | * @param instanceId A unique ID to use for the sub-orchestration instance. 118 | */ 119 | public callSubOrchestratorWithRetry( 120 | name: string, 121 | retryOptions: RetryOptions, 122 | input?: unknown, 123 | instanceId?: string) 124 | : Task { 125 | throw new Error("This is a placeholder."); 126 | } 127 | 128 | /** 129 | * Schedules a durable HTTP call to the specified endpoint. 130 | * 131 | * @param req The durable HTTP request to schedule. 132 | */ 133 | public callHttp(req: DurableHttpRequest): Task { 134 | throw new Error("This is a placeholder"); 135 | } 136 | 137 | /** 138 | * Restarts the orchestration by clearing its history. 139 | * 140 | * @param The JSON-serializable data to re-initialize the instance with. 141 | */ 142 | public continueAsNew(input: unknown): Task { 143 | throw new Error("This is a placeholder."); 144 | } 145 | 146 | /** 147 | * Creates a durable timer that expires at a specified time. 148 | * 149 | * All durable timers created using this method must either expire or be 150 | * cancelled using [[TimerTask]].[[cancel]] before the orchestrator 151 | * function completes. Otherwise, the underlying framework will keep the 152 | * instance alive until the timer expires. 153 | * 154 | * Timers currently cannot be scheduled further than 7 days into the 155 | * future. 156 | * 157 | * @param fireAt The time at which the timer should expire. 158 | * @returns A TimerTask that completes when the durable timer expires. 159 | */ 160 | public createTimer(fireAt: Date): TimerTask { 161 | throw new Error("This is a placeholder."); 162 | } 163 | 164 | /** 165 | * Gets the input of the current orchestrator function as a deserialized 166 | * value. 167 | */ 168 | public getInput(): unknown { 169 | throw new Error("This is a placeholder."); 170 | } 171 | 172 | /** 173 | * Creates a new GUID that is safe for replay within an orchestration or 174 | * operation. 175 | * 176 | * The default implementation of this method creates a name-based UUID 177 | * using the algorithm from RFC 4122 §4.3. The name input used to generate 178 | * this value is a combination of the orchestration instance ID and an 179 | * internally managed sequence number. 180 | */ 181 | public newGuid(): string { 182 | throw new Error("This is a placeholder."); 183 | } 184 | 185 | /** 186 | * Sets the JSON-serializable status of the current orchestrator function. 187 | * 188 | * The `customStatusObject` value is serialized to JSON and will be made 189 | * available to the orchestration status query APIs. The serialized JSON 190 | * value must not exceed 16 KB of UTF-16 encoded text. 191 | * 192 | * The serialized `customStatusObject` value will be made available to the 193 | * aforementioned APIs after the next `yield` or `return` statement. 194 | * 195 | * @param customStatusObject The JSON-serializable value to use as the 196 | * orchestrator function's custom status. 197 | */ 198 | public setCustomStatus(customStatusObject: unknown): void { 199 | throw new Error("This is a placeholder."); 200 | } 201 | 202 | /** 203 | * Waits asynchronously for an event to be raised with the name `name` and 204 | * returns the event data. 205 | * 206 | * External clients can raise events to a waiting orchestration instance 207 | * using [[raiseEvent]]. 208 | */ 209 | public waitForExternalEvent(name: string): Task { 210 | throw new Error("This is a placeholder."); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /test/testobjects/TestOrchestrations.ts: -------------------------------------------------------------------------------- 1 | import * as df from "../../src"; 2 | 3 | export class TestOrchestrations { 4 | public static AnyAOrB: any = df.orchestrator(function*(context: any) { 5 | const completeInOrder = context.df.getInput(); 6 | 7 | const tasks = []; 8 | tasks.push(context.df.callActivity("TaskA", !completeInOrder)); 9 | tasks.push(context.df.callActivity("TaskB", completeInOrder)); 10 | 11 | const output = yield context.df.Task.any(tasks); 12 | return output.result; 13 | }); 14 | 15 | public static CallActivityNoInput: any = df.orchestrator(function*(context: any) { 16 | const returnValue = yield context.df.callActivity("ReturnsFour"); 17 | return returnValue; 18 | }); 19 | 20 | public static CallEntitySet: any = df.orchestrator(function*(context: any) { 21 | const entity = context.df.getInput() as df.EntityId; 22 | yield context.df.callEntity(entity, "set", "testString"); 23 | 24 | return "OK"; 25 | }); 26 | 27 | public static CheckForLocksNone: any = df.orchestrator(function*(context: any) { 28 | return context.df.isLocked(); 29 | }); 30 | 31 | public static ContinueAsNewCounter: any = df.orchestrator(function*(context: any) { 32 | const currentValueObject = context.df.getInput(); 33 | let currentValue = currentValueObject 34 | ? currentValueObject.value 35 | ? currentValueObject.value 36 | : 0 37 | : 0; 38 | currentValue++; 39 | 40 | yield context.df.continueAsNew({ value: currentValue }); 41 | 42 | return currentValue; 43 | }); 44 | 45 | public static FanOutFanInDiskUsage: any = df.orchestrator(function*(context: any) { 46 | const directory = context.df.getInput(); 47 | const files = yield context.df.callActivity("GetFileList", directory); 48 | 49 | const tasks = []; 50 | for (const file of files) { 51 | tasks.push(context.df.callActivity("GetFileSize", file)); 52 | } 53 | 54 | const results = yield context.df.Task.all(tasks); 55 | const totalBytes = results.reduce((prev: any, curr: any) => prev + curr, 0); 56 | 57 | return totalBytes; 58 | }); 59 | 60 | public static GetAndReleaseLock: any = df.orchestrator(function*(context: any) { 61 | const entities = context.df.getInput(); 62 | 63 | const lock = yield context.df.lock(entities); 64 | 65 | return "ok"; 66 | }); 67 | 68 | public static GetLockBadly: any = df.orchestrator(function*(context: any) { 69 | const locksToGet = context.df.getInput(); 70 | 71 | const lock = yield context.df.lock(locksToGet); 72 | 73 | return "ok"; 74 | }); 75 | 76 | public static GuidGenerator: any = df.orchestrator(function*(context: any) { 77 | const outputs: string[] = []; 78 | 79 | for (let i = 0; i < 3; i++) { 80 | outputs.push(context.df.newGuid()); 81 | } 82 | 83 | return outputs; 84 | }); 85 | 86 | public static PassThrough: any = df.orchestrator(function*(context: any) { 87 | const input = context.df.getInput(); 88 | return input; 89 | }); 90 | 91 | public static SayHelloInline: any = df.orchestrator(function*(context: any) { 92 | const input = context.df.getInput(); 93 | return `Hello, ${input}!`; 94 | }); 95 | 96 | public static SayHelloInlineInproperYield: any = df.orchestrator(function*(context: any) { 97 | const input = yield context.df.getInput(); 98 | return `Hello, ${input}!`; 99 | }); 100 | 101 | public static SayHelloWithActivity: any = df.orchestrator(function*(context: any) { 102 | const input = context.df.getInput(); 103 | const output = yield context.df.callActivity("Hello", input); 104 | return output; 105 | }); 106 | 107 | public static SayHelloWithActivityDirectReturn: any = df.orchestrator(function*(context: any) { 108 | const input = context.df.getInput(); 109 | return context.df.callActivity("Hello", input); 110 | }); 111 | 112 | public static SayHelloWithActivityRetry: any = df.orchestrator(function*(context: any) { 113 | const input = context.df.getInput(); 114 | const retryOptions = new df.RetryOptions(10000, 2); 115 | const output = yield context.df.callActivityWithRetry("Hello", retryOptions, input); 116 | return output; 117 | }); 118 | 119 | public static SayHelloWithActivityRetryNoOptions: any = df.orchestrator(function*(context: any) { 120 | const output = yield context.df.callActivityWithRetry("Hello", undefined, "World"); 121 | return output; 122 | }); 123 | 124 | public static SayHelloWithCustomStatus: any = df.orchestrator(function*(context: any) { 125 | const output = []; 126 | 127 | output.push(yield context.df.callActivity("Hello", "Tokyo")); 128 | context.df.setCustomStatus("Tokyo"); 129 | output.push(yield context.df.callActivity("Hello", "Seattle")); 130 | context.df.setCustomStatus("Seattle"); 131 | output.push(yield context.df.callActivity("Hello", "London")); 132 | context.df.setCustomStatus("London"); 133 | 134 | return output; 135 | }); 136 | 137 | public static SayHelloWithSubOrchestrator: any = df.orchestrator(function*(context: any) { 138 | const input = context.df.getInput(); 139 | const childId = context.df.instanceId + ":0"; 140 | const output = yield context.df.callSubOrchestrator("SayHelloWithActivity", input, childId); 141 | return output; 142 | }); 143 | 144 | public static SayHelloWithSubOrchestratorNoSubId: any = df.orchestrator(function*(context: any) { 145 | const input = context.df.getInput(); 146 | const output = yield context.df.callSubOrchestrator("SayHelloWithActivity", input); 147 | return output; 148 | }); 149 | 150 | public static MultipleSubOrchestratorNoSubId: any = df.orchestrator(function*(context: any) { 151 | const input = context.df.getInput(); 152 | const subOrchName1 = "SayHelloWithActivity"; 153 | const subOrchName2 = "SayHelloInline"; 154 | const output = context.df.callSubOrchestrator(subOrchName1, `${input}_${subOrchName1}_0`); 155 | const output2 = context.df.callSubOrchestrator(subOrchName2, `${input}_${subOrchName2}_1`); 156 | const output3 = context.df.callSubOrchestrator(subOrchName1, `${input}_${subOrchName1}_2`); 157 | const output4 = context.df.callSubOrchestrator(subOrchName2, `${input}_${subOrchName2}_3`); 158 | return yield context.df.Task.all([output, output2, output3, output4]); 159 | }); 160 | 161 | public static SayHelloWithSubOrchestratorRetry: any = df.orchestrator(function*(context: any) { 162 | const input = context.df.getInput(); 163 | const childId = context.df.instanceId + ":0"; 164 | const retryOptions = new df.RetryOptions(10000, 2); 165 | const output = yield context.df.callSubOrchestratorWithRetry("SayHelloInline", retryOptions, input, childId); 166 | return output; 167 | }); 168 | 169 | public static SayHelloWithSubOrchestratorRetryNoOptions: any = df.orchestrator(function*(context: any) { 170 | const childId = context.df.instanceId + ":0"; 171 | const output = yield context.df.callSubOrchestratorWithRetry("SayHelloInline", undefined, "World", childId); 172 | return output; 173 | }); 174 | 175 | public static SayHelloSequence: any = df.orchestrator(function*(context: any) { 176 | const output = []; 177 | 178 | output.push(yield context.df.callActivity("Hello", "Tokyo")); 179 | output.push(yield context.df.callActivity("Hello", "Seattle")); 180 | output.push(yield context.df.callActivity("Hello", "London")); 181 | 182 | return output; 183 | }); 184 | 185 | public static SendHttpRequest: any = df.orchestrator(function*(context: any) { 186 | const input = context.df.getInput() as df.DurableHttpRequest; 187 | const output = yield context.df.callHttp( 188 | input.method, 189 | input.uri, 190 | input.content, 191 | input.headers, 192 | input.tokenSource); 193 | return output; 194 | }); 195 | 196 | /** 197 | * This orchestrator and its corresponding history replicate conditions under 198 | * which there are not sufficient OrchestratorStartedEvents in the history 199 | * array to satisfy the currentUtcDateTime advancement logic. 200 | */ 201 | public static TimestampExhaustion: any = df.orchestrator(function*(context: any) { 202 | const payload = context.df.getInput(); 203 | 204 | yield context.df.callActivity("Merge"); 205 | 206 | if (payload.delayMergeUntilSecs) { 207 | const now = new Date(context.df.currentUtcDateTime).getTime(); 208 | const fireAt = new Date(now + payload.delayMergeUntilSecs * 1000); 209 | yield context.df.createTimer(fireAt); 210 | } 211 | 212 | let x = 0; 213 | do { 214 | const result = yield context.df.callActivity("CheckIfMerged"); 215 | const hasMerged = result.output; 216 | 217 | if (hasMerged) { 218 | return "Merge successful"; 219 | } else { 220 | yield context.df.waitForExternalEvent("CheckPrForMerge"); 221 | } 222 | 223 | x++; 224 | } while (x < 10); 225 | }); 226 | 227 | public static WaitForExternalEvent: any = df.orchestrator(function*(context: any) { 228 | const name = yield context.df.waitForExternalEvent("start"); 229 | 230 | const returnValue = yield context.df.callActivity("Hello", name); 231 | 232 | return returnValue; 233 | }); 234 | 235 | public static WaitOnTimer: any = df.orchestrator(function*(context: any) { 236 | const fireAt = context.df.getInput(); 237 | 238 | yield context.df.createTimer(fireAt); 239 | 240 | return "Timer fired!"; 241 | }); 242 | 243 | public static ThrowsExceptionFromActivity: any = df.orchestrator(function*(context: any) 244 | : IterableIterator { 245 | yield context.df.callActivity("ThrowsErrorActivity"); 246 | }); 247 | 248 | public static ThrowsExceptionFromActivityWithCatch: any = df.orchestrator(function*(context: any) 249 | : IterableIterator { 250 | try { 251 | yield context.df.callActivity("ThrowsErrorActivity"); 252 | } catch (e) { 253 | const input = context.df.getInput(); 254 | const output = yield context.df.callActivity("Hello", input); 255 | 256 | return output; 257 | } 258 | }); 259 | 260 | public static ThrowsExceptionInline: any = df.orchestrator(function*(context: any) 261 | : IterableIterator { 262 | throw Error("Exception from Orchestrator"); 263 | }); 264 | } 265 | -------------------------------------------------------------------------------- /src/durableorchestrationclient.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:member-access 2 | 3 | import { HttpRequest } from "@azure/functions"; 4 | import axios, { AxiosInstance, AxiosResponse } from "axios"; 5 | import cloneDeep = require("lodash/cloneDeep"); 6 | import process = require("process"); 7 | import url = require("url"); 8 | import uuid = require("uuid/v1"); 9 | import { isURL } from "validator"; 10 | import { Constants, DurableOrchestrationStatus, EntityId, EntityStateResponse, 11 | GetStatusOptions, HttpCreationPayload, HttpManagementPayload, 12 | IHttpRequest, IHttpResponse, IOrchestrationFunctionContext, OrchestrationClientInputData, 13 | OrchestrationRuntimeStatus, PurgeHistoryResult, Utils, 14 | } from "./classes"; 15 | import { WebhookUtils } from "./webhookutils"; 16 | 17 | /** 18 | * Returns an OrchestrationClient instance. 19 | * @param context The context object of the Azure function whose body 20 | * calls this method. 21 | * @example Get an orchestration client instance 22 | * ```javascript 23 | * const df = require("durable-functions"); 24 | * 25 | * module.exports = async function (context, req) { 26 | * const client = df.getClient(context); 27 | * const instanceId = await client.startNew(req.params.functionName, undefined, req.body); 28 | * 29 | * return client.createCheckStatusResponse(req, instanceId); 30 | * }; 31 | * ``` 32 | */ 33 | export function getClient(context: unknown): DurableOrchestrationClient { 34 | let clientData = getClientData(context as IOrchestrationFunctionContext); 35 | 36 | if (!process.env.WEBSITE_HOSTNAME || process.env.WEBSITE_HOSTNAME.includes("0.0.0.0")) { 37 | clientData = correctClientData(clientData); 38 | } 39 | 40 | return new DurableOrchestrationClient(clientData); 41 | } 42 | 43 | function getClientData(context: IOrchestrationFunctionContext): OrchestrationClientInputData { 44 | if (context.bindings) { 45 | const matchingInstances = Object.keys(context.bindings) 46 | .map((key) => context.bindings[key]) 47 | .filter((val) => OrchestrationClientInputData.isOrchestrationClientInputData(val)); 48 | 49 | if (matchingInstances && matchingInstances.length > 0) { 50 | return matchingInstances[0] as OrchestrationClientInputData; 51 | } 52 | } 53 | 54 | throw new Error("An orchestration client function must have an orchestrationClient input binding. Check your function.json definition."); 55 | } 56 | 57 | function correctClientData(clientData: OrchestrationClientInputData): OrchestrationClientInputData { 58 | const returnValue = cloneDeep(clientData); 59 | 60 | returnValue.creationUrls = correctUrls(clientData.creationUrls) as HttpCreationPayload; 61 | returnValue.managementUrls = correctUrls(clientData.managementUrls) as HttpManagementPayload; 62 | 63 | return returnValue; 64 | } 65 | 66 | function correctUrls(obj: { [key: string]: string }): { [key: string]: string } { 67 | const returnValue = cloneDeep(obj); 68 | 69 | const keys = Object.getOwnPropertyNames(obj); 70 | keys.forEach((key) => { 71 | const value = obj[key]; 72 | 73 | if (isURL(value, { 74 | protocols: ["http", "https"], 75 | require_tld: false, 76 | require_protocol: true, 77 | })) { 78 | const valueAsUrl = new url.URL(value); 79 | returnValue[key] = value.replace(valueAsUrl.origin, Constants.DefaultLocalOrigin); 80 | } 81 | }); 82 | 83 | return returnValue; 84 | } 85 | 86 | /** 87 | * Client for starting, querying, terminating and raising events to 88 | * orchestration instances. 89 | */ 90 | export class DurableOrchestrationClient { 91 | /** 92 | * The name of the task hub configured on this orchestration client 93 | * instance. 94 | */ 95 | public readonly taskHubName: string; 96 | /** @hidden */ 97 | public readonly uniqueWebhookOrigins: string[]; 98 | 99 | private readonly axiosInstance: AxiosInstance; 100 | 101 | private readonly eventNamePlaceholder = "{eventName}"; 102 | private readonly functionNamePlaceholder = "{functionName}"; 103 | private readonly instanceIdPlaceholder = "[/{instanceId}]"; 104 | private readonly reasonPlaceholder = "{text}"; 105 | 106 | private readonly createdTimeFromQueryKey = "createdTimeFrom"; 107 | private readonly createdTimeToQueryKey = "createdTimeTo"; 108 | private readonly runtimeStatusQueryKey = "runtimeStatus"; 109 | private readonly showHistoryQueryKey = "showHistory"; 110 | private readonly showHistoryOutputQueryKey = "showHistoryOutput"; 111 | private readonly showInputQueryKey = "showInput"; 112 | 113 | private urlValidationOptions: ValidatorJS.IsURLOptions = { 114 | protocols: ["http", "https"], 115 | require_tld: false, 116 | require_protocol: true, 117 | require_valid_protocol: true, 118 | }; 119 | 120 | /** 121 | * @param clientData The object representing the orchestrationClient input 122 | * binding of the Azure function that will use this client. 123 | */ 124 | constructor( 125 | private readonly clientData: OrchestrationClientInputData, 126 | ) { 127 | if (!clientData) { 128 | throw new TypeError(`clientData: Expected OrchestrationClientInputData but got ${typeof clientData}`); 129 | } 130 | 131 | this.axiosInstance = axios.create({ 132 | validateStatus: (status: number) => status < 600, 133 | headers: { 134 | post: { 135 | "Content-Type": "application/json", 136 | }, 137 | }, 138 | maxContentLength: Infinity, 139 | }); 140 | this.taskHubName = this.clientData.taskHubName; 141 | this.uniqueWebhookOrigins = this.extractUniqueWebhookOrigins(this.clientData); 142 | } 143 | 144 | /** 145 | * Creates an HTTP response that is useful for checking the status of the 146 | * specified instance. 147 | * @param request The HTTP request that triggered the current orchestration 148 | * instance. 149 | * @param instanceId The ID of the orchestration instance to check. 150 | * @returns An HTTP 202 response with a Location header and a payload 151 | * containing instance management URLs. 152 | */ 153 | public createCheckStatusResponse(request: IHttpRequest | HttpRequest | undefined, instanceId: string): IHttpResponse { 154 | const httpManagementPayload = this.getClientResponseLinks(request, instanceId); 155 | 156 | return { 157 | status: 202, 158 | body: httpManagementPayload, 159 | headers: { 160 | "Content-Type": "application/json", 161 | "Location": httpManagementPayload.statusQueryGetUri, 162 | "Retry-After": 10, 163 | }, 164 | }; 165 | } 166 | 167 | /** 168 | * Creates an [[HttpManagementPayload]] object that contains instance 169 | * management HTTP endpoints. 170 | * @param instanceId The ID of the orchestration instance to check. 171 | */ 172 | public createHttpManagementPayload(instanceId: string): HttpManagementPayload { 173 | return this.getClientResponseLinks(undefined, instanceId); 174 | } 175 | 176 | /** 177 | * Gets the status of the specified orchestration instance. 178 | * @param instanceId The ID of the orchestration instance to query. 179 | * @param showHistory Boolean marker for including execution history in the 180 | * response. 181 | * @param showHistoryOutput Boolean marker for including input and output 182 | * in the execution history response. 183 | */ 184 | public async getStatus( 185 | instanceId: string, 186 | showHistory?: boolean, 187 | showHistoryOutput?: boolean, 188 | showInput?: boolean, 189 | ): Promise { 190 | try { 191 | const options: GetStatusOptions = { 192 | instanceId, 193 | showHistory, 194 | showHistoryOutput, 195 | showInput, 196 | }; 197 | const response = await this.getStatusInternal(options); 198 | 199 | switch (response.status) { 200 | case 200: // instance completed 201 | case 202: // instance in progress 202 | case 400: // instance failed or terminated 203 | case 404: // instance not found or pending 204 | case 500: // instance failed with unhandled exception 205 | return response.data as DurableOrchestrationStatus; 206 | default: 207 | return Promise.reject(new Error(`Webhook returned unrecognized status code ${response.status}`)); 208 | } 209 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 210 | throw error.message; 211 | } 212 | } 213 | 214 | /** 215 | * Gets the status of all orchestration instances. 216 | */ 217 | public async getStatusAll(): Promise { 218 | try { 219 | const response = await this.getStatusInternal({}); 220 | return response.data as DurableOrchestrationStatus[]; 221 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 222 | throw error.message; 223 | } 224 | } 225 | 226 | /** 227 | * Gets the status of all orchestration instances that match the specified 228 | * conditions. 229 | * @param createdTimeFrom Return orchestration instances which were created 230 | * after this Date. 231 | * @param createdTimeTo Return orchestration instances which were created 232 | * before this DateTime. 233 | * @param runtimeStatus Return orchestration instances which match any of 234 | * the runtimeStatus values in this array. 235 | */ 236 | public async getStatusBy( 237 | createdTimeFrom: Date | undefined, 238 | createdTimeTo: Date | undefined, 239 | runtimeStatus: OrchestrationRuntimeStatus[], 240 | ): Promise { 241 | try { 242 | const options: GetStatusOptions = { 243 | createdTimeFrom, 244 | createdTimeTo, 245 | runtimeStatus, 246 | }; 247 | const response = await this.getStatusInternal(options); 248 | 249 | if (response.status > 202) { 250 | return Promise.reject(new Error(`Webhook returned status code ${response.status}: ${response.data}`)); 251 | } else { 252 | return response.data as DurableOrchestrationStatus[]; 253 | } 254 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 255 | throw error.message; 256 | } 257 | } 258 | 259 | /** 260 | * Purge the history for a concerete instance. 261 | * @param instanceId The ID of the orchestration instance to purge. 262 | */ 263 | public async purgeInstanceHistory(instanceId: string): Promise { 264 | const template = this.clientData.managementUrls.purgeHistoryDeleteUri; 265 | const idPlaceholder = this.clientData.managementUrls.id; 266 | 267 | const webhookUrl = template.replace(idPlaceholder, instanceId); 268 | 269 | try { 270 | const response = await this.axiosInstance.delete(webhookUrl); 271 | switch (response.status) { 272 | case 200: // instance found 273 | return response.data as PurgeHistoryResult; 274 | case 404: // instance not found 275 | return new PurgeHistoryResult(0); 276 | default: 277 | return Promise.reject(new Error(`Webhook returned unrecognized status code ${response.status}`)); 278 | } 279 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 280 | throw error.message; 281 | } 282 | } 283 | 284 | /** 285 | * Purge the orchestration history for isntances that match the conditions. 286 | * @param createdTimeFrom Start creation time for querying instances for 287 | * purging. 288 | * @param createdTimeTo End creation time fo rquerying instanes for 289 | * purging. 290 | * @param runtimeStatus List of runtime statuses for querying instances for 291 | * purging. Only Completed, Terminated or Failed will be processed. 292 | */ 293 | public async purgeInstanceHistoryBy( 294 | createdTimeFrom: Date, 295 | createdTimeTo?: Date, 296 | runtimeStatus?: OrchestrationRuntimeStatus[], 297 | ): Promise { 298 | const idPlaceholder = this.clientData.managementUrls.id; 299 | let requestUrl = this.clientData.managementUrls.statusQueryGetUri 300 | .replace(idPlaceholder, ""); 301 | 302 | if (!(createdTimeFrom instanceof Date)) { 303 | throw new Error("createdTimeFrom must be a valid Date"); 304 | } 305 | 306 | if (createdTimeFrom) { 307 | requestUrl += `&${this.createdTimeFromQueryKey}=${createdTimeFrom.toISOString()}`; 308 | } 309 | 310 | if (createdTimeTo) { 311 | requestUrl += `&${this.createdTimeToQueryKey}=${createdTimeTo.toISOString()}`; 312 | } 313 | 314 | if (runtimeStatus && runtimeStatus.length > 0) { 315 | const statusesString = runtimeStatus 316 | .map((value) => value.toString()) 317 | .reduce((acc, curr, i, arr) => { 318 | return acc + (i > 0 ? "," : "") + curr; 319 | }); 320 | 321 | requestUrl += `&${this.runtimeStatusQueryKey}=${statusesString}`; 322 | } 323 | 324 | try { 325 | const response = await this.axiosInstance.delete(requestUrl); 326 | switch (response.status) { 327 | case 200: // instance found 328 | return response.data as PurgeHistoryResult; 329 | case 404: // instance not found 330 | return new PurgeHistoryResult(0); 331 | default: 332 | return Promise.reject(new Error(`Webhook returned unrecognized status code ${response.status}`)); 333 | } 334 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 335 | throw error.message; 336 | } 337 | } 338 | 339 | /** 340 | * Sends an event notification message to a waiting orchestration instance. 341 | * @param instanceId The ID of the orchestration instance that will handle 342 | * the event. 343 | * @param eventName The name of the event. 344 | * @param eventData The JSON-serializable data associated with the event. 345 | * @param taskHubName The TaskHubName of the orchestration that will handle 346 | * the event. 347 | * @param connectionName The name of the connection string associated with 348 | * `taskHubName.` 349 | * @returns A promise that resolves when the event notification message has 350 | * been enqueued. 351 | * 352 | * In order to handle the event, the target orchestration instance must be 353 | * waiting for an event named `eventName` using 354 | * [[waitForExternalEvent]]. 355 | * 356 | * If the specified instance is not found or not running, this operation 357 | * will have no effect. 358 | */ 359 | public async raiseEvent( 360 | instanceId: string, 361 | eventName: string, 362 | eventData: unknown, 363 | taskHubName?: string, 364 | connectionName?: string, 365 | ): Promise { 366 | if (!eventName) { 367 | throw new Error("eventName must be a valid string."); 368 | } 369 | 370 | const idPlaceholder = this.clientData.managementUrls.id; 371 | let requestUrl = this.clientData.managementUrls.sendEventPostUri 372 | .replace(idPlaceholder, instanceId) 373 | .replace(this.eventNamePlaceholder, eventName); 374 | 375 | if (taskHubName) { 376 | requestUrl = requestUrl.replace(this.clientData.taskHubName, taskHubName); 377 | } 378 | 379 | if (connectionName) { 380 | requestUrl = requestUrl.replace(/(connection=)([\w]+)/gi, "$1" + connectionName); 381 | } 382 | 383 | try { 384 | const response = await this.axiosInstance.post(requestUrl, JSON.stringify(eventData)); 385 | switch (response.status) { 386 | case 202: // event accepted 387 | case 410: // instance completed or failed 388 | return; 389 | case 404: 390 | return Promise.reject(new Error(`No instance with ID '${instanceId}' found.`)); 391 | case 400: 392 | return Promise.reject(new Error("Only application/json request content is supported")); 393 | default: 394 | return Promise.reject(new Error(`Webhook returned unrecognized status code ${response.status}`)); 395 | } 396 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 397 | throw error.message; 398 | } 399 | } 400 | 401 | /** 402 | * Tries to read the current state of an entity. Returnes undefined if the 403 | * entity does not exist, or if the JSON-serialized state of the entity is 404 | * larger than 16KB. 405 | * @param T The JSON-serializable type of the entity. 406 | * @param entityId The target entity. 407 | * @param taskHubName The TaskHubName of the target entity. 408 | * @param connectionName The name of the connection string associated with 409 | * [taskHubName]. 410 | * @returns A response containing the current state of the entity. 411 | */ 412 | public async readEntityState(entityId: EntityId, taskHubName?: string, connectionName?: string): Promise> { 413 | if (!(this.clientData.baseUrl && this.clientData.requiredQueryStringParameters)) { 414 | throw new Error("Cannot use the readEntityState API with this version of the Durable Task Extension."); 415 | } 416 | 417 | const requestUrl = WebhookUtils.getReadEntityUrl(this.clientData.baseUrl, 418 | this.clientData.requiredQueryStringParameters, 419 | entityId.name, 420 | entityId.key, 421 | taskHubName, 422 | connectionName); 423 | 424 | try { 425 | const response = await this.axiosInstance.get(requestUrl); 426 | switch (response.status) { 427 | case 200: // entity exists 428 | return new EntityStateResponse(true, response.data as T); 429 | case 404: // entity does not exist 430 | return new EntityStateResponse(false, undefined); 431 | default: 432 | return Promise.reject(new Error(`Webhook returned unrecognized status code ${response.status}`)); 433 | } 434 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 435 | throw error.message; 436 | } 437 | } 438 | 439 | /** 440 | * Rewinds the specified failed orchestration instance with a reason. 441 | * @param instanceId The ID of the orchestration instance to rewind. 442 | * @param reason The reason for rewinding the orchestration instance. 443 | * @returns A promise that resolves when the rewind message is enqueued. 444 | * 445 | * This feature is currently in preview. 446 | */ 447 | public async rewind(instanceId: string, reason: string): Promise { 448 | const idPlaceholder = this.clientData.managementUrls.id; 449 | const requestUrl = this.clientData.managementUrls.rewindPostUri 450 | .replace(idPlaceholder, instanceId) 451 | .replace(this.reasonPlaceholder, reason); 452 | 453 | try { 454 | const response = await this.axiosInstance.post(requestUrl); 455 | switch (response.status) { 456 | case 202: 457 | return; 458 | case 404: 459 | return Promise.reject(new Error(`No instance with ID '${instanceId}' found.`)); 460 | case 410: 461 | return Promise.reject(new Error("The rewind operation is only supported on failed orchestration instances.")); 462 | default: 463 | return Promise.reject(new Error(`Webhook returned unrecognized status code ${response.status}`)); 464 | } 465 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 466 | throw error.message; 467 | } 468 | } 469 | 470 | /** 471 | * Signals an entity to perform an operation. 472 | * @param entityId The target entity. 473 | * @param operationName The name of the operation. 474 | * @param operationContent The content for the operation. 475 | * @param taskHubName The TaskHubName of the target entity. 476 | * @param connectionName The name of the connection string associated with [taskHubName]. 477 | */ 478 | public async signalEntity( 479 | entityId: EntityId, 480 | operationName?: string, 481 | operationContent?: unknown, 482 | taskHubName?: string, 483 | connectionName?: string, 484 | ): Promise { 485 | if (!(this.clientData.baseUrl && this.clientData.requiredQueryStringParameters)) { 486 | throw new Error("Cannot use the signalEntity API with this version of the Durable Task Extension."); 487 | } 488 | 489 | const requestUrl = WebhookUtils.getSignalEntityUrl(this.clientData.baseUrl, 490 | this.clientData.requiredQueryStringParameters, 491 | entityId.name, 492 | entityId.key, 493 | operationName, 494 | taskHubName, 495 | connectionName); 496 | 497 | try { 498 | const response = await this.axiosInstance.post(requestUrl, JSON.stringify(operationContent)); 499 | switch (response.status) { 500 | case 202: // signal accepted 501 | return; 502 | default: 503 | return Promise.reject(new Error(`Webhook returned unrecognized status code ${response.status}`)); 504 | } 505 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 506 | throw error.message; 507 | } 508 | } 509 | 510 | /** 511 | * Starts a new instance of the specified orchestrator function. 512 | * 513 | * If an orchestration instance with the specified ID already exists, the 514 | * existing instance will be silently replaced by this new instance. 515 | * @param orchestratorFunctionName The name of the orchestrator function to 516 | * start. 517 | * @param instanceId The ID to use for the new orchestration instance. If 518 | * no instanceId is specified, the Durable Functions extension will 519 | * generate a random GUID (recommended). 520 | * @param input JSON-serializable input value for the orchestrator 521 | * function. 522 | * @returns The ID of the new orchestration instance. 523 | */ 524 | public async startNew(orchestratorFunctionName: string, instanceId?: string, input?: unknown): Promise { 525 | if (!orchestratorFunctionName) { 526 | throw new Error("orchestratorFunctionName must be a valid string."); 527 | } 528 | 529 | let requestUrl = this.clientData.creationUrls.createNewInstancePostUri; 530 | requestUrl = requestUrl 531 | .replace(this.functionNamePlaceholder, orchestratorFunctionName) 532 | .replace(this.instanceIdPlaceholder, (instanceId ? `/${instanceId}` : "")); 533 | 534 | try { 535 | const response = await this.axiosInstance.post(requestUrl, input !== undefined ? JSON.stringify(input) : ""); 536 | if (response.data && response.status <= 202) { 537 | return (response.data as HttpManagementPayload).id; 538 | } else { 539 | return Promise.reject(new Error(response.data as string)); 540 | } 541 | } catch (message) { 542 | throw new Error(message.error); 543 | } 544 | } 545 | 546 | /** 547 | * Terminates a running orchestration instance. 548 | * @param instanceId The ID of the orchestration instance to terminate. 549 | * @param reason The reason for terminating the orchestration instance. 550 | * @returns A promise that resolves when the terminate message is enqueued. 551 | * 552 | * Terminating an orchestration instance has no effect on any in-flight 553 | * activity function executions or sub-orchestrations that were started 554 | * by the current orchestration instance. 555 | */ 556 | public async terminate(instanceId: string, reason: string): Promise { 557 | const idPlaceholder = this.clientData.managementUrls.id; 558 | const requestUrl = this.clientData.managementUrls.terminatePostUri 559 | .replace(idPlaceholder, instanceId) 560 | .replace(this.reasonPlaceholder, reason); 561 | 562 | try { 563 | const response = await this.axiosInstance.post(requestUrl); 564 | switch (response.status) { 565 | case 202: // terminate accepted 566 | case 410: // instance completed or failed 567 | return; 568 | case 404: 569 | return Promise.reject(new Error(`No instance with ID '${instanceId}' found.`)); 570 | default: 571 | return Promise.reject(new Error(`Webhook returned unrecognized status code ${response.status}`)); 572 | } 573 | } catch (error) { // error object is axios-specific, not a JavaScript Error; extract relevant bit 574 | throw error.message; 575 | } 576 | } 577 | 578 | /** 579 | * Creates an HTTP response which either contains a payload of management 580 | * URLs for a non-completed instance or contains the payload containing 581 | * the output of the completed orchestration. 582 | * 583 | * If the orchestration does not complete within the specified timeout, 584 | * then the HTTP response will be identical to that of 585 | * [[createCheckStatusResponse]]. 586 | * 587 | * @param request The HTTP request that triggered the current function. 588 | * @param instanceId The unique ID of the instance to check. 589 | * @param timeoutInMilliseconds Total allowed timeout for output from the 590 | * durable function. The default value is 10 seconds. 591 | * @param retryIntervalInMilliseconds The timeout between checks for output 592 | * from the durable function. The default value is 1 second. 593 | */ 594 | public async waitForCompletionOrCreateCheckStatusResponse( 595 | request: HttpRequest, 596 | instanceId: string, 597 | timeoutInMilliseconds: number = 10000, 598 | retryIntervalInMilliseconds: number = 1000, 599 | ): Promise { 600 | if (retryIntervalInMilliseconds > timeoutInMilliseconds) { 601 | throw new Error(`Total timeout ${timeoutInMilliseconds} (ms) should be bigger than retry timeout ${retryIntervalInMilliseconds} (ms)`); 602 | } 603 | 604 | const hrStart = process.hrtime(); 605 | while (true) { 606 | const status = await this.getStatus(instanceId); 607 | 608 | if (status) { 609 | switch (status.runtimeStatus) { 610 | case OrchestrationRuntimeStatus.Completed: 611 | return this.createHttpResponse(200, status.output); 612 | case OrchestrationRuntimeStatus.Canceled: 613 | case OrchestrationRuntimeStatus.Terminated: 614 | return this.createHttpResponse(200, status); 615 | case OrchestrationRuntimeStatus.Failed: 616 | return this.createHttpResponse(500, status); 617 | } 618 | } 619 | 620 | const hrElapsed = process.hrtime(hrStart); 621 | const hrElapsedMilliseconds = Utils.getHrMilliseconds(hrElapsed); 622 | 623 | if (hrElapsedMilliseconds < timeoutInMilliseconds) { 624 | const remainingTime = timeoutInMilliseconds - hrElapsedMilliseconds; 625 | await Utils.sleep(remainingTime > retryIntervalInMilliseconds 626 | ? retryIntervalInMilliseconds 627 | : remainingTime); 628 | } else { 629 | return this.createCheckStatusResponse(request, instanceId); 630 | } 631 | } 632 | } 633 | 634 | private createHttpResponse(statusCode: number, body: unknown): IHttpResponse { 635 | const bodyAsJson = JSON.stringify(body); 636 | return { 637 | status: statusCode, 638 | body: bodyAsJson, 639 | headers: { 640 | "Content-Type": "application/json", 641 | }, 642 | }; 643 | } 644 | 645 | private getClientResponseLinks(request: IHttpRequest | HttpRequest | undefined, instanceId: string): HttpManagementPayload { 646 | const payload = { ...this.clientData.managementUrls }; 647 | 648 | (Object.keys(payload) as Array<(keyof HttpManagementPayload)>).forEach((key) => { 649 | if (this.hasValidRequestUrl(request) && isURL(payload[key], this.urlValidationOptions)) { 650 | const requestUrl = new url.URL((request as HttpRequest).url || (request as IHttpRequest).http.url); 651 | const dataUrl = new url.URL(payload[key]); 652 | payload[key] = payload[key].replace(dataUrl.origin, requestUrl.origin); 653 | } 654 | 655 | payload[key] = payload[key].replace(this.clientData.managementUrls.id, instanceId); 656 | }); 657 | 658 | return payload; 659 | } 660 | 661 | private hasValidRequestUrl(request: IHttpRequest | HttpRequest | undefined): boolean { 662 | const isHttpRequest = request !== undefined && (request as HttpRequest).url !== undefined; 663 | const isIHttpRequest = request !== undefined && (request as IHttpRequest).http !== undefined; 664 | return isHttpRequest || isIHttpRequest && (request as IHttpRequest).http.url !== undefined; 665 | } 666 | 667 | private extractUniqueWebhookOrigins(clientData: OrchestrationClientInputData): string[] { 668 | const origins = this.extractWebhookOrigins(clientData.creationUrls) 669 | .concat(this.extractWebhookOrigins(clientData.managementUrls)); 670 | 671 | const uniqueOrigins = origins.reduce((acc, curr) => { 672 | if (acc.indexOf(curr) === -1) { 673 | acc.push(curr); 674 | } 675 | return acc; 676 | }, []); 677 | 678 | return uniqueOrigins; 679 | } 680 | 681 | private extractWebhookOrigins(obj: { [key: string]: string }): string[] { 682 | const origins: string[] = []; 683 | 684 | const keys = Object.getOwnPropertyNames(obj); 685 | keys.forEach((key) => { 686 | const value = obj[key]; 687 | 688 | if (isURL(value, this.urlValidationOptions)) { 689 | const valueAsUrl = new url.URL(value); 690 | const origin = valueAsUrl.origin; 691 | origins.push(origin); 692 | } 693 | }); 694 | 695 | return origins; 696 | } 697 | 698 | private async getStatusInternal(options: GetStatusOptions): Promise { 699 | const template = this.clientData.managementUrls.statusQueryGetUri; 700 | const idPlaceholder = this.clientData.managementUrls.id; 701 | 702 | let requestUrl = template.replace(idPlaceholder, typeof(options.instanceId) === "string" ? options.instanceId : ""); 703 | if (options.taskHubName) { 704 | requestUrl = requestUrl.replace(this.clientData.taskHubName, options.taskHubName); 705 | } 706 | if (options.connectionName) { 707 | requestUrl = requestUrl.replace(/(connection=)([\w]+)/gi, "$1" + options.connectionName); 708 | } 709 | if (options.showHistory) { 710 | requestUrl += `&${this.showHistoryQueryKey}=${options.showHistory}`; 711 | } 712 | if (options.showHistoryOutput) { 713 | requestUrl += `&${this.showHistoryOutputQueryKey}=${options.showHistoryOutput}`; 714 | } 715 | if (options.createdTimeFrom) { 716 | requestUrl += `&${this.createdTimeFromQueryKey}=${options.createdTimeFrom.toISOString()}`; 717 | } 718 | if (options.createdTimeTo) { 719 | requestUrl += `&${this.createdTimeToQueryKey}=${options.createdTimeTo.toISOString()}`; 720 | } 721 | if (options.runtimeStatus && options.runtimeStatus.length > 0) { 722 | const statusesString = options.runtimeStatus 723 | .map((value) => value.toString()) 724 | .reduce((acc, curr, i, arr) => { 725 | return acc + (i > 0 ? "," : "") + curr; 726 | }); 727 | 728 | requestUrl += `&${this.runtimeStatusQueryKey}=${statusesString}`; 729 | } 730 | if (typeof options.showInput === "boolean") { 731 | requestUrl += `&${this.showInputQueryKey}=${options.showInput}`; 732 | } 733 | 734 | return this.axiosInstance.get(requestUrl); 735 | } 736 | } 737 | --------------------------------------------------------------------------------