@hatchet-dev/typescript-sdk 1.34.0-alpha.1 → 1.34.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 (71) hide show
  1. package/README.md +1 -0
  2. package/clients/admin/rpc.d.ts +1 -1
  3. package/clients/admin/rpc.js +3 -3
  4. package/clients/admin/trigger-request.d.ts +18 -0
  5. package/clients/admin/trigger-request.js +66 -0
  6. package/clients/event/event-client.d.ts +4 -17
  7. package/clients/event/event-client.js +5 -43
  8. package/clients/event/rpc.d.ts +22 -3
  9. package/clients/event/rpc.js +66 -3
  10. package/clients/transport/fetch-transport.d.ts +73 -0
  11. package/clients/transport/fetch-transport.js +152 -0
  12. package/clients/transport/grpc-target.js +48 -3
  13. package/clients/transport/index.d.ts +2 -1
  14. package/clients/transport/index.js +6 -1
  15. package/clients/transport/node-transport.js +1 -2
  16. package/clients/transport/transport.d.ts +6 -0
  17. package/clients/transport/transport.js +7 -0
  18. package/clients/transport/ts-proto-client.js +16 -2
  19. package/core/client.d.ts +102 -0
  20. package/core/client.js +250 -0
  21. package/core/features/events.d.ts +25 -0
  22. package/core/features/events.js +57 -0
  23. package/core/features/logs.d.ts +26 -0
  24. package/core/features/logs.js +51 -0
  25. package/core/features/rate-limits.d.ts +19 -0
  26. package/core/features/rate-limits.js +30 -0
  27. package/core/features/runs.d.ts +33 -0
  28. package/core/features/runs.js +84 -0
  29. package/core/features/streams.d.ts +11 -0
  30. package/core/features/streams.js +23 -0
  31. package/core/features/workflows.d.ts +22 -0
  32. package/core/features/workflows.js +46 -0
  33. package/core/index.d.ts +42 -4
  34. package/core/index.js +79 -5
  35. package/core/logger.d.ts +19 -0
  36. package/core/logger.js +45 -0
  37. package/core/run-detail.d.ts +14 -0
  38. package/core/run-detail.js +79 -0
  39. package/core/run-ref.d.ts +63 -0
  40. package/core/run-ref.js +217 -0
  41. package/core/types.d.ts +182 -0
  42. package/core/types.js +2 -0
  43. package/dist/check-edge-entry.mjs +72 -14
  44. package/dist/check-edge-entry.test.ts +57 -0
  45. package/package.json +5 -2
  46. package/protoc/v1/streams.d.ts +79 -0
  47. package/protoc/v1/streams.js +511 -0
  48. package/protoc-es/v1/streams_pb.d.ts +133 -0
  49. package/protoc-es/v1/streams_pb.js +41 -0
  50. package/scripts/check-edge-entry.mjs +72 -14
  51. package/scripts/check-edge-entry.test.ts +57 -0
  52. package/util/batch.js +2 -1
  53. package/util/config-loader/token.d.ts +5 -0
  54. package/util/config-loader/token.js +8 -0
  55. package/util/errors/bulk-trigger-partial-error.d.ts +17 -0
  56. package/util/errors/bulk-trigger-partial-error.js +55 -0
  57. package/v1/client/admin.d.ts +4 -54
  58. package/v1/client/admin.js +9 -72
  59. package/v1/client/client.d.ts +14 -1
  60. package/v1/client/client.js +18 -0
  61. package/v1/client/features/index.d.ts +1 -0
  62. package/v1/client/features/index.js +1 -0
  63. package/v1/client/features/runs.d.ts +9 -27
  64. package/v1/client/features/runs.js +10 -55
  65. package/v1/client/features/streams.d.ts +47 -0
  66. package/v1/client/features/streams.js +210 -0
  67. package/v1/examples/durable-streams/publish-latency.d.ts +15 -0
  68. package/v1/examples/durable-streams/publish-latency.js +50 -0
  69. package/v1/examples/durable_callback_ordering/workflow.js +5 -1
  70. package/version.d.ts +1 -1
  71. package/version.js +1 -1
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.WorkflowRunRef = exports.MAX_POLL_INTERVAL_MS = exports.INITIAL_POLL_INTERVAL_MS = void 0;
16
+ const hatchet_error_1 = __importDefault(require("../util/errors/hatchet-error"));
17
+ const abort_error_1 = require("../util/abort-error");
18
+ const data_contracts_1 = require("../clients/rest/generated/data-contracts");
19
+ const run_detail_1 = require("./run-detail");
20
+ /** The first wait between two `GetRunDetails` polls. */
21
+ exports.INITIAL_POLL_INTERVAL_MS = 250;
22
+ /** The longest wait between two polls; the interval doubles until it gets here. */
23
+ exports.MAX_POLL_INTERVAL_MS = 5000;
24
+ /**
25
+ * The signal one poll is issued with: it fires when the caller's signal fires or when the
26
+ * deadline arrives, whichever is first. `AbortSignal.any` and `AbortSignal.timeout` are
27
+ * missing on some runtimes the core entry targets, so a plain controller, timer and
28
+ * forwarded abort are used everywhere; the timer is also one a test's fake clock controls.
29
+ */
30
+ function pollSignal(signal, remainingMs) {
31
+ const controller = new AbortController();
32
+ let timedOut = false;
33
+ const forwardAbort = () => controller.abort(signal === null || signal === void 0 ? void 0 : signal.reason);
34
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', forwardAbort, { once: true });
35
+ const timer = remainingMs === undefined
36
+ ? undefined
37
+ : setTimeout(() => {
38
+ timedOut = true;
39
+ controller.abort();
40
+ }, remainingMs);
41
+ return {
42
+ signal: controller.signal,
43
+ timedOut: () => timedOut,
44
+ dispose: () => {
45
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', forwardAbort);
46
+ if (timer !== undefined)
47
+ clearTimeout(timer);
48
+ },
49
+ };
50
+ }
51
+ function wait(ms, signal) {
52
+ return new Promise((resolve) => {
53
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
54
+ resolve();
55
+ return;
56
+ }
57
+ const timer = setTimeout(() => {
58
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
59
+ resolve();
60
+ }, ms);
61
+ const onAbort = () => {
62
+ clearTimeout(timer);
63
+ resolve();
64
+ };
65
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onAbort, { once: true });
66
+ });
67
+ }
68
+ /**
69
+ * A reference to a triggered run. `result()` polls `GetRunDetails` until the run is done,
70
+ * which is the one way to wait for a run that works on every platform the core client runs
71
+ * on: no stream is held open, so a Worker request or a browser tab can wait on it.
72
+ */
73
+ class WorkflowRunRef {
74
+ constructor(workflowRunId, runs, parentWorkflowRunId, standaloneTaskName, defaultSignal) {
75
+ this.runs = runs;
76
+ this.workflowRunId = workflowRunId;
77
+ this.parentWorkflowRunId = parentWorkflowRunId;
78
+ this._standaloneTaskName = standaloneTaskName;
79
+ this.defaultSignal = defaultSignal;
80
+ }
81
+ get runId() {
82
+ return this.getWorkflowRunId();
83
+ }
84
+ getWorkflowRunId() {
85
+ return __awaiter(this, void 0, void 0, function* () {
86
+ return this.workflowRunId;
87
+ });
88
+ }
89
+ /** The run's result; the same as `result()` with no options. */
90
+ get output() {
91
+ return this.result();
92
+ }
93
+ /** The run's current state. */
94
+ details() {
95
+ return this.runs.getDetails(this.workflowRunId);
96
+ }
97
+ /**
98
+ * Waits for the run to finish and returns its output: the standalone task's output for a
99
+ * task, or the outputs keyed by task name for a workflow.
100
+ *
101
+ * The wait polls with backoff, starting at 250 ms and capped at 5 s with jitter. It is
102
+ * unbounded unless `timeoutMs` is set (rejects with a `HatchetError`) or `signal` fires
103
+ * (rejects with an `AbortError`). Both bound the poll in flight as well as the waits
104
+ * between polls, so a stalled `GetRunDetails` cannot hold the result past the deadline.
105
+ *
106
+ * A failed or cancelled run rejects the way the Node client's `result()` does: with the
107
+ * array of the tasks' error messages when any task reported one. When no task reported an
108
+ * error the run resolves with the tasks' outputs whatever its status, as it does on the
109
+ * Node client, since that is the shape an explicit cancellation leaves. A run with no
110
+ * tasks at all rejects with an `Error` naming the run's status.
111
+ */
112
+ result() {
113
+ return __awaiter(this, arguments, void 0, function* (options = {}) {
114
+ var _a;
115
+ const signal = (_a = options.signal) !== null && _a !== void 0 ? _a : this.defaultSignal;
116
+ const startedAt = Date.now();
117
+ const deadline = options.timeoutMs !== undefined ? startedAt + options.timeoutMs : undefined;
118
+ let interval = exports.INITIAL_POLL_INTERVAL_MS;
119
+ const abortError = () => (0, abort_error_1.createAbortError)(`waiting for run ${this.workflowRunId} was aborted`);
120
+ const timeoutError = () => new hatchet_error_1.default(`timed out after ${options.timeoutMs} ms waiting for run ${this.workflowRunId}`);
121
+ for (;;) {
122
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
123
+ throw abortError();
124
+ }
125
+ const remaining = deadline !== undefined ? deadline - Date.now() : undefined;
126
+ if (remaining !== undefined && remaining <= 0) {
127
+ throw timeoutError();
128
+ }
129
+ // Whatever the transport rejected with, an abort by the caller's signal surfaces as the
130
+ // AbortError the check above throws and one by the deadline as the same HatchetError;
131
+ // the deadline also goes to the call so the transport applies a Connect timeout.
132
+ const poll = pollSignal(signal, remaining);
133
+ let detail;
134
+ try {
135
+ detail = yield this.runs.getDetails(this.workflowRunId, { signal: poll.signal, deadline });
136
+ }
137
+ catch (e) {
138
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
139
+ throw abortError();
140
+ }
141
+ if (poll.timedOut() || (deadline !== undefined && Date.now() >= deadline)) {
142
+ throw timeoutError();
143
+ }
144
+ throw e;
145
+ }
146
+ finally {
147
+ poll.dispose();
148
+ }
149
+ if (detail.done) {
150
+ return this.resolveResult(detail);
151
+ }
152
+ const now = Date.now();
153
+ if (deadline !== undefined && now >= deadline) {
154
+ throw timeoutError();
155
+ }
156
+ // Jitter of up to 20% keeps many waiters from polling in lockstep; the cap holds for
157
+ // the jittered wait too.
158
+ let delay = Math.min(Math.round(interval * (1 + Math.random() * 0.2)), exports.MAX_POLL_INTERVAL_MS);
159
+ if (deadline !== undefined) {
160
+ delay = Math.min(delay, Math.max(0, deadline - now));
161
+ }
162
+ yield wait(delay, signal);
163
+ interval = Math.min(interval * 2, exports.MAX_POLL_INTERVAL_MS);
164
+ }
165
+ });
166
+ }
167
+ resolveResult(detail) {
168
+ if (detail.status === data_contracts_1.V1TaskStatus.COMPLETED) {
169
+ return Promise.resolve(this.outputs(detail));
170
+ }
171
+ const tasks = Object.values(detail.taskRuns);
172
+ const errors = tasks
173
+ .map((task) => task.error)
174
+ .filter((error) => error !== undefined && error !== '');
175
+ if (errors.length > 0) {
176
+ return Promise.reject(errors);
177
+ }
178
+ // A task with no error under a terminal status is what an explicit cancellation
179
+ // (`ctx.cancel()` on a batch member, for one) leaves behind; the Node client resolves it
180
+ // with the task's output rather than failing the wait, and this keeps that contract.
181
+ if (tasks.length > 0) {
182
+ return Promise.resolve(this.outputs(detail));
183
+ }
184
+ const outcome = detail.status === data_contracts_1.V1TaskStatus.CANCELLED ? 'was cancelled' : 'failed';
185
+ return Promise.reject(new Error(`run ${this.workflowRunId} ${outcome}`));
186
+ }
187
+ /**
188
+ * The outputs keyed by task name, or the standalone task's own output, each read the way
189
+ * the Node client reads them; a task output that is not JSON throws its `SyntaxError`.
190
+ */
191
+ outputs(detail) {
192
+ const outputs = {};
193
+ for (const task of Object.values(detail.taskRuns)) {
194
+ outputs[task.readableId] = (0, run_detail_1.parseTaskOutput)(task);
195
+ }
196
+ if (this._standaloneTaskName) {
197
+ return outputs[this._standaloneTaskName];
198
+ }
199
+ return outputs;
200
+ }
201
+ toJSON() {
202
+ return __awaiter(this, void 0, void 0, function* () {
203
+ return JSON.stringify({ workflowRunId: this.workflowRunId });
204
+ });
205
+ }
206
+ cancel() {
207
+ return __awaiter(this, void 0, void 0, function* () {
208
+ yield this.runs.cancel({ ids: [this.workflowRunId] });
209
+ });
210
+ }
211
+ replay() {
212
+ return __awaiter(this, void 0, void 0, function* () {
213
+ yield this.runs.replay({ ids: [this.workflowRunId] });
214
+ });
215
+ }
216
+ }
217
+ exports.WorkflowRunRef = WorkflowRunRef;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The option and result types shared by the core client and the Node client. Everything in
3
+ * this module is edge-safe: it imports nothing from Node, so the Node client's types extend
4
+ * these where they need more.
5
+ */
6
+ import type { Transport } from '@connectrpc/connect';
7
+ import type { FetchTlsConfig } from '../clients/transport/fetch-transport';
8
+ import type { Priority } from '../v1/declaration';
9
+ import type { WorkerLabelComparator } from '../protoc/v1/shared/trigger';
10
+ import type { RateLimitDuration } from '../protoc/workflows/workflows';
11
+ import type { V1TaskStatus } from '../clients/rest/generated/data-contracts';
12
+ import type { RetrierConfig } from '../util/retrier';
13
+ import type { Logger, LogLevel } from '../util/logger/logger';
14
+ import type { UnaryCallOptions } from '../clients/transport/ts-proto-client';
15
+ /** Builds the logger a client component logs through. */
16
+ export type LogConstructor = (context: string, logLevel?: LogLevel) => Logger;
17
+ /**
18
+ * Per-call options for a unary RPC: `signal` aborts the call, `deadline` (a `Date` or epoch
19
+ * milliseconds) bounds it, `metadata` adds request headers. The same options the Node
20
+ * client's RPC clients take.
21
+ */
22
+ export type CallOptions = UnaryCallOptions;
23
+ /**
24
+ * The core client's configuration. It is explicit by design: nothing is read from the
25
+ * environment, a config file or the filesystem, so the same object works in a Worker, a
26
+ * browser bundle and Node.
27
+ */
28
+ export interface CoreClientConfig {
29
+ /** The tenant API token. */
30
+ token: string;
31
+ /**
32
+ * The engine's base URL, for example `https://engine.example.com:7070`: an absolute
33
+ * `http://` or `https://` URL with no username, password, query string or fragment, whose
34
+ * scheme agrees with `tls` (`http://` needs `{ strategy: 'none' }`). When neither this nor
35
+ * `hostPort` is set, the address is read from the token's `grpc_broadcast_address` claim,
36
+ * so `{ token }` alone reaches the engine the token was issued for.
37
+ */
38
+ serverUrl?: string;
39
+ /** The engine's `host:port`, combined with `tls` into the base URL. */
40
+ hostPort?: string;
41
+ /**
42
+ * Defaults to `{ strategy: 'tls' }`. The token travels on every call, so plain HTTP has to
43
+ * be chosen explicitly with `{ strategy: 'none' }`.
44
+ */
45
+ tls?: FetchTlsConfig;
46
+ /**
47
+ * The prefix applied to workflow names and event keys, the same way the Node client applies
48
+ * `HATCHET_CLIENT_NAMESPACE`: it is lowercased and gets a trailing underscore when missing.
49
+ */
50
+ namespace?: string;
51
+ /**
52
+ * A ready transport, in place of the fetch transport the client builds from the fields
53
+ * above. It is used as is: the bearer token, the URL and TLS checks and the message size
54
+ * limits apply to the transport the client builds, so a supplied transport owns its own
55
+ * authentication and limits.
56
+ */
57
+ transport?: Transport;
58
+ /** The `fetch` the transport sends with. Defaults to the runtime's global `fetch`. */
59
+ fetch?: typeof globalThis.fetch;
60
+ /**
61
+ * The largest response accepted, in bytes after HTTP decompression; a larger one rejects
62
+ * with a `ConnectError` of code `ResourceExhausted`. Defaults to 4 MiB, the Node client's
63
+ * `grpc_max_recv_message_length` default.
64
+ */
65
+ maxReceiveMessageBytes?: number;
66
+ /**
67
+ * The largest request sent, in bytes; a larger one rejects with a `ConnectError` of code
68
+ * `ResourceExhausted` before anything is sent. Defaults to 4 MiB, the Node client's
69
+ * `grpc_max_send_message_length` default.
70
+ */
71
+ maxSendMessageBytes?: number;
72
+ /** Retry settings for the unary calls, the same shape as the Node client's `retrier`. */
73
+ retrier?: RetrierConfig;
74
+ logger?: LogConstructor;
75
+ logLevel?: LogLevel;
76
+ }
77
+ export type DesiredWorkerLabelOpt = {
78
+ value: string | number;
79
+ required?: boolean;
80
+ weight?: number;
81
+ comparator?: WorkerLabelComparator;
82
+ };
83
+ /**
84
+ * The options a single `TriggerWorkflow` call accepts. The Node client's `admin.runWorkflow`
85
+ * takes exactly this type.
86
+ */
87
+ export type TriggerRunOptions = {
88
+ parentId?: string | undefined;
89
+ /**
90
+ * (optional) the parent task run external id.
91
+ *
92
+ * This is the field understood by the workflows gRPC API (`parent_task_run_external_id`).
93
+ */
94
+ parentTaskRunExternalId?: string | undefined;
95
+ /**
96
+ * @deprecated Use `parentTaskRunExternalId` instead.
97
+ * Kept for backward compatibility; will be mapped to `parentTaskRunExternalId`.
98
+ */
99
+ parentStepRunId?: string | undefined;
100
+ childIndex?: number | undefined;
101
+ childKey?: string | undefined;
102
+ additionalMetadata?: Record<string, string> | undefined;
103
+ desiredWorkerId?: string | undefined;
104
+ priority?: Priority;
105
+ desiredWorkerLabels?: Record<string, DesiredWorkerLabelOpt>;
106
+ _standaloneTaskName?: string | undefined;
107
+ };
108
+ /** One entry of a bulk trigger. */
109
+ export type TriggerRun<I = object> = {
110
+ workflowName: string;
111
+ input: I;
112
+ options?: TriggerRunOptions;
113
+ };
114
+ /** Options for pushing an event. */
115
+ export interface PushEventOptions {
116
+ additionalMetadata?: Record<string, string>;
117
+ priority?: number;
118
+ scope?: string;
119
+ }
120
+ /** One event of a bulk push, with per-event overrides of the push options. */
121
+ export interface EventWithMetadata<T> {
122
+ payload: T;
123
+ additionalMetadata?: Record<string, unknown>;
124
+ priority?: number;
125
+ scope?: string;
126
+ }
127
+ export type CreateRateLimitOpts = {
128
+ key: string;
129
+ limit: number;
130
+ duration?: RateLimitDuration;
131
+ };
132
+ /** The state of one task of a run, as `GetRunDetails` reports it. */
133
+ export type TaskRunDetail = {
134
+ externalId: string;
135
+ readableId: string;
136
+ status: V1TaskStatus;
137
+ /** The output decoded as JSON; `null` when the task stored nothing, `null` or text that is not JSON. */
138
+ output: unknown;
139
+ /** The output text as the engine stored it; absent when the task stored nothing. */
140
+ rawOutput?: string;
141
+ error?: string;
142
+ isEvicted: boolean;
143
+ };
144
+ /** The state of a run, as `GetRunDetails` reports it. `taskRuns` is keyed by readable id. */
145
+ export type RunDetail = {
146
+ status: V1TaskStatus;
147
+ done: boolean;
148
+ input: unknown;
149
+ additionalMetadata: unknown;
150
+ isEvicted: boolean;
151
+ taskRuns: Record<string, TaskRunDetail>;
152
+ };
153
+ /** The filter fields shared by the core and Node run filters. */
154
+ export type RunFilterBase = {
155
+ /** Defaults to one hour ago. */
156
+ since?: Date;
157
+ until?: Date;
158
+ statuses?: V1TaskStatus[];
159
+ additionalMetadata?: Record<string, string>;
160
+ };
161
+ /**
162
+ * The core client's run filter. It takes workflow ids rather than names because resolving a
163
+ * name is a REST call, which the core client does not make.
164
+ */
165
+ export type RunFilter = RunFilterBase & {
166
+ workflowIds?: string[];
167
+ };
168
+ export type CancelRunOpts = {
169
+ ids?: string[];
170
+ filters?: RunFilter;
171
+ };
172
+ export type ReplayRunOpts = {
173
+ ids?: string[];
174
+ filters?: RunFilter;
175
+ };
176
+ /** Options for waiting on a run's result. */
177
+ export interface ResultOptions {
178
+ /** Rejects with a `HatchetError` once this many milliseconds have passed. No limit by default. */
179
+ timeoutMs?: number;
180
+ /** Rejects with an `AbortError` when the signal fires. */
181
+ signal?: AbortSignal;
182
+ }
package/core/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,20 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
  /* eslint-disable no-console */
3
- // Verifies the edge entry point (`@hatchet-dev/typescript-sdk/edge`) imports nothing
4
- // from Node. Bundles dist/edge/index.js for a workerd-like browser target and fails on
5
- // any `node:` specifier or Node builtin reached from it, transitively.
3
+ // Verifies that an entry point imports nothing from Node. Bundles the entry for a
4
+ // workerd-like browser target and fails on any `node:` specifier or Node builtin reached
5
+ // from it, transitively, on any package named with `--forbid`, and on any SDK module in the
6
+ // bundle that touches a Node-only global (which a bundler cannot see, since nothing imports
7
+ // them): `process`, `Buffer`, `setImmediate`, a timer's `.unref()`, `AbortSignal.timeout`
8
+ // and `AbortSignal.any`. A use the module feature-detects (`typeof setImmediate`,
9
+ // `timer.unref?.()`, `globalThis.Buffer`) is allowed.
6
10
  //
7
- // Run after `pnpm run tsc:build` (the `check:edge` script does both). An alternative
8
- // entry can be given as the first argument to inspect another module, for example
9
- // `node scripts/check-edge-entry.mjs dist/index.js` to see what the root reaches.
11
+ // Run after `pnpm run tsc:build`:
12
+ // node scripts/check-edge-entry.mjs # dist/edge/index.js
13
+ // node scripts/check-edge-entry.mjs dist/core/index.js --forbid axios,nice-grpc
14
+ // The `check:edge` and `check:core` scripts build first and then run the two entries.
10
15
  import { builtinModules } from 'node:module';
11
- import { existsSync } from 'node:fs';
16
+ import { existsSync, readFileSync } from 'node:fs';
12
17
  import { resolve, dirname } from 'node:path';
13
18
  import { fileURLToPath } from 'node:url';
14
- import { build } from 'esbuild';
19
+ import { build, transformSync } from 'esbuild';
15
20
 
16
21
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
- const entry = resolve(root, process.argv[2] ?? 'dist/edge/index.js');
22
+
23
+ const args = process.argv.slice(2);
24
+ const forbidden = new Set();
25
+ let entryArg = 'dist/edge/index.js';
26
+ for (let i = 0; i < args.length; i += 1) {
27
+ if (args[i] === '--forbid') {
28
+ for (const pkg of (args[i + 1] ?? '').split(',')) if (pkg) forbidden.add(pkg);
29
+ i += 1;
30
+ } else {
31
+ entryArg = args[i];
32
+ }
33
+ }
34
+
35
+ const entry = resolve(root, entryArg);
36
+ const label = entryArg.replace(/^dist\//, '').replace(/\/index\.js$/, '');
18
37
 
19
38
  if (!existsSync(entry)) {
20
39
  console.error(`entry not built: ${entry} is missing. Run \`pnpm run tsc:build\` first.`);
@@ -63,22 +82,61 @@ const result = await build({
63
82
  });
64
83
 
65
84
  const bundled = Object.keys(result.metafile.inputs);
66
- const sdkInputs = bundled.filter((f) => f.startsWith('dist/'));
85
+ const sdkInputs = bundled.filter((f) => !f.includes('node_modules/'));
67
86
  const packageInputs = [...new Set(bundled.filter((f) => f.includes('node_modules/')).map((f) => {
68
87
  const m = f.match(/node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)/);
69
88
  return m ? m[1] : f;
70
89
  }))].sort();
71
90
 
72
- console.log(`edge entry: bundled ${sdkInputs.length} SDK module(s) and ${packageInputs.length} package(s)`);
91
+ console.log(`${label} entry: bundled ${sdkInputs.length} SDK module(s) and ${packageInputs.length} package(s)`);
73
92
  if (packageInputs.length) console.log(`packages: ${packageInputs.join(', ')}`);
74
93
 
94
+ let failed = false;
95
+
75
96
  if (violations.size > 0) {
76
- console.error('\nedge entry reaches Node builtins:');
97
+ failed = true;
98
+ console.error(`\n${label} entry reaches Node builtins:`);
77
99
  for (const [specifier, importers] of [...violations.entries()].sort()) {
78
100
  console.error(` ${specifier}`);
79
101
  for (const importer of [...importers].sort()) console.error(` from ${importer}`);
80
102
  }
81
- process.exit(1);
82
103
  }
83
104
 
84
- console.log('edge entry is free of Node builtins');
105
+ const forbiddenHits = packageInputs.filter((pkg) => forbidden.has(pkg));
106
+ if (forbiddenHits.length > 0) {
107
+ failed = true;
108
+ console.error(`\n${label} entry bundles forbidden package(s): ${forbiddenHits.join(', ')}`);
109
+ }
110
+
111
+ // These are globals in Node that workerd and browsers lack, or that some runtimes the entry
112
+ // targets lack (`AbortSignal.any`, `AbortSignal.timeout`); a module that reads them fails at
113
+ // runtime without ever importing anything. A module that feature-detects one may use it: a
114
+ // `globalThis.Buffer` access (which the generated bindings guard) and an optional
115
+ // `.unref?.()` call are their own guards, and a `typeof` check of the global anywhere in the
116
+ // module allows its use in that module.
117
+ const nodeGlobals = [
118
+ { what: 'the process or Buffer globals', pattern: /(?<!globalThis\.)\b(?:process|Buffer)\s*\./ },
119
+ { what: 'setImmediate', pattern: /(?<![.\w$])setImmediate\s*\(/, guard: /typeof\s+setImmediate\b/ },
120
+ { what: "a timer's .unref()", pattern: /(?<!\?)\.unref\s*\(/ },
121
+ {
122
+ what: 'AbortSignal.timeout or AbortSignal.any',
123
+ pattern: /\bAbortSignal\s*\.\s*(?:timeout|any)\b/,
124
+ guard: /typeof\s+AbortSignal\s*\.\s*(?:timeout|any)\b/,
125
+ },
126
+ ];
127
+ // Comments describe these globals without touching them, so they are stripped first. esbuild
128
+ // drops them while leaving string literals alone, which a regex over `//` cannot promise.
129
+ const stripComments = (source) => transformSync(source, { loader: 'js', legalComments: 'none' }).code;
130
+ const sources = new Map(sdkInputs.map((f) => [f, stripComments(readFileSync(resolve(root, f), 'utf8'))]));
131
+ for (const { what, pattern, guard } of nodeGlobals) {
132
+ const hits = sdkInputs.filter((f) => pattern.test(sources.get(f)) && !(guard && guard.test(sources.get(f))));
133
+ if (hits.length > 0) {
134
+ failed = true;
135
+ console.error(`\n${label} entry bundles SDK module(s) that use ${what}:`);
136
+ for (const f of hits) console.error(` ${f}`);
137
+ }
138
+ }
139
+
140
+ if (failed) process.exit(1);
141
+
142
+ console.log(`${label} entry is free of Node builtins${forbidden.size ? ' and forbidden packages' : ''}`);
@@ -0,0 +1,57 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+
6
+ const script = resolve(__dirname, 'check-edge-entry.mjs');
7
+
8
+ /** Runs the checker on a module holding `source`, as if it were an SDK module in the bundle. */
9
+ function check(source: string): { status: number | null; stderr: string } {
10
+ const dir = mkdtempSync(join(tmpdir(), 'check-edge-entry-'));
11
+ try {
12
+ const entry = join(dir, 'entry.js');
13
+ writeFileSync(entry, source);
14
+ const result = spawnSync(process.execPath, [script, entry], { encoding: 'utf8' });
15
+ return { status: result.status, stderr: result.stderr };
16
+ } finally {
17
+ rmSync(dir, { recursive: true, force: true });
18
+ }
19
+ }
20
+
21
+ describe('check-edge-entry', () => {
22
+ it('passes a module that uses none of the Node-only globals', () => {
23
+ const result = check(`
24
+ // setImmediate(() => {}) in a comment is fine, as is AbortSignal.any in prose.
25
+ /* process.exit() described here is fine too */
26
+ export const wait = (ms) => new Promise((r) => setTimeout(r, ms));
27
+ export const url = 'https://example.com//path';
28
+ `);
29
+ expect(result.stderr).toBe('');
30
+ expect(result.status).toBe(0);
31
+ });
32
+
33
+ it.each([
34
+ ['setImmediate', 'export const tick = () => setImmediate(() => {});', /setImmediate/],
35
+ ['process.nextTick', 'export const tick = () => process.nextTick(() => {});', /process or Buffer/],
36
+ ['Buffer', "export const bytes = Buffer.from('x');", /process or Buffer/],
37
+ ['Buffer after // inside a string', "export const p = 'a//b'; export const bytes = Buffer.from('x');", /process or Buffer/],
38
+ ['.unref(', 'export const timer = setTimeout(() => {}, 1); timer.unref();', /\.unref\(\)/],
39
+ ['AbortSignal.timeout', 'export const signal = AbortSignal.timeout(5);', /AbortSignal\.timeout or AbortSignal\.any/],
40
+ ['AbortSignal.any', 'export const signal = AbortSignal.any([]);', /AbortSignal\.timeout or AbortSignal\.any/],
41
+ ])('fails a module that uses %s', (_name, source, message) => {
42
+ const result = check(source);
43
+ expect(result.status).toBe(1);
44
+ expect(result.stderr).toMatch(message);
45
+ });
46
+
47
+ it.each([
48
+ ['typeof setImmediate', "export const tick = typeof setImmediate === 'function' ? (f) => setImmediate(f) : (f) => setTimeout(f, 0);"],
49
+ ['an optional .unref?.()', 'export const timer = setTimeout(() => {}, 1); timer.unref?.();'],
50
+ ['typeof AbortSignal.any', "export const has = typeof AbortSignal.any === 'function';"],
51
+ ['globalThis.Buffer', "export const bytes = globalThis.Buffer ? globalThis.Buffer.from('x') : new Uint8Array();"],
52
+ ])('allows a feature-detected %s', (_name, source) => {
53
+ const result = check(source);
54
+ expect(result.stderr).toBe('');
55
+ expect(result.status).toBe(0);
56
+ });
57
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatchet-dev/typescript-sdk",
3
- "version": "1.34.0-alpha.1",
3
+ "version": "1.34.0",
4
4
  "engines": {
5
5
  "node": ">=20"
6
6
  },
@@ -163,12 +163,14 @@
163
163
  "typedoc-plugin-markdown": "^4.10.0",
164
164
  "typedoc-plugin-no-inherit": "^1.6.1",
165
165
  "typescript": "^5.8.2",
166
- "typescript-eslint": "^8.56.1"
166
+ "typescript-eslint": "^8.56.1",
167
+ "undici": "^7.29.1"
167
168
  },
168
169
  "dependencies": {
169
170
  "@bufbuild/protobuf": "^2.15.0",
170
171
  "@connectrpc/connect": "~2.1.2",
171
172
  "@connectrpc/connect-node": "~2.1.2",
173
+ "@connectrpc/connect-web": "~2.1.2",
172
174
  "@types/qs": "^6.15.0",
173
175
  "abort-controller-x": "^0.5.0",
174
176
  "axios": "^1.15.2",
@@ -236,6 +238,7 @@
236
238
  "tsc:build": "pnpm run dump-version && tsc && resolve-tspaths",
237
239
  "test:unit": "jest --testMatch='**/*.test.ts'",
238
240
  "check:edge": "pnpm run tsc:build && node scripts/check-edge-entry.mjs",
241
+ "check:core": "pnpm run tsc:build && node scripts/check-edge-entry.mjs dist/core/index.js --forbid axios,nice-grpc,@grpc/grpc-js,@connectrpc/connect-node,long,protobufjs,yaml",
239
242
  "check:exports": "pnpm run tsc:build && node scripts/check-exports.mjs",
240
243
  "test:e2e": "jest --config jest.e2e.config.ts --silent --forceExit --runInBand",
241
244
  "test:unit:watch": "jest --testMatch='**/*.test.ts' --watch",