catalyst-core 1.0.0 → 1.0.1-canary.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.
Files changed (47) hide show
  1. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +15 -20
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
  8. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +41 -9
  10. package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
  11. package/dist/native/bridge/hooks/aiProviders.js +27 -0
  12. package/dist/native/bridge/hooks/useAI.js +9 -8
  13. package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
  14. package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
  15. package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
  16. package/dist/native/buildAppIos.js +8 -2
  17. package/dist/native/buildIos/plugins.js +3 -1
  18. package/dist/native/internalPluginUtils.js +78 -0
  19. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
  20. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
  21. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
  22. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
  23. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
  24. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
  25. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
  26. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
  27. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
  29. package/dist/native/pluginComposerIos.js +5 -1
  30. package/dist/otel.js +34 -1
  31. package/dist/server/expressServer.js +1 -0
  32. package/dist/server/renderer/handler.jsx +23 -0
  33. package/dist/vite/vite.config.client.js +1 -1
  34. package/dist/vite/vite.config.js +0 -4
  35. package/dist/web-router/components/MetaTag.jsx +6 -13
  36. package/dist/web-router/components/MetaTag.test.jsx +40 -33
  37. package/mcp_v2/conversion-tasks.json +10 -9
  38. package/mcp_v2/knowledge-base.json +34 -4
  39. package/mcp_v2/test/config.test.ts +52 -0
  40. package/mcp_v2/tools/config.d.ts +18 -0
  41. package/mcp_v2/tools/config.js +40 -0
  42. package/mcp_v2/tools/conversion.js +2 -2
  43. package/package.json +6 -10
  44. package/test/hooks/aiProviders.test.ts +33 -0
  45. package/test/hooks/useSafeNavigate.test.ts +21 -0
  46. package/test/native/aiPluginComposition.test.js +161 -0
  47. package/test/server/handler.test.ts +27 -0
@@ -5,7 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useNativeTransition = void 0;
7
7
  var _react = require("react");
8
- var _reactRouter = require("react-router");
8
+ var _useSafeNavigate = require("./useSafeNavigate.js");
9
9
  var _NativeBridge = _interopRequireDefault(require("../utils/NativeBridge.js"));
10
10
  var _NativeInterfaces = require("../constants/NativeInterfaces.js");
11
11
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
@@ -40,7 +40,7 @@ const MIN_TIMEOUT = 800;
40
40
  * @param defaults - Default transition options applied to every navigate call
41
41
  */
42
42
  const useNativeTransition = (defaults = {}) => {
43
- const router = (0, _reactRouter.useNavigate)();
43
+ const router = (0, _useSafeNavigate.useSafeNavigate)();
44
44
  const isNative = typeof window !== "undefined" && _NativeBridge.default.isAvailable();
45
45
  const [transitioning, setTransitioning] = (0, _react.useState)(false);
46
46
  const overlayRef = (0, _react.useRef)(null);
@@ -0,0 +1,10 @@
1
+ import { useNavigate } from "react-router";
2
+ /**
3
+ * useNavigate() that is safe to call while server rendering.
4
+ *
5
+ * Server rendering never navigates, so it skips useNavigate there. In dev SSR src/native is compiled to
6
+ * CommonJS while the server's StaticRouter is ESM, which can load a second copy of react-router with no
7
+ * Router context; useNavigate would then throw and take the request, and the dev server, down. `window`
8
+ * is fixed per environment, so the hook order is stable.
9
+ */
10
+ export declare const useSafeNavigate: () => ReturnType<typeof useNavigate>;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useSafeNavigate = void 0;
7
+ var _reactRouter = require("react-router");
8
+ // Stand-in for useNavigate() during server rendering, where nothing can navigate.
9
+ const ssrNavigate = () => {};
10
+
11
+ /**
12
+ * useNavigate() that is safe to call while server rendering.
13
+ *
14
+ * Server rendering never navigates, so it skips useNavigate there. In dev SSR src/native is compiled to
15
+ * CommonJS while the server's StaticRouter is ESM, which can load a second copy of react-router with no
16
+ * Router context; useNavigate would then throw and take the request, and the dev server, down. `window`
17
+ * is fixed per environment, so the hook order is stable.
18
+ */
19
+ const useSafeNavigate = () =>
20
+ // eslint-disable-next-line react-hooks/rules-of-hooks
21
+ typeof window === "undefined" ? ssrNavigate : (0, _reactRouter.useNavigate)();
22
+ exports.useSafeNavigate = useSafeNavigate;
@@ -10,7 +10,8 @@ const {
10
10
  } = require("./pluginComposerIos.js");
11
11
  const {
12
12
  resolveInternalPluginsRoot,
13
- resolvePluginConfig
13
+ resolvePluginConfig,
14
+ resolveAIPluginSource
14
15
  } = require("./internalPluginUtils.js");
15
16
  const {
16
17
  formatBuildError
@@ -36,9 +37,14 @@ async function main() {
36
37
  } = build;
37
38
  try {
38
39
  progress.log("Starting build process...", "info");
39
- const pluginConfig = resolvePluginConfig(WEBVIEW_CONFIG);
40
+ const aiPlugin = resolveAIPluginSource(WEBVIEW_CONFIG, process.cwd(), (message, status = "info") => progress.log(message, status));
41
+ const pluginConfig = {
42
+ ...resolvePluginConfig(WEBVIEW_CONFIG),
43
+ ...aiPlugin.toggles
44
+ };
40
45
  const pluginComposition = composeIosPlugins({
41
46
  corePluginsRoot: resolveInternalPluginsRoot(catalystCorePath),
47
+ externalPluginRoots: aiPlugin.roots,
42
48
  iosProjectPath: PROJECT_DIR,
43
49
  pluginConfig,
44
50
  log: (message, status = "info") => progress.log(message, status)
@@ -38,7 +38,9 @@ function formatSwiftPackageRequirement(dependency) {
38
38
  throw new Error(`Unsupported iOS package requirement type: ${dependency.requirement.type}`);
39
39
  }
40
40
  function formatSwiftPackageEntries(dependencies) {
41
- return dependencies.map(d => ` .package(url: "${d.url}", ${formatSwiftPackageRequirement(d)})`);
41
+ return dependencies.map(d =>
42
+ // Local packages (requirement.type "path") are addressed by directory, not URL + version.
43
+ d.requirement.type === "path" ? ` .package(path: ${JSON.stringify(d.url)})` : ` .package(url: "${d.url}", ${formatSwiftPackageRequirement(d)})`);
42
44
  }
43
45
  function formatSwiftProductEntries(dependencies) {
44
46
  return dependencies.flatMap(d => d.products.map(p => ` .product(name: "${p}", package: "${d.package}")`));
@@ -64,6 +64,45 @@ function readIosUrlSchemes(value, fieldName, sourcePath) {
64
64
  };
65
65
  });
66
66
  }
67
+ function readLocalIosDependency(entry, entryField, sourcePath) {
68
+ const pluginDir = path.dirname(sourcePath);
69
+ const relativePath = mustBeNonEmptyString(entry.path, `${entryField}.path`, sourcePath);
70
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - containment within pluginDir is enforced immediately below.
71
+ const resolved = path.resolve(pluginDir, relativePath);
72
+ if (resolved !== pluginDir && !resolved.startsWith(`${pluginDir}${path.sep}`)) {
73
+ throw new Error(`'${entryField}.path' must stay within the plugin directory: ${relativePath} in ${sourcePath}`);
74
+ }
75
+ if (!isDir(resolved)) {
76
+ throw new Error(`'${entryField}.path' does not exist: ${relativePath} in ${sourcePath}`);
77
+ }
78
+ // The lexical check above does not see symlinks: re-check containment on canonical paths so a link
79
+ // inside the plugin cannot point the Swift package outside it, and return the canonical path.
80
+ const realPluginDir = fs.realpathSync(pluginDir);
81
+ const realResolved = fs.realpathSync(resolved);
82
+ if (realResolved !== realPluginDir && !realResolved.startsWith(`${realPluginDir}${path.sep}`)) {
83
+ throw new Error(`'${entryField}.path' resolves outside the plugin directory: ${relativePath} in ${sourcePath}`);
84
+ }
85
+ if (entry.from != null || entry.exact != null || entry.url != null) {
86
+ throw new Error(`'${entryField}' with 'path' must not also set 'url', 'from' or 'exact' in ${sourcePath}`);
87
+ }
88
+ const products = readStringArray(entry.products, `${entryField}.products`, sourcePath, {
89
+ required: true,
90
+ nonEmpty: true
91
+ });
92
+ if (new Set(products).size !== products.length) {
93
+ throw new Error(`Duplicate product(s) found in '${entryField}.products' in ${sourcePath}`);
94
+ }
95
+ return {
96
+ // The absolute path doubles as the identity key the composer merges dependencies on.
97
+ url: realResolved,
98
+ package: entry.package == null ? path.basename(realResolved) : mustBeNonEmptyString(entry.package, `${entryField}.package`, sourcePath),
99
+ products,
100
+ requirement: {
101
+ type: "path",
102
+ version: "local"
103
+ }
104
+ };
105
+ }
67
106
  function readIosDependencies(value, fieldName, sourcePath) {
68
107
  if (value == null) {
69
108
  return [];
@@ -76,6 +115,13 @@ function readIosDependencies(value, fieldName, sourcePath) {
76
115
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
77
116
  throw new Error(`'${entryField}' must be an object in ${sourcePath}`);
78
117
  }
118
+
119
+ // A dependency is either a remote package ({ url, from|exact }) or a Swift package shipped inside
120
+ // the plugin itself ({ path }, relative to the plugin directory). Local packages let a plugin
121
+ // pin a vendored copy instead of cloning a large or LFS-backed upstream repo.
122
+ if (entry.path != null) {
123
+ return readLocalIosDependency(entry, entryField, sourcePath);
124
+ }
79
125
  const url = mustBeNonEmptyString(entry.url, `${entryField}.url`, sourcePath);
80
126
  const packageIdentity = entry.package == null ? derivePackageIdentityFromUrl(url, `${entryField}.url`, sourcePath) : mustBeNonEmptyString(entry.package, `${entryField}.package`, sourcePath);
81
127
  const products = readStringArray(entry.products, `${entryField}.products`, sourcePath, {
@@ -224,7 +270,39 @@ function resolvePluginConfig(WEBVIEW_CONFIG) {
224
270
  }
225
271
  return pluginConfig;
226
272
  }
273
+
274
+ /**
275
+ * catalyst-ai ships its native iOS module as a plugin (plugins/ai in the npm package). It is
276
+ * composed only when the app opts in with WEBVIEW_CONFIG.ai.enabled, mirroring Android's
277
+ * syncAIPackageIfEnabled. Returns the extra plugin roots plus the toggle to merge into the
278
+ * plugin config; when the package is missing it warns and leaves the build untouched.
279
+ */
280
+ function resolveAIPluginSource(WEBVIEW_CONFIG, projectRoot, log = () => {}) {
281
+ if (WEBVIEW_CONFIG?.ai?.enabled !== true) {
282
+ return {
283
+ roots: [],
284
+ toggles: {}
285
+ };
286
+ }
287
+
288
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - fixed package-relative path under the app's node_modules.
289
+ const aiPluginsRoot = path.join(projectRoot, "node_modules", "catalyst-ai", "plugins");
290
+ if (!isDir(aiPluginsRoot)) {
291
+ log("ai.enabled=true but catalyst-ai not found in node_modules — skipping native AI plugin", "warning");
292
+ return {
293
+ roots: [],
294
+ toggles: {}
295
+ };
296
+ }
297
+ return {
298
+ roots: [aiPluginsRoot],
299
+ toggles: {
300
+ ai: true
301
+ }
302
+ };
303
+ }
227
304
  module.exports = {
305
+ resolveAIPluginSource,
228
306
  discoverInternalPlugins,
229
307
  parsePluginManifest,
230
308
  resolvePluginConfig,
@@ -0,0 +1,221 @@
1
+ //
2
+ // FrameworkServerUtils+NativeAI.swift
3
+ // iosnativeWebView
4
+ //
5
+ // Native-AI routes (/ai/stream, /ai/generate) and their supplier API, split out of FrameworkServerUtils.swift
6
+ // to keep that file within the lint limits. Kotlin equivalent: the AI routes in startKtorServer().
7
+ //
8
+
9
+ import Foundation
10
+ import Network
11
+ import os
12
+
13
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "FrameworkServer")
14
+
15
+ extension FrameworkServerUtils {
16
+ // MARK: - Native AI API
17
+
18
+ /// Installs the engine that answers `POST /ai/stream` and `POST /ai/generate`. Each request calls the supplier
19
+ /// with the (system-prompt-prefixed) prompt, the request's `genConfig` and its conversation id. While it is
20
+ /// `nil` (the default, and after passing `nil`) both routes answer "not ready" (`AI-005`) instead of running.
21
+ /// Set by the AI plugin once its engine has loaded.
22
+ public func setNativeAiSupplier(_ supplier: NativeAISupplier?) {
23
+ aiLock.lock(); defer { aiLock.unlock() }
24
+ nativeAiSupplier = supplier
25
+ }
26
+
27
+ /// Sets the system prompt the server prepends (as `"<prompt>\n\n<user prompt>"`) to every AI request before it
28
+ /// reaches the supplier. Pass an empty string to prepend nothing, for engines that keep their instructions in
29
+ /// their own session. Read per request, so changes apply to the next one.
30
+ public func setNativeSystemPrompt(_ systemPrompt: String) {
31
+ aiLock.lock(); defer { aiLock.unlock() }
32
+ nativeSystemPrompt = systemPrompt
33
+ }
34
+
35
+ /// URL JS posts to for streaming; scheme follows whatever the listener actually runs (HTTP/HTTPS),
36
+ /// built the same way as served-file URLs.
37
+ public func nativeAIStreamURL() -> String? {
38
+ guard isServerRunning else { return nil }
39
+ let scheme = isHTTPS ? "https" : "http"
40
+ return "\(scheme)://localhost:\(serverPort)/framework-\(sessionId)/ai/stream"
41
+ }
42
+
43
+
44
+ // MARK: - Native AI routes
45
+ // Kotlin equivalent: the post("/ai/stream"), post("/ai/generate") and options(...) routes in
46
+ // startKtorServer(). The engine behind the supplier lives in catalyst-ai, not here.
47
+
48
+ private func aiSnapshot() -> (supplier: NativeAISupplier?, systemPrompt: String) {
49
+ aiLock.lock(); defer { aiLock.unlock() }
50
+ return (nativeAiSupplier, nativeSystemPrompt)
51
+ }
52
+
53
+ /// The server prepends the engine-provided system prompt, exactly like Android.
54
+ /// Engines that manage their own instructions simply never call setNativeSystemPrompt.
55
+ private func composePrompt(_ request: NativeAIRequest, systemPrompt: String) -> String {
56
+ systemPrompt.isEmpty ? request.prompt : "\(systemPrompt)\n\n\(request.prompt)"
57
+ }
58
+
59
+ func sendAIPreflight(on connection: NWConnection) {
60
+ sendHTTPResponse(on: connection, statusCode: 200, body: Data(), headers: [
61
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
62
+ "Access-Control-Max-Age": "86400",
63
+ ])
64
+ }
65
+
66
+ func cancelAIStream(for connection: NWConnection) {
67
+ requestStateLock.lock()
68
+ let task = aiStreamTasks.removeValue(forKey: ObjectIdentifier(connection))
69
+ requestStateLock.unlock()
70
+ task?.cancel()
71
+ }
72
+
73
+ /// A client that stops reading must not keep the model running: if a write is not accepted within this long,
74
+ /// the connection is dropped, which fails the send, ends the stream and cancels generation upstream.
75
+ static let aiWriteTimeout: TimeInterval = 30
76
+
77
+ private func sendRaw(_ data: Data, on connection: NWConnection) async -> Bool {
78
+ await withCheckedContinuation { continuation in
79
+ let deadline = DispatchWorkItem { connection.cancel() }
80
+ DispatchQueue.global().asyncAfter(deadline: .now() + Self.aiWriteTimeout, execute: deadline)
81
+ connection.send(content: data, completion: .contentProcessed { error in
82
+ deadline.cancel()
83
+ if let error = error {
84
+ logger.debug("AI stream send failed: \(error.localizedDescription)")
85
+ }
86
+ continuation.resume(returning: error == nil)
87
+ })
88
+ }
89
+ }
90
+
91
+ private func sseHeaders() -> Data {
92
+ var head = "HTTP/1.1 200 OK\r\n"
93
+ head += "Access-Control-Allow-Origin: \(allowedOrigin)\r\n"
94
+ head += "Access-Control-Allow-Methods: POST, OPTIONS\r\n"
95
+ head += "Access-Control-Allow-Headers: *\r\n"
96
+ head += "Content-Type: text/event-stream\r\n"
97
+ head += "Cache-Control: no-cache\r\n"
98
+ head += "X-Accel-Buffering: no\r\n"
99
+ head += "Connection: close\r\n"
100
+ head += "\r\n"
101
+ return Data(head.utf8)
102
+ }
103
+
104
+ func startAIStream(_ request: NativeAIRequest, on connection: NWConnection) {
105
+ cancelConnectionTimeout(for: connection)
106
+ let key = ObjectIdentifier(connection)
107
+
108
+ // Held across Task creation so the task's own cleanup cannot run before it is registered.
109
+ requestStateLock.lock()
110
+ aiStreamTasks[key] = Task { [weak self] in
111
+ guard let self = self else { connection.cancel(); return }
112
+ await self.runAIStream(request, on: connection)
113
+ self.cancelAIStream(for: connection)
114
+ connection.cancel()
115
+ }
116
+ requestStateLock.unlock()
117
+ }
118
+
119
+ private func runAIStream(_ request: NativeAIRequest, on connection: NWConnection) async {
120
+ guard await sendRaw(sseHeaders(), on: connection) else { return }
121
+
122
+ let (supplier, systemPrompt) = aiSnapshot()
123
+ let prompt = composePrompt(request, systemPrompt: systemPrompt)
124
+
125
+ guard let supplier = supplier else {
126
+ _ = await sendRaw(NativeAISSE.error("Native AI not initialised — call initAI() first", code: NativeAIErrorCode.streamNotReady), on: connection)
127
+ return
128
+ }
129
+ guard !request.prompt.isEmpty else {
130
+ _ = await sendRaw(NativeAISSE.error("prompt is empty", code: NativeAIErrorCode.invalidRequestBody), on: connection)
131
+ return
132
+ }
133
+
134
+ do {
135
+ let stream = try await supplier(prompt, request.genConfig, request.conversationId)
136
+ guard await sendRaw(NativeAISSE.conversationId(stream.conversationId), on: connection) else { return }
137
+ for try await token in stream.tokens {
138
+ // A failed send means the client disconnected; leaving the loop terminates the
139
+ // token stream, which is what stops generation upstream.
140
+ guard await sendRaw(NativeAISSE.token(token), on: connection) else { return }
141
+ }
142
+ _ = await sendRaw(NativeAISSE.done(), on: connection)
143
+ } catch is CancellationError {
144
+ logger.debug("Native AI stream cancelled (client disconnected)")
145
+ } catch {
146
+ let code = (error as? NativeAIError)?.code ?? NativeAIErrorCode.requestFailed
147
+ logger.error("Native AI stream error [\(code)] conversation=\(request.conversationId ?? "<new>"): \(error.localizedDescription)")
148
+ _ = await sendRaw(NativeAISSE.error(error.localizedDescription, code: code), on: connection)
149
+ }
150
+ }
151
+
152
+ private static let aiGenerateTimeout: TimeInterval = 120
153
+
154
+ /// Drains the token stream fully, then answers with one JSON body: { output, conversationId, tokenCount }.
155
+ func runAIGenerate(_ request: NativeAIRequest, on connection: NWConnection) {
156
+ cancelConnectionTimeout(for: connection)
157
+ let key = ObjectIdentifier(connection)
158
+
159
+ requestStateLock.lock()
160
+ aiStreamTasks[key] = Task { [weak self] in
161
+ guard let self = self else { connection.cancel(); return }
162
+ await self.respondAIGenerate(request, on: connection)
163
+ self.cancelAIStream(for: connection)
164
+ }
165
+ requestStateLock.unlock()
166
+ }
167
+
168
+ private func respondAIGenerate(_ request: NativeAIRequest, on connection: NWConnection) async {
169
+ func fail(_ status: Int, _ message: String, _ code: String) {
170
+ let body = (try? JSONSerialization.data(withJSONObject: ["error": message, "code": code])) ?? Data()
171
+ sendHTTPResponse(on: connection, statusCode: status, body: body, headers: ["Content-Type": "application/json"])
172
+ }
173
+
174
+ let (supplier, systemPrompt) = aiSnapshot()
175
+ guard let supplier = supplier else {
176
+ fail(503, "Native AI not initialised — call initAI() first", NativeAIErrorCode.streamNotReady)
177
+ return
178
+ }
179
+ guard !request.prompt.isEmpty else {
180
+ fail(400, "prompt is empty", NativeAIErrorCode.invalidRequestBody)
181
+ return
182
+ }
183
+
184
+ let prompt = composePrompt(request, systemPrompt: systemPrompt)
185
+ do {
186
+ let result: (output: String, conversationId: String, tokenCount: Int) = try await withThrowingTaskGroup(of: (String, String, Int)?.self) { group in
187
+ group.addTask {
188
+ let stream = try await supplier(prompt, request.genConfig, request.conversationId)
189
+ var output = ""
190
+ var count = 0
191
+ for try await token in stream.tokens { output += token; count += 1 }
192
+ return (output, stream.conversationId, count)
193
+ }
194
+ group.addTask {
195
+ try await Task.sleep(nanoseconds: UInt64(Self.aiGenerateTimeout * 1_000_000_000))
196
+ return nil
197
+ }
198
+ defer { group.cancelAll() }
199
+ guard let first = try await group.next(), let value = first else {
200
+ throw NativeAIError(message: "generate timed out after \(Int(Self.aiGenerateTimeout))s")
201
+ }
202
+ return value
203
+ }
204
+ let body = try JSONSerialization.data(withJSONObject: [
205
+ "output": result.output,
206
+ "conversationId": result.conversationId,
207
+ "tokenCount": result.tokenCount,
208
+ ])
209
+ sendHTTPResponse(on: connection, statusCode: 200, body: body, headers: ["Content-Type": "application/json"])
210
+ } catch is CancellationError {
211
+ connection.cancel()
212
+ } catch let error as NativeAIError where error.message.hasPrefix("generate timed out") {
213
+ fail(504, error.message, error.code)
214
+ } catch {
215
+ let code = (error as? NativeAIError)?.code ?? NativeAIErrorCode.requestFailed
216
+ logger.error("Native AI generate error [\(code)] conversation=\(request.conversationId ?? "<new>"): \(error.localizedDescription)")
217
+ fail(500, error.localizedDescription, code)
218
+ }
219
+ }
220
+
221
+ }
@@ -0,0 +1,215 @@
1
+ //
2
+ // FrameworkServerUtils+Requests.swift
3
+ // iosnativeWebView
4
+ //
5
+ // HTTP request assembly (headers + Content-Length body across reads) and routing for the framework server,
6
+ // split out of FrameworkServerUtils.swift to keep that type within the lint limits.
7
+ //
8
+
9
+ import Foundation
10
+ import Network
11
+ import os
12
+
13
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "FrameworkServer")
14
+
15
+ extension FrameworkServerUtils {
16
+ /// Completion body of receiveHTTPRequest. Internal so tests can drive the
17
+ /// error branch directly; a real receive error is timing-dependent.
18
+ ///
19
+ /// A POST's headers and body can arrive in separate reads, so bytes are
20
+ /// buffered per connection until the headers end (CRLFCRLF) and Content-Length
21
+ /// bytes of body are present. A GET has no body and dispatches immediately.
22
+ func handleReceivedData(_ data: Data?, isComplete: Bool, error: NWError?, on connection: NWConnection) {
23
+ if let error = error {
24
+ discardPendingRequest(for: connection)
25
+ handleReceiveError(error, on: connection)
26
+ return
27
+ }
28
+
29
+ guard let data = data else {
30
+ discardPendingRequest(for: connection)
31
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
32
+ return
33
+ }
34
+
35
+ let key = ObjectIdentifier(connection)
36
+ // Append to the connection's own buffer in place: `old + new` would copy every byte received so far on
37
+ // each 8 KB read.
38
+ requestStateLock.lock()
39
+ var buffer = pendingRequests.removeValue(forKey: key) ?? Data()
40
+ buffer.append(data)
41
+ requestStateLock.unlock()
42
+
43
+ let terminator = Data("\r\n\r\n".utf8)
44
+ guard let headerEnd = buffer.range(of: terminator) else {
45
+ if buffer.count > Self.maxHeaderBytes {
46
+ discardPendingRequest(for: connection)
47
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
48
+ } else if isComplete {
49
+ // Client closed mid-headers: fall through to the legacy single-read behaviour.
50
+ discardPendingRequest(for: connection)
51
+ dispatchRequest(headerData: buffer, body: Data(), isComplete: isComplete, on: connection)
52
+ } else {
53
+ requestStateLock.lock(); pendingRequests[key] = buffer; requestStateLock.unlock()
54
+ receiveHTTPRequest(on: connection)
55
+ }
56
+ return
57
+ }
58
+
59
+ let headerData = buffer[buffer.startIndex..<headerEnd.lowerBound]
60
+ let body = buffer[headerEnd.upperBound...]
61
+ guard let expected = Self.contentLength(inHeaders: headerData) else {
62
+ discardPendingRequest(for: connection)
63
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
64
+ return
65
+ }
66
+
67
+ if expected > Self.maxBodyBytes {
68
+ discardPendingRequest(for: connection)
69
+ sendHTTPResponse(on: connection, statusCode: 413, body: "Payload Too Large")
70
+ return
71
+ }
72
+ if body.count < expected {
73
+ if isComplete {
74
+ // The client closed the connection before sending the declared body: reject rather than
75
+ // handle a truncated request.
76
+ discardPendingRequest(for: connection)
77
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
78
+ return
79
+ }
80
+ requestStateLock.lock(); pendingRequests[key] = buffer; requestStateLock.unlock()
81
+ receiveHTTPRequest(on: connection)
82
+ return
83
+ }
84
+
85
+ discardPendingRequest(for: connection)
86
+ dispatchRequest(headerData: Data(headerData), body: Data(body.prefix(max(expected, 0))), isComplete: isComplete, on: connection)
87
+ }
88
+
89
+ func discardPendingRequest(for connection: NWConnection) {
90
+ requestStateLock.lock()
91
+ pendingRequests.removeValue(forKey: ObjectIdentifier(connection))
92
+ requestStateLock.unlock()
93
+ }
94
+
95
+ /// The declared body length: 0 when there is no Content-Length header, nil when it is malformed or negative
96
+ /// (the request is then rejected instead of being read as an empty body).
97
+ static func contentLength(inHeaders headerData: Data) -> Int? {
98
+ guard let text = String(data: headerData, encoding: .utf8) else { return nil }
99
+ for line in text.components(separatedBy: "\r\n") {
100
+ let parts = line.split(separator: ":", maxSplits: 1)
101
+ if parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces).lowercased() == "content-length" {
102
+ guard let length = Int(parts[1].trimmingCharacters(in: .whitespaces)), length >= 0 else { return nil }
103
+ return length
104
+ }
105
+ }
106
+ return 0
107
+ }
108
+
109
+ /// True when the peer is on this device. The listener is not bound to loopback only (that predates the AI
110
+ /// routes and also serves files), so the AI routes — which run the on-device model — check the peer themselves.
111
+ static func isLoopbackPeer(_ endpoint: NWEndpoint) -> Bool {
112
+ guard case let .hostPort(host, _) = endpoint else { return false }
113
+ switch host {
114
+ case .ipv4(let address):
115
+ return address.isLoopback
116
+ case .ipv6(let address):
117
+ return address.isLoopback || address.asIPv4?.isLoopback == true
118
+ case .name(let name, _):
119
+ return name == "localhost"
120
+ @unknown default:
121
+ return false
122
+ }
123
+ }
124
+
125
+ func dispatchRequest(headerData: Data, body: Data, isComplete: Bool, on connection: NWConnection) {
126
+ guard let requestString = String(data: headerData, encoding: .utf8) else {
127
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
128
+ return
129
+ }
130
+
131
+ let keepOpen = processHTTPRequest(requestString, body: body, on: connection)
132
+
133
+ // A client half-closing after sending its request must not kill an SSE stream we are still writing.
134
+ if isComplete && !keepOpen {
135
+ connection.cancel()
136
+ }
137
+ }
138
+
139
+ /// Returns true when the connection is now owned by a long-lived AI stream and must stay open.
140
+ @discardableResult
141
+ func processHTTPRequest(_ requestString: String, body: Data = Data(), on connection: NWConnection) -> Bool {
142
+ let lines = requestString.components(separatedBy: "\r\n")
143
+ guard let requestLine = lines.first else {
144
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
145
+ return false
146
+ }
147
+
148
+ let components = requestLine.components(separatedBy: " ")
149
+ guard components.count >= 2 else {
150
+ sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
151
+ return false
152
+ }
153
+
154
+ let method = components[0]
155
+ let aiPrefix = "/framework-\(self.sessionId)/ai/"
156
+ let isAIRoute = components[1] == aiPrefix + "stream" || components[1] == aiPrefix + "generate"
157
+
158
+ if isAIRoute {
159
+ guard Self.isLoopbackPeer(connection.endpoint) else {
160
+ logger.warning("Rejected AI request from a non-loopback peer")
161
+ sendHTTPResponse(on: connection, statusCode: 403, body: "Forbidden")
162
+ return false
163
+ }
164
+ switch method {
165
+ case "OPTIONS":
166
+ sendAIPreflight(on: connection)
167
+ return false
168
+ case "POST":
169
+ if components[1] == aiPrefix + "stream" {
170
+ startAIStream(NativeAIRequest(body: body), on: connection)
171
+ return true
172
+ }
173
+ runAIGenerate(NativeAIRequest(body: body), on: connection)
174
+ return true
175
+ default:
176
+ sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
177
+ return false
178
+ }
179
+ }
180
+
181
+ guard method == "GET" else {
182
+ sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
183
+ return false
184
+ }
185
+
186
+ let path = components[1]
187
+
188
+ // Handle status endpoint
189
+ if path == "/framework-\(self.sessionId)/status" {
190
+ fileQueue.sync {
191
+ let statusResponse = """
192
+ {
193
+ "status": "running",
194
+ "sessionId": "\(self.sessionId)",
195
+ "port": \(self.serverPort),
196
+ "servedFiles": \(self.servedFiles.count)
197
+ }
198
+ """
199
+ sendHTTPResponse(on: connection, statusCode: 200, body: statusResponse, contentType: "application/json")
200
+ }
201
+ return false
202
+ }
203
+
204
+ // Handle file requests
205
+ if path.hasPrefix("/framework-\(self.sessionId)/file-") {
206
+ let fileId = String(path.dropFirst("/framework-\(self.sessionId)/file-".count))
207
+ serveFile(fileId: fileId, on: connection)
208
+ return false
209
+ }
210
+
211
+ // Invalid route
212
+ sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.fileNotFound, body: "Not Found")
213
+ return false
214
+ }
215
+ }