diff --git a/README.md b/README.md index 84f4da0..a7ff15b 100644 --- a/README.md +++ b/README.md @@ -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` 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` 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 diff --git a/package.json b/package.json index 34b2c97..7eec17b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/functions", - "version": "4.16.5", + "version": "4.17.0", "description": "Microsoft Azure Functions NodeJS Framework", "keywords": [ "azure", diff --git a/src/constants.ts b/src/constants.ts index bd44bd4..d67ffd5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -export const version = '4.16.5'; +export const version = '4.17.0'; export const returnBindingKey = '$return'; diff --git a/src/trigger.ts b/src/trigger.ts index 11ee6a4..1328c35 100644 --- a/src/trigger.ts +++ b/src/trigger.ts @@ -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', diff --git a/test/connectorTrigger.test.ts b/test/connectorTrigger.test.ts index 4fbe5ad..b84bdb6 100644 --- a/test/connectorTrigger.test.ts +++ b/test/connectorTrigger.test.ts @@ -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({}); @@ -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({}); @@ -63,6 +155,26 @@ describe('connectorTrigger', () => { }); describe('app.connectorTrigger', () => { + it('should register a typed batch handler with polling settings', () => { + const options: ConnectorTriggerFunctionOptions = { + deliveryMode: 'Poll', + connection: 'ConnectorNamespace', + triggerConfigName: 'config', + cardinality: 'many', + handler: (events, context) => { + context.log(events.join(', ')); + }, + }; + + expect(() => app.connectorTrigger('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', { @@ -71,4 +183,24 @@ describe('connectorTrigger', () => { }).to.not.throw(); }); }); + + describe('polling batch payloads', () => { + it('should decode string batches using existing Node.js JSON conversion', () => { + 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([]); + }); + }); }); diff --git a/test/types/index.test.ts b/test/types/index.test.ts index 94ea8f7..5240319 100644 --- a/test/types/index.test.ts +++ b/test/types/index.test.ts @@ -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('serviceBusTopic', serviceBusTopicFunctionOptions); } +type ConnectorTriggerOptions = import('../../types').ConnectorTriggerOptions; +type ConnectorTriggerFunctionOptions = import('../../types').ConnectorTriggerFunctionOptions; + +const connectorPollOptions: ConnectorTriggerFunctionOptions = { + 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('pollConnector', connectorPollOptions); + app.connectorTrigger('webhookConnector', { handler: () => {} }); +} + type ExpectedAvadHandler = import('../../types').CosmosDBv4Handler>; type ValidAvadHandler = ( documents: CosmosDBChangeFeedItem[], @@ -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; diff --git a/types/connectorTrigger.d.ts b/types/connectorTrigger.d.ts index 94601bf..92706a9 100644 --- a/types/connectorTrigger.d.ts +++ b/types/connectorTrigger.d.ts @@ -20,6 +20,46 @@ export interface ConnectorTriggerFunctionOptions retry?: RetryOptions; } -export interface ConnectorTriggerOptions extends Record {} +export interface ConnectorTriggerOptions extends Record { + /** + * The connector event delivery mode. If omitted, the Functions host defaults to `Webhook`. + */ + deliveryMode?: 'Poll' | 'Webhook'; + + /** + * 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`. + */ + 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;