From 3684e431dd5db620035db398ccd12642b7756540 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 14 Aug 2026 10:37:20 -0700 Subject: [PATCH] Add nexus-standalone-activity sample --- README.md | 1 + nexus-standalone-activity/.eslintignore | 2 + nexus-standalone-activity/.eslintrc.js | 43 ++++++++++++ nexus-standalone-activity/.gitignore | 3 + nexus-standalone-activity/.npmrc | 1 + nexus-standalone-activity/.nvmrc | 1 + nexus-standalone-activity/.post-create | 13 ++++ nexus-standalone-activity/.prettierignore | 1 + nexus-standalone-activity/.prettierrc | 2 + nexus-standalone-activity/README.md | 59 ++++++++++++++++ nexus-standalone-activity/package.json | 51 ++++++++++++++ nexus-standalone-activity/src/activities.ts | 7 ++ nexus-standalone-activity/src/api.ts | 13 ++++ nexus-standalone-activity/src/handler.ts | 22 ++++++ .../mocha/nexus-standalone-activity.test.ts | 59 ++++++++++++++++ nexus-standalone-activity/src/shared.ts | 3 + nexus-standalone-activity/src/starter.ts | 32 +++++++++ nexus-standalone-activity/src/worker.ts | 28 ++++++++ nexus-standalone-activity/tsconfig.json | 13 ++++ pnpm-lock.yaml | 70 +++++++++++++++++++ 20 files changed, 424 insertions(+) create mode 100644 nexus-standalone-activity/.eslintignore create mode 100644 nexus-standalone-activity/.eslintrc.js create mode 100644 nexus-standalone-activity/.gitignore create mode 100644 nexus-standalone-activity/.npmrc create mode 100644 nexus-standalone-activity/.nvmrc create mode 100644 nexus-standalone-activity/.post-create create mode 100644 nexus-standalone-activity/.prettierignore create mode 100644 nexus-standalone-activity/.prettierrc create mode 100644 nexus-standalone-activity/README.md create mode 100644 nexus-standalone-activity/package.json create mode 100644 nexus-standalone-activity/src/activities.ts create mode 100644 nexus-standalone-activity/src/api.ts create mode 100644 nexus-standalone-activity/src/handler.ts create mode 100644 nexus-standalone-activity/src/mocha/nexus-standalone-activity.test.ts create mode 100644 nexus-standalone-activity/src/shared.ts create mode 100644 nexus-standalone-activity/src/starter.ts create mode 100644 nexus-standalone-activity/src/worker.ts create mode 100644 nexus-standalone-activity/tsconfig.json diff --git a/README.md b/README.md index 34d6a84de..50ad7d27e 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ and you'll be given the list of sample options. - [**Nexus Hello**](./nexus-hello): Demonstrates how to define a Nexus Service, implement the Operation handlers, and call the Operations from a Workflow. - [**Nexus Cancellation**](./nexus-cancellation): Demonstrates how to cancel a Nexus Operation from a caller workflow using a CancellationScope - [**Nexus Standalone Operations**](./nexus-standalone-operations): Execute Nexus Operations directly from a Temporal Client, without a caller Workflow. +- [**Nexus Standalone Activity**](./nexus-standalone-activity): Use a `TemporalOperationHandler` to execute a Nexus Operation as a standalone Activity. #### Workflow APIs diff --git a/nexus-standalone-activity/.eslintignore b/nexus-standalone-activity/.eslintignore new file mode 100644 index 000000000..54495c777 --- /dev/null +++ b/nexus-standalone-activity/.eslintignore @@ -0,0 +1,2 @@ +lib +.eslintrc.js diff --git a/nexus-standalone-activity/.eslintrc.js b/nexus-standalone-activity/.eslintrc.js new file mode 100644 index 000000000..32d63d64b --- /dev/null +++ b/nexus-standalone-activity/.eslintrc.js @@ -0,0 +1,43 @@ +const { builtinModules } = require('module'); + +const ALLOWED_NODE_BUILTINS = new Set(['assert']); + +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + plugins: ['@typescript-eslint', 'deprecation'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + ], + rules: { + '@typescript-eslint/no-floating-promises': 'error', + 'deprecation/deprecation': 'warn', + 'object-shorthand': ['error', 'always'], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + }, + overrides: [ + { + files: ['src/workflows.ts', 'src/workflows-*.ts', 'src/workflows/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + ...builtinModules.filter((m) => !ALLOWED_NODE_BUILTINS.has(m)).flatMap((m) => [m, `node:${m}`]), + ], + }, + }, + ], +}; diff --git a/nexus-standalone-activity/.gitignore b/nexus-standalone-activity/.gitignore new file mode 100644 index 000000000..ab02b9d9d --- /dev/null +++ b/nexus-standalone-activity/.gitignore @@ -0,0 +1,3 @@ +lib +node_modules +tsconfig.tsbuildinfo diff --git a/nexus-standalone-activity/.npmrc b/nexus-standalone-activity/.npmrc new file mode 100644 index 000000000..43c97e719 --- /dev/null +++ b/nexus-standalone-activity/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/nexus-standalone-activity/.nvmrc b/nexus-standalone-activity/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/nexus-standalone-activity/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/nexus-standalone-activity/.post-create b/nexus-standalone-activity/.post-create new file mode 100644 index 000000000..120467f6c --- /dev/null +++ b/nexus-standalone-activity/.post-create @@ -0,0 +1,13 @@ +Use Node version 20.3+ (v22.x is recommended): + +Mac: {cyan brew install node@22} +Other: https://nodejs.org/en/download/ + +Install this sample's main-branch SDK dependencies: + +{cyan pnpm install} + +Then follow README.md to start a compatible Temporal dev server, create the Nexus endpoint, and run: + +{cyan pnpm run worker} +{cyan pnpm run starter} diff --git a/nexus-standalone-activity/.prettierignore b/nexus-standalone-activity/.prettierignore new file mode 100644 index 000000000..a65b41774 --- /dev/null +++ b/nexus-standalone-activity/.prettierignore @@ -0,0 +1 @@ +lib diff --git a/nexus-standalone-activity/.prettierrc b/nexus-standalone-activity/.prettierrc new file mode 100644 index 000000000..965d50bff --- /dev/null +++ b/nexus-standalone-activity/.prettierrc @@ -0,0 +1,2 @@ +printWidth: 120 +singleQuote: true diff --git a/nexus-standalone-activity/README.md b/nexus-standalone-activity/README.md new file mode 100644 index 000000000..301ad8aef --- /dev/null +++ b/nexus-standalone-activity/README.md @@ -0,0 +1,59 @@ +# Nexus Operation backed by a standalone Activity + +This sample demonstrates how a Nexus Operation implemented with a `TemporalOperationHandler` can start a standalone Activity. The starter invokes the Nexus Operation directly from a Temporal Client, the handler starts the Activity, and the Activity result completes the Operation. + +These APIs are experimental and may change in future releases. + +## Structure + +- `src/api.ts` defines the Nexus Service and its typed input and output. +- `src/activities.ts` implements the Activity that backs the Nexus Operation. +- `src/handler.ts` implements the Operation with `TemporalOperationHandler` and starts the standalone Activity through its typed Activity client. +- `src/worker.ts` runs a Worker that handles both Nexus and Activity tasks. +- `src/starter.ts` starts the Nexus Operation directly from a Temporal Client. + +## Run locally + +This sample requires the [Temporal CLI build with standalone Nexus Operations](https://github.com/temporalio/cli/releases/tag/v1.7.4-standalone-nexus-operations), with Activity callbacks enabled as shown below. + +1. Install dependencies from this directory: + + ```sh + pnpm install + ``` + +2. Start the Temporal dev server with the two namespaces and required feature flags: + + ```sh + temporal server start-dev \ + --namespace default \ + --namespace greeting-handler \ + --dynamic-config-value activity.enableCallbacks=true + ``` + +3. Create a Nexus endpoint that routes to the handler Worker: + + ```sh + temporal operator nexus endpoint create \ + --name greeting-endpoint \ + --target-namespace greeting-handler \ + --target-task-queue greeting-handler-task-queue + ``` + +4. In a second shell, start the Worker: + + ```sh + TEMPORAL_NAMESPACE=greeting-handler pnpm run worker + ``` + +5. In a third shell, run the starter from the caller namespace: + + ```sh + TEMPORAL_NAMESPACE=default pnpm run starter + ``` + +Expected output: + +```text +Hello, Temporal! +``` diff --git a/nexus-standalone-activity/package.json b/nexus-standalone-activity/package.json new file mode 100644 index 000000000..69330fbf2 --- /dev/null +++ b/nexus-standalone-activity/package.json @@ -0,0 +1,51 @@ +{ + "name": "nexus-standalone-activity", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "tsc --build", + "build.watch": "tsc --build --watch", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "worker": "ts-node src/worker.ts", + "worker.watch": "nodemon --watch src src/worker.ts", + "starter": "ts-node src/starter.ts", + "test": "mocha --exit --require ts-node/register --require source-map-support/register src/mocha/*.test.ts" + }, + "nodemonConfig": { + "execMap": { + "ts": "ts-node" + }, + "ext": "ts", + "watch": [ + "src" + ] + }, + "dependencies": { + "@temporalio/activity": "link:../../../sdk-typescript/packages/activity", + "@temporalio/client": "link:../../../sdk-typescript/packages/client", + "@temporalio/envconfig": "link:../../../sdk-typescript/packages/envconfig", + "@temporalio/nexus": "link:../../../sdk-typescript/packages/nexus", + "@temporalio/worker": "link:../../../sdk-typescript/packages/worker", + "nanoid": "~3.3.12", + "nexus-rpc": "^0.0.2" + }, + "devDependencies": { + "@temporalio/testing": "link:../../../sdk-typescript/packages/testing", + "@tsconfig/node22": "^22.0.0", + "@types/mocha": "10.x", + "@types/node": "^22.9.1", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-deprecation": "^3.0.0", + "mocha": "10.x", + "nodemon": "^3.1.7", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-node": "^10.9.2", + "typescript": "^5.6.3" + } +} diff --git a/nexus-standalone-activity/src/activities.ts b/nexus-standalone-activity/src/activities.ts new file mode 100644 index 000000000..59445b6e6 --- /dev/null +++ b/nexus-standalone-activity/src/activities.ts @@ -0,0 +1,7 @@ +import { GreetInput, GreetOutput } from './api'; + +export async function greet(input: GreetInput): Promise { + return { + message: `Hello, ${input.name}!`, + }; +} diff --git a/nexus-standalone-activity/src/api.ts b/nexus-standalone-activity/src/api.ts new file mode 100644 index 000000000..b54e22260 --- /dev/null +++ b/nexus-standalone-activity/src/api.ts @@ -0,0 +1,13 @@ +import * as nexus from 'nexus-rpc'; + +export const greetingService = nexus.service('greetingService', { + greet: nexus.operation(), +}); + +export interface GreetInput { + name: string; +} + +export interface GreetOutput { + message: string; +} diff --git a/nexus-standalone-activity/src/handler.ts b/nexus-standalone-activity/src/handler.ts new file mode 100644 index 000000000..f82574678 --- /dev/null +++ b/nexus-standalone-activity/src/handler.ts @@ -0,0 +1,22 @@ +import * as nexus from 'nexus-rpc'; +import * as temporalNexus from '@temporalio/nexus'; +import * as activities from './activities'; +import { GreetInput, greetingService } from './api'; + +function activityIdForGreeting(input: GreetInput): string { + return `greeting-${input.name}`; +} + +export const greetingServiceHandler = nexus.serviceHandler(greetingService, { + greet: new temporalNexus.TemporalOperationHandler({ + async start(_ctx, client, input) { + return await client.typedActivity().startActivity('greet', { + // Use a business identifier from the Operation input so callers can identify the + // same Activity independently of any individual Nexus request. + id: activityIdForGreeting(input), + args: [input], + startToCloseTimeout: '10s', + }); + }, + }), +}); diff --git a/nexus-standalone-activity/src/mocha/nexus-standalone-activity.test.ts b/nexus-standalone-activity/src/mocha/nexus-standalone-activity.test.ts new file mode 100644 index 000000000..b3308e125 --- /dev/null +++ b/nexus-standalone-activity/src/mocha/nexus-standalone-activity.test.ts @@ -0,0 +1,59 @@ +import assert from 'assert'; +import { randomUUID } from 'crypto'; +import { after, before, describe, it } from 'mocha'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker } from '@temporalio/worker'; +import * as activities from '../activities'; +import { greetingService } from '../api'; +import { greetingServiceHandler } from '../handler'; +import { TASK_QUEUE } from '../shared'; + +describe('Nexus operation backed by a standalone Activity', () => { + let endpointName: string; + let testEnv: TestWorkflowEnvironment; + + before(async () => { + endpointName = `test-nexus-activity-${randomUUID()}`; + const executable = process.env.TEMPORAL_CLI_PATH + ? { type: 'existing-path' as const, path: process.env.TEMPORAL_CLI_PATH } + : { type: 'cached-download' as const, version: 'v1.7.4-standalone-nexus-operations' }; + testEnv = await TestWorkflowEnvironment.createLocal({ + server: { + executable, + extraArgs: ['--dynamic-config-value', 'activity.enableCallbacks=true'], + }, + }); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('runs the Activity and returns its result through Nexus', async () => { + await testEnv.createNexusEndpoint(endpointName, TASK_QUEUE); + const { client, nativeConnection } = testEnv; + + const worker = await Worker.create({ + connection: nativeConnection, + namespace: 'default', + taskQueue: TASK_QUEUE, + activities, + nexusServices: [greetingServiceHandler], + }); + + await worker.runUntil(async () => { + const nexusClient = client.nexus.createServiceClient({ + endpoint: endpointName, + service: greetingService, + }); + + const result = await nexusClient.executeOperation( + greetingService.operations.greet, + { name: 'Test' }, + { id: randomUUID(), scheduleToCloseTimeout: '10s' }, + ); + + assert.equal(result.message, 'Hello, Test!'); + }); + }); +}); diff --git a/nexus-standalone-activity/src/shared.ts b/nexus-standalone-activity/src/shared.ts new file mode 100644 index 000000000..df792815b --- /dev/null +++ b/nexus-standalone-activity/src/shared.ts @@ -0,0 +1,3 @@ +export const ENDPOINT_NAME = 'greeting-endpoint'; +export const HANDLER_NAMESPACE = 'greeting-handler'; +export const TASK_QUEUE = 'greeting-handler-task-queue'; diff --git a/nexus-standalone-activity/src/starter.ts b/nexus-standalone-activity/src/starter.ts new file mode 100644 index 000000000..5ddf5fc58 --- /dev/null +++ b/nexus-standalone-activity/src/starter.ts @@ -0,0 +1,32 @@ +import { Client, Connection } from '@temporalio/client'; +import { loadClientConnectConfig } from '@temporalio/envconfig'; +import { nanoid } from 'nanoid'; +import { greetingService } from './api'; +import { ENDPOINT_NAME } from './shared'; + +async function run() { + const config = loadClientConnectConfig(); + const connection = await Connection.connect(config.connectionOptions); + const client = new Client({ connection, namespace: config.namespace ?? 'default' }); + + const nexusClient = client.nexus.createServiceClient({ + endpoint: ENDPOINT_NAME, + service: greetingService, + }); + + const result = await nexusClient.executeOperation( + greetingService.operations.greet, + { name: 'Temporal' }, + { + id: nanoid(), + scheduleToCloseTimeout: '10s', + }, + ); + + console.log(result.message); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/nexus-standalone-activity/src/worker.ts b/nexus-standalone-activity/src/worker.ts new file mode 100644 index 000000000..18d12b297 --- /dev/null +++ b/nexus-standalone-activity/src/worker.ts @@ -0,0 +1,28 @@ +import { loadClientConnectConfig } from '@temporalio/envconfig'; +import { NativeConnection, Worker } from '@temporalio/worker'; +import * as activities from './activities'; +import { greetingServiceHandler } from './handler'; +import { HANDLER_NAMESPACE, TASK_QUEUE } from './shared'; + +async function run() { + const config = loadClientConnectConfig(); + const connection = await NativeConnection.connect(config.connectionOptions); + try { + const worker = await Worker.create({ + connection, + namespace: config.namespace ?? HANDLER_NAMESPACE, + taskQueue: TASK_QUEUE, + activities, + nexusServices: [greetingServiceHandler], + }); + + await worker.run(); + } finally { + await connection.close(); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/nexus-standalone-activity/tsconfig.json b/nexus-standalone-activity/tsconfig.json new file mode 100644 index 000000000..488f2c62a --- /dev/null +++ b/nexus-standalone-activity/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "version": "5.6.3", + "compilerOptions": { + "lib": ["es2021"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "./src", + "outDir": "./lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31589647b..b3dcc7a95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2792,6 +2792,76 @@ importers: specifier: ^5.6.3 version: 5.7.3 + nexus-standalone-activity: + dependencies: + '@temporalio/activity': + specifier: link:../../../sdk-typescript/packages/activity + version: link:../../../sdk-typescript/packages/activity + '@temporalio/client': + specifier: link:../../../sdk-typescript/packages/client + version: link:../../../sdk-typescript/packages/client + '@temporalio/envconfig': + specifier: link:../../../sdk-typescript/packages/envconfig + version: link:../../../sdk-typescript/packages/envconfig + '@temporalio/nexus': + specifier: link:../../../sdk-typescript/packages/nexus + version: link:../../../sdk-typescript/packages/nexus + '@temporalio/worker': + specifier: link:../../../sdk-typescript/packages/worker + version: link:../../../sdk-typescript/packages/worker + nanoid: + specifier: ~3.3.12 + version: 3.3.12 + nexus-rpc: + specifier: ^0.0.2 + version: 0.0.2 + devDependencies: + '@temporalio/testing': + specifier: link:../../../sdk-typescript/packages/testing + version: link:../../../sdk-typescript/packages/testing + '@tsconfig/node22': + specifier: ^22.0.0 + version: 22.0.5 + '@types/mocha': + specifier: 10.x + version: 10.0.10 + '@types/node': + specifier: ^22.9.1 + version: 22.12.0 + '@typescript-eslint/eslint-plugin': + specifier: ^8.18.0 + version: 8.22.0(@typescript-eslint/parser@8.22.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/parser': + specifier: ^8.18.0 + version: 8.22.0(eslint@8.57.1)(typescript@5.7.3) + eslint: + specifier: ^8.57.1 + version: 8.57.1 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.1) + eslint-plugin-deprecation: + specifier: ^3.0.0 + version: 3.0.0(eslint@8.57.1)(typescript@5.7.3) + mocha: + specifier: 10.x + version: 10.2.0(ts-node@10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3)) + nodemon: + specifier: ^3.1.7 + version: 3.1.9 + prettier: + specifier: ^3.4.2 + version: 3.4.2 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3) + typescript: + specifier: ^5.6.3 + version: 5.7.3 + nexus-standalone-operations: dependencies: '@temporalio/client':