@nanobpm/urban 0.7.0 → 0.9.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/dist/runtime/core/manifest.d.ts +37 -1
- package/dist/runtime/core/modules/actions.d.ts +45 -0
- package/dist/runtime/core/modules/actions.js +113 -0
- package/dist/runtime/core/modules/llm.d.ts +65 -0
- package/dist/runtime/core/modules/llm.js +146 -0
- package/dist/runtime/core/modules/surfaces.js +9 -0
- package/dist/runtime/core/modules/workers.d.ts +6 -0
- package/dist/runtime/core/modules/workers.js +48 -5
- package/dist/runtime/index.d.ts +6 -2
- package/dist/runtime/index.js +3 -1
- package/dist/runtime/schema/nano-app.schema.json +32 -0
- package/package.json +1 -1
- package/src/runtime/core/manifest.ts +37 -1
- package/src/runtime/core/modules/actions.test.ts +239 -0
- package/src/runtime/core/modules/actions.ts +161 -0
- package/src/runtime/core/modules/llm.test.ts +237 -0
- package/src/runtime/core/modules/llm.ts +209 -0
- package/src/runtime/core/modules/surfaces.ts +10 -0
- package/src/runtime/core/modules/workers.test.ts +158 -0
- package/src/runtime/core/modules/workers.ts +62 -5
- package/src/runtime/core/validate.test.ts +8 -0
- package/src/runtime/index.ts +19 -1
- package/src/runtime/schema/nano-app.schema.json +32 -0
|
@@ -32,6 +32,24 @@ export interface WorkerDecl {
|
|
|
32
32
|
llm?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* One named LLM binding (`llm.<id>` in the manifest). A binding is usable as a worker
|
|
37
|
+
* (LLM-as-worker, ADR 0022 §E role 1) or as a chat surface agent. `provider` selects how
|
|
38
|
+
* the endpoint is resolved (`"env"` reads it from the environment); `model` is typically
|
|
39
|
+
* an env template. When `output.decision` is set, the model's JSON reply is fed through
|
|
40
|
+
* that DMN decision (the "rails") and the decision's output is returned instead.
|
|
41
|
+
*/
|
|
42
|
+
export interface LlmBinding {
|
|
43
|
+
/** Provider selector. `"env"` resolves an OpenAI-compatible endpoint from the environment. */
|
|
44
|
+
provider: string;
|
|
45
|
+
/** Model id, typically an env template (e.g. `${NANO_APP_LLM_MODEL}`). */
|
|
46
|
+
model: string;
|
|
47
|
+
/** Constrains the model's structured output — currently a DMN decision id (the rails). */
|
|
48
|
+
output?: { decision?: string };
|
|
49
|
+
/** Action-API tools the agent may call (chat-agent role; unused by the worker role). */
|
|
50
|
+
tools?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
export interface TriggerDecl {
|
|
36
54
|
id: string;
|
|
37
55
|
type: string;
|
|
@@ -54,6 +72,23 @@ export interface SurfaceDecl {
|
|
|
54
72
|
sourceName?: string;
|
|
55
73
|
}
|
|
56
74
|
|
|
75
|
+
/**
|
|
76
|
+
* An app-authored action handler override (ADR 0055 phase 3). Each declaration binds a
|
|
77
|
+
* route to a handler module (default-exports an `ActionHandler`), letting an app wrap the
|
|
78
|
+
* generic pages start/cancel/message actions with business logic. Mounted before the
|
|
79
|
+
* generic pages action routes, so an exact override shadows the generic one.
|
|
80
|
+
*/
|
|
81
|
+
export interface ActionDecl {
|
|
82
|
+
/** Route path to serve, e.g. "/app/actions/cancel" or "/app/actions/start/convergence-loop". */
|
|
83
|
+
path: string;
|
|
84
|
+
/** Handler module path relative to the app root; default-exports an `ActionHandler`. */
|
|
85
|
+
module: string;
|
|
86
|
+
/** HTTP method to match. Default "POST". */
|
|
87
|
+
method?: string;
|
|
88
|
+
/** Match `path` as a prefix rather than exactly. Default false. */
|
|
89
|
+
prefix?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
export interface AppManifest {
|
|
58
93
|
$schema?: string;
|
|
59
94
|
schemaVersion: 1;
|
|
@@ -67,8 +102,9 @@ export interface AppManifest {
|
|
|
67
102
|
triggers?: TriggerDecl[];
|
|
68
103
|
connections?: Record<string, { type: string; [k: string]: unknown }>;
|
|
69
104
|
surfaces?: Record<string, SurfaceDecl>;
|
|
105
|
+
actions?: ActionDecl[];
|
|
70
106
|
workers?: WorkerDecl[];
|
|
71
|
-
llm?: Record<string,
|
|
107
|
+
llm?: Record<string, LlmBinding>;
|
|
72
108
|
security?: Record<string, unknown>;
|
|
73
109
|
[k: string]: unknown;
|
|
74
110
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
|
+
import type { HttpRequest } from "../host.ts";
|
|
5
|
+
import { makeRouter } from "../router.ts";
|
|
6
|
+
import { mountActions, resolveActionHandler, type ActionHandler } from "./actions.ts";
|
|
7
|
+
|
|
8
|
+
function req(method: string, path: string, opts: { query?: string; body?: string } = {}): HttpRequest {
|
|
9
|
+
return {
|
|
10
|
+
method,
|
|
11
|
+
path,
|
|
12
|
+
query: new URLSearchParams(opts.query ?? ""),
|
|
13
|
+
headers: new Headers(),
|
|
14
|
+
text: () => Promise.resolve(opts.body ?? ""),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Built {
|
|
19
|
+
router: (r: HttpRequest) => Promise<{ status?: number; body?: string }>;
|
|
20
|
+
imported: string[];
|
|
21
|
+
logs: Array<{ level: string; msg: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function build(
|
|
25
|
+
actions: RuntimeContext["manifest"]["actions"],
|
|
26
|
+
modules: Record<string, Record<string, unknown>>,
|
|
27
|
+
app: AppApi = {} as AppApi,
|
|
28
|
+
): Built {
|
|
29
|
+
const imported: string[] = [];
|
|
30
|
+
const logs: Array<{ level: string; msg: string }> = [];
|
|
31
|
+
const ctx = {
|
|
32
|
+
root: "/app",
|
|
33
|
+
manifest: { schemaVersion: 1, id: "t", name: "T", actions } as RuntimeContext["manifest"],
|
|
34
|
+
host: {
|
|
35
|
+
importModule: (path: string) => {
|
|
36
|
+
imported.push(path);
|
|
37
|
+
const mod = modules[path];
|
|
38
|
+
if (!mod) return Promise.reject(new Error(`no module at ${path}`));
|
|
39
|
+
return Promise.resolve(mod);
|
|
40
|
+
},
|
|
41
|
+
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
42
|
+
},
|
|
43
|
+
} as unknown as RuntimeContext;
|
|
44
|
+
const handle = mountActions(ctx, app);
|
|
45
|
+
const router = makeRouter(handle.routes) as unknown as Built["router"];
|
|
46
|
+
return { router, imported, logs };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("resolveActionHandler prefers default, then handler", () => {
|
|
50
|
+
const fn = () => ({});
|
|
51
|
+
assert.equal(resolveActionHandler({ default: fn }), fn);
|
|
52
|
+
assert.equal(resolveActionHandler({ handler: fn }), fn);
|
|
53
|
+
assert.equal(resolveActionHandler({ default: fn, handler: () => ({}) }), fn);
|
|
54
|
+
assert.equal(resolveActionHandler({ nope: fn }), undefined);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("routes an action to its handler with parsed body + injected app", async () => {
|
|
58
|
+
const seen: unknown[] = [];
|
|
59
|
+
const app = { id: "myApp" } as unknown as AppApi;
|
|
60
|
+
const handler: ActionHandler = (input, a) => {
|
|
61
|
+
seen.push({ body: input.body, method: input.req.method, app: a });
|
|
62
|
+
return { status: 201, body: { ok: true } };
|
|
63
|
+
};
|
|
64
|
+
const { router } = build(
|
|
65
|
+
[{ path: "/app/actions/cancel", module: "actions/cancel.ts" }],
|
|
66
|
+
{ "/app/actions/cancel.ts": { default: handler } },
|
|
67
|
+
app,
|
|
68
|
+
);
|
|
69
|
+
const res = await router(req("POST", "/app/actions/cancel", { body: JSON.stringify({ key: "k1" }) }));
|
|
70
|
+
assert.equal(res.status, 201);
|
|
71
|
+
assert.deepEqual(JSON.parse(res.body!), { ok: true });
|
|
72
|
+
assert.deepEqual(seen, [{ body: { key: "k1" }, method: "POST", app }]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("empty body parses to {} and a void return is 204", async () => {
|
|
76
|
+
const bodies: unknown[] = [];
|
|
77
|
+
const { router } = build(
|
|
78
|
+
[{ path: "/app/actions/ping", module: "m.ts" }],
|
|
79
|
+
{ "/app/m.ts": { default: (i: { body: unknown }) => { bodies.push(i.body); } } },
|
|
80
|
+
);
|
|
81
|
+
const res = await router(req("POST", "/app/actions/ping"));
|
|
82
|
+
assert.equal(res.status, 204);
|
|
83
|
+
assert.equal(res.body, undefined);
|
|
84
|
+
assert.deepEqual(bodies, [{}]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("invalid JSON body is rejected with 400 before the handler runs", async () => {
|
|
88
|
+
let called = false;
|
|
89
|
+
const { router, imported } = build(
|
|
90
|
+
[{ path: "/a", module: "m.ts" }],
|
|
91
|
+
{ "/app/m.ts": { default: () => { called = true; } } },
|
|
92
|
+
);
|
|
93
|
+
const res = await router(req("POST", "/a", { body: "{not json" }));
|
|
94
|
+
assert.equal(res.status, 400);
|
|
95
|
+
assert.match(res.body!, /must be JSON/);
|
|
96
|
+
assert.equal(called, false);
|
|
97
|
+
assert.deepEqual(imported, []); // module not even loaded
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a thrown handler error becomes a 500 with the message", async () => {
|
|
101
|
+
const { router } = build(
|
|
102
|
+
[{ path: "/a", module: "m.ts" }],
|
|
103
|
+
{ "/app/m.ts": { default: () => { throw new Error("boom"); } } },
|
|
104
|
+
);
|
|
105
|
+
const res = await router(req("POST", "/a", { body: "{}" }));
|
|
106
|
+
assert.equal(res.status, 500);
|
|
107
|
+
assert.match(res.body!, /boom/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a module that fails to load yields a 500", async () => {
|
|
111
|
+
const { router, logs } = build([{ path: "/a", module: "missing.ts" }], {});
|
|
112
|
+
const res = await router(req("POST", "/a", { body: "{}" }));
|
|
113
|
+
assert.equal(res.status, 500);
|
|
114
|
+
assert.match(res.body!, /failed to load/);
|
|
115
|
+
assert.ok(logs.some((l) => l.level === "error"));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("a module with no default/handler export yields a 500", async () => {
|
|
119
|
+
const { router } = build(
|
|
120
|
+
[{ path: "/a", module: "m.ts" }],
|
|
121
|
+
{ "/app/m.ts": { notAHandler: () => ({}) } },
|
|
122
|
+
);
|
|
123
|
+
const res = await router(req("POST", "/a", { body: "{}" }));
|
|
124
|
+
assert.equal(res.status, 500);
|
|
125
|
+
assert.match(res.body!, /no default function/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("the module is imported once and cached across requests", async () => {
|
|
129
|
+
const { router, imported } = build(
|
|
130
|
+
[{ path: "/a", module: "m.ts" }],
|
|
131
|
+
{ "/app/m.ts": { default: () => ({ body: 1 }) } },
|
|
132
|
+
);
|
|
133
|
+
await router(req("POST", "/a", { body: "{}" }));
|
|
134
|
+
await router(req("POST", "/a", { body: "{}" }));
|
|
135
|
+
assert.deepEqual(imported, ["/app/m.ts"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("method defaults to POST; a GET does not match", async () => {
|
|
139
|
+
const { router } = build(
|
|
140
|
+
[{ path: "/a", module: "m.ts" }],
|
|
141
|
+
{ "/app/m.ts": { default: () => ({ body: "ok" }) } },
|
|
142
|
+
);
|
|
143
|
+
const res = await router(req("GET", "/a"));
|
|
144
|
+
assert.equal(res.status, 404);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a custom method is honored", async () => {
|
|
148
|
+
const { router } = build(
|
|
149
|
+
[{ path: "/a", method: "PUT", module: "m.ts" }],
|
|
150
|
+
{ "/app/m.ts": { default: () => ({ body: "ok" }) } },
|
|
151
|
+
);
|
|
152
|
+
assert.equal((await router(req("PUT", "/a", { body: "{}" }))).status, 200);
|
|
153
|
+
assert.equal((await router(req("POST", "/a", { body: "{}" }))).status, 404);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("prefix routes match by path prefix and stay boundary-safe", async () => {
|
|
157
|
+
const { router } = build(
|
|
158
|
+
// declared without a trailing slash — the mount adds one so it can't over-match
|
|
159
|
+
[{ path: "/hooks", prefix: true, module: "m.ts" }],
|
|
160
|
+
{ "/app/m.ts": { default: () => ({ body: "hooked" }) } },
|
|
161
|
+
);
|
|
162
|
+
const hit = await router(req("POST", "/hooks/github", { body: "{}" }));
|
|
163
|
+
assert.equal(hit.status, 200);
|
|
164
|
+
assert.equal(JSON.parse(hit.body!), "hooked");
|
|
165
|
+
// "/hooks2" must NOT match "/hooks" (raw startsWith would have over-matched)
|
|
166
|
+
assert.equal((await router(req("POST", "/hooks2", { body: "{}" }))).status, 404);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("an absolute module path is used as-is (not joined to root)", async () => {
|
|
170
|
+
const { router, imported } = build(
|
|
171
|
+
[{ path: "/a", module: "/abs/m.ts" }],
|
|
172
|
+
{ "/abs/m.ts": { default: () => ({ body: 1 }) } },
|
|
173
|
+
);
|
|
174
|
+
await router(req("POST", "/a", { body: "{}" }));
|
|
175
|
+
assert.deepEqual(imported, ["/abs/m.ts"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("declarations missing path or module are skipped with a warning", () => {
|
|
179
|
+
const { router, logs } = build(
|
|
180
|
+
[{ path: "/a" } as unknown as { path: string; module: string }],
|
|
181
|
+
{},
|
|
182
|
+
);
|
|
183
|
+
void router;
|
|
184
|
+
assert.ok(logs.some((l) => l.level === "warn"));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a manifest path without a leading slash is normalized to match", async () => {
|
|
188
|
+
const { router } = build(
|
|
189
|
+
[{ path: "app/actions/go", module: "m.ts" }],
|
|
190
|
+
{ "/app/m.ts": { default: () => ({ body: "ok" }) } },
|
|
191
|
+
);
|
|
192
|
+
const res = await router(req("POST", "/app/actions/go", { body: "{}" }));
|
|
193
|
+
assert.equal(res.status, 200);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a rejected import is evicted from the cache so a later load can succeed", async () => {
|
|
197
|
+
let attempt = 0;
|
|
198
|
+
const imported: string[] = [];
|
|
199
|
+
const ctx = {
|
|
200
|
+
root: "/app",
|
|
201
|
+
manifest: { schemaVersion: 1, id: "t", name: "T", actions: [{ path: "/a", module: "m.ts" }] },
|
|
202
|
+
host: {
|
|
203
|
+
importModule: (path: string) => {
|
|
204
|
+
imported.push(path);
|
|
205
|
+
attempt += 1;
|
|
206
|
+
if (attempt === 1) return Promise.reject(new Error("transient"));
|
|
207
|
+
return Promise.resolve({ default: () => ({ body: "ok" }) });
|
|
208
|
+
},
|
|
209
|
+
log: () => {},
|
|
210
|
+
},
|
|
211
|
+
} as unknown as import("../context.ts").RuntimeContext;
|
|
212
|
+
const router = makeRouter(mountActions(ctx, {} as import("../context.ts").AppApi).routes) as unknown as (
|
|
213
|
+
r: HttpRequest,
|
|
214
|
+
) => Promise<{ status?: number; body?: string }>;
|
|
215
|
+
const first = await router(req("POST", "/a", { body: "{}" }));
|
|
216
|
+
assert.equal(first.status, 500); // transient failure surfaces
|
|
217
|
+
const second = await router(req("POST", "/a", { body: "{}" }));
|
|
218
|
+
assert.equal(second.status, 200); // retry re-imports and succeeds (cache was evicted)
|
|
219
|
+
assert.deepEqual(imported, ["/app/m.ts", "/app/m.ts"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a prefix route at the root path does not become //", async () => {
|
|
223
|
+
const { router } = build(
|
|
224
|
+
[{ path: "/", prefix: true, module: "m.ts" }],
|
|
225
|
+
{ "/app/m.ts": { default: () => ({ body: "root" }) } },
|
|
226
|
+
);
|
|
227
|
+
const res = await router(req("POST", "/anything/at/all", { body: "{}" }));
|
|
228
|
+
assert.equal(res.status, 200);
|
|
229
|
+
assert.equal(JSON.parse(res.body!), "root");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("method is trimmed and falls back to POST for whitespace", async () => {
|
|
233
|
+
const { router } = build(
|
|
234
|
+
[{ path: "/a", method: " post ", module: "m.ts" }, { path: "/b", method: " ", module: "m.ts" }],
|
|
235
|
+
{ "/app/m.ts": { default: () => ({ body: "ok" }) } },
|
|
236
|
+
);
|
|
237
|
+
assert.equal((await router(req("POST", "/a", { body: "{}" }))).status, 200);
|
|
238
|
+
assert.equal((await router(req("POST", "/b", { body: "{}" }))).status, 200);
|
|
239
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// actions — per-app action handler files (ADR 0055 phase 3). The pages surface serves
|
|
2
|
+
// generic start/cancel/message actions; a real app usually needs to wrap those with
|
|
3
|
+
// business logic (register a domain row before starting, reconcile app-owned rest state
|
|
4
|
+
// on cancel, correlate a message to an aggregate). An app declares such overrides in the
|
|
5
|
+
// manifest `actions[]` and authors a small module per action. Each module is injected with
|
|
6
|
+
// the app's `AppApi` (typed datasource + engine + sdk + host utils) — the same contract as
|
|
7
|
+
// a worker handler — so the handler code reads exactly like the app's own service layer.
|
|
8
|
+
//
|
|
9
|
+
// Precedence: action routes are mounted BEFORE the generic pages action routes (the router
|
|
10
|
+
// is first-match-wins), so an override at `/app/actions/cancel` shadows the generic one,
|
|
11
|
+
// while an unmatched process still falls through to the generic `/app/actions/start/` route.
|
|
12
|
+
|
|
13
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
14
|
+
import type { HttpRequest } from "../host.ts";
|
|
15
|
+
import type { ActionDecl } from "../manifest.ts";
|
|
16
|
+
import { json, normalizeRoutePath, type Route } from "../router.ts";
|
|
17
|
+
|
|
18
|
+
/** The request handed to an action handler: the raw request plus its parsed JSON body. */
|
|
19
|
+
export interface ActionRequest {
|
|
20
|
+
/** The raw HTTP request (method, path, query, headers). */
|
|
21
|
+
req: HttpRequest;
|
|
22
|
+
/**
|
|
23
|
+
* The request body parsed as JSON (`{}` when the body is empty). A body that is present
|
|
24
|
+
* but not valid JSON is rejected with `400` before the handler runs.
|
|
25
|
+
*/
|
|
26
|
+
body: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What an action handler returns; the runtime serializes `body` as JSON. */
|
|
30
|
+
export interface ActionResult {
|
|
31
|
+
/** HTTP status. Default `200`, or `204` when the handler returns nothing. */
|
|
32
|
+
status?: number;
|
|
33
|
+
/** Response payload, serialized as JSON. Omit for an empty response. */
|
|
34
|
+
body?: unknown;
|
|
35
|
+
/** Response headers merged over `content-type: application/json`. */
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An app-authored action handler: the parsed request plus the injected app API. Return an
|
|
41
|
+
* {@link ActionResult}, or nothing for an empty `204`. Throwing yields a `500` with the
|
|
42
|
+
* error message.
|
|
43
|
+
*/
|
|
44
|
+
export type ActionHandler = (
|
|
45
|
+
input: ActionRequest,
|
|
46
|
+
app: AppApi,
|
|
47
|
+
) => Promise<ActionResult | void> | ActionResult | void;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve an action handler from a loaded module, in priority order:
|
|
51
|
+
* 1. `default` (when it is a function — the common one-handler-per-file case)
|
|
52
|
+
* 2. a named export `handler`
|
|
53
|
+
*/
|
|
54
|
+
export function resolveActionHandler(mod: Record<string, unknown>): ActionHandler | undefined {
|
|
55
|
+
if (typeof mod.default === "function") return mod.default as ActionHandler;
|
|
56
|
+
if (typeof mod.handler === "function") return mod.handler as ActionHandler;
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ActionsHandle {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
routes: Route[];
|
|
63
|
+
describe(): Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mount the declared action handlers. Each declaration contributes one route; the handler
|
|
68
|
+
* module is imported lazily (and cached) on first request, so a missing or malformed module
|
|
69
|
+
* surfaces as a clear `500` on that route rather than failing the whole boot.
|
|
70
|
+
*/
|
|
71
|
+
export function mountActions(ctx: RuntimeContext, app: AppApi): ActionsHandle {
|
|
72
|
+
const decls = (ctx.manifest.actions ?? []) as ActionDecl[];
|
|
73
|
+
const routes: Route[] = [];
|
|
74
|
+
const mounted: Array<{ path: string; method: string; module: string; prefix: boolean }> = [];
|
|
75
|
+
|
|
76
|
+
const joinRoot = (p: string): string =>
|
|
77
|
+
p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
|
|
78
|
+
|
|
79
|
+
// Cache module loads so a module reused across several declarations is imported once.
|
|
80
|
+
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
|
81
|
+
const loadModule = (path: string): Promise<Record<string, unknown>> => {
|
|
82
|
+
const key = joinRoot(path);
|
|
83
|
+
let pending = moduleCache.get(key);
|
|
84
|
+
if (!pending) {
|
|
85
|
+
// Evict the entry if the import rejects, so a transient failure doesn't
|
|
86
|
+
// permanently wedge the route (a cached rejected Promise would reject forever).
|
|
87
|
+
pending = ctx.host.importModule(key).catch((err) => {
|
|
88
|
+
moduleCache.delete(key);
|
|
89
|
+
throw err;
|
|
90
|
+
});
|
|
91
|
+
moduleCache.set(key, pending);
|
|
92
|
+
}
|
|
93
|
+
return pending;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
for (const decl of decls) {
|
|
97
|
+
if (!decl?.path || !decl?.module) {
|
|
98
|
+
ctx.host.log("warn", "skipping action: needs both `path` and `module`", {
|
|
99
|
+
action: decl as unknown as Record<string, unknown>,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const method = (decl.method ?? "POST").trim().toUpperCase() || "POST";
|
|
104
|
+
const prefix = decl.prefix === true;
|
|
105
|
+
// The router's prefix match is a raw `startsWith`, so a prefix route needs a trailing
|
|
106
|
+
// slash to stay boundary-safe (otherwise "/hooks" would also match "/hooks2"). Exact
|
|
107
|
+
// routes keep the normalized (trailing-slash-stripped) path. Guard the root case so a
|
|
108
|
+
// prefix path of "/" doesn't become "//" (which would never match).
|
|
109
|
+
const base = normalizeRoutePath(decl.path, decl.path);
|
|
110
|
+
const path = prefix ? (base.endsWith("/") ? base : `${base}/`) : base;
|
|
111
|
+
|
|
112
|
+
routes.push({
|
|
113
|
+
method,
|
|
114
|
+
path,
|
|
115
|
+
prefix,
|
|
116
|
+
source: `actions:${decl.module}`,
|
|
117
|
+
handler: async (req: HttpRequest) => {
|
|
118
|
+
const raw = await req.text();
|
|
119
|
+
let body: unknown;
|
|
120
|
+
try {
|
|
121
|
+
body = raw ? JSON.parse(raw) : {};
|
|
122
|
+
} catch {
|
|
123
|
+
return json({ error: "request body must be JSON" }, 400);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let handler: ActionHandler | undefined;
|
|
127
|
+
try {
|
|
128
|
+
handler = resolveActionHandler(await loadModule(decl.module));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
ctx.host.log("error", "action handler module failed to load", {
|
|
131
|
+
path,
|
|
132
|
+
module: decl.module,
|
|
133
|
+
error: String((e as Error)?.message ?? e),
|
|
134
|
+
});
|
|
135
|
+
return json({ error: `action handler ${decl.module} failed to load` }, 500);
|
|
136
|
+
}
|
|
137
|
+
if (!handler) {
|
|
138
|
+
return json(
|
|
139
|
+
{ error: `action handler ${decl.module} exports no default function or named \`handler\`` },
|
|
140
|
+
500,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const result = await handler({ req, body }, app);
|
|
146
|
+
if (!result) return { status: 204 };
|
|
147
|
+
return {
|
|
148
|
+
status: result.status ?? 200,
|
|
149
|
+
headers: { "content-type": "application/json", ...(result.headers ?? {}) },
|
|
150
|
+
body: result.body === undefined ? undefined : JSON.stringify(result.body),
|
|
151
|
+
};
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return json({ error: String((e as Error)?.message ?? e) }, 500);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
mounted.push({ path, method, module: decl.module, prefix });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { name: "actions", routes, describe: () => ({ actions: mounted }) };
|
|
161
|
+
}
|