@triggerlink/sdk 0.3.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.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +67 -0
- package/dist/execx.d.ts +45 -0
- package/dist/execx.js +30 -0
- package/dist/function.d.ts +29 -0
- package/dist/function.js +8 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/serve.d.ts +12 -0
- package/dist/serve.js +68 -0
- package/dist/sign.d.ts +4 -0
- package/dist/sign.js +40 -0
- package/dist/step.d.ts +25 -0
- package/dist/step.js +55 -0
- package/package.json +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bearalise
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @triggerlink/sdk — TypeScript SDK (M0 prototype)
|
|
2
|
+
|
|
3
|
+
Lets Next.js / Node.js applications integrate with the TriggerLink platform using an
|
|
4
|
+
Inngest-style DX. See the protocol spec at
|
|
5
|
+
[`docs/protocol.md`](../docs/protocol.md). Currently supports the `step.run`,
|
|
6
|
+
`step.sleep` / `step.sleepUntil`, and `step.sendEvent` primitives.
|
|
7
|
+
|
|
8
|
+
## Integrating with Next.js (App Router)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @triggerlink/sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// app/api/triggerlink/route.ts
|
|
16
|
+
import { createClient, createFunction, serve } from "@triggerlink/sdk";
|
|
17
|
+
|
|
18
|
+
export const runtime = "nodejs"; // requires node:crypto and a longer execution limit
|
|
19
|
+
export const maxDuration = 300; // a single step must fit within the function limit (platform callback timeout is 5 minutes)
|
|
20
|
+
|
|
21
|
+
const client = createClient({
|
|
22
|
+
id: "web",
|
|
23
|
+
signingKey: process.env.TRIGGERLINK_SIGNING_KEY!, // must match the platform's -signing-key
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const fulfillOrder = createFunction(
|
|
27
|
+
{ id: "fulfill-order", event: "order/paid" },
|
|
28
|
+
async ({ event, step }) => {
|
|
29
|
+
const { order_id } = event.data as { order_id: string };
|
|
30
|
+
|
|
31
|
+
// One side effect per step: on retry/crash recovery, completed steps are injected from memo and not re-run
|
|
32
|
+
const tracking = await step.run("create-shipment", () =>
|
|
33
|
+
logistics.createShipment(order_id),
|
|
34
|
+
);
|
|
35
|
+
await step.run("send-sms", () => sms.send(order_id, tracking));
|
|
36
|
+
|
|
37
|
+
return { tracking };
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export const { GET, POST } = serve({ client, functions: [fulfillOrder] });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Platform-side registration (pick one):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Platform-side static introspection: point -app at the app's serve URL at startup
|
|
48
|
+
triggerlink -event-key ... -signing-key ... \
|
|
49
|
+
-app https://your-app.vercel.app/api/triggerlink
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// Or app-side self-registration (reversed direction): call once after startup, retries in the background, does not block startup
|
|
54
|
+
client.register("http://localhost:3000/api/triggerlink"); // requires eventKey
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Note: for local development, point the serve URL at `http://localhost:3000/api/triggerlink`; after changing functions in the app, call `POST /api/v1/apps/sync {"url":"..."}` to sync — no platform restart needed.
|
|
58
|
+
|
|
59
|
+
## Constraints (same as the Go SDK; see protocol section 6)
|
|
60
|
+
|
|
61
|
+
- Side effects must go inside `step.run`; the function is re-invoked from the start on every callback, so code outside steps runs repeatedly;
|
|
62
|
+
- The step call sequence must be deterministic: branches/loops may only depend on event data and the outputs of completed steps;
|
|
63
|
+
- A single step's duration must be shorter than both the deployment platform's function limit and the platform callback timeout (5 minutes by default).
|
|
64
|
+
|
|
65
|
+
## Sending events (any TS code, modeled after Inngest's `inngest.send`)
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const client = createClient({
|
|
69
|
+
id: "web",
|
|
70
|
+
signingKey: process.env.TRIGGERLINK_SIGNING_KEY!,
|
|
71
|
+
eventKey: process.env.TRIGGERLINK_EVENT_KEY!, // required for send / register
|
|
72
|
+
baseUrl: process.env.TRIGGERLINK_BASE_URL, // defaults to http://localhost:8288
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Single event; id is an idempotency key, safe to retry (generated by the platform if omitted)
|
|
76
|
+
await client.send({
|
|
77
|
+
id: `order-${orderId}-paid`,
|
|
78
|
+
name: "order/paid",
|
|
79
|
+
data: { order_id: orderId },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Or a batch
|
|
83
|
+
await client.send([{ name: "x/y" }, { name: "x/z", data: { n: 1 } }]);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm install
|
|
90
|
+
npm test # tsc build + node:test (simulates the platform's three-callback progression / memo injection / signature verification / error paths)
|
|
91
|
+
npm run build # outputs dist/ (ESM + .d.ts)
|
|
92
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface ClientOptions {
|
|
2
|
+
/** 应用标识,如 "web",出现在内省清单中 */
|
|
3
|
+
id: string;
|
|
4
|
+
/** 与平台 -signing-key 共享的签名密钥 */
|
|
5
|
+
signingKey: string;
|
|
6
|
+
/** 与平台 -event-key 一致;send / register 需要 */
|
|
7
|
+
eventKey?: string;
|
|
8
|
+
/** 平台地址;send / register 需要,缺省 "http://localhost:8288" */
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
/** send 的入参事件(协议第 9 节)。id 缺省平台生成;提供则按 ID 幂等去重。 */
|
|
12
|
+
export interface SendEventInput<T = unknown> {
|
|
13
|
+
id?: string;
|
|
14
|
+
name: string;
|
|
15
|
+
data?: T;
|
|
16
|
+
ts?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SendResult {
|
|
19
|
+
ids: string[];
|
|
20
|
+
status: number;
|
|
21
|
+
}
|
|
22
|
+
export interface RegisterOptions {
|
|
23
|
+
/** 重试间隔毫秒,缺省 2000 */
|
|
24
|
+
intervalMs?: number;
|
|
25
|
+
/** 最大尝试次数,缺省 150(≈5 分钟,覆盖平台晚于应用启动的场景) */
|
|
26
|
+
attempts?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface Client {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly signingKey: string;
|
|
31
|
+
/** 发送单个事件或事件数组到平台(仿 Inngest `inngest.send`)。 */
|
|
32
|
+
send<T = unknown>(events: SendEventInput<T> | SendEventInput<T>[]): Promise<SendResult>;
|
|
33
|
+
/**
|
|
34
|
+
* 向平台管理 API 自注册本应用(POST /api/v1/apps),等价于平台 -app 静态内省的反向操作。
|
|
35
|
+
* 平台会为内省回调本应用的 serve URL,因此需要在应用可对外服务之后调用;
|
|
36
|
+
* 后台异步重试,不阻塞启动。等价于重复调用可兼作函数变更后的 sync。
|
|
37
|
+
*/
|
|
38
|
+
register(serveUrl: string, opts?: RegisterOptions): void;
|
|
39
|
+
}
|
|
40
|
+
export declare function createClient(opts: ClientOptions): Client;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function createClient(opts) {
|
|
2
|
+
if (!opts.id)
|
|
3
|
+
throw new Error("createClient: id is required");
|
|
4
|
+
if (!opts.signingKey)
|
|
5
|
+
throw new Error("createClient: signingKey is required");
|
|
6
|
+
const baseUrl = (opts.baseUrl ?? "http://localhost:8288").replace(/\/+$/, "");
|
|
7
|
+
async function send(events) {
|
|
8
|
+
if (!opts.eventKey)
|
|
9
|
+
throw new Error("client.send: eventKey is required (createClient 时传入)");
|
|
10
|
+
const list = Array.isArray(events) ? events : [events];
|
|
11
|
+
if (list.length === 0)
|
|
12
|
+
throw new Error("client.send: events must not be empty");
|
|
13
|
+
for (const e of list) {
|
|
14
|
+
if (!e.name)
|
|
15
|
+
throw new Error("client.send: event.name is required");
|
|
16
|
+
}
|
|
17
|
+
const resp = await fetch(`${baseUrl}/v1/events`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: {
|
|
20
|
+
Authorization: `Bearer ${opts.eventKey}`,
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify(Array.isArray(events) ? list : list[0]),
|
|
24
|
+
});
|
|
25
|
+
if (!resp.ok) {
|
|
26
|
+
const text = await resp.text().catch(() => "");
|
|
27
|
+
throw new Error(`client.send: platform returned ${resp.status}: ${text}`);
|
|
28
|
+
}
|
|
29
|
+
return (await resp.json());
|
|
30
|
+
}
|
|
31
|
+
function register(serveUrl, regOpts = {}) {
|
|
32
|
+
if (!opts.eventKey)
|
|
33
|
+
throw new Error("client.register: eventKey is required (createClient 时传入)");
|
|
34
|
+
if (!serveUrl)
|
|
35
|
+
throw new Error("client.register: serveUrl is required");
|
|
36
|
+
const intervalMs = regOpts.intervalMs ?? 2000;
|
|
37
|
+
const maxAttempts = regOpts.attempts ?? 150;
|
|
38
|
+
let attempts = 0;
|
|
39
|
+
const tryRegister = async () => {
|
|
40
|
+
attempts++;
|
|
41
|
+
try {
|
|
42
|
+
const resp = await fetch(`${baseUrl}/api/v1/apps`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${opts.eventKey}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({ url: serveUrl }),
|
|
49
|
+
});
|
|
50
|
+
if (resp.ok) {
|
|
51
|
+
console.log(`[triggerlink] registered ${serveUrl} (platform ${resp.status})`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.warn(`[triggerlink] register #${attempts}: platform HTTP ${resp.status}`);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.warn(`[triggerlink] register #${attempts}: ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
}
|
|
59
|
+
if (attempts < maxAttempts)
|
|
60
|
+
setTimeout(tryRegister, intervalMs);
|
|
61
|
+
else
|
|
62
|
+
console.warn("[triggerlink] register gave up, please POST /api/v1/apps manually");
|
|
63
|
+
};
|
|
64
|
+
void tryRegister();
|
|
65
|
+
}
|
|
66
|
+
return { id: opts.id, signingKey: opts.signingKey, send, register };
|
|
67
|
+
}
|
package/dist/execx.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** 平台注入的单个 step memo。 */
|
|
2
|
+
export interface StepState {
|
|
3
|
+
id: string;
|
|
4
|
+
status: string;
|
|
5
|
+
output?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface OpError {
|
|
8
|
+
message: string;
|
|
9
|
+
stack?: string;
|
|
10
|
+
retryable: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** 应用 → 平台的执行指令(serve 序列化为响应)。 */
|
|
13
|
+
export interface Opcode {
|
|
14
|
+
op: "StepComplete" | "StepError" | "RunComplete" | "RunError" | "Sleep" | "SendEvent";
|
|
15
|
+
id?: string;
|
|
16
|
+
step_id?: string;
|
|
17
|
+
output?: unknown;
|
|
18
|
+
error?: OpError;
|
|
19
|
+
until?: string;
|
|
20
|
+
events?: OutgoingEvent[];
|
|
21
|
+
}
|
|
22
|
+
/** step.sendEvent 待扇出的事件(id/ts 缺省由平台补全,id 缺省为确定性派生)。 */
|
|
23
|
+
export interface OutgoingEvent {
|
|
24
|
+
id?: string;
|
|
25
|
+
name: string;
|
|
26
|
+
data?: unknown;
|
|
27
|
+
ts?: string;
|
|
28
|
+
}
|
|
29
|
+
/** step 中断:正常控制流,由 serve 捕获并序列化为 opcode。 */
|
|
30
|
+
export declare class StepInterrupt extends Error {
|
|
31
|
+
readonly opcode: Opcode;
|
|
32
|
+
constructor(opcode: Opcode);
|
|
33
|
+
}
|
|
34
|
+
/** 单次函数调用的执行上下文。 */
|
|
35
|
+
export declare class ExecCtx {
|
|
36
|
+
readonly functionId: string;
|
|
37
|
+
readonly steps: Record<string, StepState>;
|
|
38
|
+
private readonly counters;
|
|
39
|
+
constructor(functionId: string, steps?: Record<string, StepState>);
|
|
40
|
+
/**
|
|
41
|
+
* memo 键:hex(sha256(functionID + ":" + stepID + ":" + 序号)),序号从 0 起。
|
|
42
|
+
* 平台视其为不透明字符串,只要求同一 run 多次重入间确定(协议第 6 节)。
|
|
43
|
+
*/
|
|
44
|
+
nextHash(stepId: string): string;
|
|
45
|
+
}
|
package/dist/execx.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 执行上下文与 opcode 类型(协议第 4/5/6 节;对应 Go 的 sdk/internal/execx)。
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
/** step 中断:正常控制流,由 serve 捕获并序列化为 opcode。 */
|
|
4
|
+
export class StepInterrupt extends Error {
|
|
5
|
+
opcode;
|
|
6
|
+
constructor(opcode) {
|
|
7
|
+
super(`step interrupt: ${opcode.op}`);
|
|
8
|
+
this.name = "StepInterrupt";
|
|
9
|
+
this.opcode = opcode;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** 单次函数调用的执行上下文。 */
|
|
13
|
+
export class ExecCtx {
|
|
14
|
+
functionId;
|
|
15
|
+
steps;
|
|
16
|
+
counters = new Map();
|
|
17
|
+
constructor(functionId, steps) {
|
|
18
|
+
this.functionId = functionId;
|
|
19
|
+
this.steps = steps ?? {};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* memo 键:hex(sha256(functionID + ":" + stepID + ":" + 序号)),序号从 0 起。
|
|
23
|
+
* 平台视其为不透明字符串,只要求同一 run 多次重入间确定(协议第 6 节)。
|
|
24
|
+
*/
|
|
25
|
+
nextHash(stepId) {
|
|
26
|
+
const n = this.counters.get(stepId) ?? 0;
|
|
27
|
+
this.counters.set(stepId, n + 1);
|
|
28
|
+
return createHash("sha256").update(`${this.functionId}:${stepId}:${n}`).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { StepTool } from "./step.js";
|
|
2
|
+
/** 触发函数的事件(与平台 eventPayload 同构)。 */
|
|
3
|
+
export interface EventPayload<T = unknown> {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
data: T;
|
|
7
|
+
ts: string;
|
|
8
|
+
}
|
|
9
|
+
/** 传给用户 handler 的上下文。 */
|
|
10
|
+
export interface HandlerContext<T = unknown> {
|
|
11
|
+
event: EventPayload<T>;
|
|
12
|
+
step: StepTool;
|
|
13
|
+
runId: string;
|
|
14
|
+
attempt: number;
|
|
15
|
+
}
|
|
16
|
+
export interface FunctionOpts {
|
|
17
|
+
/** 稳定标识,改名会丢历史 memo 关联 */
|
|
18
|
+
id: string;
|
|
19
|
+
/** 订阅的事件名 */
|
|
20
|
+
event: string;
|
|
21
|
+
/** 重试上限;0/缺省 = 平台默认(4) */
|
|
22
|
+
retries?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface TriggerFunction<T = unknown> {
|
|
25
|
+
readonly opts: Required<FunctionOpts>;
|
|
26
|
+
readonly handler: (ctx: HandlerContext<T>) => Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
/** 定义一个 durable 函数。副作用必须放进 step.run(协议第 6 节约束)。 */
|
|
29
|
+
export declare function createFunction<T = unknown>(opts: FunctionOpts, handler: (ctx: HandlerContext<T>) => Promise<unknown>): TriggerFunction<T>;
|
package/dist/function.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** 定义一个 durable 函数。副作用必须放进 step.run(协议第 6 节约束)。 */
|
|
2
|
+
export function createFunction(opts, handler) {
|
|
3
|
+
if (!opts.id)
|
|
4
|
+
throw new Error("createFunction: id is required");
|
|
5
|
+
if (!opts.event)
|
|
6
|
+
throw new Error("createFunction: event is required");
|
|
7
|
+
return { opts: { retries: 0, ...opts }, handler };
|
|
8
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createClient, type Client, type ClientOptions, type SendEventInput, type SendResult, type RegisterOptions, } from "./client.js";
|
|
2
|
+
export { createFunction, type TriggerFunction, type FunctionOpts, type HandlerContext, type EventPayload, } from "./function.js";
|
|
3
|
+
export { serve, sdkVersion, type ServeOptions } from "./serve.js";
|
|
4
|
+
export { StepInterrupt, type Opcode, type StepState, type OutgoingEvent } from "./execx.js";
|
|
5
|
+
export type { StepTool } from "./step.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @triggerlink/sdk — TriggerLink TypeScript SDK(M0 原型)。
|
|
2
|
+
// 用法见 sdk-ts/README.md 与 docs/protocol.md。
|
|
3
|
+
export { createClient, } from "./client.js";
|
|
4
|
+
export { createFunction, } from "./function.js";
|
|
5
|
+
export { serve, sdkVersion } from "./serve.js";
|
|
6
|
+
export { StepInterrupt } from "./execx.js";
|
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Client } from "./client.js";
|
|
2
|
+
import type { TriggerFunction } from "./function.js";
|
|
3
|
+
export declare const sdkVersion = "triggerlink-ts/0.3.0";
|
|
4
|
+
export declare const SIGNATURE_HEADER = "x-triggerlink-signature";
|
|
5
|
+
export interface ServeOptions {
|
|
6
|
+
client: Client;
|
|
7
|
+
functions: TriggerFunction[];
|
|
8
|
+
}
|
|
9
|
+
export declare function serve(opts: ServeOptions): {
|
|
10
|
+
GET: (req: Request) => Promise<Response>;
|
|
11
|
+
POST: (req: Request) => Promise<Response>;
|
|
12
|
+
};
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ExecCtx, StepInterrupt } from "./execx.js";
|
|
2
|
+
import { createStepTool, errMessage } from "./step.js";
|
|
3
|
+
import { verifySignature } from "./sign.js";
|
|
4
|
+
export const sdkVersion = "triggerlink-ts/0.3.0";
|
|
5
|
+
export const SIGNATURE_HEADER = "x-triggerlink-signature";
|
|
6
|
+
const MAX_BODY = 10 << 20; // 10 MB,与平台一致
|
|
7
|
+
function json(data, status = 200) {
|
|
8
|
+
return Response.json(data, { status });
|
|
9
|
+
}
|
|
10
|
+
export function serve(opts) {
|
|
11
|
+
const byID = new Map();
|
|
12
|
+
for (const fn of opts.functions)
|
|
13
|
+
byID.set(fn.opts.id, fn);
|
|
14
|
+
async function GET(req) {
|
|
15
|
+
if (!verifySignature(opts.client.signingKey, req.headers.get(SIGNATURE_HEADER), new Uint8Array(0))) {
|
|
16
|
+
return json({ error: "unauthorized" }, 401);
|
|
17
|
+
}
|
|
18
|
+
return json({
|
|
19
|
+
sdk: sdkVersion,
|
|
20
|
+
app_id: opts.client.id,
|
|
21
|
+
functions: [...byID.values()].map((f) => ({
|
|
22
|
+
id: f.opts.id,
|
|
23
|
+
event: f.opts.event,
|
|
24
|
+
retries: f.opts.retries,
|
|
25
|
+
})),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function POST(req) {
|
|
29
|
+
const body = new Uint8Array(await req.arrayBuffer());
|
|
30
|
+
if (body.byteLength > MAX_BODY)
|
|
31
|
+
return json({ error: "body too large" }, 400);
|
|
32
|
+
if (!verifySignature(opts.client.signingKey, req.headers.get(SIGNATURE_HEADER), body)) {
|
|
33
|
+
return json({ error: "unauthorized" }, 401);
|
|
34
|
+
}
|
|
35
|
+
let cbReq;
|
|
36
|
+
try {
|
|
37
|
+
cbReq = JSON.parse(new TextDecoder().decode(body));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return json({ error: "invalid request" }, 400);
|
|
41
|
+
}
|
|
42
|
+
const fn = byID.get(cbReq?.ctx?.function_id);
|
|
43
|
+
if (!fn)
|
|
44
|
+
return json({ error: "function not found" }, 404);
|
|
45
|
+
const ec = new ExecCtx(cbReq.ctx.function_id, cbReq.ctx.steps);
|
|
46
|
+
const step = createStepTool(ec);
|
|
47
|
+
let opcode;
|
|
48
|
+
try {
|
|
49
|
+
const output = await fn.handler({
|
|
50
|
+
event: cbReq.ctx.event,
|
|
51
|
+
step,
|
|
52
|
+
runId: cbReq.ctx.run_id,
|
|
53
|
+
attempt: cbReq.ctx.attempt,
|
|
54
|
+
});
|
|
55
|
+
opcode = { op: "RunComplete", output };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err instanceof StepInterrupt) {
|
|
59
|
+
opcode = err.opcode;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
opcode = { op: "RunError", error: { message: errMessage(err), retryable: true } };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return json(opcode);
|
|
66
|
+
}
|
|
67
|
+
return { GET, POST };
|
|
68
|
+
}
|
package/dist/sign.d.ts
ADDED
package/dist/sign.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 平台↔应用 HMAC-SHA256 签名验证(协议第 2 节)。
|
|
2
|
+
// 头部格式:t=<unix秒>,v1=<hex(hmac_sha256(key, "<t>.<body>"))>
|
|
3
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
4
|
+
const TOLERANCE_SEC = 5 * 60; // ±5 分钟,与 Go SDK 一致
|
|
5
|
+
function computeSig(key, ts, body) {
|
|
6
|
+
const mac = createHmac("sha256", key);
|
|
7
|
+
mac.update(`${ts}.`);
|
|
8
|
+
mac.update(body);
|
|
9
|
+
return mac.digest("hex");
|
|
10
|
+
}
|
|
11
|
+
/** 生成签名头部值(平台侧模拟、测试用;SDK 正常路径只验签)。 */
|
|
12
|
+
export function sign(key, body, now = new Date()) {
|
|
13
|
+
const t = Math.floor(now.getTime() / 1000).toString();
|
|
14
|
+
return `t=${t},v1=${computeSig(key, t, body)}`;
|
|
15
|
+
}
|
|
16
|
+
/** 校验签名头部值;不合法一律返回 false(调用方应回 401)。 */
|
|
17
|
+
export function verifySignature(key, header, body) {
|
|
18
|
+
if (!header)
|
|
19
|
+
return false;
|
|
20
|
+
let ts = "";
|
|
21
|
+
let sig = "";
|
|
22
|
+
for (const part of header.split(",")) {
|
|
23
|
+
if (part.startsWith("t="))
|
|
24
|
+
ts = part.slice(2);
|
|
25
|
+
else if (part.startsWith("v1="))
|
|
26
|
+
sig = part.slice(3);
|
|
27
|
+
}
|
|
28
|
+
if (!ts || !sig)
|
|
29
|
+
return false;
|
|
30
|
+
const sec = Number(ts);
|
|
31
|
+
if (!Number.isFinite(sec))
|
|
32
|
+
return false;
|
|
33
|
+
const skew = Math.abs(Date.now() / 1000 - sec);
|
|
34
|
+
if (skew > TOLERANCE_SEC)
|
|
35
|
+
return false;
|
|
36
|
+
const expected = computeSig(key, ts, body);
|
|
37
|
+
const a = Buffer.from(expected, "utf8");
|
|
38
|
+
const b = Buffer.from(sig, "utf8");
|
|
39
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
40
|
+
}
|
package/dist/step.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ExecCtx, type OutgoingEvent } from "./execx.js";
|
|
2
|
+
/** handler 内可用的 step 工具。 */
|
|
3
|
+
export interface StepTool {
|
|
4
|
+
/**
|
|
5
|
+
* 执行一个 durable step:fn 的返回值被平台持久化,崩溃恢复后直接注入不重跑。
|
|
6
|
+
* memo 命中 → 返回缓存值;未命中 → 执行 fn,然后抛 StepInterrupt 中断函数,
|
|
7
|
+
* 由 serve 序列化为 opcode 交平台持久化并发起下一次回调。
|
|
8
|
+
*/
|
|
9
|
+
run<T>(id: string, fn: () => Promise<T> | T): Promise<T>;
|
|
10
|
+
/**
|
|
11
|
+
* 挂起当前 run 直至 durMs 之后:函数中断,平台到点重新回调恢复。
|
|
12
|
+
* 挂起期间不占连接与计算;恢复重入时 memo 命中直接返回,不会重复睡眠。
|
|
13
|
+
*/
|
|
14
|
+
sleep(id: string, durMs: number): Promise<void>;
|
|
15
|
+
/** 挂起当前 run 直至绝对时刻 at(sleep 的绝对时间版本)。 */
|
|
16
|
+
sleepUntil(id: string, at: Date | string): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* 在函数内可靠扇出事件:平台先落库后路由,崩溃不丢。
|
|
19
|
+
* memo 语义同 run——恢复重入时直接返回已发事件 ID 列表,不会重复发送。
|
|
20
|
+
*/
|
|
21
|
+
sendEvent(id: string, events: OutgoingEvent | OutgoingEvent[]): Promise<string[]>;
|
|
22
|
+
}
|
|
23
|
+
/** 为一次函数调用构造 step 工具。 */
|
|
24
|
+
export declare function createStepTool(ec: ExecCtx): StepTool;
|
|
25
|
+
export declare function errMessage(err: unknown): string;
|
package/dist/step.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// step 原语(协议第 5/6 节;对应 Go 的 sdk/step)。
|
|
2
|
+
import { StepInterrupt } from "./execx.js";
|
|
3
|
+
/** 为一次函数调用构造 step 工具。 */
|
|
4
|
+
export function createStepTool(ec) {
|
|
5
|
+
return {
|
|
6
|
+
async run(id, fn) {
|
|
7
|
+
const h = ec.nextHash(id);
|
|
8
|
+
const memo = ec.steps[h];
|
|
9
|
+
if (memo && memo.status === "completed") {
|
|
10
|
+
return memo.output;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const output = await fn();
|
|
14
|
+
throw new StepInterrupt({ op: "StepComplete", id: h, step_id: id, output });
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
if (err instanceof StepInterrupt)
|
|
18
|
+
throw err;
|
|
19
|
+
throw new StepInterrupt({
|
|
20
|
+
op: "StepError",
|
|
21
|
+
id: h,
|
|
22
|
+
step_id: id,
|
|
23
|
+
error: { message: errMessage(err), stack: errStack(err), retryable: true },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
async sleep(id, durMs) {
|
|
28
|
+
return this.sleepUntil(id, new Date(Date.now() + durMs));
|
|
29
|
+
},
|
|
30
|
+
async sleepUntil(id, at) {
|
|
31
|
+
const h = ec.nextHash(id);
|
|
32
|
+
const memo = ec.steps[h];
|
|
33
|
+
if (memo && memo.status === "completed")
|
|
34
|
+
return; // 已睡过(平台唤醒时已置 completed)
|
|
35
|
+
const until = (typeof at === "string" ? new Date(at) : at).toISOString();
|
|
36
|
+
throw new StepInterrupt({ op: "Sleep", id: h, step_id: id, until });
|
|
37
|
+
},
|
|
38
|
+
async sendEvent(id, events) {
|
|
39
|
+
const h = ec.nextHash(id);
|
|
40
|
+
const memo = ec.steps[h];
|
|
41
|
+
if (memo && memo.status === "completed")
|
|
42
|
+
return memo.output;
|
|
43
|
+
const list = Array.isArray(events) ? events : [events];
|
|
44
|
+
if (list.length === 0)
|
|
45
|
+
throw new Error(`step.sendEvent "${id}": no events`);
|
|
46
|
+
throw new StepInterrupt({ op: "SendEvent", id: h, step_id: id, events: list });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function errMessage(err) {
|
|
51
|
+
return err instanceof Error ? err.message : String(err);
|
|
52
|
+
}
|
|
53
|
+
function errStack(err) {
|
|
54
|
+
return err instanceof Error ? err.stack : undefined;
|
|
55
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@triggerlink/sdk",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "TriggerLink TypeScript SDK (M0 prototype): durable functions for Next.js / Node.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"test": "npm run build && node --test \"test/*.test.mjs\""
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/Richardo1o1/TriggerLink.git",
|
|
26
|
+
"directory": "sdk-ts"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"typescript": "^5.5.0"
|
|
31
|
+
}
|
|
32
|
+
}
|