diff --git a/production/.dockerignore b/production/.dockerignore new file mode 100644 index 00000000..d3e62887 --- /dev/null +++ b/production/.dockerignore @@ -0,0 +1,2 @@ +node_modules +certs \ No newline at end of file diff --git a/production/.gitignore b/production/.gitignore index 6f4292a8..c60c025d 100644 --- a/production/.gitignore +++ b/production/.gitignore @@ -1,3 +1,5 @@ lib node_modules -workflow-bundle.js \ No newline at end of file +workflow-bundle.js +workflow-bundle.js.map +certs \ No newline at end of file diff --git a/production/Dockerfile b/production/Dockerfile new file mode 100644 index 00000000..46aa51f3 --- /dev/null +++ b/production/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 + +FROM node:16-bullseye-slim + +RUN apt update && apt install -y ca-certificates + +ENV NODE_ENV=production +WORKDIR /app + +COPY ["package.json", "./"] +RUN npm install --production + +ARG TEMPORAL_SERVER="host.docker.internal:7233" +ENV TEMPORAL_SERVER=$TEMPORAL_SERVER + +ARG NAMESPACE="default" +ENV NAMESPACE=$NAMESPACE + +COPY . . +CMD [ "node", "lib/worker.js" ] \ No newline at end of file diff --git a/production/README.md b/production/README.md index a4a1d087..5eb2063f 100644 --- a/production/README.md +++ b/production/README.md @@ -23,5 +23,29 @@ Hello, Temporal! ### Running this sample in production 1. `npm run build` to build the Worker script and Activities code. -1. `npm run build:workflow` to build the Workflow code bundle. -1. `NODE_ENV=production node lib/worker.js` to run the production Worker. +2. `npm run build:workflow` to build the Workflow code bundle. +3. `NODE_ENV=production node lib/worker.js` to run the production Worker. + +If you use Docker in production, replace step 3 with: + +``` +docker build . --tag my-temporal-worker --build-arg TEMPORAL_SERVER=host.docker.internal:7233 +docker run -p 3000:3000 my-temporal-worker +``` + +### Connecting to deployed Temporal Server + +We use [`src/connection.ts`](./src/connection.ts) for connecting to Temporal Server from both the Client and Worker. When connecting to Temporal Server running on our local machine, the defaults (`localhost:7233` for `node lib/worker.js` and `host.docker.internal:7233` for Docker) work. When connecting to a production Temporal Server, we need to: + +- Provide the GRPC endpoint, like `TEMPORAL_SERVER=loren.temporal-dev.tmprl.cloud:7233` +- Provide the namespace, like `NAMESPACE=loren.temporal-dev` +- Put the TLS certificate in `certs/server.pem` +- Put the TLS private key in `certs/server.key` +- If using Docker, mount `certs/` into the container by adding `--mount type=bind,source="$(pwd)"/certs,target=/app/certs` to `docker run` + +With Docker, the full commands would be: + +``` +docker build . --tag my-temporal-worker --build-arg TEMPORAL_SERVER=loren.temporal-dev.tmprl.cloud:7233 --build-arg NAMESPACE=loren.temporal-dev +docker run -p 3000:3000 --mount type=bind,source="$(pwd)"/certs,target=/app/certs my-temporal-worker +``` diff --git a/production/package.json b/production/package.json index 1eb2519e..1126b0c3 100644 --- a/production/package.json +++ b/production/package.json @@ -21,6 +21,7 @@ ] }, "dependencies": { + "micri": "^4.5.0", "@temporalio/activity": "^1.0.0-rc.1", "@temporalio/client": "^1.0.0-rc.1", "@temporalio/worker": "^1.0.0-rc.1", diff --git a/production/src/client.ts b/production/src/client.ts index 9f04fe5f..ec85459a 100644 --- a/production/src/client.ts +++ b/production/src/client.ts @@ -1,14 +1,13 @@ import { Connection, WorkflowClient } from '@temporalio/client'; +import { connectionOptions, namespace } from './connection'; import { example } from './workflows'; async function run() { - const connection = await Connection.connect(); // Connect to localhost with default ConnectionOptions. - // In production, pass options to the Connection constructor to configure TLS and other settings. - // This is optional but we leave this here to remind you there is a gRPC connection being established. + const connection = await Connection.connect(connectionOptions); const client = new WorkflowClient({ connection, - // In production you will likely specify `namespace` here; it is 'default' if omitted + namespace, }); const result = await client.execute(example, { diff --git a/production/src/connection.ts b/production/src/connection.ts new file mode 100644 index 00000000..0ca661c0 --- /dev/null +++ b/production/src/connection.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'fs'; +import { fileNotFound } from './errors'; + +const { TEMPORAL_SERVER, NODE_ENV = 'development', NAMESPACE = 'default' } = process.env; + +export { NAMESPACE as namespace }; + +const isDeployed = ['production', 'staging'].includes(NODE_ENV); + +interface ConnectionOptions { + address: string; + tls?: { clientCertPair: { crt: Buffer; key: Buffer } }; +} + +export const connectionOptions: ConnectionOptions = { + address: TEMPORAL_SERVER || 'localhost:7233', +}; + +if (isDeployed) { + try { + const crt = readFileSync('./certs/server.pem'); + const key = readFileSync('./certs/server.key'); + + if (crt && key) { + connectionOptions.tls = { + clientCertPair: { + crt, + key, + }, + }; + } + } catch (e) { + if (!fileNotFound(e)) { + throw e; + } + } +} diff --git a/production/src/errors.ts b/production/src/errors.ts new file mode 100644 index 00000000..98354173 --- /dev/null +++ b/production/src/errors.ts @@ -0,0 +1,20 @@ +type ErrorWithCode = { + code: string; +}; + +function isErrorWithCode(error: unknown): error is ErrorWithCode { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as Record).code === 'string' + ); +} + +export function getErrorCode(error: unknown) { + if (isErrorWithCode(error)) return error.code; +} + +export function fileNotFound(error: unknown) { + return getErrorCode(error) === 'ENOENT'; +} diff --git a/production/src/worker.ts b/production/src/worker.ts index 366dd940..ff21b9ba 100644 --- a/production/src/worker.ts +++ b/production/src/worker.ts @@ -1,5 +1,7 @@ -import { Worker } from '@temporalio/worker'; +import { NativeConnection, Worker } from '@temporalio/worker'; +import { serve } from 'micri'; import * as activities from './activities'; +import { connectionOptions, namespace } from './connection'; // @@@SNIPSTART typescript-production-worker const workflowOption = () => @@ -13,12 +15,31 @@ const workflowOption = () => : { workflowsPath: require.resolve('./workflows') }; async function run() { + console.log('connectionOptions:', connectionOptions); + const connection = await NativeConnection.connect(connectionOptions); + const worker = await Worker.create({ + connection, + namespace, ...workflowOption(), activities, taskQueue: 'production-sample', }); + const server = serve(async () => { + return worker.getStatus(); + }); + + server.listen(process.env.PORT || 3000); + + server.on('error', (err) => { + console.error(err); + }); + + for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGUSR2']) { + process.on(signal, () => server.close()); + } + await worker.run(); } // @@@SNIPEND