Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,38 @@ npm install @azure/functions
- The programming model version is strictly tied to the version of the [`@azure/functions`](https://www.npmjs.com/package/@azure/functions) npm package, and is versioned independently of the [runtime](https://learn.microsoft.com/azure/azure-functions/functions-versions?pivots=programming-language-javascript). Both the runtime and the programming model use "4" as their latest major version, but that is purely a coincidence.
- You can't mix the v3 and v4 programming models in the same function app. As soon as you register one v4 function in your app, any v3 functions registered in _function.json_ files are ignored.

### Connector trigger TypeScript compatibility in 4.17.0

Version 4.17.0 adds explicitly typed polling and batching properties to `ConnectorTriggerOptions` and `ConnectorTriggerFunctionOptions`.
Previously, these properties were accepted through a `Record<string, unknown>` index signature.
This tightens the TypeScript contract and can cause existing code to fail compilation, even when the configuration values are valid at runtime.
Custom properties remain supported.

In particular, TypeScript widens properties in an unannotated configuration variable to `string`:

```typescript
import { trigger } from '@azure/functions';

const options = { deliveryMode: 'Webhook' };
// No longer compiles: string is not assignable to 'Poll' | 'Webhook'.
trigger.connectorTrigger(options);
```

Annotate configuration variables with the exported options type:

```typescript
import { trigger, type ConnectorTriggerOptions } from '@azure/functions';

const options: ConnectorTriggerOptions = { deliveryMode: 'Webhook' };
trigger.connectorTrigger(options);
```

For `app.connectorTrigger`, use `ConnectorTriggerFunctionOptions<T>` for a configuration variable that includes a handler.
Alternatively, preserve literal types using `as const`, or pass the options object directly to the API.
Use the exact values `'Poll'` or `'Webhook'` for `deliveryMode`, and `'one'` or `'many'` for `cardinality`.
The connection and trigger configuration names must be strings; batch size, concurrency, and scaling threshold overrides must be numbers.
Poll delivery also now checks at registration that both `connection` and `triggerConfigName` are supplied.

## Usage

### TypeScript
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azure/functions",
"version": "4.16.5",
"version": "4.17.0",
"description": "Microsoft Azure Functions NodeJS Framework",
"keywords": [
"azure",
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

export const version = '4.16.5';
Comment thread
swapnil-nagar marked this conversation as resolved.
export const version = '4.17.0';

export const returnBindingKey = '$return';
8 changes: 8 additions & 0 deletions src/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ export function mcpPrompt(options: McpPromptTriggerOptions): McpPromptTrigger {
}

export function connectorTrigger(options: ConnectorTriggerOptions): ConnectorTrigger {
if (options.deliveryMode === 'Poll') {
const missingOptions = ['connection', 'triggerConfigName'].filter(
(key) => options[key] === undefined || options[key] === null
);
if (missingOptions.length > 0) {
throw new Error(`The following options are required for Poll delivery mode: ${missingOptions.join(', ')}`);
}
}
return addTriggerBindingName({
...options,
type: 'connectorTrigger',
Expand Down
134 changes: 133 additions & 1 deletion test/connectorTrigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@
import 'mocha';
import { expect } from 'chai';
import { app, trigger } from '../src';
import { fromRpcTypedData } from '../src/converters/fromRpcTypedData';
import { toCoreFunctionMetadata } from '../src/converters/toCoreFunctionMetadata';
import { InvocationContext } from '../types';
import { ConnectorTriggerFunctionOptions, ConnectorTriggerOptions, InvocationContext } from '../types';

describe('connectorTrigger', () => {
const _handler = (_triggerInput: unknown, _context: InvocationContext) => {};

describe('trigger.connectorTrigger', () => {
const pollOptions: ConnectorTriggerOptions = {
deliveryMode: 'Poll',
connection: 'ConnectorNamespace',
triggerConfigName: '%OnNewEmailTriggerConfigName%',
};

it('should create a trigger with correct type and options', () => {
const result = trigger.connectorTrigger({});

Expand All @@ -34,9 +41,94 @@ describe('connectorTrigger', () => {

expect(result1.name).to.equal(result2.name);
});

it('should preserve all polling options and custom properties', () => {
const options: ConnectorTriggerOptions = {
...pollOptions,
cardinality: 'many',
maxBatchSize: 10,
maxConcurrentCalls: 2,
targetPendingEventThreshold: 16,
customField: 'custom',
};

expect(trigger.connectorTrigger(options)).to.include(options);
});

it('should leave optional polling settings omitted so the host supplies defaults', () => {
const result = trigger.connectorTrigger(pollOptions);

expect(result).to.have.all.keys('name', 'type', 'deliveryMode', 'connection', 'triggerConfigName');
});

it('should leave the default delivery mode omitted', () => {
expect(trigger.connectorTrigger({})).to.have.all.keys('name', 'type');
});

it('should accept Webhook without polling settings', () => {
expect(trigger.connectorTrigger({ deliveryMode: 'Webhook' })).to.include({ deliveryMode: 'Webhook' });
});

for (const key of ['connection', 'triggerConfigName']) {
for (const value of [undefined, null]) {
it(`should require ${key} in Poll mode when it is ${String(value)}`, () => {
expect(() => trigger.connectorTrigger({ ...pollOptions, [key]: value })).to.throw(key);
});
}
}

it('should report all missing Poll settings', () => {
expect(() => trigger.connectorTrigger({ deliveryMode: 'Poll' })).to.throw(
'The following options are required for Poll delivery mode: connection, triggerConfigName'
);
});

it('should preserve zero values without adding numeric restrictions', () => {
const options: ConnectorTriggerOptions = {
...pollOptions,
maxBatchSize: 0,
maxConcurrentCalls: 0,
targetPendingEventThreshold: 0,
};

expect(trigger.connectorTrigger(options)).to.include(options);
});

for (const key of ['maxBatchSize', 'maxConcurrentCalls']) {
it(`should allow ${key} to be overridden independently`, () => {
const options = { ...pollOptions, [key]: 2 };

expect(trigger.connectorTrigger(options)).to.include(options);
});
}
});

describe('toCoreFunctionMetadata with connectorTrigger', () => {
for (const cardinality of ['one', 'many'] as const) {
it(`should serialize Poll metadata with cardinality ${cardinality}`, () => {
const options: ConnectorTriggerOptions = {
deliveryMode: 'Poll',
connection: 'ConnectorNamespace',
triggerConfigName: '%OnNewEmailTriggerConfigName%',
cardinality,
maxBatchSize: 10,
maxConcurrentCalls: 2,
targetPendingEventThreshold: 16,
customField: 'custom',
};
const binding = trigger.connectorTrigger(options);
const result = toCoreFunctionMetadata('onNewEmail', { handler: _handler, trigger: binding });

expect(result.bindings[binding.name]).to.deep.equal({
...options,
name: binding.name,
type: 'connectorTrigger',
direction: 'in',
properties: { supportsDeferredBinding: 'false' },
});
});
}

it('should produce correct metadata for connectorTrigger', () => {
const connectorTriggerBinding = trigger.connectorTrigger({});

Expand All @@ -63,6 +155,26 @@ describe('connectorTrigger', () => {
});

describe('app.connectorTrigger', () => {
it('should register a typed batch handler with polling settings', () => {
const options: ConnectorTriggerFunctionOptions<string[]> = {
deliveryMode: 'Poll',
connection: 'ConnectorNamespace',
triggerConfigName: 'config',
cardinality: 'many',
handler: (events, context) => {
context.log(events.join(', '));
},
};

expect(() => app.connectorTrigger<string[]>('pollConnectorTrigger', options)).to.not.throw();
});

it('should validate polling settings during registration', () => {
expect(() =>
app.connectorTrigger('invalidPollConnectorTrigger', { deliveryMode: 'Poll', handler: _handler })
).to.throw('connection, triggerConfigName');
});

it('should register without throwing', () => {
expect(() => {
app.connectorTrigger('testConnectorTrigger', {
Expand All @@ -71,4 +183,24 @@ describe('connectorTrigger', () => {
}).to.not.throw();
});
});

describe('polling batch payloads', () => {
it('should decode string batches using existing Node.js JSON conversion', () => {
Comment thread
swapnil-nagar marked this conversation as resolved.
expect(fromRpcTypedData({ collectionString: { string: ['{"id":"first"}', 'second'] } })).to.deep.equal([
{ id: 'first' },
'second',
]);
});

it('should decode byte batches as Buffers', () => {
expect(fromRpcTypedData({ collectionBytes: { bytes: [Buffer.from('first')] } })).to.deep.equal([
Buffer.from('first'),
]);
});

it('should decode empty batches', () => {
expect(fromRpcTypedData({ collectionString: { string: [] } })).to.deep.equal([]);
expect(fromRpcTypedData({ collectionBytes: { bytes: [] } })).to.deep.equal([]);
});
});
});
79 changes: 79 additions & 0 deletions test/types/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,83 @@ const invalidServiceBusTopicTriggerOptions: ServiceBusTopicTriggerOptions = {
};

declare const app: typeof import('../../types').app;
declare const trigger: typeof import('../../types').trigger;
function registerServiceBusTopic(): void {
app.serviceBusTopic<string>('serviceBusTopic', serviceBusTopicFunctionOptions);
}

type ConnectorTriggerOptions = import('../../types').ConnectorTriggerOptions;
type ConnectorTriggerFunctionOptions<T = unknown> = import('../../types').ConnectorTriggerFunctionOptions<T>;

const connectorPollOptions: ConnectorTriggerFunctionOptions<TodoItem[]> = {
deliveryMode: 'Poll',
connection: 'ConnectorNamespace',
triggerConfigName: '%TriggerConfigName%',
cardinality: 'many',
maxBatchSize: 10,
maxConcurrentCalls: 2,
targetPendingEventThreshold: 16,
customField: 'custom',
handler: (events) => {
const event: TodoItem | undefined = events[0];
void event?.id;
},
};

const connectorWebhookOptions: ConnectorTriggerOptions = { deliveryMode: 'Webhook' };
const connectorDefaultOptions: ConnectorTriggerOptions = {};

function configureConnectorTriggerFromVariables(): void {
const widenedOptions = { deliveryMode: 'Webhook' };
// @ts-expect-error unannotated configuration variables widen deliveryMode to string
trigger.connectorTrigger(widenedOptions);

const typedOptions: ConnectorTriggerOptions = { deliveryMode: 'Webhook' };
trigger.connectorTrigger(typedOptions);

const literalOptions = { deliveryMode: 'Webhook' } as const;
trigger.connectorTrigger(literalOptions);

const widenedBatchOptions = { cardinality: 'many' };
// @ts-expect-error unannotated configuration variables widen cardinality to string
trigger.connectorTrigger(widenedBatchOptions);

const typedBatchOptions: ConnectorTriggerOptions = { cardinality: 'many' };
trigger.connectorTrigger(typedBatchOptions);

const widenedFunctionOptions = { deliveryMode: 'Webhook', handler: () => {} };
// @ts-expect-error function configuration variables also need literal deliveryMode types
app.connectorTrigger('widenedConnector', widenedFunctionOptions);

const typedFunctionOptions: ConnectorTriggerFunctionOptions = {
deliveryMode: 'Webhook',
handler: () => {},
};
app.connectorTrigger('typedConnector', typedFunctionOptions);
}

const invalidConnectorOptions: ConnectorTriggerOptions = {
// @ts-expect-error deliveryMode only accepts Poll or Webhook
deliveryMode: 'invalid',
// @ts-expect-error connection must be a string
connection: 123,
// @ts-expect-error triggerConfigName must be a string
triggerConfigName: 123,
// @ts-expect-error cardinality only accepts one or many
cardinality: 'invalid',
// @ts-expect-error maxBatchSize must be a number
maxBatchSize: '10',
// @ts-expect-error maxConcurrentCalls must be a number
maxConcurrentCalls: '2',
// @ts-expect-error targetPendingEventThreshold must be a number
targetPendingEventThreshold: '16',
};

function registerConnectorTrigger(): void {
app.connectorTrigger<TodoItem[]>('pollConnector', connectorPollOptions);
app.connectorTrigger('webhookConnector', { handler: () => {} });
}

type ExpectedAvadHandler = import('../../types').CosmosDBv4Handler<CosmosDBChangeFeedItem<TodoItem>>;
type ValidAvadHandler = (
documents: CosmosDBChangeFeedItem<TodoItem>[],
Expand All @@ -99,5 +172,11 @@ void serviceBusTopicTriggerOptions;
void serviceBusTopicFunctionOptions;
void invalidServiceBusTopicTriggerOptions;
void registerServiceBusTopic;
void connectorPollOptions;
void connectorWebhookOptions;
void connectorDefaultOptions;
void configureConnectorTriggerFromVariables;
void invalidConnectorOptions;
void registerConnectorTrigger;
void validHandlerAssignableToAvad;
void invalidHandlerAssignableToAvad;
42 changes: 41 additions & 1 deletion types/connectorTrigger.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,46 @@ export interface ConnectorTriggerFunctionOptions<T = unknown>
retry?: RetryOptions;
}

export interface ConnectorTriggerOptions extends Record<string, unknown> {}
export interface ConnectorTriggerOptions extends Record<string, unknown> {
/**
* The connector event delivery mode. If omitted, the Functions host defaults to `Webhook`.
*/
deliveryMode?: 'Poll' | 'Webhook';
Comment thread
swapnil-nagar marked this conversation as resolved.

/**
* The connector connection setting. Required when `deliveryMode` is `Poll`.
*/
connection?: string;

/**
* The connector trigger configuration name. Supports app setting expressions such as `%TriggerConfigName%`.
* Required when `deliveryMode` is `Poll`.
*/
triggerConfigName?: string;

/**
* Set to `many` to enable batching. If omitted or set to `one`, a single event is passed to the function.
* For batches, use an array as the handler's payload type, for example `app.connectorTrigger<MyEvent[]>`.
*/
cardinality?: 'many' | 'one';

/**
* An optional per-function override for the maximum number of events per batch.
* If omitted, Poll delivery uses the extension's app-wide host.json default.
*/
maxBatchSize?: number;

/**
* An optional per-function override for the maximum number of concurrent single-event invocations.
* If omitted, Poll delivery uses the extension's app-wide host.json default.
* Batched invocations are processed one at a time by the extension.
*/
maxConcurrentCalls?: number;

/**
* The target number of pending events per function instance used for scaling.
*/
targetPendingEventThreshold?: number;
}

export type ConnectorTrigger = FunctionTrigger & ConnectorTriggerOptions;
Loading