@nanobpm/urban 0.6.0 → 0.8.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/host.d.ts +4 -0
- package/dist/runtime/core/manifest.d.ts +25 -0
- package/dist/runtime/core/modules/actions.d.ts +45 -0
- package/dist/runtime/core/modules/actions.js +113 -0
- package/dist/runtime/core/modules/gateway.d.ts +2 -0
- package/dist/runtime/core/modules/gateway.js +2 -1
- package/dist/runtime/core/modules/pages.d.ts +42 -0
- package/dist/runtime/core/modules/pages.js +670 -0
- package/dist/runtime/core/modules/surfaces.js +16 -0
- package/dist/runtime/engine/nanosdk.d.ts +6 -0
- package/dist/runtime/engine/nanosdk.js +3 -0
- package/dist/runtime/index.d.ts +5 -1
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/schema/nano-app.schema.json +32 -0
- package/package.json +1 -1
- package/src/runtime/core/host.ts +2 -0
- package/src/runtime/core/manifest.ts +26 -0
- package/src/runtime/core/modules/actions.test.ts +239 -0
- package/src/runtime/core/modules/actions.ts +161 -0
- package/src/runtime/core/modules/gateway.ts +2 -1
- package/src/runtime/core/modules/pages.test.ts +224 -0
- package/src/runtime/core/modules/pages.ts +705 -0
- package/src/runtime/core/modules/surfaces.ts +18 -0
- package/src/runtime/core/runtime.test.ts +4 -0
- package/src/runtime/core/validate.test.ts +8 -0
- package/src/runtime/engine/nanosdk.test.ts +18 -0
- package/src/runtime/engine/nanosdk.ts +8 -0
- package/src/runtime/index.ts +5 -0
- package/src/runtime/schema/nano-app.schema.json +32 -0
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// linked `.form` and posts completion back. The `chat` surface is stubbed as a mount point
|
|
4
4
|
// (LLM wiring is a separate concern). Surfaces contribute routes to the shared server.
|
|
5
5
|
import { html, json, normalizeRoutePath } from "../router.js";
|
|
6
|
+
import { mountActions } from "./actions.js";
|
|
7
|
+
import { mountPages } from "./pages.js";
|
|
6
8
|
/** Escape HTML-significant characters before embedding a value in markup. */
|
|
7
9
|
function escapeHtml(s) {
|
|
8
10
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
@@ -83,6 +85,20 @@ export function mountSurfaces(ctx, app) {
|
|
|
83
85
|
<p>Chat surface mount point (agent: <code>${escapeHtml(chat.agent ?? "?")}</code>). LLM wiring pending.</p>`),
|
|
84
86
|
});
|
|
85
87
|
}
|
|
88
|
+
// App-authored action overrides mount BEFORE the generic pages routes so an exact
|
|
89
|
+
// override (e.g. /app/actions/cancel) shadows the generic action; the router is
|
|
90
|
+
// first-match-wins.
|
|
91
|
+
const actions = mountActions(ctx, app);
|
|
92
|
+
if (actions.routes.length > 0) {
|
|
93
|
+
routes.push(...actions.routes);
|
|
94
|
+
enabled.push(`actions(${actions.routes.length})`);
|
|
95
|
+
}
|
|
96
|
+
// The pages surface (the schema-driven page runtime) mounts its own routes.
|
|
97
|
+
const pages = mountPages(ctx, app);
|
|
98
|
+
if (pages.routes.length > 0) {
|
|
99
|
+
routes.push(...pages.routes);
|
|
100
|
+
enabled.push("pages@/");
|
|
101
|
+
}
|
|
86
102
|
ctx.host.log("info", "surfaces mounted", { enabled });
|
|
87
103
|
return { name: "surfaces", routes, describe: () => ({ enabled }) };
|
|
88
104
|
}
|
|
@@ -53,6 +53,9 @@ export interface NanoSdkClient {
|
|
|
53
53
|
variables?: Record<string, unknown>;
|
|
54
54
|
awaitCompletion?: boolean;
|
|
55
55
|
}, options?: unknown): Promise<Record<string, unknown>>;
|
|
56
|
+
cancelProcessInstance(input: {
|
|
57
|
+
processInstanceKey: string | number;
|
|
58
|
+
}, options?: unknown): Promise<unknown>;
|
|
56
59
|
publishMessage(input: {
|
|
57
60
|
name: string;
|
|
58
61
|
correlationKey?: string;
|
|
@@ -101,6 +104,9 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
101
104
|
processInstanceKey: string;
|
|
102
105
|
variables?: Record<string, unknown>;
|
|
103
106
|
}>;
|
|
107
|
+
cancelInstance(input: {
|
|
108
|
+
processInstanceKey: string;
|
|
109
|
+
}): Promise<void>;
|
|
104
110
|
publishMessage(input: {
|
|
105
111
|
name: string;
|
|
106
112
|
correlationKey?: string;
|
|
@@ -66,6 +66,9 @@ export class SdkEngineClient {
|
|
|
66
66
|
variables: body.variables ?? undefined,
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
|
+
async cancelInstance(input) {
|
|
70
|
+
await this.client.cancelProcessInstance({ processInstanceKey: input.processInstanceKey });
|
|
71
|
+
}
|
|
69
72
|
async publishMessage(input) {
|
|
70
73
|
await this.client.publishMessage({
|
|
71
74
|
name: input.name,
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { createUrbanApp } from "./core/runtime.ts";
|
|
2
2
|
export type { CreateUrbanAppOptions, MountFlags, UrbanApp, } from "./core/runtime.ts";
|
|
3
3
|
export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.ts";
|
|
4
|
-
export type { AppManifest, DataSource, DomainType, SurfaceDecl, TriggerDecl, TypeField, WorkerDecl, } from "./core/manifest.ts";
|
|
4
|
+
export type { ActionDecl, AppManifest, DataSource, DomainType, SurfaceDecl, TriggerDecl, TypeField, WorkerDecl, } from "./core/manifest.ts";
|
|
5
5
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
|
|
6
6
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
7
7
|
export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WorkerSubscription, } from "./core/host.ts";
|
|
@@ -10,6 +10,10 @@ export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
|
|
|
10
10
|
export type { ProvisionedSource } from "./core/modules/datasource.ts";
|
|
11
11
|
export { makeGateway, Table } from "./core/modules/gateway.ts";
|
|
12
12
|
export type { ColumnMeta, DataSource as GatewayDataSource, ExecResult, ForeignKeyMeta, Row, TableMeta, } from "./core/modules/gateway.ts";
|
|
13
|
+
export { createPagesRoutes, mountPages } from "./core/modules/pages.ts";
|
|
14
|
+
export type { PagesDataSource, PagesDeps, PagesHandle, PagesOptions } from "./core/modules/pages.ts";
|
|
15
|
+
export { mountActions, resolveActionHandler } from "./core/modules/actions.ts";
|
|
16
|
+
export type { ActionHandler, ActionRequest, ActionResult, ActionsHandle } from "./core/modules/actions.ts";
|
|
13
17
|
export type { AppJobHandler } from "./core/modules/workers.ts";
|
|
14
18
|
export { resolveHandler } from "./core/modules/workers.ts";
|
|
15
19
|
export { evalCorrelation } from "./core/modules/triggers.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -8,6 +8,8 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
|
|
|
8
8
|
// Data layer
|
|
9
9
|
export { DataLayer, TypeRepo } from "./core/modules/datasource.js";
|
|
10
10
|
export { makeGateway, Table } from "./core/modules/gateway.js";
|
|
11
|
+
export { createPagesRoutes, mountPages } from "./core/modules/pages.js";
|
|
12
|
+
export { mountActions, resolveActionHandler } from "./core/modules/actions.js";
|
|
11
13
|
export { resolveHandler } from "./core/modules/workers.js";
|
|
12
14
|
export { evalCorrelation } from "./core/modules/triggers.js";
|
|
13
15
|
// Adapters + engine + run entrypoint
|
|
@@ -52,6 +52,11 @@
|
|
|
52
52
|
"additionalProperties": { "$ref": "#/$defs/connection" }
|
|
53
53
|
},
|
|
54
54
|
"surfaces": { "$ref": "#/$defs/surfaces" },
|
|
55
|
+
"actions": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"items": { "$ref": "#/$defs/action" },
|
|
58
|
+
"description": "App-authored action handler overrides (ADR 0055 §3): each binds an HTTP route to a handler module that wraps the generic pages start/cancel/message actions with business logic, injected with the app's runtime API."
|
|
59
|
+
},
|
|
55
60
|
"workers": {
|
|
56
61
|
"type": "array",
|
|
57
62
|
"items": { "$ref": "#/$defs/worker" },
|
|
@@ -367,6 +372,33 @@
|
|
|
367
372
|
}
|
|
368
373
|
}
|
|
369
374
|
},
|
|
375
|
+
"action": {
|
|
376
|
+
"type": "object",
|
|
377
|
+
"additionalProperties": false,
|
|
378
|
+
"required": ["path", "module"],
|
|
379
|
+
"description": "An app-authored action handler override (ADR 0055 §3): binds a route to a handler module (default-exports an ActionHandler). Mounted before the generic pages action routes, so an exact override shadows the generic one.",
|
|
380
|
+
"properties": {
|
|
381
|
+
"path": {
|
|
382
|
+
"type": "string",
|
|
383
|
+
"minLength": 1,
|
|
384
|
+
"description": "Route path to serve, e.g. '/app/actions/cancel' or '/app/actions/start/convergence-loop'."
|
|
385
|
+
},
|
|
386
|
+
"module": {
|
|
387
|
+
"type": "string",
|
|
388
|
+
"minLength": 1,
|
|
389
|
+
"description": "Handler module path relative to the app root; default-exports (or names `handler`) an ActionHandler."
|
|
390
|
+
},
|
|
391
|
+
"method": {
|
|
392
|
+
"type": "string",
|
|
393
|
+
"minLength": 1,
|
|
394
|
+
"description": "HTTP method to match. Default 'POST'."
|
|
395
|
+
},
|
|
396
|
+
"prefix": {
|
|
397
|
+
"type": "boolean",
|
|
398
|
+
"description": "Match `path` as a prefix rather than exactly. Default false."
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
},
|
|
370
402
|
"llmBinding": {
|
|
371
403
|
"type": "object",
|
|
372
404
|
"additionalProperties": false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface EngineClient {
|
|
|
105
105
|
variables?: Record<string, unknown>;
|
|
106
106
|
awaitCompletion?: boolean;
|
|
107
107
|
}): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }>;
|
|
108
|
+
/** Cancel a running process instance (the pages surface's row-cancel action). */
|
|
109
|
+
cancelInstance(input: { processInstanceKey: string }): Promise<void>;
|
|
108
110
|
/** Publish a message for correlation. */
|
|
109
111
|
publishMessage(input: {
|
|
110
112
|
name: string;
|
|
@@ -44,6 +44,31 @@ export interface SurfaceDecl {
|
|
|
44
44
|
enabled?: boolean;
|
|
45
45
|
path?: string;
|
|
46
46
|
agent?: string;
|
|
47
|
+
/** pages surface: directory of `*.page.json` (relative to app root). Default `pages`. */
|
|
48
|
+
pagesDir?: string;
|
|
49
|
+
/** pages surface: the page served at `/`. Default `home`. */
|
|
50
|
+
homePage?: string;
|
|
51
|
+
/** pages surface: max rows a `dataGrid` fetch returns. Default 200. */
|
|
52
|
+
rowLimit?: number;
|
|
53
|
+
/** pages surface: the injected default datasource name. Default `app`. */
|
|
54
|
+
sourceName?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An app-authored action handler override (ADR 0055 phase 3). Each declaration binds a
|
|
59
|
+
* route to a handler module (default-exports an `ActionHandler`), letting an app wrap the
|
|
60
|
+
* generic pages start/cancel/message actions with business logic. Mounted before the
|
|
61
|
+
* generic pages action routes, so an exact override shadows the generic one.
|
|
62
|
+
*/
|
|
63
|
+
export interface ActionDecl {
|
|
64
|
+
/** Route path to serve, e.g. "/app/actions/cancel" or "/app/actions/start/convergence-loop". */
|
|
65
|
+
path: string;
|
|
66
|
+
/** Handler module path relative to the app root; default-exports an `ActionHandler`. */
|
|
67
|
+
module: string;
|
|
68
|
+
/** HTTP method to match. Default "POST". */
|
|
69
|
+
method?: string;
|
|
70
|
+
/** Match `path` as a prefix rather than exactly. Default false. */
|
|
71
|
+
prefix?: boolean;
|
|
47
72
|
}
|
|
48
73
|
|
|
49
74
|
export interface AppManifest {
|
|
@@ -59,6 +84,7 @@ export interface AppManifest {
|
|
|
59
84
|
triggers?: TriggerDecl[];
|
|
60
85
|
connections?: Record<string, { type: string; [k: string]: unknown }>;
|
|
61
86
|
surfaces?: Record<string, SurfaceDecl>;
|
|
87
|
+
actions?: ActionDecl[];
|
|
62
88
|
workers?: WorkerDecl[];
|
|
63
89
|
llm?: Record<string, unknown>;
|
|
64
90
|
security?: Record<string, unknown>;
|
|
@@ -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
|
+
}
|
|
@@ -68,7 +68,8 @@ export interface DataSource {
|
|
|
68
68
|
table<T extends object = Row>(name: string, pk?: string): Table<T>;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** Double-quote a SQL identifier (table/column), escaping embedded quotes. */
|
|
72
|
+
export function quoteIdent(name: string): string {
|
|
72
73
|
return `"${name.replaceAll('"', '""')}"`;
|
|
73
74
|
}
|
|
74
75
|
|