@aptre/bldr-saucer 0.2.3 → 0.2.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aptre/bldr-saucer",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
4
4
  "description": "Native webview bridge for Bldr using Saucer",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -33,11 +33,11 @@
33
33
  "release:publish": "git push && git push --tags"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@aptre/bldr-saucer-darwin-arm64": "0.2.3",
37
- "@aptre/bldr-saucer-darwin-x64": "0.2.3",
38
- "@aptre/bldr-saucer-linux-x64": "0.2.3",
39
- "@aptre/bldr-saucer-linux-arm64": "0.2.3",
40
- "@aptre/bldr-saucer-win32-x64": "0.2.3"
36
+ "@aptre/bldr-saucer-darwin-arm64": "0.2.6",
37
+ "@aptre/bldr-saucer-darwin-x64": "0.2.6",
38
+ "@aptre/bldr-saucer-linux-x64": "0.2.6",
39
+ "@aptre/bldr-saucer-linux-arm64": "0.2.6",
40
+ "@aptre/bldr-saucer-win32-x64": "0.2.6"
41
41
  },
42
42
  "files": [
43
43
  "index.js",
@@ -46,7 +46,7 @@
46
46
  "CMakeLists.txt"
47
47
  ],
48
48
  "devDependencies": {
49
- "@aptre/common": "^0.30.1"
49
+ "@aptre/common": "^0.30.3"
50
50
  },
51
51
  "dependencies": {
52
52
  "@aptre/protobuf-es-lite": "^0.5.2"
@@ -345,6 +345,33 @@ bool DecodeFetchResponse(const uint8_t* buf, size_t len, FetchResponse& out) {
345
345
  return true;
346
346
  }
347
347
 
348
+ bool DecodeEvalJSRequest(const uint8_t* buf, size_t len, EvalJSRequest& out) {
349
+ size_t offset = 0;
350
+ while (offset < len) {
351
+ uint32_t field;
352
+ uint8_t wire;
353
+ if (!decodeTag(buf, len, offset, field, wire)) return false;
354
+ switch (field) {
355
+ case 1: { // code
356
+ if (wire != kLengthDelimited) return false;
357
+ if (!decodeString(buf, len, offset, out.code)) return false;
358
+ break;
359
+ }
360
+ default:
361
+ if (!skipField(buf, len, offset, wire)) return false;
362
+ break;
363
+ }
364
+ }
365
+ return true;
366
+ }
367
+
368
+ std::vector<uint8_t> EncodeEvalJSResponse(const EvalJSResponse& resp) {
369
+ std::vector<uint8_t> buf;
370
+ encodeString(buf, 1, resp.result);
371
+ encodeString(buf, 2, resp.error);
372
+ return buf;
373
+ }
374
+
348
375
  bool DecodeSaucerInit(const uint8_t* buf, size_t len, SaucerInit& out) {
349
376
  size_t offset = 0;
350
377
  while (offset < len) {
package/src/fetch_proto.h CHANGED
@@ -57,6 +57,23 @@ struct FetchResponse {
57
57
  ResponseData data;
58
58
  };
59
59
 
60
+ // EvalJSRequest corresponds to saucer.EvalJSRequest.
61
+ struct EvalJSRequest {
62
+ std::string code; // field 1
63
+ };
64
+
65
+ // EvalJSResponse corresponds to saucer.EvalJSResponse.
66
+ struct EvalJSResponse {
67
+ std::string result; // field 1
68
+ std::string error; // field 2
69
+ };
70
+
71
+ // DecodeEvalJSRequest decodes an EvalJSRequest protobuf message.
72
+ bool DecodeEvalJSRequest(const uint8_t* buf, size_t len, EvalJSRequest& out);
73
+
74
+ // EncodeEvalJSResponse encodes an EvalJSResponse protobuf message.
75
+ std::vector<uint8_t> EncodeEvalJSResponse(const EvalJSResponse& resp);
76
+
60
77
  // EncodeFetchRequest_Info serializes a FetchRequest with request_info (field 1).
61
78
  std::vector<uint8_t> EncodeFetchRequest_Info(const FetchRequestInfo& info);
62
79
 
package/src/main.cpp CHANGED
@@ -4,15 +4,77 @@
4
4
  #include "pipe_connection.h"
5
5
  #include "scheme_forwarder.h"
6
6
 
7
+ #include <atomic>
8
+ #include <condition_variable>
7
9
  #include <cstdlib>
10
+ #include <cstring>
8
11
  #include <iostream>
12
+ #include <memory>
13
+ #include <mutex>
9
14
  #include <string>
10
15
  #include <thread>
16
+ #include <unordered_map>
17
+ #include <vector>
18
+
19
+ // EvalRegistry tracks pending eval requests and their results.
20
+ // Worker threads register a request ID, execute JS that posts results via
21
+ // the saucer message channel, then wait on a condition variable for the
22
+ // message handler to deliver the result.
23
+ struct EvalRegistry {
24
+ struct Pending {
25
+ bool ready = false;
26
+ std::string result;
27
+ std::string error;
28
+ };
29
+
30
+ std::mutex mtx;
31
+ std::condition_variable cv;
32
+ std::unordered_map<std::string, Pending> pending;
33
+
34
+ // Register registers a new eval request and returns the ID.
35
+ void Register(const std::string& id) {
36
+ std::lock_guard<std::mutex> lock(mtx);
37
+ pending[id] = Pending{};
38
+ }
39
+
40
+ // Deliver delivers a result for a pending eval request.
41
+ // Returns true if the ID was found.
42
+ bool Deliver(const std::string& id, const std::string& result, const std::string& error) {
43
+ std::lock_guard<std::mutex> lock(mtx);
44
+ auto it = pending.find(id);
45
+ if (it == pending.end()) {
46
+ return false;
47
+ }
48
+ it->second.ready = true;
49
+ it->second.result = result;
50
+ it->second.error = error;
51
+ cv.notify_all();
52
+ return true;
53
+ }
54
+
55
+ // Wait waits for a result for the given eval ID (up to timeout_ms).
56
+ // Returns the response, with empty fields on timeout.
57
+ bldr::proto::EvalJSResponse Wait(const std::string& id, int timeout_ms) {
58
+ std::unique_lock<std::mutex> lock(mtx);
59
+ cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), [this, &id] {
60
+ auto it = pending.find(id);
61
+ return it != pending.end() && it->second.ready;
62
+ });
63
+ bldr::proto::EvalJSResponse resp;
64
+ auto it = pending.find(id);
65
+ if (it != pending.end()) {
66
+ resp.result = std::move(it->second.result);
67
+ resp.error = std::move(it->second.error);
68
+ pending.erase(it);
69
+ }
70
+ return resp;
71
+ }
72
+ };
11
73
 
12
74
  coco::stray start(saucer::application* app) {
13
75
  const char* runtime_id_env = std::getenv("BLDR_RUNTIME_ID");
14
76
  if (!runtime_id_env) {
15
- std::cerr << "BLDR_RUNTIME_ID not set" << std::endl;
77
+ std::cerr << "[bldr-saucer] BLDR_RUNTIME_ID not set" << std::endl;
16
78
  co_return;
17
79
  }
18
80
  std::string runtime_id = runtime_id_env;
@@ -22,7 +84,7 @@ coco::stray start(saucer::application* app) {
22
84
  if (init_b64) {
23
85
  auto data = bldr::proto::Base64Decode(init_b64);
24
86
  if (!data.empty() && !bldr::proto::DecodeSaucerInit(data.data(), data.size(), saucer_init)) {
25
- std::cerr << "Failed to decode BLDR_SAUCER_INIT" << std::endl;
87
+ std::cerr << "[bldr-saucer] failed to decode BLDR_SAUCER_INIT" << std::endl;
26
88
  }
27
89
  }
28
90
 
@@ -30,7 +92,7 @@ coco::stray start(saucer::application* app) {
30
92
  bldr::PipeClient pipe;
31
93
  std::string pipe_path = ".pipe-" + runtime_id;
32
94
  if (!pipe.connect(pipe_path)) {
33
- std::cerr << "Failed to connect to pipe: " << pipe_path << std::endl;
95
+ std::cerr << "[bldr-saucer] failed to connect to pipe: " << pipe_path << std::endl;
34
96
  co_return;
35
97
  }
36
98
 
@@ -41,12 +103,12 @@ coco::stray start(saucer::application* app) {
41
103
  config.enable_keepalive = false;
42
104
  auto session = yamux::Session::Client(std::move(conn), config);
43
105
  if (!session) {
44
- std::cerr << "Failed to create yamux session" << std::endl;
106
+ std::cerr << "[bldr-saucer] failed to create yamux session" << std::endl;
45
107
  co_return;
46
108
  }
47
109
 
48
- // Create the scheme forwarder.
49
- bldr::SchemeForwarder forwarder(session.get());
110
+ // Create the scheme forwarder (shared_ptr to avoid use-after-free in detached threads).
111
+ auto forwarder = std::make_shared<bldr::SchemeForwarder>(session.get());
50
112
 
51
113
  // Register bldr:// scheme BEFORE creating the webview.
52
114
  saucer::webview::register_scheme("bldr");
@@ -58,15 +120,13 @@ coco::stray start(saucer::application* app) {
58
120
  window->set_size({1024, 768});
59
121
 
60
122
  // Handle bldr:// scheme: forward all requests to Go over yamux.
61
- webview->handle_stream_scheme("bldr", [&forwarder](saucer::scheme::request req, saucer::scheme::stream_writer writer) {
62
- // Forward in background thread to not block the scheme handler.
63
- std::thread([&forwarder, req = std::move(req), writer = std::move(writer)]() mutable {
64
- forwarder.forward(req, writer);
123
+ webview->handle_stream_scheme("bldr", [forwarder](saucer::scheme::request req, saucer::scheme::stream_writer writer) {
124
+ std::thread([forwarder, req = std::move(req), writer = std::move(writer)]() mutable {
125
+ forwarder->forward(req, writer);
65
126
  }).detach();
66
127
  });
67
128
 
68
- // Use set_html with JavaScript redirect to work around WebKit's
69
- // loadFileURL being called incorrectly for custom schemes.
129
+ // Navigate via HTML redirect (works around WebKit's loadFileURL issue with custom schemes).
70
130
  std::string nav_url = "bldr:///index.html";
71
131
  const char* doc_id_env = std::getenv("BLDR_WEB_DOCUMENT_ID");
72
132
  if (doc_id_env && doc_id_env[0] != '\0') {
@@ -82,18 +142,162 @@ coco::stray start(saucer::application* app) {
82
142
  webview->set_dev_tools(true);
83
143
  }
84
144
 
145
+ // Shutdown guard: prevents webview->execute() calls after webview destruction.
146
+ // The mutex ensures no thread is inside execute() when we set the flag.
147
+ auto webview_mtx = std::make_shared<std::mutex>();
148
+ auto webview_alive = std::make_shared<std::atomic<bool>>(true);
149
+
150
+ // Eval result registry: worker threads register pending evals, the message
151
+ // handler delivers results from JavaScript back to the waiting thread.
152
+ auto eval_registry = std::make_shared<EvalRegistry>();
153
+
154
+ // Register a message handler to intercept eval results from JavaScript.
155
+ // The Go side wraps JS code so it posts the result via postMessage with a
156
+ // prefix format: __bldr_eval:<eval_id>:r:<result> or __bldr_eval:<eval_id>:e:<error>.
157
+ // The smartview's own handler returns unhandled for unrecognized messages,
158
+ // so this handler sees them next.
159
+ constexpr std::string_view eval_prefix = "__bldr_eval:";
160
+ webview->on<saucer::webview::event::message>({{.func = [eval_registry, eval_prefix](std::string_view message) -> saucer::status {
161
+ if (!message.starts_with(eval_prefix)) {
162
+ return saucer::status::unhandled;
163
+ }
164
+
165
+ // Parse prefix format: __bldr_eval:<eval_id>:<type>:<data>
166
+ auto rest = message.substr(eval_prefix.size());
167
+ auto sep1 = rest.find(':');
168
+ if (sep1 == std::string_view::npos || sep1 + 2 >= rest.size()) {
169
+ return saucer::status::unhandled;
170
+ }
171
+ auto sep2 = rest.find(':', sep1 + 1);
172
+ if (sep2 == std::string_view::npos) {
173
+ return saucer::status::unhandled;
174
+ }
175
+
176
+ std::string eval_id(rest.substr(0, sep1));
177
+ char type = rest[sep1 + 1];
178
+ std::string data(rest.substr(sep2 + 1));
179
+
180
+ if (type == 'r') {
181
+ eval_registry->Deliver(eval_id, data, "");
182
+ } else {
183
+ eval_registry->Deliver(eval_id, "", data);
184
+ }
185
+ return saucer::status::handled;
186
+ }}});
187
+
188
+ // Start accept loop for Go-initiated streams (debug eval).
189
+ // webview is a std::expected; use &(*webview) to get a pointer to the contained value.
190
+ auto* webview_ptr = &(*webview);
191
+ auto eval_counter = std::make_shared<std::atomic<uint64_t>>(0);
192
+ std::thread accept_thread([session, webview_ptr, webview_mtx, webview_alive, eval_registry, eval_counter]() {
193
+ while (true) {
194
+ auto [stream, err] = session->Accept();
195
+ if (err != yamux::Error::OK || !stream) {
196
+ break;
197
+ }
198
+
199
+ // Handle each stream in a detached thread so accept loop continues.
200
+ std::thread([stream, webview_ptr, webview_mtx, webview_alive, eval_registry, eval_counter]() {
201
+ // Read length-prefixed command frame.
202
+ uint8_t len_buf[4];
203
+ size_t total = 0;
204
+ while (total < 4) {
205
+ auto [n, rerr] = stream->Read(len_buf + total, 4 - total);
206
+ if (rerr != yamux::Error::OK || n == 0) {
207
+ stream->Close();
208
+ return;
209
+ }
210
+ total += n;
211
+ }
212
+ uint32_t msg_len;
213
+ std::memcpy(&msg_len, len_buf, 4);
214
+ if (msg_len > 10 * 1024 * 1024) {
215
+ stream->Close();
216
+ return;
217
+ }
218
+
219
+ std::vector<uint8_t> data(msg_len);
220
+ total = 0;
221
+ while (total < msg_len) {
222
+ auto [n, rerr] = stream->Read(data.data() + total, msg_len - total);
223
+ if (rerr != yamux::Error::OK || n == 0) break;
224
+ total += n;
225
+ }
226
+ if (total < msg_len) {
227
+ stream->Close();
228
+ return;
229
+ }
230
+
231
+ // Decode the EvalJSRequest protobuf from Go.
232
+ bldr::proto::EvalJSRequest req;
233
+ if (!bldr::proto::DecodeEvalJSRequest(data.data(), data.size(), req)) {
234
+ stream->Close();
235
+ return;
236
+ }
237
+
238
+ // The code from Go is already wrapped in an async IIFE that posts
239
+ // the result via postMessage. It contains a placeholder __EVAL_ID__
240
+ // that we replace with a unique ID for result correlation.
241
+ std::string code = std::move(req.code);
242
+ std::string eval_id = "e" + std::to_string(eval_counter->fetch_add(1));
243
+
244
+ // Replace the __EVAL_ID__ placeholder with the actual eval ID.
245
+ const std::string placeholder = "__EVAL_ID__";
246
+ auto pos = code.find(placeholder);
247
+ if (pos != std::string::npos) {
248
+ code.replace(pos, placeholder.size(), eval_id);
249
+ }
250
+
251
+ // Register the eval request before executing the code.
252
+ eval_registry->Register(eval_id);
253
+
254
+ // Execute the JavaScript code in the webview (guarded against shutdown).
255
+ // Cast to webview* to call webview::execute(cstring_view) instead of
256
+ // smartview::execute(format_string) which has a consteval constructor
257
+ // that breaks std::thread lambdas in C++23.
258
+ {
259
+ std::lock_guard<std::mutex> lock(*webview_mtx);
260
+ if (webview_alive->load()) {
261
+ static_cast<saucer::webview*>(webview_ptr)->execute(code);
262
+ }
263
+ }
264
+
265
+ // Wait for the JavaScript result (30 second timeout).
266
+ auto resp = eval_registry->Wait(eval_id, 30000);
267
+ if (resp.result.empty() && resp.error.empty()) {
268
+ resp.error = "eval timeout";
269
+ }
270
+
271
+ // Encode the EvalJSResponse protobuf and send it back.
272
+ auto resp_buf = bldr::proto::EncodeEvalJSResponse(resp);
273
+ uint8_t resp_len_buf[4];
274
+ uint32_t resp_len = static_cast<uint32_t>(resp_buf.size());
275
+ std::memcpy(resp_len_buf, &resp_len, 4);
276
+ stream->Write(resp_len_buf, 4);
277
+ stream->Write(resp_buf.data(), resp_buf.size());
278
+ stream->Close();
279
+ }).detach();
280
+ }
281
+ });
282
+ accept_thread.detach();
283
+
85
284
  window->show();
86
285
  co_await app->finish();
87
286
 
88
- // Cleanup.
287
+ // Shutdown: close session first (causes Accept/Read/Write to return errors,
288
+ // winding down detached threads), then mark webview as dead.
89
289
  session->Close();
290
+ {
291
+ std::lock_guard<std::mutex> lock(*webview_mtx);
292
+ webview_alive->store(false);
293
+ }
90
294
  pipe.close();
91
295
  }
92
296
 
93
297
  int main() {
94
298
  auto app_result = saucer::application::create({.id = "bldr"});
95
299
  if (!app_result) {
96
- std::cerr << "Failed to create application" << std::endl;
300
+ std::cerr << "[bldr-saucer] failed to create application" << std::endl;
97
301
  return 1;
98
302
  }
99
303
  return app_result->run(start);
@@ -20,7 +20,8 @@ PipeClient::~PipeClient() {
20
20
  }
21
21
 
22
22
  bool PipeClient::connect(const std::string& pipe_path) {
23
- std::lock_guard<std::mutex> lock(mutex_);
23
+ std::lock_guard<std::mutex> rlock(read_mtx_);
24
+ std::lock_guard<std::mutex> wlock(write_mtx_);
24
25
 
25
26
  #ifdef _WIN32
26
27
  // Windows named pipe connection
@@ -82,9 +83,22 @@ bool PipeClient::connect(const std::string& pipe_path) {
82
83
  }
83
84
 
84
85
  void PipeClient::close() {
85
- std::lock_guard<std::mutex> lock(mutex_);
86
+ // Set disconnected first so blocked reads/writes return.
86
87
  connected_ = false;
87
88
 
89
+ #ifndef _WIN32
90
+ // Shut down the socket to unblock any thread in a blocking read.
91
+ // This is safe to call without holding locks since shutdown on a
92
+ // valid fd is thread-safe and causes blocked read/write to return.
93
+ int fd = fd_;
94
+ if (fd >= 0) {
95
+ ::shutdown(fd, SHUT_RDWR);
96
+ }
97
+ #endif
98
+
99
+ std::lock_guard<std::mutex> rlock(read_mtx_);
100
+ std::lock_guard<std::mutex> wlock(write_mtx_);
101
+
88
102
  #ifdef _WIN32
89
103
  if (handle_ != INVALID_HANDLE_VALUE) {
90
104
  CloseHandle(handle_);
@@ -107,7 +121,7 @@ std::vector<uint8_t> PipeClient::read() {
107
121
  }
108
122
 
109
123
  std::vector<uint8_t> PipeClient::read_with_timeout(int timeout_ms) {
110
- std::lock_guard<std::mutex> lock(mutex_);
124
+ std::lock_guard<std::mutex> lock(read_mtx_);
111
125
  std::vector<uint8_t> result;
112
126
 
113
127
  if (!connected_) {
@@ -186,7 +200,7 @@ std::vector<uint8_t> PipeClient::read_with_timeout(int timeout_ms) {
186
200
  }
187
201
 
188
202
  bool PipeClient::write(const uint8_t* data, size_t length) {
189
- std::lock_guard<std::mutex> lock(mutex_);
203
+ std::lock_guard<std::mutex> lock(write_mtx_);
190
204
 
191
205
  if (!connected_ || data == nullptr || length == 0) {
192
206
  return false;
package/src/pipe_client.h CHANGED
@@ -64,7 +64,8 @@ private:
64
64
  int fd_ = -1;
65
65
  #endif
66
66
  std::atomic<bool> connected_{false};
67
- std::mutex mutex_;
67
+ std::mutex read_mtx_;
68
+ std::mutex write_mtx_;
68
69
  };
69
70
 
70
71
  } // namespace bldr
@@ -3,7 +3,6 @@
3
3
  #include <algorithm>
4
4
  #include <cctype>
5
5
  #include <cstring>
6
- #include <iostream>
7
6
 
8
7
  namespace bldr {
9
8
 
@@ -26,7 +25,6 @@ void SchemeForwarder::forward(const saucer::scheme::request& req,
26
25
  // Open a new yamux stream.
27
26
  auto [stream, err] = session_->OpenStream();
28
27
  if (err != yamux::Error::OK || !stream) {
29
- std::cerr << "[forwarder] failed to open yamux stream" << std::endl;
30
28
  sendError(writer, 502);
31
29
  return;
32
30
  }
@@ -48,7 +46,6 @@ void SchemeForwarder::forward(const saucer::scheme::request& req,
48
46
  // Serialize and send FetchRequestInfo frame.
49
47
  auto reqInfoMsg = proto::EncodeFetchRequest_Info(info);
50
48
  if (!writeFrame(stream.get(), reqInfoMsg)) {
51
- std::cerr << "[forwarder] failed to write request info" << std::endl;
52
49
  stream->Close();
53
50
  sendError(writer, 502);
54
51
  return;
@@ -65,7 +62,6 @@ void SchemeForwarder::forward(const saucer::scheme::request& req,
65
62
 
66
63
  auto reqDataMsg = proto::EncodeFetchRequest_Data(bodyData);
67
64
  if (!writeFrame(stream.get(), reqDataMsg)) {
68
- std::cerr << "[forwarder] failed to write request body" << std::endl;
69
65
  stream->Close();
70
66
  sendError(writer, 502);
71
67
  return;
@@ -87,7 +83,6 @@ void SchemeForwarder::forward(const saucer::scheme::request& req,
87
83
 
88
84
  proto::FetchResponse resp;
89
85
  if (!proto::DecodeFetchResponse(frame.data(), frame.size(), resp)) {
90
- std::cerr << "[forwarder] failed to decode response" << std::endl;
91
86
  if (!started) {
92
87
  sendError(writer, 502);
93
88
  }
@@ -133,7 +128,9 @@ void SchemeForwarder::forward(const saucer::scheme::request& req,
133
128
  }
134
129
  }
135
130
 
136
- writer.finish();
131
+ if (started) {
132
+ writer.finish();
133
+ }
137
134
  stream->Close();
138
135
  }
139
136