@volter/twin-temporal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +104 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +36 -0
  5. package/dist/src/generated/hpack.json +1 -0
  6. package/dist/src/generated/temporal-api.json +1 -0
  7. package/dist/src/index.d.ts +18 -0
  8. package/dist/src/index.js +70 -0
  9. package/dist/src/temporal-budget.d.ts +31 -0
  10. package/dist/src/temporal-budget.js +50 -0
  11. package/dist/src/temporal-capabilities.d.ts +7 -0
  12. package/dist/src/temporal-capabilities.js +650 -0
  13. package/dist/src/temporal-conformance.d.ts +8 -0
  14. package/dist/src/temporal-conformance.js +54 -0
  15. package/dist/src/temporal-connector.d.ts +32 -0
  16. package/dist/src/temporal-connector.js +121 -0
  17. package/dist/src/temporal-engine.d.ts +274 -0
  18. package/dist/src/temporal-engine.js +2132 -0
  19. package/dist/src/temporal-grpc.d.ts +51 -0
  20. package/dist/src/temporal-grpc.js +124 -0
  21. package/dist/src/temporal-h2.d.ts +40 -0
  22. package/dist/src/temporal-h2.js +644 -0
  23. package/dist/src/temporal-history.d.ts +27 -0
  24. package/dist/src/temporal-history.js +85 -0
  25. package/dist/src/temporal-http.d.ts +17 -0
  26. package/dist/src/temporal-http.js +160 -0
  27. package/dist/src/temporal-json.d.ts +7 -0
  28. package/dist/src/temporal-json.js +68 -0
  29. package/dist/src/temporal-proto.d.ts +17 -0
  30. package/dist/src/temporal-proto.js +46 -0
  31. package/dist/src/temporal-server.d.ts +25 -0
  32. package/dist/src/temporal-server.js +56 -0
  33. package/dist/src/temporal-twin.d.ts +25 -0
  34. package/dist/src/temporal-twin.js +100 -0
  35. package/dist/src/temporal-visibility.d.ts +49 -0
  36. package/dist/src/temporal-visibility.js +293 -0
  37. package/package.json +59 -0
  38. package/src/cli.ts +36 -0
  39. package/src/generated/hpack.json +1 -0
  40. package/src/generated/temporal-api.json +1 -0
  41. package/src/index.ts +81 -0
  42. package/src/temporal-budget.ts +75 -0
  43. package/src/temporal-capabilities.ts +679 -0
  44. package/src/temporal-conformance.ts +52 -0
  45. package/src/temporal-connector.ts +118 -0
  46. package/src/temporal-engine.ts +2176 -0
  47. package/src/temporal-grpc.ts +139 -0
  48. package/src/temporal-h2.ts +614 -0
  49. package/src/temporal-history.ts +99 -0
  50. package/src/temporal-http.ts +146 -0
  51. package/src/temporal-json.ts +77 -0
  52. package/src/temporal-proto.ts +53 -0
  53. package/src/temporal-server.ts +67 -0
  54. package/src/temporal-twin.ts +102 -0
  55. package/src/temporal-visibility.ts +245 -0
@@ -0,0 +1,85 @@
1
+ // THE ENGINE SLOT — where a workflow's history lives (docs/contributing/architecture.md#the-engine-slot).
2
+ //
3
+ // Temporal's state has two halves. What a list or a describe answers — a run's status, type, task
4
+ // queue, search attributes, and the server's own bookkeeping for it (pending tasks, timers, buffered
5
+ // events) — is a tree subject, `workflow-execution`, written through the kernel's write path like any
6
+ // pack's resource. The run's HISTORY is the other half: an append-only sequence of events a worker
7
+ // replays in full on every workflow task. Folding it into the subject would make each append rewrite
8
+ // every event before it, so it sits beside the tree as content-addressed chunks in the World's blob
9
+ // store (one chunk per transition's new events, keyed by its sha256), and the subject names its
10
+ // chunks in order (`_state.history`). A branch reads its chunks through `readResourceBlob`, which
11
+ // falls back to the ancestors that wrote them, so a fork replays exactly the history its tree names.
12
+ //
13
+ // This module is the only one that writes outside the tree, and it does so only through the blob
14
+ // store: no file, no database. Everything else reads through `TemporalStore`.
15
+ import { applyTwinWrite, blobDigest, getActiveBlobStore, readResourceBlob, resourceChain, twinResourcesOfType, ownFields, worldNow } from '@volter/world-core';
16
+ import { join } from 'node:path';
17
+ export const TEMPORAL_SERVICE = 'temporal';
18
+ const chunkCache = new Map(); // cache: a key names one immutable chunk; bounded, per root
19
+ /** Chunks held in memory; past this the least recently used go (a long-lived host reads the rest back). */
20
+ const CHUNK_CACHE_LIMIT = 4096;
21
+ function remember(key, events) {
22
+ chunkCache.delete(key);
23
+ chunkCache.set(key, events);
24
+ while (chunkCache.size > CHUNK_CACHE_LIMIT)
25
+ chunkCache.delete(chunkCache.keys().next().value);
26
+ }
27
+ export class TemporalStore {
28
+ root;
29
+ constructor(root) {
30
+ this.root = root;
31
+ }
32
+ /** Every subject of one type, as its own fields. READ-ONLY: the kernel's memoized tree and parsed
33
+ * log rows share nested objects with what this hands out, so a caller that changes a subject
34
+ * copies it first (the engine's RunTx does, deeply) — editing one in place corrupts the log the
35
+ * kernel digests. */
36
+ list(type) {
37
+ return twinResourcesOfType(TEMPORAL_SERVICE, type, this.root).map((resource) => ({ ...ownFields(resource), id: resource.id }));
38
+ }
39
+ get(type, id) {
40
+ return this.list(type).find((subject) => subject.id === id);
41
+ }
42
+ /** Write one subject (all of its fields) under `operation`, the RPC or the server act that moved it. */
43
+ async put(type, id, fields, operation, input) {
44
+ await applyTwinWrite(TEMPORAL_SERVICE, {
45
+ operation,
46
+ subjectType: type,
47
+ subjectId: id,
48
+ // exactly what the log will hold: a JSON round trip drops undefined members, so the entry the
49
+ // kernel keeps in memory is the one it reads back (its history digests compare the two)
50
+ fields: JSON.parse(JSON.stringify(fields)),
51
+ ...(input ? { input } : {}),
52
+ occurredAt: worldNow(),
53
+ }, this.root);
54
+ }
55
+ /** Store a run's new events as one chunk; answers the chunk's key for the subject to name. */
56
+ async appendChunk(events) {
57
+ const bytes = new TextEncoder().encode(JSON.stringify(events));
58
+ const key = `history/${blobDigest(bytes)}.json`;
59
+ const dir = resourceChain(TEMPORAL_SERVICE, this.root)[0];
60
+ await getActiveBlobStore().put(join(dir, key), bytes);
61
+ // keyed by root: a branch's first read of a chunk goes through the blob store's ancestry, so a
62
+ // chunk missing from it is seen as missing, not served from another root's memory
63
+ remember(`${this.root ?? ''}\0${key}`, events);
64
+ return key;
65
+ }
66
+ /** A run's events, in order, from the chunks its subject names. */
67
+ async readChunks(keys) {
68
+ const out = [];
69
+ for (const key of keys) {
70
+ const cacheKey = `${this.root ?? ''}\0${key}`;
71
+ let events = chunkCache.get(cacheKey);
72
+ if (events)
73
+ remember(cacheKey, events);
74
+ if (!events) {
75
+ const bytes = await readResourceBlob(TEMPORAL_SERVICE, key, this.root);
76
+ if (bytes === null)
77
+ throw new Error(`temporal: history chunk ${key} is missing from the World's blob store`);
78
+ events = JSON.parse(new TextDecoder().decode(bytes));
79
+ remember(cacheKey, events);
80
+ }
81
+ out.push(...events);
82
+ }
83
+ return out;
84
+ }
85
+ }
@@ -0,0 +1,17 @@
1
+ import { type TwinFetchHandlerResult } from '@volter/world-core';
2
+ import { type TemporalEngine } from './temporal-engine.js';
3
+ /** One HTTP API request, answered through the RPC table. */
4
+ export declare function handleTemporalHttpRequest(engine: TemporalEngine, request: {
5
+ method: string;
6
+ path: string;
7
+ body: string;
8
+ }): Promise<TwinFetchHandlerResult>;
9
+ /** Every route the HTTP API serves (the served subset of the annotated routes), for the manifest. */
10
+ export declare function servedHttpRoutes(): string[];
11
+ export type TemporalTwinFetchOptions = {
12
+ root?: string;
13
+ readOnly?: boolean;
14
+ engine?: TemporalEngine;
15
+ };
16
+ /** The twin's HTTP surface: the World's doors (GET /twin, GET /twin/store/workflows) and the HTTP API. */
17
+ export declare function createTemporalTwinFetch(options?: TemporalTwinFetchOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,160 @@
1
+ // THE HTTP API — the frontend's HTTP/JSON surface (Temporal serves it on :7243 beside gRPC's :7233),
2
+ // routed by the google.api.http annotations the vendored protos carry on each method, over the same
3
+ // RPC table as the gRPC wire: a served method answers here exactly what it answers there, and every
4
+ // other annotated route answers 501 with the gRPC-gateway error body. Bodies and answers are proto3
5
+ // JSON (temporal-json.ts); the twin's own doors (GET /twin, the store) ride the same fetch.
6
+ //
7
+ // This is also the twin's fetch surface for the World (createTemporalTwinFetch): its doors, the
8
+ // replay the invariants run, and a hosted World's in-process wire all come through here.
9
+ import { createTwinFetchFromHandler, statefulTwinManifest, WORLD_BOOT_PATH } from '@volter/world-core';
10
+ import { handleTemporalCall, SERVED } from "./temporal-twin.js";
11
+ import { engineForRoot, executionInfo } from "./temporal-engine.js";
12
+ import { GrpcError, GrpcStatus } from "./temporal-grpc.js";
13
+ import { fromProto3Json, toProto3Json } from "./temporal-json.js";
14
+ import { OPERATOR_SERVICE, WORKFLOW_SERVICE, serviceMethods, temporalRoot } from "./temporal-proto.js";
15
+ /** gRPC status → HTTP status, as grpc-gateway maps them (runtime/errors.go HTTPStatusFromCode). */
16
+ const HTTP_STATUS = {
17
+ 0: 200, 1: 499, 2: 500, 3: 400, 4: 504, 5: 404, 6: 409, 7: 403, 8: 429, 9: 400, 10: 409, 11: 400, 12: 501, 13: 500, 14: 503, 15: 500, 16: 401,
18
+ };
19
+ function routesOf(service) {
20
+ const out = [];
21
+ const methods = temporalRoot.lookupService(service).methods;
22
+ for (const [method, shape] of serviceMethods(service)) {
23
+ // the annotations, as protobufjs keeps a method's options: `(google.api.http).post` → the path
24
+ const declared = (methods[method].options ?? {});
25
+ const bindings = [];
26
+ for (const prefix of ['(google.api.http)', '(google.api.http).additional_bindings']) {
27
+ for (const verb of ['get', 'post', 'put', 'patch', 'delete']) {
28
+ const path = declared[`${prefix}.${verb}`];
29
+ if (path)
30
+ bindings.push({ verb: verb.toUpperCase(), path, ...(declared[`${prefix}.body`] ? { body: declared[`${prefix}.body`] } : {}) });
31
+ }
32
+ }
33
+ for (const binding of bindings) {
34
+ const params = [];
35
+ const source = binding.path.split(/(\{[^}]+\})/).map((part) => {
36
+ const variable = /^\{([a-z0-9_.]+)(?:=[^}]*)?\}$/.exec(part);
37
+ if (!variable)
38
+ return part.replace(/[.*+?^$()|[\]\\]/g, '\\$&');
39
+ params.push(variable[1]);
40
+ return '([^/]+)';
41
+ }).join('');
42
+ out.push({ verb: binding.verb, pattern: new RegExp(`^${source}$`), params, body: binding.body, service, method, requestType: shape.requestType, responseType: shape.responseType });
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+ let routeTable; // cache: a pure function of the committed descriptor
48
+ function routes() {
49
+ routeTable ??= [...routesOf(WORKFLOW_SERVICE), ...routesOf(OPERATOR_SERVICE)];
50
+ return routeTable;
51
+ }
52
+ const camel = (name) => name.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
53
+ /** Set a dotted proto path (`execution.workflow_id`) on a plain request. */
54
+ function setPath(target, path, value) {
55
+ const parts = path.split('.').map(camel);
56
+ let cursor = target;
57
+ for (const part of parts.slice(0, -1))
58
+ cursor = (cursor[part] ??= {});
59
+ cursor[parts.at(-1)] = value;
60
+ }
61
+ /** A query-string value, typed by the field it lands on. */
62
+ function typedQueryValue(type, path, raw) {
63
+ let current = type;
64
+ let field;
65
+ for (const part of path.split('.').map(camel)) {
66
+ field = current?.fields[part];
67
+ if (!field)
68
+ throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, `unknown query parameter "${path}"`);
69
+ field.resolve();
70
+ current = field.resolvedType && 'fields' in field.resolvedType ? field.resolvedType : undefined;
71
+ }
72
+ if (!field)
73
+ return raw;
74
+ if (['int32', 'uint32', 'sint32', 'fixed32', 'sfixed32', 'double', 'float'].includes(field.type))
75
+ return Number(raw);
76
+ if (field.type === 'bool')
77
+ return raw === 'true';
78
+ return raw;
79
+ }
80
+ /** One HTTP API request, answered through the RPC table. */
81
+ export async function handleTemporalHttpRequest(engine, request) {
82
+ const url = new URL(request.path, 'http://twin.local');
83
+ const matches = routes().filter((route) => route.pattern.test(url.pathname));
84
+ const route = matches.find((candidate) => candidate.verb === request.method);
85
+ if (!route) {
86
+ if (matches.length)
87
+ return { status: 405, body: { code: GrpcStatus.UNIMPLEMENTED, message: `Method Not Allowed`, details: [] } };
88
+ return { status: 404, body: { code: GrpcStatus.NOT_FOUND, message: 'Not Found', details: [] } };
89
+ }
90
+ try {
91
+ let plain = {};
92
+ if (route.body === '*' && request.body.trim()) {
93
+ const parsed = JSON.parse(request.body);
94
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
95
+ throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, 'the request body must be a JSON object');
96
+ try {
97
+ plain = fromProto3Json(route.requestType, parsed);
98
+ }
99
+ catch (error) {
100
+ throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, error.message);
101
+ }
102
+ }
103
+ else if (route.body === undefined) {
104
+ for (const [name, raw] of url.searchParams)
105
+ setPath(plain, name, typedQueryValue(route.requestType, name, raw));
106
+ try {
107
+ plain = fromProto3Json(route.requestType, plain);
108
+ }
109
+ catch (error) {
110
+ throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, error.message);
111
+ }
112
+ }
113
+ let values;
114
+ try {
115
+ values = route.pattern.exec(url.pathname).slice(1).map(decodeURIComponent);
116
+ }
117
+ catch {
118
+ throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, 'malformed percent-escape in the path');
119
+ }
120
+ route.params.forEach((param, i) => setPath(plain, param, values[i]));
121
+ const answer = await handleTemporalCall(engine, route.service, route.method, plain, { signal: new AbortController().signal, metadata: {} });
122
+ return { status: 200, body: toProto3Json(route.responseType, answer) };
123
+ }
124
+ catch (error) {
125
+ if (error instanceof SyntaxError)
126
+ return { status: 400, body: { code: GrpcStatus.INVALID_ARGUMENT, message: `invalid JSON body: ${error.message}`, details: [] } };
127
+ const code = error instanceof GrpcError ? error.code : GrpcStatus.INTERNAL;
128
+ return { status: HTTP_STATUS[code] ?? 500, body: { code, message: error.message, details: [] } };
129
+ }
130
+ }
131
+ /** Every route the HTTP API serves (the served subset of the annotated routes), for the manifest. */
132
+ export function servedHttpRoutes() {
133
+ return routes().filter((route) => SERVED[route.service]?.[route.method]).map((route) => `${route.verb} ${route.pattern.source.slice(1, -1).replace(/\(\[\^\/\]\+\)/g, '{…}').replace(/\\/g, '')} → ${route.method}`);
134
+ }
135
+ /** The twin's HTTP surface: the World's doors (GET /twin, GET /twin/store/workflows) and the HTTP API. */
136
+ export function createTemporalTwinFetch(options = {}) {
137
+ const engine = options.engine ?? engineForRoot({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly ? { readOnly: true } : {}) });
138
+ const serveDoors = createTwinFetchFromHandler((request) => handleTemporalHttpRequest(engine, request), {
139
+ ...(options.root !== undefined ? { root: options.root } : {}),
140
+ ...(options.readOnly ? { readOnly: true } : {}),
141
+ manifest: statefulTwinManifest({
142
+ vendor: 'temporal',
143
+ twinOf: 'the Temporal frontend (WorkflowService over gRPC; its HTTP API on this fetch)',
144
+ stores: 'namespaces, custom search attributes and workflow runs, each run\'s history as chunks beside the tree',
145
+ identity: 'No authentication, as Temporal\'s dev server: the gRPC wire and the HTTP API accept any caller on loopback.',
146
+ notes: 'Point the SDKs at TEMPORAL_ADDRESS (host:port, gRPC). Timers, timeouts and retries run on the World clock.',
147
+ }),
148
+ stores: {
149
+ workflows: () => engine.runs().map((run) => ({ namespace: run.namespace, ...executionInfo(run) })),
150
+ },
151
+ });
152
+ const boot = globalThis.process?.env?.VOLTER_WORLD_BOOT_ID;
153
+ return async (request) => {
154
+ // the World asks each twin which boot it belongs to (serve-http.ts WORLD_BOOT_PATH); this fetch answers it itself
155
+ // because it is served by this pack's own listener, not the kernel's HTTP seam
156
+ if (boot && new URL(request.url).pathname === WORLD_BOOT_PATH)
157
+ return Response.json({ boot });
158
+ return serveDoors(request);
159
+ };
160
+ }
@@ -0,0 +1,7 @@
1
+ import type protobuf from 'protobufjs';
2
+ type Obj = Record<string, unknown>;
3
+ /** The engine's plain form → proto3 JSON. */
4
+ export declare function toProto3Json(type: protobuf.Type, value: Obj): Obj;
5
+ /** Proto3 JSON → the engine's plain form (throws on an unknown field or a malformed value). */
6
+ export declare function fromProto3Json(type: protobuf.Type, value: Obj): Obj;
7
+ export {};
@@ -0,0 +1,68 @@
1
+ import { durationMs, toDuration, toTimestamp } from "./temporal-engine.js";
2
+ const LONGS = new Set(['int64', 'uint64', 'sint64', 'fixed64', 'sfixed64']);
3
+ function rfc3339(value) {
4
+ const ms = Number(value.seconds ?? 0) * 1000 + Math.floor(Number(value.nanos ?? 0) / 1e6);
5
+ return new Date(ms).toISOString();
6
+ }
7
+ function durationText(value) {
8
+ const ms = durationMs(value);
9
+ return `${ms % 1000 === 0 ? ms / 1000 : (ms / 1000).toFixed(3).replace(/0+$/, '')}s`;
10
+ }
11
+ function convert(field, value, direction) {
12
+ field.resolve();
13
+ const one = (item) => {
14
+ if (item === null || item === undefined)
15
+ return item;
16
+ if (field.resolvedType && 'fields' in field.resolvedType) {
17
+ const type = field.resolvedType;
18
+ if (type.fullName === '.google.protobuf.Timestamp') {
19
+ if (direction === 'out')
20
+ return rfc3339(item);
21
+ const ms = Date.parse(String(item));
22
+ if (Number.isNaN(ms))
23
+ throw new Error(`${field.name}: not an RFC 3339 timestamp`);
24
+ return toTimestamp(ms);
25
+ }
26
+ if (type.fullName === '.google.protobuf.Duration') {
27
+ if (direction === 'out')
28
+ return durationText(item);
29
+ const match = /^(-?\d+(?:\.\d+)?)s$/.exec(String(item));
30
+ if (!match)
31
+ throw new Error(`${field.name}: not a duration ("<seconds>s")`);
32
+ return toDuration(Math.round(Number(match[1]) * 1000)) ?? {};
33
+ }
34
+ return walk(type, item, direction);
35
+ }
36
+ if (LONGS.has(field.type))
37
+ return direction === 'out' ? String(item) : Number(item);
38
+ return item;
39
+ };
40
+ if (field.map) {
41
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, one(item)]));
42
+ }
43
+ if (field.repeated)
44
+ return value.map(one);
45
+ return one(value);
46
+ }
47
+ function walk(type, value, direction) {
48
+ const out = {};
49
+ for (const [key, item] of Object.entries(value)) {
50
+ // proto3 JSON accepts the proto field name as well as the lowerCamel one
51
+ const field = type.fields[key] ?? type.fields[key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase())];
52
+ if (!field) {
53
+ if (direction === 'in')
54
+ throw new Error(`unknown field "${key}" for ${type.fullName.slice(1)}`);
55
+ continue;
56
+ }
57
+ out[field.name] = convert(field, item, direction);
58
+ }
59
+ return out;
60
+ }
61
+ /** The engine's plain form → proto3 JSON. */
62
+ export function toProto3Json(type, value) {
63
+ return walk(type, value, 'out');
64
+ }
65
+ /** Proto3 JSON → the engine's plain form (throws on an unknown field or a malformed value). */
66
+ export function fromProto3Json(type, value) {
67
+ return walk(type, value, 'in');
68
+ }
@@ -0,0 +1,17 @@
1
+ import protobuf from 'protobufjs';
2
+ export declare const temporalRoot: protobuf.Root;
3
+ export declare const WORKFLOW_SERVICE = "temporal.api.workflowservice.v1.WorkflowService";
4
+ export declare const OPERATOR_SERVICE = "temporal.api.operatorservice.v1.OperatorService";
5
+ export declare const HEALTH_SERVICE = "grpc.health.v1.Health";
6
+ /** protobufjs's toObject options for the engine's plain form (see the header). */
7
+ export declare const OBJECT: protobuf.IConversionOptions;
8
+ export declare function messageType(name: string): protobuf.Type;
9
+ /** A service's methods, by name, with their request and response types. */
10
+ export declare function serviceMethods(service: string): Map<string, {
11
+ requestType: protobuf.Type;
12
+ responseType: protobuf.Type;
13
+ requestStream: boolean;
14
+ responseStream: boolean;
15
+ }>;
16
+ export declare function decodeMessage(type: protobuf.Type, bytes: Uint8Array): Record<string, unknown>;
17
+ export declare function encodeMessage(type: protobuf.Type, value: Record<string, unknown>): Uint8Array;
@@ -0,0 +1,46 @@
1
+ // THE TEMPORAL API'S TYPES — protobufjs over the committed descriptor (src/generated/temporal-api.json,
2
+ // derived from spec/proto by ../proto-json.ts). Nothing here parses .proto text or reads a file: the
3
+ // descriptor is a JSON import, so the serve path loads the same bytes everywhere.
4
+ //
5
+ // The engine keeps every message in protobufjs's plain-object form with enums as their names, 64-bit
6
+ // integers as JS numbers, bytes as base64 strings and absent fields left out (`OBJECT`), which is
7
+ // what a JSON column stores and what `fromObject` takes back without loss for this API: the only
8
+ // 64-bit fields are event ids, counts and nanosecond-free seconds, all far below 2^53.
9
+ import protobuf from 'protobufjs';
10
+ import descriptor from './generated/temporal-api.json' with { type: 'json' };
11
+ export const temporalRoot = protobuf.Root.fromJSON(descriptor);
12
+ export const WORKFLOW_SERVICE = 'temporal.api.workflowservice.v1.WorkflowService';
13
+ export const OPERATOR_SERVICE = 'temporal.api.operatorservice.v1.OperatorService';
14
+ export const HEALTH_SERVICE = 'grpc.health.v1.Health';
15
+ /** protobufjs's toObject options for the engine's plain form (see the header). */
16
+ export const OBJECT = { enums: String, longs: Number, bytes: String, defaults: false, arrays: false, objects: false, oneofs: false };
17
+ const typeCache = new Map(); // cache: lookups into the committed descriptor
18
+ export function messageType(name) {
19
+ let type = typeCache.get(name);
20
+ if (!type) {
21
+ type = temporalRoot.lookupType(name);
22
+ typeCache.set(name, type);
23
+ }
24
+ return type;
25
+ }
26
+ /** A service's methods, by name, with their request and response types. */
27
+ export function serviceMethods(service) {
28
+ const svc = temporalRoot.lookupService(service);
29
+ svc.resolveAll();
30
+ const out = new Map();
31
+ for (const method of svc.methodsArray) {
32
+ out.set(method.name, {
33
+ requestType: method.resolvedRequestType,
34
+ responseType: method.resolvedResponseType,
35
+ requestStream: Boolean(method.requestStream),
36
+ responseStream: Boolean(method.responseStream),
37
+ });
38
+ }
39
+ return out;
40
+ }
41
+ export function decodeMessage(type, bytes) {
42
+ return type.toObject(type.decode(bytes), OBJECT);
43
+ }
44
+ export function encodeMessage(type, value) {
45
+ return type.encode(type.fromObject(value)).finish();
46
+ }
@@ -0,0 +1,25 @@
1
+ import { type H2Handler, type Http1Handler } from './temporal-h2.js';
2
+ import { TEMPORAL_LOOPBACK_HOST } from './temporal-grpc.js';
3
+ import { type TemporalEngine } from './temporal-engine.js';
4
+ export { TEMPORAL_LOOPBACK_HOST };
5
+ export type H2Server = {
6
+ port: number;
7
+ stop: () => Promise<void>;
8
+ };
9
+ /** Listen on loopback for HTTP/2 (and, given `http1`, HTTP/1) connections. */
10
+ export declare function serveH2(options: {
11
+ port?: number;
12
+ handler: H2Handler;
13
+ http1?: Http1Handler;
14
+ }): Promise<H2Server>;
15
+ export type TemporalTwinServer = {
16
+ /** the frontend port — what TEMPORAL_ADDRESS (`127.0.0.1:<port>`) names */
17
+ port: number;
18
+ engine: TemporalEngine;
19
+ stop: () => Promise<void>;
20
+ };
21
+ export declare function createTemporalTwinServer(options?: {
22
+ port?: number;
23
+ root?: string;
24
+ readOnly?: boolean;
25
+ }): Promise<TemporalTwinServer>;
@@ -0,0 +1,56 @@
1
+ // THE LISTENER — one loopback TCP port that speaks what a Temporal frontend speaks: gRPC over HTTP/2
2
+ // (h2c, prior knowledge; temporal-h2.ts + temporal-grpc.ts) for the SDKs, and HTTP/1 for the rest
3
+ // (temporal-http.ts: the World's doors and the frontend's HTTP API). Temporal serves its HTTP API on
4
+ // a second port (7243); here the first bytes of a connection say which it is, so a World's one port
5
+ // per service carries both. The World clock ticks here (the engine's timers, timeouts and retries).
6
+ import { createServer } from 'node:net';
7
+ import { acceptH2Connection } from "./temporal-h2.js";
8
+ import { grpcStreamHandler, TEMPORAL_LOOPBACK_HOST } from "./temporal-grpc.js";
9
+ import { handleTemporalCall } from "./temporal-twin.js";
10
+ import { engineForRoot, releaseEngine } from "./temporal-engine.js";
11
+ import { createTemporalTwinFetch } from "./temporal-http.js";
12
+ import { HEALTH_SERVICE, OPERATOR_SERVICE, WORKFLOW_SERVICE } from "./temporal-proto.js";
13
+ export { TEMPORAL_LOOPBACK_HOST };
14
+ /** Listen on loopback for HTTP/2 (and, given `http1`, HTTP/1) connections. */
15
+ export async function serveH2(options) {
16
+ const connections = new Set();
17
+ const server = createServer((socket) => {
18
+ const connection = acceptH2Connection(socket, options.handler, options.http1);
19
+ connections.add(connection);
20
+ socket.on('close', () => connections.delete(connection));
21
+ });
22
+ await new Promise((resolve, reject) => {
23
+ server.once('error', reject);
24
+ server.listen(options.port ?? 0, TEMPORAL_LOOPBACK_HOST, () => resolve());
25
+ });
26
+ const address = server.address();
27
+ const port = typeof address === 'object' && address ? address.port : 0;
28
+ return {
29
+ port,
30
+ stop: () => new Promise((resolve) => {
31
+ for (const connection of connections)
32
+ connection.teardown();
33
+ server.close(() => resolve());
34
+ }),
35
+ };
36
+ }
37
+ export async function createTemporalTwinServer(options = {}) {
38
+ const engine = engineForRoot({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly ? { readOnly: true } : {}) });
39
+ const server = await serveH2({
40
+ ...(options.port !== undefined ? { port: options.port } : {}),
41
+ handler: grpcStreamHandler({
42
+ services: [WORKFLOW_SERVICE, OPERATOR_SERVICE, HEALTH_SERVICE],
43
+ handler: (service, method, request, ctx) => handleTemporalCall(engine, service, method, request, ctx),
44
+ }),
45
+ http1: createTemporalTwinFetch({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly ? { readOnly: true } : {}), engine }),
46
+ });
47
+ engine.start();
48
+ return {
49
+ port: server.port,
50
+ engine,
51
+ stop: async () => {
52
+ await server.stop();
53
+ releaseEngine(engine);
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,25 @@
1
+ import { type GrpcCallContext } from './temporal-grpc.js';
2
+ import type { TemporalEngine } from './temporal-engine.js';
3
+ type Obj = Record<string, unknown>;
4
+ type Served = (engine: TemporalEngine, request: Obj, ctx: GrpcCallContext) => Promise<Obj> | Obj;
5
+ /** The Temporal server version this twin answers as: the API it vendors is the one the 1.15 SDKs
6
+ * (sdk-core 12c656f7) speak, which Temporal server 1.29 serves. */
7
+ export declare const TEMPORAL_SERVER_VERSION = "1.29.1";
8
+ /** GetSystemInfo's capabilities, each one true only when this twin does the thing it names. */
9
+ export declare const TEMPORAL_CAPABILITY_FLAGS: {
10
+ signalAndQueryHeader: boolean;
11
+ internalErrorDifferentiation: boolean;
12
+ activityFailureIncludeHeartbeat: boolean;
13
+ supportsSchedules: boolean;
14
+ encodedFailureAttributes: boolean;
15
+ buildIdBasedVersioning: boolean;
16
+ upsertMemo: boolean;
17
+ eagerWorkflowStart: boolean;
18
+ sdkMetadata: boolean;
19
+ countGroupByExecutionStatus: boolean;
20
+ nexus: boolean;
21
+ };
22
+ export declare const SERVED: Record<string, Record<string, Served>>;
23
+ /** One unary call, answered by the engine or refused UNIMPLEMENTED. */
24
+ export declare function handleTemporalCall(engine: TemporalEngine, service: string, method: string, request: Obj, ctx: GrpcCallContext): Promise<Obj>;
25
+ export {};
@@ -0,0 +1,100 @@
1
+ // THE RPC TABLE — which of Temporal's frontend RPCs this twin answers, and with what.
2
+ //
3
+ // The denominator is the vendored protos' own service definitions (WorkflowService,
4
+ // OperatorService, and gRPC's Health): every method they declare reaches `handleTemporalCall`.
5
+ // A method in `SERVED` goes to the engine; every other one answers gRPC UNIMPLEMENTED naming the
6
+ // method, which is what the SDKs treat as "this server does not have that" — never a fabricated
7
+ // success. `temporal-capabilities.ts` reads the same table, so a served method is a `done` with a
8
+ // verify and an unserved one is a `todo`, by construction.
9
+ import { GrpcError, GrpcStatus } from "./temporal-grpc.js";
10
+ import { HEALTH_SERVICE, OPERATOR_SERVICE, WORKFLOW_SERVICE } from "./temporal-proto.js";
11
+ /** The Temporal server version this twin answers as: the API it vendors is the one the 1.15 SDKs
12
+ * (sdk-core 12c656f7) speak, which Temporal server 1.29 serves. */
13
+ export const TEMPORAL_SERVER_VERSION = '1.29.1';
14
+ /** GetSystemInfo's capabilities, each one true only when this twin does the thing it names. */
15
+ export const TEMPORAL_CAPABILITY_FLAGS = {
16
+ signalAndQueryHeader: true,
17
+ internalErrorDifferentiation: true,
18
+ activityFailureIncludeHeartbeat: true,
19
+ supportsSchedules: false,
20
+ encodedFailureAttributes: true,
21
+ buildIdBasedVersioning: false,
22
+ upsertMemo: true,
23
+ eagerWorkflowStart: false,
24
+ sdkMetadata: true,
25
+ countGroupByExecutionStatus: false,
26
+ nexus: false,
27
+ };
28
+ export const SERVED = {
29
+ [WORKFLOW_SERVICE]: {
30
+ GetSystemInfo: () => ({ serverVersion: TEMPORAL_SERVER_VERSION, capabilities: TEMPORAL_CAPABILITY_FLAGS }),
31
+ DescribeNamespace: (engine, request) => engine.namespace(String(request.namespace || request.id || '')),
32
+ ListNamespaces: (engine) => ({ namespaces: engine.namespaces() }),
33
+ RegisterNamespace: (engine, request) => engine.registerNamespace(request),
34
+ StartWorkflowExecution: (engine, request) => engine.startWorkflowExecution(request),
35
+ SignalWithStartWorkflowExecution: (engine, request) => engine.signalWithStartWorkflowExecution(request),
36
+ SignalWorkflowExecution: (engine, request) => engine.signalWorkflowExecution(request),
37
+ RequestCancelWorkflowExecution: (engine, request) => engine.requestCancelWorkflowExecution(request),
38
+ TerminateWorkflowExecution: (engine, request) => engine.terminateWorkflowExecution(request),
39
+ GetWorkflowExecutionHistory: (engine, request, ctx) => engine.getWorkflowExecutionHistory(request, ctx.signal, ctx.deadline),
40
+ DescribeWorkflowExecution: (engine, request) => engine.describeWorkflowExecution(request),
41
+ ListWorkflowExecutions: (engine, request) => engine.listWorkflowExecutions(request),
42
+ CountWorkflowExecutions: (engine, request) => engine.countWorkflowExecutions(request),
43
+ PollWorkflowTaskQueue: (engine, request, ctx) => engine.pollWorkflowTaskQueue(request, ctx.signal, ctx.deadline),
44
+ RespondWorkflowTaskCompleted: (engine, request) => engine.respondWorkflowTaskCompleted(request),
45
+ RespondWorkflowTaskFailed: (engine, request) => engine.respondWorkflowTaskFailed(request),
46
+ PollActivityTaskQueue: (engine, request, ctx) => engine.pollActivityTaskQueue(request, ctx.signal, ctx.deadline),
47
+ RespondActivityTaskCompleted: (engine, request) => engine.respondActivityTaskCompleted(request),
48
+ RespondActivityTaskCompletedById: (engine, request) => engine.respondActivityTaskCompleted(request, true),
49
+ RespondActivityTaskFailed: (engine, request) => engine.respondActivityTaskFailed(request),
50
+ RespondActivityTaskFailedById: (engine, request) => engine.respondActivityTaskFailed(request, true),
51
+ RespondActivityTaskCanceled: (engine, request) => engine.respondActivityTaskCanceled(request),
52
+ RespondActivityTaskCanceledById: (engine, request) => engine.respondActivityTaskCanceled(request, true),
53
+ RecordActivityTaskHeartbeat: (engine, request) => engine.recordActivityTaskHeartbeat(request),
54
+ RecordActivityTaskHeartbeatById: (engine, request) => engine.recordActivityTaskHeartbeat(request, true),
55
+ QueryWorkflow: (engine, request, ctx) => engine.queryWorkflow(request, ctx.signal, ctx.deadline),
56
+ RespondQueryTaskCompleted: (engine, request) => engine.respondQueryTaskCompleted(request),
57
+ // this server never routes a task to a sticky queue, so there is no stickiness to reset
58
+ ResetStickyTaskQueue: (engine, request) => {
59
+ const namespace = String(request.namespace ?? '');
60
+ engine.namespace(namespace);
61
+ engine.resolveRun(namespace, request.execution); // NOT_FOUND for no such execution
62
+ return {};
63
+ },
64
+ ShutdownWorker: (engine, request) => engine.shutdownWorker(request),
65
+ },
66
+ [OPERATOR_SERVICE]: {
67
+ ListSearchAttributes: (engine, request) => engine.listSearchAttributes(request),
68
+ AddSearchAttributes: (engine, request) => engine.addSearchAttributes(request),
69
+ RemoveSearchAttributes: (engine, request) => engine.removeSearchAttributes(request),
70
+ },
71
+ [HEALTH_SERVICE]: {
72
+ // grpc.health.v1: the empty name is the server as a whole; an unknown service is NOT_FOUND
73
+ Check: (_engine, request) => {
74
+ const service = String(request.service ?? '');
75
+ if (service && ![WORKFLOW_SERVICE, OPERATOR_SERVICE, HEALTH_SERVICE].includes(service))
76
+ throw new GrpcError(GrpcStatus.NOT_FOUND, `unknown service ${service}`);
77
+ return { status: 'SERVING' };
78
+ },
79
+ },
80
+ };
81
+ /** One unary call, answered by the engine or refused UNIMPLEMENTED. */
82
+ export async function handleTemporalCall(engine, service, method, request, ctx) {
83
+ const served = SERVED[service]?.[method];
84
+ const trace = process.env.TEMPORAL_TWIN_TRACE === '1';
85
+ if (trace)
86
+ process.stderr.write(`[temporal] → ${method} ${JSON.stringify(request).slice(0, 400)}\n`);
87
+ if (!served)
88
+ throw new GrpcError(GrpcStatus.UNIMPLEMENTED, `${method} is not implemented by this Temporal twin`);
89
+ try {
90
+ const answer = await served(engine, request, ctx);
91
+ if (trace)
92
+ process.stderr.write(`[temporal] ← ${method} ${JSON.stringify(answer).slice(0, 400)}\n`);
93
+ return answer;
94
+ }
95
+ catch (error) {
96
+ if (trace)
97
+ process.stderr.write(`[temporal] ✗ ${method} ${error.message}\n`);
98
+ throw error;
99
+ }
100
+ }