@skaile/forge-app-actions 0.3.0 → 0.4.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/README.md +111 -4
- package/bin/forge-actions.mjs +274 -0
- package/dist/module.d.mts +10 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +55 -16
- package/dist/runtime/chat/ai-sdk.d.ts +23 -0
- package/dist/runtime/chat/ai-sdk.js +25 -0
- package/dist/runtime/client/index.d.ts +57 -0
- package/dist/runtime/client/index.js +60 -0
- package/dist/runtime/internal/bearer.d.ts +6 -0
- package/dist/runtime/internal/bearer.js +5 -0
- package/dist/runtime/internal/manifest.d.ts +17 -0
- package/dist/runtime/internal/manifest.js +0 -0
- package/dist/runtime/server/http/create-handler.d.ts +50 -0
- package/dist/runtime/server/http/create-handler.js +135 -0
- package/dist/runtime/server/http/index.d.ts +8 -0
- package/dist/runtime/server/http/index.js +1 -0
- package/dist/runtime/server/http/route.d.ts +16 -0
- package/dist/runtime/server/http/route.js +28 -0
- package/dist/runtime/server/index.d.ts +1 -0
- package/dist/runtime/server/index.js +1 -0
- package/dist/runtime/server/mcp/create-handler.d.ts +8 -6
- package/dist/runtime/server/mcp/create-handler.js +8 -26
- package/dist/runtime/server/mcp/route.d.ts +2 -0
- package/dist/runtime/server/mcp/route.js +7 -10
- package/dist/runtime/server/utils/invoke-action.d.ts +19 -0
- package/dist/runtime/server/utils/invoke-action.js +32 -0
- package/dist/runtime/server/utils/server-actions.d.ts +57 -12
- package/dist/runtime/server/utils/server-actions.js +20 -2
- package/package.json +26 -2
- package/src/runtime/chat/ai-sdk.ts +53 -0
- package/src/runtime/client/index.ts +131 -0
- package/src/runtime/internal/bearer.ts +11 -0
- package/src/runtime/internal/manifest.ts +19 -0
- package/src/runtime/server/http/create-handler.ts +220 -0
- package/src/runtime/server/http/index.ts +9 -0
- package/src/runtime/server/http/route.ts +54 -0
- package/src/runtime/server/index.ts +1 -0
- package/src/runtime/server/mcp/create-handler.ts +16 -43
- package/src/runtime/server/mcp/route.ts +8 -11
- package/src/runtime/server/utils/invoke-action.ts +71 -0
- package/src/runtime/server/utils/server-actions.ts +95 -14
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
const AUDIENCES = ["user", "llm"];
|
|
2
3
|
function withoutSchemaKey(schema) {
|
|
3
4
|
const { $schema: _draft, ...rest } = schema;
|
|
4
5
|
return rest;
|
|
@@ -18,23 +19,40 @@ export function defineServerAction(args) {
|
|
|
18
19
|
`defineServerAction("${id}"): a server action must declare a description \u2014 the model reads it.`
|
|
19
20
|
);
|
|
20
21
|
}
|
|
22
|
+
const requested = args.audience ?? AUDIENCES;
|
|
23
|
+
if (requested.length === 0 || requested.some((entry) => !AUDIENCES.includes(entry))) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`defineServerAction("${id}"): audience must be a non-empty subset of ${JSON.stringify(AUDIENCES)}.`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const audience = Object.freeze([...new Set(requested)]);
|
|
21
29
|
const inputZod = args.input ?? z.object({});
|
|
22
30
|
const inputSchema = withoutSchemaKey(
|
|
23
31
|
z.toJSONSchema(inputZod, { target: "draft-07", io: "input" })
|
|
24
32
|
);
|
|
33
|
+
const name = `app.${id}`;
|
|
25
34
|
return {
|
|
26
35
|
id,
|
|
27
|
-
name
|
|
36
|
+
name,
|
|
28
37
|
description: args.description,
|
|
29
38
|
kind: args.kind ?? "effect",
|
|
39
|
+
audience,
|
|
30
40
|
inputZod,
|
|
31
41
|
inputSchema,
|
|
32
42
|
async run(input, ctx) {
|
|
33
43
|
const parsed = inputZod.parse(input);
|
|
34
|
-
|
|
44
|
+
const context = { ...ctx, name };
|
|
45
|
+
await args.authorize?.(parsed, context);
|
|
46
|
+
return await args.run(parsed, context);
|
|
35
47
|
}
|
|
36
48
|
};
|
|
37
49
|
}
|
|
50
|
+
export function createServerActionDefiner() {
|
|
51
|
+
return (args) => defineServerAction(args);
|
|
52
|
+
}
|
|
53
|
+
export function forAudience(actions, audience) {
|
|
54
|
+
return actions.filter((action) => action.audience.includes(audience));
|
|
55
|
+
}
|
|
38
56
|
export class ServerActionRegistry {
|
|
39
57
|
actions = /* @__PURE__ */ new Map();
|
|
40
58
|
register(action) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaile/forge-app-actions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Browser-declared app actions for Skaile forge apps: defineForgeAction / defineClientCapability, the agent capability bridge, command palette and approval dialog, as a Nuxt module.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.9",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"types": "./dist/runtime/chat/server.d.ts",
|
|
24
24
|
"import": "./dist/runtime/chat/server.js"
|
|
25
25
|
},
|
|
26
|
+
"./chat/ai-sdk": {
|
|
27
|
+
"types": "./dist/runtime/chat/ai-sdk.d.ts",
|
|
28
|
+
"import": "./dist/runtime/chat/ai-sdk.js"
|
|
29
|
+
},
|
|
26
30
|
"./server": {
|
|
27
31
|
"types": "./dist/runtime/server/index.d.ts",
|
|
28
32
|
"import": "./dist/runtime/server/index.js"
|
|
@@ -31,14 +35,26 @@
|
|
|
31
35
|
"types": "./dist/runtime/server/mcp/index.d.ts",
|
|
32
36
|
"import": "./dist/runtime/server/mcp/index.js"
|
|
33
37
|
},
|
|
38
|
+
"./http": {
|
|
39
|
+
"types": "./dist/runtime/server/http/index.d.ts",
|
|
40
|
+
"import": "./dist/runtime/server/http/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./client": {
|
|
43
|
+
"types": "./dist/runtime/client/index.d.ts",
|
|
44
|
+
"import": "./dist/runtime/client/index.js"
|
|
45
|
+
},
|
|
34
46
|
"./types": {
|
|
35
47
|
"types": "./dist/runtime/types/index.d.ts",
|
|
36
48
|
"import": "./dist/runtime/types/index.js"
|
|
37
49
|
}
|
|
38
50
|
},
|
|
51
|
+
"bin": {
|
|
52
|
+
"forge-actions": "./bin/forge-actions.mjs"
|
|
53
|
+
},
|
|
39
54
|
"files": [
|
|
40
55
|
"dist",
|
|
41
56
|
"src/runtime",
|
|
57
|
+
"bin",
|
|
42
58
|
"README.md"
|
|
43
59
|
],
|
|
44
60
|
"repository": {
|
|
@@ -64,7 +80,8 @@
|
|
|
64
80
|
"@skaile/forge-chat": "^0.2.0",
|
|
65
81
|
"@skaile/workspaces": "^6.0.0",
|
|
66
82
|
"nuxt": ">=3.0.0",
|
|
67
|
-
"vue": ">=3.0.0"
|
|
83
|
+
"vue": ">=3.0.0",
|
|
84
|
+
"ai": "^7.0.124"
|
|
68
85
|
},
|
|
69
86
|
"peerDependenciesMeta": {
|
|
70
87
|
"@skaile/forge-chat": {
|
|
@@ -72,6 +89,9 @@
|
|
|
72
89
|
},
|
|
73
90
|
"@skaile/workspaces": {
|
|
74
91
|
"optional": true
|
|
92
|
+
},
|
|
93
|
+
"ai": {
|
|
94
|
+
"optional": true
|
|
75
95
|
}
|
|
76
96
|
},
|
|
77
97
|
"dependencies": {
|
|
@@ -89,6 +109,7 @@
|
|
|
89
109
|
"@types/bun": "^1.2.0",
|
|
90
110
|
"@types/node": "^22.0.0",
|
|
91
111
|
"@vitejs/plugin-vue": "^6.0.5",
|
|
112
|
+
"ai": "^7.0.155",
|
|
92
113
|
"bun-types": "^1.3.11",
|
|
93
114
|
"h3": "^1.15.11",
|
|
94
115
|
"happy-dom": "^20.9.0",
|
|
@@ -99,5 +120,8 @@
|
|
|
99
120
|
"vitest": "^3.2.0",
|
|
100
121
|
"vue": "^3.5.28",
|
|
101
122
|
"vue-tsc": "^2.1.0"
|
|
123
|
+
},
|
|
124
|
+
"engines": {
|
|
125
|
+
"node": ">=20"
|
|
102
126
|
}
|
|
103
127
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI SDK projection of server actions (`@skaile/forge-app-actions/chat/ai-sdk`).
|
|
3
|
+
*
|
|
4
|
+
* The only module of this package that imports `ai`, which is an optional peer:
|
|
5
|
+
* hosts that never serve actions to an AI SDK chat never load it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { jsonSchema, type ToolSet, tool } from "ai";
|
|
9
|
+
import { toolNameOf } from "../internal/tool-name";
|
|
10
|
+
import { invokeAction } from "../server/utils/invoke-action";
|
|
11
|
+
import type { ServerAction, ServerActionScope } from "../server/utils/server-actions";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The given server actions as AI SDK tools, named `toolNameOf(action.name)` like
|
|
15
|
+
* their MCP counterparts, with the action's description and JSON input schema.
|
|
16
|
+
* Narrow with `forAudience(actions, "llm")` first to honor audiences.
|
|
17
|
+
*
|
|
18
|
+
* Every execution goes through `invokeAction` with `scope`: input is parsed and the
|
|
19
|
+
* action's own `authorize` runs inside `action.run`, once, at call time. Fresh
|
|
20
|
+
* authority therefore comes from that callback reading the app's current store,
|
|
21
|
+
* never from `scope`, which is captured when the tool set is built. A failed call
|
|
22
|
+
* (invalid input, denied, handler error) throws an `Error` carrying the outcome's
|
|
23
|
+
* message, which the AI SDK reports to the model as a tool error.
|
|
24
|
+
*
|
|
25
|
+
* @throws when two different action names map to one tool name.
|
|
26
|
+
*/
|
|
27
|
+
export function serverActionToolSet(
|
|
28
|
+
actions: readonly ServerAction[],
|
|
29
|
+
scope: ServerActionScope,
|
|
30
|
+
): ToolSet {
|
|
31
|
+
const tools: ToolSet = Object.create(null);
|
|
32
|
+
const owners = new Map<string, string>();
|
|
33
|
+
|
|
34
|
+
for (const action of actions) {
|
|
35
|
+
const toolName = toolNameOf(action.name);
|
|
36
|
+
const other = owners.get(toolName);
|
|
37
|
+
if (other !== undefined) {
|
|
38
|
+
throw new Error(`Tool name collision: ${other} and ${action.name} both map to ${toolName}`);
|
|
39
|
+
}
|
|
40
|
+
owners.set(toolName, action.name);
|
|
41
|
+
|
|
42
|
+
tools[toolName] = tool({
|
|
43
|
+
description: action.description,
|
|
44
|
+
inputSchema: jsonSchema(action.inputSchema as Parameters<typeof jsonSchema>[0]),
|
|
45
|
+
execute: async (input: unknown) => {
|
|
46
|
+
const outcome = await invokeAction(action, input, scope);
|
|
47
|
+
if (!outcome.ok) throw new Error(outcome.message);
|
|
48
|
+
return outcome.result ?? null;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return tools;
|
|
53
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// client/index.ts
|
|
2
|
+
//
|
|
3
|
+
// Browser/Node client of the HTTP projection (`forgeAppActions.http`). Imports
|
|
4
|
+
// types only: no Vue, H3, AI SDK or server code ends up in a client bundle.
|
|
5
|
+
|
|
6
|
+
import type { ActionManifestEntry, ActionsManifest } from "../internal/manifest";
|
|
7
|
+
import type { ActionInput, ActionOutput, ServerAction } from "../server/utils/server-actions";
|
|
8
|
+
|
|
9
|
+
export type { ActionManifestEntry, ActionsManifest };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The transport. It resolves to the already decoded response body and rejects
|
|
13
|
+
* on a non-2xx status, exactly as Nuxt's `$fetch` does, so `$fetch` can be
|
|
14
|
+
* injected as is. Declared through a method type so its parameters are compared
|
|
15
|
+
* bivariantly: `$fetch` accepts a narrower `body` type than `unknown`.
|
|
16
|
+
*/
|
|
17
|
+
export type ActionsFetch = {
|
|
18
|
+
bivarianceHack(
|
|
19
|
+
url: string,
|
|
20
|
+
init: { method: "GET" | "POST"; body?: unknown; headers?: Record<string, string> },
|
|
21
|
+
): Promise<unknown>;
|
|
22
|
+
}["bivarianceHack"];
|
|
23
|
+
|
|
24
|
+
export interface ActionsClientOptions {
|
|
25
|
+
/** Prefix of `route`, e.g. `https://app.example.com`. Defaults to none (same origin). */
|
|
26
|
+
baseUrl?: string;
|
|
27
|
+
/** Where the host mounted the HTTP adapter. Defaults to `/api/actions`. */
|
|
28
|
+
route?: string;
|
|
29
|
+
/** Defaults to a `fetch`-based transport; Nuxt callers pass `$fetch`. */
|
|
30
|
+
fetch?: ActionsFetch;
|
|
31
|
+
/** Sent with every request (e.g. `Authorization`); a function is read per request. */
|
|
32
|
+
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the default transport throws for a non-2xx response. Carries the fields
|
|
37
|
+
* Nuxt's `$fetch` errors do: `statusCode`, `statusMessage` and `data`, the
|
|
38
|
+
* decoded response body (for an action failure the H3 error body, whose own
|
|
39
|
+
* `data.issues` lists schema errors).
|
|
40
|
+
*/
|
|
41
|
+
export class ActionsClientError extends Error {
|
|
42
|
+
readonly statusCode: number;
|
|
43
|
+
readonly statusMessage: string;
|
|
44
|
+
readonly data: unknown;
|
|
45
|
+
|
|
46
|
+
constructor(statusCode: number, statusMessage: string, message: string, data: unknown) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "ActionsClientError";
|
|
49
|
+
this.statusCode = statusCode;
|
|
50
|
+
this.statusMessage = statusMessage;
|
|
51
|
+
this.data = data;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function decode(text: string): unknown {
|
|
56
|
+
if (text === "") return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(text);
|
|
59
|
+
} catch {
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const nativeFetch: ActionsFetch = async (url, init) => {
|
|
65
|
+
const hasBody = init.body !== undefined;
|
|
66
|
+
const response = await globalThis.fetch(url, {
|
|
67
|
+
method: init.method,
|
|
68
|
+
headers: {
|
|
69
|
+
accept: "application/json",
|
|
70
|
+
...(hasBody ? { "content-type": "application/json" } : {}),
|
|
71
|
+
...init.headers,
|
|
72
|
+
},
|
|
73
|
+
body: hasBody ? JSON.stringify(init.body) : undefined,
|
|
74
|
+
});
|
|
75
|
+
const data = decode(await response.text());
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const body = (typeof data === "object" && data !== null ? data : {}) as {
|
|
78
|
+
statusMessage?: unknown;
|
|
79
|
+
message?: unknown;
|
|
80
|
+
};
|
|
81
|
+
const statusMessage =
|
|
82
|
+
typeof body.statusMessage === "string" ? body.statusMessage : response.statusText;
|
|
83
|
+
const message = typeof body.message === "string" ? body.message : statusMessage;
|
|
84
|
+
throw new ActionsClientError(response.status, statusMessage, message, data);
|
|
85
|
+
}
|
|
86
|
+
return data;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export interface ActionsClient<R extends readonly ServerAction[]> {
|
|
90
|
+
/**
|
|
91
|
+
* Calls the action and resolves to its result. A failure rejects with the
|
|
92
|
+
* transport's error, which carries `statusCode`, `statusMessage` and `data`.
|
|
93
|
+
*/
|
|
94
|
+
call<K extends R[number]["id"]>(
|
|
95
|
+
id: K,
|
|
96
|
+
input: ActionInput<Extract<R[number], { id: K }>>,
|
|
97
|
+
): Promise<ActionOutput<Extract<R[number], { id: K }>>>;
|
|
98
|
+
/** The `user`-audience actions the server offers, with their JSON input schemas. */
|
|
99
|
+
manifest(): Promise<ActionsManifest<R[number]["id"]>>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A typed client of the HTTP adapter. `R` is the app's action catalogue
|
|
104
|
+
* (`typeof SERVERAKTIONEN`), so `call` checks ids and inputs and returns the
|
|
105
|
+
* handler's output type; import the catalogue as a type only.
|
|
106
|
+
*/
|
|
107
|
+
export function createActionsClient<R extends readonly ServerAction[]>(
|
|
108
|
+
options: ActionsClientOptions = {},
|
|
109
|
+
): ActionsClient<R> {
|
|
110
|
+
const base = `${(options.baseUrl ?? "").replace(/\/+$/, "")}/${(options.route ?? "/api/actions").replace(/^\/+|\/+$/g, "")}`;
|
|
111
|
+
const transport = options.fetch ?? nativeFetch;
|
|
112
|
+
|
|
113
|
+
const headersOf = async () => {
|
|
114
|
+
const headers = options.headers;
|
|
115
|
+
return typeof headers === "function" ? await headers() : headers;
|
|
116
|
+
};
|
|
117
|
+
const send = async (method: "GET" | "POST", url: string, body?: unknown) => {
|
|
118
|
+
const headers = await headersOf();
|
|
119
|
+
return transport(url, {
|
|
120
|
+
method,
|
|
121
|
+
...(body === undefined ? {} : { body }),
|
|
122
|
+
...(headers ? { headers } : {}),
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
call: (id, input) =>
|
|
128
|
+
send("POST", `${base}/${encodeURIComponent(id)}`, input ?? {}) as Promise<never>,
|
|
129
|
+
manifest: () => send("GET", base) as Promise<ActionsManifest<R[number]["id"]>>,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const BEARER = /^Bearer\s+(\S.*)$/i;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The credential of an `Authorization: Bearer …` header value, or `null` when
|
|
5
|
+
* the header is absent, not a bearer scheme, or empty. Shared by the MCP and
|
|
6
|
+
* HTTP adapters so both read credentials the same way.
|
|
7
|
+
*/
|
|
8
|
+
export function bearerOf(header: string | string[] | null | undefined): string | null {
|
|
9
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
10
|
+
return value?.match(BEARER)?.[1]?.trim() || null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types of the HTTP action manifest. Types only: the client imports them
|
|
3
|
+
* without pulling any server code.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** One action as `GET <route>` lists it. */
|
|
7
|
+
export interface ActionManifestEntry<Id extends string = string> {
|
|
8
|
+
id: Id;
|
|
9
|
+
/** Canonical name `app.<id>`. */
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
kind: "effect" | "query";
|
|
13
|
+
/** JSON Schema (draft-07, input side) of the action's input. */
|
|
14
|
+
inputSchema: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ActionsManifest<Id extends string = string> {
|
|
18
|
+
actions: ActionManifestEntry<Id>[];
|
|
19
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// server/http/create-handler.ts
|
|
2
|
+
//
|
|
3
|
+
// Projects server actions as a plain JSON-over-HTTP API. Deliberately outside
|
|
4
|
+
// `runtime/server/utils/` (like the MCP adapter) so the Nuxt module's auto-import
|
|
5
|
+
// of that directory never pulls it in, and written against `node:http` only:
|
|
6
|
+
// no H3, Vue or AI SDK import, so Express/Vite hosts can mount it too.
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
import { bearerOf } from "../../internal/bearer";
|
|
10
|
+
import type { ActionManifestEntry, ActionsManifest } from "../../internal/manifest";
|
|
11
|
+
import { invokeAction } from "../utils/invoke-action";
|
|
12
|
+
import { forAudience, type ServerAction } from "../utils/server-actions";
|
|
13
|
+
|
|
14
|
+
/** What the `authorize` callback receives and may change. */
|
|
15
|
+
export interface ActionsHttpAuthorizeDecision {
|
|
16
|
+
/** The host event (under Nitro an `H3Event`), or the Node request when the host passed none. */
|
|
17
|
+
event: unknown;
|
|
18
|
+
req: IncomingMessage;
|
|
19
|
+
/** `Authorization: Bearer …` without the prefix, or `null` when there is no bearer header. */
|
|
20
|
+
bearer: string | null;
|
|
21
|
+
/** Defaults to `false`; both the manifest and every call answer 401 unless the callback sets it. */
|
|
22
|
+
allowed: boolean;
|
|
23
|
+
/** What the actions see as `ctx.principal`. */
|
|
24
|
+
principal?: unknown;
|
|
25
|
+
/** What the actions see as `ctx.deps`, e.g. injected service clients. */
|
|
26
|
+
deps?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ActionsHttpHandlerOptions {
|
|
30
|
+
/** Narrowed to the `user` audience by the handler itself; the rest answer 404. */
|
|
31
|
+
actions: readonly ServerAction[];
|
|
32
|
+
/** Mount path, e.g. `/api/actions`. The manifest is `GET <route>`, a call `POST <route>/<id>`. */
|
|
33
|
+
route: string;
|
|
34
|
+
/**
|
|
35
|
+
* Asked once per request, before any action id is looked up or any body is
|
|
36
|
+
* read. Set `decision.allowed = true` to let it in. An error thrown here
|
|
37
|
+
* denies. A host that authenticates by cookie MUST also check the request's
|
|
38
|
+
* origin here before allowing a `POST`: this adapter performs no CSRF defence.
|
|
39
|
+
*/
|
|
40
|
+
authorize: (decision: ActionsHttpAuthorizeDecision) => void | Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Per-request inputs a host may supply instead of the handler deriving them from `req`. */
|
|
44
|
+
export interface ActionsHttpRequestInput {
|
|
45
|
+
/** Passed to `authorize` and to the actions as `ctx.event`. Defaults to `req`. */
|
|
46
|
+
event?: unknown;
|
|
47
|
+
/** The raw request body, for hosts whose framework already consumed the stream. Called only after authorization. */
|
|
48
|
+
readBody?: () => Promise<string | undefined>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Bodies above this answer 413. */
|
|
52
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
/** The body shape an H3 error serializes to, so Nuxt clients decode it like any other error. */
|
|
55
|
+
function errorBody(statusCode: number, message: string, data?: unknown) {
|
|
56
|
+
return {
|
|
57
|
+
statusCode,
|
|
58
|
+
statusMessage: message,
|
|
59
|
+
message,
|
|
60
|
+
...(data === undefined ? {} : { data }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function send(
|
|
65
|
+
res: ServerResponse,
|
|
66
|
+
status: number,
|
|
67
|
+
body: unknown,
|
|
68
|
+
headers: Record<string, string> = {},
|
|
69
|
+
) {
|
|
70
|
+
res.writeHead(status, {
|
|
71
|
+
"content-type": "application/json; charset=utf-8",
|
|
72
|
+
"cache-control": "no-store",
|
|
73
|
+
"x-content-type-options": "nosniff",
|
|
74
|
+
...headers,
|
|
75
|
+
});
|
|
76
|
+
res.end(JSON.stringify(body) ?? "null");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readStream(req: IncomingMessage): Promise<string> {
|
|
80
|
+
const chunks: Buffer[] = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
for await (const chunk of req) {
|
|
83
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer);
|
|
84
|
+
size += buffer.length;
|
|
85
|
+
if (size > MAX_BODY_BYTES) throw Object.assign(new Error("body too large"), { tooLarge: true });
|
|
86
|
+
chunks.push(buffer);
|
|
87
|
+
}
|
|
88
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where `req.url` points relative to `route`: the manifest (`null`), an action
|
|
93
|
+
* id (already decoded), or outside the API (`undefined`).
|
|
94
|
+
*/
|
|
95
|
+
function actionIdOf(url: string | undefined, route: string): string | null | undefined {
|
|
96
|
+
const pathname = new URL(url ?? "/", "http://localhost").pathname;
|
|
97
|
+
if (pathname !== route && !pathname.startsWith(`${route}/`)) return undefined;
|
|
98
|
+
const rest = pathname.slice(route.length).replace(/^\/+/, "");
|
|
99
|
+
if (rest === "") return null;
|
|
100
|
+
if (rest.includes("/")) return undefined;
|
|
101
|
+
try {
|
|
102
|
+
return decodeURIComponent(rest);
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function manifestOf(actions: readonly ServerAction[]): ActionsManifest {
|
|
109
|
+
return {
|
|
110
|
+
actions: actions.map(
|
|
111
|
+
(action): ActionManifestEntry => ({
|
|
112
|
+
id: action.id,
|
|
113
|
+
name: action.name,
|
|
114
|
+
description: action.description,
|
|
115
|
+
kind: action.kind,
|
|
116
|
+
inputSchema: action.inputSchema,
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A Node `(req, res, input?)` handler for the HTTP projection of server actions.
|
|
124
|
+
*
|
|
125
|
+
* - `GET <route>`: `{ actions: [{ id, name, description, kind, inputSchema }] }` of the
|
|
126
|
+
* `user`-audience actions, `cache-control: no-store`.
|
|
127
|
+
* - `POST <route>/<id>`: the JSON body is the action's input; the response is its raw
|
|
128
|
+
* result. A failure is an H3-shaped error body (`statusCode`, `statusMessage`, `message`,
|
|
129
|
+
* `data.issues` for schema errors) with the outcome's status.
|
|
130
|
+
*
|
|
131
|
+
* Both routes deny by default (401) until `authorize` sets `decision.allowed`.
|
|
132
|
+
* Unknown and non-`user` actions answer 404, malformed JSON 400. Input parsing and
|
|
133
|
+
* the action's own `authorize` happen inside `action.run`, once, via `invokeAction`.
|
|
134
|
+
*/
|
|
135
|
+
export function createActionsHttpHandler(
|
|
136
|
+
options: ActionsHttpHandlerOptions,
|
|
137
|
+
): (req: IncomingMessage, res: ServerResponse, input?: ActionsHttpRequestInput) => Promise<void> {
|
|
138
|
+
const route = `/${options.route.replace(/^\/+|\/+$/g, "")}`;
|
|
139
|
+
const actions = forAudience(options.actions, "user");
|
|
140
|
+
const byId = new Map<string, ServerAction>();
|
|
141
|
+
for (const action of actions) {
|
|
142
|
+
const other = byId.get(action.id);
|
|
143
|
+
if (other) throw new Error(`Duplicate action id: ${action.id} (${other.name}, ${action.name})`);
|
|
144
|
+
byId.set(action.id, action);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return async (req, res, input = {}) => {
|
|
148
|
+
const id = actionIdOf(req.url, route);
|
|
149
|
+
if (id === undefined) {
|
|
150
|
+
return send(res, 404, errorBody(404, "Not found"));
|
|
151
|
+
}
|
|
152
|
+
const method = req.method ?? "GET";
|
|
153
|
+
const allowedMethod = id === null ? "GET" : "POST";
|
|
154
|
+
if (method !== allowedMethod && !(id === null && method === "HEAD")) {
|
|
155
|
+
return send(
|
|
156
|
+
res,
|
|
157
|
+
405,
|
|
158
|
+
errorBody(405, `Use ${allowedMethod} here`),
|
|
159
|
+
{ allow: allowedMethod },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const event = input.event ?? req;
|
|
164
|
+
const decision: ActionsHttpAuthorizeDecision = {
|
|
165
|
+
event,
|
|
166
|
+
req,
|
|
167
|
+
bearer: bearerOf(req.headers.authorization),
|
|
168
|
+
allowed: false,
|
|
169
|
+
principal: undefined,
|
|
170
|
+
deps: undefined,
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
await options.authorize(decision);
|
|
174
|
+
} catch {
|
|
175
|
+
decision.allowed = false;
|
|
176
|
+
}
|
|
177
|
+
if (decision.allowed !== true) {
|
|
178
|
+
return send(res, 401, errorBody(401, "Invalid or missing credential"), {
|
|
179
|
+
"www-authenticate": "Bearer",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (id === null) return send(res, 200, manifestOf(actions));
|
|
184
|
+
|
|
185
|
+
const action = byId.get(id);
|
|
186
|
+
if (!action) return send(res, 404, errorBody(404, `Unknown action: ${id}`));
|
|
187
|
+
|
|
188
|
+
let raw: string | undefined;
|
|
189
|
+
try {
|
|
190
|
+
raw = await (input.readBody ? input.readBody() : readStream(req));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const tooLarge = (error as { tooLarge?: boolean } | null)?.tooLarge === true;
|
|
193
|
+
return send(res, tooLarge ? 413 : 400, errorBody(tooLarge ? 413 : 400, "Unreadable body"));
|
|
194
|
+
}
|
|
195
|
+
let body: unknown = {};
|
|
196
|
+
if (raw !== undefined && raw.trim() !== "") {
|
|
197
|
+
try {
|
|
198
|
+
body = JSON.parse(raw);
|
|
199
|
+
} catch {
|
|
200
|
+
return send(res, 400, errorBody(400, "Malformed JSON body"));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const outcome = await invokeAction(action, body, {
|
|
205
|
+
principal: decision.principal,
|
|
206
|
+
event,
|
|
207
|
+
deps: decision.deps,
|
|
208
|
+
});
|
|
209
|
+
if (outcome.ok) return send(res, 200, outcome.result);
|
|
210
|
+
return send(
|
|
211
|
+
res,
|
|
212
|
+
outcome.status,
|
|
213
|
+
errorBody(
|
|
214
|
+
outcome.status,
|
|
215
|
+
outcome.message,
|
|
216
|
+
outcome.issues === undefined ? undefined : { issues: outcome.issues },
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit entry point for the HTTP projection of server actions, via the
|
|
3
|
+
* `./http` subpath. The Nuxt module mounts the routes itself when
|
|
4
|
+
* `forgeAppActions.http` is set; use these to serve actions from elsewhere.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { ActionManifestEntry, ActionsManifest } from "../../internal/manifest";
|
|
8
|
+
export * from "./create-handler";
|
|
9
|
+
export type { HttpAuthorizeDecision } from "./route";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// server/http/route.ts
|
|
2
|
+
//
|
|
3
|
+
// The Nitro handler `forgeAppActions.http` mounts. Not importable from app code;
|
|
4
|
+
// apps reach the same projection through `@skaile/forge-app-actions/http`.
|
|
5
|
+
|
|
6
|
+
import type { H3Event } from "h3";
|
|
7
|
+
import { defineEventHandler, readRawBody } from "h3";
|
|
8
|
+
import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
|
|
9
|
+
import { serverActionRegistry } from "../utils/server-actions";
|
|
10
|
+
import { createActionsHttpHandler } from "./create-handler";
|
|
11
|
+
|
|
12
|
+
/** What the `forge-app-actions:http:authorize` hook receives and may change. */
|
|
13
|
+
export interface HttpAuthorizeDecision {
|
|
14
|
+
event: H3Event;
|
|
15
|
+
/** `Authorization: Bearer …` without the prefix, or `null` when there is no bearer header. */
|
|
16
|
+
bearer: string | null;
|
|
17
|
+
/** Defaults to `false`; manifest and calls answer 401 unless the hook sets it. */
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
/** What the actions see as `ctx.principal`. */
|
|
20
|
+
principal?: unknown;
|
|
21
|
+
/** What the actions see as `ctx.deps`, e.g. injected service clients. */
|
|
22
|
+
deps?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `GET <route>` (manifest) and `POST <route>/:id` over the registered server actions. */
|
|
26
|
+
export default defineEventHandler(async (event) => {
|
|
27
|
+
const { http } = useRuntimeConfig(event).forgeAppActions as { http: { route: string } };
|
|
28
|
+
// The handler writes the response itself; h3 sees `res.writableEnded` and sends nothing more.
|
|
29
|
+
await createActionsHttpHandler({
|
|
30
|
+
actions: serverActionRegistry.list(),
|
|
31
|
+
route: http.route,
|
|
32
|
+
authorize: async (inner) => {
|
|
33
|
+
const decision: HttpAuthorizeDecision = {
|
|
34
|
+
event,
|
|
35
|
+
bearer: inner.bearer,
|
|
36
|
+
allowed: false,
|
|
37
|
+
principal: undefined,
|
|
38
|
+
deps: undefined,
|
|
39
|
+
};
|
|
40
|
+
// The hook's typing comes from the module's type template, which is absent
|
|
41
|
+
// when this package is type-checked on its own.
|
|
42
|
+
const { hooks } = useNitroApp() as unknown as {
|
|
43
|
+
hooks: { callHook(name: string, decision: HttpAuthorizeDecision): Promise<void> };
|
|
44
|
+
};
|
|
45
|
+
await hooks.callHook("forge-app-actions:http:authorize", decision);
|
|
46
|
+
inner.allowed = decision.allowed === true;
|
|
47
|
+
inner.principal = decision.principal;
|
|
48
|
+
inner.deps = decision.deps;
|
|
49
|
+
},
|
|
50
|
+
})(event.node.req, event.node.res, {
|
|
51
|
+
event,
|
|
52
|
+
readBody: () => readRawBody(event, "utf8"),
|
|
53
|
+
});
|
|
54
|
+
});
|