@decocms/runtime 1.0.0-alpha.5 → 1.0.1

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.
@@ -1,44 +1,227 @@
1
- import { serveStatic } from "hono/bun";
2
- import { Hono } from "hono";
3
1
  import { devServerProxy } from "./dev-server-proxy";
4
- import { Handler } from "hono/types";
2
+ import { resolve, dirname } from "path";
5
3
 
6
- interface AssetServerConfig {
7
- env: "development" | "production" | "test";
8
- localDevProxyUrl?: string | URL;
9
- assetsDirectory?: string;
4
+ export interface AssetServerConfig {
5
+ /**
6
+ * Environment mode. Determines whether to proxy to dev server or serve static files.
7
+ * @default process.env.NODE_ENV || "development"
8
+ */
9
+ env?: "development" | "production" | "test";
10
+
11
+ /**
12
+ * URL of the Vite dev server for development mode.
13
+ * @default "http://localhost:4000"
14
+ */
15
+ devServerUrl?: string | URL;
16
+
17
+ /**
18
+ * Directory containing the built client assets.
19
+ * For production bundles, use `resolveClientDir()` to get the correct path.
20
+ * @default "./dist/client"
21
+ */
22
+ clientDir?: string;
23
+
24
+ /**
25
+ * Function to check if a path should be handled by the API server.
26
+ * Return true for API routes, false for static files.
27
+ * If not provided, defaults to serving everything as static.
28
+ */
29
+ isServerPath?: (path: string) => boolean;
30
+ }
31
+
32
+ const DEFAULT_DEV_SERVER_URL = "http://localhost:4000";
33
+ const DEFAULT_CLIENT_DIR = "./dist/client";
34
+
35
+ /**
36
+ * Check if a resolved file path is safely within the allowed base directory.
37
+ * Prevents path traversal attacks (e.g., /../../../etc/passwd).
38
+ *
39
+ * @param filePath - The resolved absolute file path
40
+ * @param baseDir - The base directory that files must be within
41
+ * @returns true if the path is safe, false if it's a traversal attempt
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * isPathWithinDirectory("/app/client/style.css", "/app/client") // true
46
+ * isPathWithinDirectory("/etc/passwd", "/app/client") // false
47
+ * ```
48
+ */
49
+ export function isPathWithinDirectory(
50
+ filePath: string,
51
+ baseDir: string,
52
+ ): boolean {
53
+ const resolvedBase = resolve(baseDir);
54
+ const resolvedPath = resolve(filePath);
55
+ return (
56
+ resolvedPath === resolvedBase || resolvedPath.startsWith(resolvedBase + "/")
57
+ );
10
58
  }
11
59
 
12
- const DEFAULT_LOCAL_DEV_PROXY_URL = "http://localhost:4000";
13
- const DEFAULT_ASSETS_DIRECTORY = "./dist/client";
60
+ export interface ResolveAssetPathOptions {
61
+ /** The decoded URL pathname (e.g., "/assets/style.css") */
62
+ requestPath: string;
63
+ /** The base directory containing static files */
64
+ clientDir: string;
65
+ }
66
+
67
+ /**
68
+ * Resolve a URL pathname to a file path, with path traversal protection.
69
+ * Returns null if the path is a traversal attempt.
70
+ *
71
+ * @returns File path, or null if unsafe
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * resolveAssetPathWithTraversalCheck({ requestPath: "/style.css", clientDir: "/app/client" })
76
+ * // "/app/client/style.css"
77
+ *
78
+ * resolveAssetPathWithTraversalCheck({ requestPath: "/../../../etc/passwd", clientDir: "/app/client" })
79
+ * // null (blocked)
80
+ * ```
81
+ */
82
+ export function resolveAssetPathWithTraversalCheck({
83
+ requestPath,
84
+ clientDir,
85
+ }: ResolveAssetPathOptions): string | null {
86
+ const relativePath = requestPath.startsWith("/")
87
+ ? requestPath.slice(1)
88
+ : requestPath;
89
+ const filePath = resolve(clientDir, relativePath);
90
+
91
+ // Security: block path traversal attempts
92
+ if (!isPathWithinDirectory(filePath, clientDir)) {
93
+ return null;
94
+ }
95
+
96
+ return filePath;
97
+ }
14
98
 
15
- interface HonoApp {
16
- use: (path: string, handler: Handler) => void;
17
- get: (path: string, handler: Handler) => void;
99
+ /**
100
+ * Resolve the client directory path relative to the running script.
101
+ * Use this for production bundles where the script location differs from CWD.
102
+ *
103
+ * @param importMetaUrl - Pass `import.meta.url` from the calling module
104
+ * @param relativePath - Path relative to the script (default: "../client")
105
+ * @returns Absolute path to the client directory
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const clientDir = resolveClientDir(import.meta.url, "../client");
110
+ * ```
111
+ */
112
+ export function resolveClientDir(
113
+ importMetaUrl: string,
114
+ relativePath = "../client",
115
+ ): string {
116
+ const scriptUrl = new URL(importMetaUrl);
117
+ const scriptDir = dirname(scriptUrl.pathname);
118
+ return resolve(scriptDir, relativePath);
18
119
  }
19
120
 
20
- export const applyAssetServerRoutes = (
21
- app: HonoApp,
22
- config: AssetServerConfig,
23
- ) => {
24
- const environment = config.env;
25
- const localDevProxyUrl =
26
- config.localDevProxyUrl ?? DEFAULT_LOCAL_DEV_PROXY_URL;
27
- const assetsDirectory = config.assetsDirectory ?? DEFAULT_ASSETS_DIRECTORY;
28
-
29
- if (environment === "development") {
30
- app.use("*", devServerProxy(localDevProxyUrl));
31
- } else if (environment === "production") {
32
- app.use("/assets/*", serveStatic({ root: assetsDirectory }));
33
- app.get("*", serveStatic({ path: `${assetsDirectory}/index.html` }));
121
+ /**
122
+ * TODO(@camudo): make "modes" so we can for example try to serve the asset then fallback to api on miss
123
+ * or try to serve the api call then fallback to asset or index.html on 404
124
+ */
125
+
126
+ /**
127
+ * Create an asset handler that works with Bun.serve.
128
+ *
129
+ * In development: Proxies requests to Vite dev server
130
+ * In production: Serves static files with SPA fallback
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * const handleAssets = createAssetHandler({
135
+ * env: process.env.NODE_ENV as "development" | "production",
136
+ * clientDir: resolveClientDir(import.meta.url),
137
+ * isServerPath: (path) => path.startsWith("/api/") || path.startsWith("/mcp/"),
138
+ * });
139
+ *
140
+ * Bun.serve({
141
+ * fetch: async (request) => {
142
+ * return await handleAssets(request) ?? app.fetch(request);
143
+ * },
144
+ * });
145
+ * ```
146
+ */
147
+ export function createAssetHandler(config: AssetServerConfig = {}) {
148
+ const {
149
+ env = (process.env.NODE_ENV as "development" | "production" | "test") ||
150
+ "development",
151
+ devServerUrl = DEFAULT_DEV_SERVER_URL,
152
+ clientDir = DEFAULT_CLIENT_DIR,
153
+ isServerPath = () => false,
154
+ } = config;
155
+
156
+ // Development: Create a proxy handler
157
+ if (env === "development") {
158
+ const proxyHandler = devServerProxy(devServerUrl);
159
+
160
+ return async function handleAssets(
161
+ request: Request,
162
+ ): Promise<Response | null> {
163
+ // In dev, proxy everything except server paths
164
+ const url = new URL(request.url);
165
+ if (isServerPath(url.pathname)) {
166
+ return null;
167
+ }
168
+
169
+ // Create a minimal Hono context for the proxy
170
+ const fakeContext = {
171
+ req: { raw: request, url: request.url },
172
+ };
173
+ return proxyHandler(fakeContext as any);
174
+ };
34
175
  }
35
- };
36
176
 
37
- export const createAssetServer = (config: AssetServerConfig) => {
38
- const app = new Hono();
39
- applyAssetServerRoutes(app, config);
40
- return app;
41
- };
177
+ // Production: Serve static files
178
+ return async function handleAssets(
179
+ request: Request,
180
+ ): Promise<Response | null> {
181
+ // Only handle GET requests
182
+ if (request.method !== "GET") {
183
+ return null;
184
+ }
185
+
186
+ const requestUrl = new URL(request.url);
42
187
 
43
- export const createAssetServerFetcher = (config: AssetServerConfig) =>
44
- createAssetServer(config).fetch;
188
+ // Decode the pathname to handle URL-encoded characters (e.g., %20 -> space)
189
+ // decodeURIComponent can throw URIError for malformed sequences (e.g., %E0%A4%A)
190
+ let path: string;
191
+ try {
192
+ path = decodeURIComponent(requestUrl.pathname);
193
+ } catch {
194
+ // Malformed URL encoding - return null to let API server handle or return 400
195
+ return null;
196
+ }
197
+
198
+ // Let API server handle its routes
199
+ if (isServerPath(path)) {
200
+ return null;
201
+ }
202
+
203
+ // Resolve path with traversal check
204
+ const filePath = resolveAssetPathWithTraversalCheck({
205
+ requestPath: path,
206
+ clientDir,
207
+ });
208
+ if (!filePath) {
209
+ return null; // Path traversal attempt blocked
210
+ }
211
+
212
+ // Try to serve the requested file, fall back to index.html for SPA routing
213
+ const indexPath = resolve(clientDir, "index.html");
214
+ for (const pathToTry of [filePath, indexPath]) {
215
+ try {
216
+ const file = Bun.file(pathToTry);
217
+ if (await file.exists()) {
218
+ return new Response(file);
219
+ }
220
+ } catch {
221
+ // Continue to next path
222
+ }
223
+ }
224
+
225
+ return null;
226
+ };
227
+ }
@@ -1,14 +1,13 @@
1
1
  /* oxlint-disable no-explicit-any */
2
2
  import { z } from "zod";
3
3
  import type { MCPConnection } from "../connection.ts";
4
- import { createPrivateTool, createStreamableTool } from "../mastra.ts";
5
4
  import {
6
5
  createMCPFetchStub,
7
6
  type MCPClientFetchStub,
8
7
  type ToolBinder,
9
8
  } from "../mcp.ts";
9
+ import { createPrivateTool, createStreamableTool } from "../tools.ts";
10
10
  import { CHANNEL_BINDING } from "./channels.ts";
11
- import { VIEW_BINDING } from "./views.ts";
12
11
 
13
12
  // ToolLike is a simplified version of the Tool interface that matches what we need for bindings
14
13
  export interface ToolLike<
@@ -89,12 +88,10 @@ export const bindingClient = <TDefinition extends readonly ToolBinder[]>(
89
88
  };
90
89
  };
91
90
 
92
- export type MCPBindingClient<T extends ReturnType<typeof bindingClient>> =
91
+ export type MCPBindingClient<T extends ReturnType<typeof bindingClient<any>>> =
93
92
  ReturnType<T["forConnection"]>;
94
93
 
95
94
  export const ChannelBinding = bindingClient(CHANNEL_BINDING);
96
- export const ViewBinding = bindingClient(VIEW_BINDING);
97
-
98
95
  export type { Callbacks } from "./channels.ts";
99
96
 
100
97
  export const impl = <TBinder extends Binder>(
@@ -1,16 +1,13 @@
1
1
  import { CHANNEL_BINDING } from "./channels.ts";
2
- import { VIEW_BINDING as VIEWS_BINDING } from "./views.ts";
3
2
 
4
3
  // Import new Resources 2.0 bindings function
5
4
  import { LANGUAGE_MODEL_BINDING } from "@decocms/bindings/llm";
6
- import { createResourceBindings } from "./resources/bindings.ts";
7
5
 
8
6
  // Export types and utilities from binder
9
7
  export {
10
8
  bindingClient,
11
9
  ChannelBinding,
12
10
  impl,
13
- ViewBinding,
14
11
  type Binder,
15
12
  type BinderImplementation,
16
13
  type MCPBindingClient,
@@ -23,40 +20,10 @@ export * from "./channels.ts";
23
20
  // Export binding utilities
24
21
  export * from "./utils.ts";
25
22
 
26
- // Export views schemas
27
- export * from "./views.ts";
28
-
29
- // Re-export Resources bindings function for convenience
30
- export { createResourceBindings };
31
-
32
- // Export resources types and schemas
33
- export * from "./resources/bindings.ts";
34
- export * from "./resources/helpers.ts";
35
- export * from "./resources/schemas.ts";
36
-
37
- // Export deconfig helpers and types
38
- export {
39
- getMetadataString as deconfigGetMetadataString,
40
- getMetadataValue as deconfigGetMetadataValue,
41
- normalizeDirectory as deconfigNormalizeDirectory,
42
- ResourcePath,
43
- ResourceUri,
44
- } from "./deconfig/helpers.ts";
45
- export { createDeconfigResource } from "./deconfig/index.ts";
46
- export type {
47
- DeconfigClient,
48
- DeconfigResourceOptions,
49
- EnhancedResourcesTools,
50
- ResourcesBinding,
51
- ResourcesTools,
52
- } from "./deconfig/index.ts";
53
- export { deconfigTools } from "./deconfig/types.ts";
54
-
55
23
  export { streamToResponse } from "./language-model/utils.ts";
56
24
 
57
25
  export const WellKnownBindings = {
58
26
  Channel: CHANNEL_BINDING,
59
- View: VIEWS_BINDING,
60
27
  LanguageModel: LANGUAGE_MODEL_BINDING,
61
28
  // Note: Resources is not included here since it's a generic function
62
29
  // Use createResourceBindings(dataSchema) directly for Resources 2.0
@@ -25,94 +25,3 @@ export function streamToResponse(
25
25
  },
26
26
  });
27
27
  }
28
-
29
- export function responseToStream(
30
- response: Response,
31
- ): ReadableStream<LanguageModelV2StreamPart> {
32
- if (!response.body) {
33
- throw new Error("Response body is null");
34
- }
35
-
36
- return response.body.pipeThrough(new TextDecoderStream()).pipeThrough(
37
- new TransformStream<string, LanguageModelV2StreamPart>({
38
- transform(chunk, controller) {
39
- // Split by newlines and parse each line
40
- const lines = chunk.split("\n");
41
-
42
- for (const line of lines) {
43
- if (line.trim()) {
44
- try {
45
- const parsed = JSON.parse(line) as LanguageModelV2StreamPart;
46
- controller.enqueue(parsed);
47
- } catch (error) {
48
- console.error("Failed to parse stream chunk:", error);
49
- }
50
- }
51
- }
52
- },
53
- }),
54
- );
55
- }
56
-
57
- /**
58
- * Lazy promise wrapper that defers execution until the promise is awaited.
59
- * The factory function is only called when .then() is invoked for the first time.
60
- */
61
- class Lazy<T> implements PromiseLike<T> {
62
- private promise: Promise<T> | null = null;
63
-
64
- constructor(private factory: () => Promise<T>) {}
65
-
66
- private getOrCreatePromise(): Promise<T> {
67
- if (!this.promise) {
68
- this.promise = this.factory();
69
- }
70
- return this.promise;
71
- }
72
-
73
- // eslint-disable-next-line no-thenable
74
- then<TResult1 = T, TResult2 = never>(
75
- onfulfilled?:
76
- | ((value: T) => TResult1 | PromiseLike<TResult1>)
77
- | null
78
- | undefined,
79
- onrejected?:
80
- | ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
81
- | null
82
- | undefined,
83
- ): PromiseLike<TResult1 | TResult2> {
84
- return this.getOrCreatePromise().then(onfulfilled, onrejected);
85
- }
86
-
87
- catch<TResult = never>(
88
- onrejected?:
89
- | ((reason: unknown) => TResult | PromiseLike<TResult>)
90
- | null
91
- | undefined,
92
- ): Promise<T | TResult> {
93
- return this.getOrCreatePromise().catch(onrejected);
94
- }
95
-
96
- finally(onfinally?: (() => void) | null | undefined): Promise<T> {
97
- return this.getOrCreatePromise().finally(onfinally);
98
- }
99
- }
100
-
101
- /**
102
- * Creates a lazy promise that only executes when awaited.
103
- *
104
- * @param factory - A function that returns a Promise<T>
105
- * @returns A Promise-like object that defers execution until .then() is called
106
- *
107
- * @example
108
- * ```ts
109
- * const lazyData = lazy(() => fetchExpensiveData());
110
- * // fetchExpensiveData() is NOT called yet
111
- *
112
- * const result = await lazyData;
113
- * // fetchExpensiveData() is called NOW
114
- * ```
115
- */
116
- export function lazy<T>(factory: () => Promise<T>): Promise<T> {
117
- return new Lazy(factory) as unknown as Promise<T>;
118
- }