@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.
Files changed (42) hide show
  1. package/README.md +111 -4
  2. package/bin/forge-actions.mjs +274 -0
  3. package/dist/module.d.mts +10 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +55 -16
  6. package/dist/runtime/chat/ai-sdk.d.ts +23 -0
  7. package/dist/runtime/chat/ai-sdk.js +25 -0
  8. package/dist/runtime/client/index.d.ts +57 -0
  9. package/dist/runtime/client/index.js +60 -0
  10. package/dist/runtime/internal/bearer.d.ts +6 -0
  11. package/dist/runtime/internal/bearer.js +5 -0
  12. package/dist/runtime/internal/manifest.d.ts +17 -0
  13. package/dist/runtime/internal/manifest.js +0 -0
  14. package/dist/runtime/server/http/create-handler.d.ts +50 -0
  15. package/dist/runtime/server/http/create-handler.js +135 -0
  16. package/dist/runtime/server/http/index.d.ts +8 -0
  17. package/dist/runtime/server/http/index.js +1 -0
  18. package/dist/runtime/server/http/route.d.ts +16 -0
  19. package/dist/runtime/server/http/route.js +28 -0
  20. package/dist/runtime/server/index.d.ts +1 -0
  21. package/dist/runtime/server/index.js +1 -0
  22. package/dist/runtime/server/mcp/create-handler.d.ts +8 -6
  23. package/dist/runtime/server/mcp/create-handler.js +8 -26
  24. package/dist/runtime/server/mcp/route.d.ts +2 -0
  25. package/dist/runtime/server/mcp/route.js +7 -10
  26. package/dist/runtime/server/utils/invoke-action.d.ts +19 -0
  27. package/dist/runtime/server/utils/invoke-action.js +32 -0
  28. package/dist/runtime/server/utils/server-actions.d.ts +57 -12
  29. package/dist/runtime/server/utils/server-actions.js +20 -2
  30. package/package.json +26 -2
  31. package/src/runtime/chat/ai-sdk.ts +53 -0
  32. package/src/runtime/client/index.ts +131 -0
  33. package/src/runtime/internal/bearer.ts +11 -0
  34. package/src/runtime/internal/manifest.ts +19 -0
  35. package/src/runtime/server/http/create-handler.ts +220 -0
  36. package/src/runtime/server/http/index.ts +9 -0
  37. package/src/runtime/server/http/route.ts +54 -0
  38. package/src/runtime/server/index.ts +1 -0
  39. package/src/runtime/server/mcp/create-handler.ts +16 -43
  40. package/src/runtime/server/mcp/route.ts +8 -11
  41. package/src/runtime/server/utils/invoke-action.ts +71 -0
  42. 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: `app.${id}`,
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
- return await args.run(parsed, ctx);
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.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
+ });
@@ -7,3 +7,4 @@
7
7
  export * from "./utils/capability-registry";
8
8
  export * from "./utils/client-capability-bridge";
9
9
  export * from "./utils/server-actions";
10
+ export * from "./utils/invoke-action";