@gjsify/http-soup-bridge 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @gjsify/http-soup-bridge
2
+
3
+ Vala-based main-thread bridge for libsoup HTTP server. Used by
4
+ [`@gjsify/http`](../http/) to keep MCP / SSE / long-poll workloads stable on
5
+ GJS.
6
+
7
+ ## Why this package exists
8
+
9
+ `@gjsify/http` is built on top of `Soup.Server` / `Soup.ServerMessage`. Two
10
+ GJS↔libsoup binding races make the server crash silently with
11
+ `gjs exited with code null` for any non-trivial workload that involves a
12
+ non-GJS HTTP client (MCP Inspector subprocess, browser EventSource, raw
13
+ `node -e fetch`):
14
+
15
+ 1. **Boxed-Source GC race.** A JS wrapper around a libsoup-internal
16
+ `GLib.Source` survives past the underlying source's lifetime. SpiderMonkey
17
+ GC eventually finalises it (`g_timeout_add_seconds(10, …)` in
18
+ `GjsContextPrivate::trigger_gc_if_needed`), the finalizer calls
19
+ `g_source_unref` on a freed source, and `g_source_unref_internal:
20
+ assertion 'old_ref > 0' failed` is followed immediately by SIGSEGV.
21
+
22
+ 2. **Shared-`GMainContext` ref imbalance.** The thread-default `GMainContext`
23
+ is shared between libsoup's `SoupMessageIOHTTP1.async_context`
24
+ (`refs/libsoup/libsoup/server/http1/soup-server-message-io-http1.c:70,87`)
25
+ and GJS's own `MainLoop::spin` (`refs/gjs/gjs/mainloop.cpp:28-29`). The
26
+ libsoup C-side ref/unref pairs are correct; the imbalance comes from the
27
+ GJS binding layer, where some Boxed wrapper retains a ref past the
28
+ underlying object's lifetime. Surfaces as
29
+ `g_main_context_unref: assertion 'g_atomic_int_get (&context->ref_count)
30
+ > 0' failed`.
31
+
32
+ Both crashes have the same shape: a JS-visible refcounted libsoup boxed (or
33
+ something containing one) ends up at refcount zero on the C side while a JS
34
+ finalizer still believes it owns a reference.
35
+
36
+ The solution is to keep every libsoup boxed in C-space. This package
37
+ exposes three thin GObject classes from Vala — `Server`, `Request`,
38
+ `Response` — that own the underlying `Soup.Server` / `Soup.ServerMessage`
39
+ privately. JS callers only see the bridge classes plus signals dispatched
40
+ through `GLib.Idle.add()` to the main context, so SpiderMonkey GC can never
41
+ race a libsoup-side cleanup.
42
+
43
+ The same pattern is used in
44
+ [`@gjsify/webrtc-native`](../../web/webrtc-native/) for the equivalent
45
+ GstWebRTC threading problem.
46
+
47
+ ## Implementation outline
48
+
49
+ * `src/vala/server.vala` — `Server` class wraps `Soup.Server`, emits
50
+ `request_received(req, res)` / `upgrade(req, iostream, head)` signals.
51
+ * `src/vala/request.vala` — `Request` class wraps `Soup.ServerMessage`'s
52
+ read side: method, url, headers, body, peer-close detection.
53
+ * `src/vala/response.vala` — `Response` class wraps the write side:
54
+ `set_header`, `write_head`, `write_chunk`, `end`, plus the pause/unpause
55
+ bookkeeping that previously lived in `@gjsify/http`'s
56
+ `SoupMessageLifecycle.ts`.
57
+ * `src/vala/peer-close-watch.vala` — C-side helper that does
58
+ `g_socket_create_source(IN | HUP | ERR)` + non-blocking
59
+ `g_socket_receive(MSG_PEEK, 1)` to detect peer half-close on paused
60
+ long-poll messages. (This is the capability we couldn't get from JS:
61
+ `Gio.Socket.condition_check` doesn't expose `POLLRDHUP` and
62
+ `Gio.Socket.receive_message(MSG_PEEK)` is not introspectable.)
63
+
64
+ ## Build
65
+
66
+ ```bash
67
+ yarn workspace @gjsify/http-soup-bridge run init:meson
68
+ yarn workspace @gjsify/http-soup-bridge run build:meson
69
+ yarn workspace @gjsify/http-soup-bridge run build:prebuilds
70
+ ```
71
+
72
+ This produces `prebuilds/linux-x86_64/{libgjsifyhttpsoupbridge.so,
73
+ GjsifyHttpSoupBridge-1.0.gir, GjsifyHttpSoupBridge-1.0.typelib}`. The
74
+ `@gjsify/cli` runtime injects `LD_LIBRARY_PATH` and `GI_TYPELIB_PATH` from
75
+ the `gjsify.prebuilds` field at startup, so consumers don't need to install
76
+ the bridge into a system path.
77
+
78
+ The CI workflow at `.github/workflows/prebuilds.yml` builds prebuilds for
79
+ linux-x86_64, linux-aarch64, linux-ppc64, linux-s390x, and linux-riscv64
80
+ and auto-commits them to the repo. x86_64 and aarch64 use native GitHub
81
+ runners; ppc64, s390x, and riscv64 use QEMU via `uraimo/run-on-arch-action`.
82
+
83
+ ## TS types
84
+
85
+ Until `@girs/gjsifyhttpsoupbridge-1.0` is published to npm, types are
86
+ generated locally from the freshly-built GIR via `ts-for-gir`:
87
+
88
+ ```bash
89
+ npx @ts-for-gir/cli generate \
90
+ --package --npmScope=@girs --outdir=node_modules \
91
+ --girDirectories=packages/node/http-soup-bridge/build \
92
+ GjsifyHttpSoupBridge-1.0
93
+ ```
94
+
95
+ After publication this step disappears — the package's `dependencies`
96
+ already references `@girs/gjsifyhttpsoupbridge-1.0`.
File without changes
@@ -0,0 +1,9 @@
1
+ import GjsifyHttpSoupBridge from "gi://GjsifyHttpSoupBridge?version=1.0";
2
+ const Server = GjsifyHttpSoupBridge.Server;
3
+ const Request = GjsifyHttpSoupBridge.Request;
4
+ const Response = GjsifyHttpSoupBridge.Response;
5
+ export {
6
+ Request,
7
+ Response,
8
+ Server
9
+ };
@@ -0,0 +1,7 @@
1
+ import GjsifyHttpSoupBridge from 'gi://GjsifyHttpSoupBridge?version=1.0';
2
+ export declare const Server: typeof GjsifyHttpSoupBridge.Server;
3
+ export type Server = GjsifyHttpSoupBridge.Server;
4
+ export declare const Request: typeof GjsifyHttpSoupBridge.Request;
5
+ export type Request = GjsifyHttpSoupBridge.Request;
6
+ export declare const Response: typeof GjsifyHttpSoupBridge.Response;
7
+ export type Response = GjsifyHttpSoupBridge.Response;
package/meson.build ADDED
@@ -0,0 +1,39 @@
1
+ project('GjsifyHttpSoupBridge', ['c', 'vala'], version: '1.0')
2
+
3
+ root_dir = meson.current_source_dir()
4
+
5
+ dependencies = [
6
+ dependency('glib-2.0'),
7
+ dependency('gobject-2.0'),
8
+ dependency('gio-2.0'),
9
+ dependency('libsoup-3.0', version: '>=3.6'),
10
+ ]
11
+
12
+ sources = files(
13
+ 'src/vala/peer-close-watch.vala',
14
+ 'src/vala/response.vala',
15
+ 'src/vala/request.vala',
16
+ 'src/vala/server.vala',
17
+ )
18
+
19
+ libGjsifyHttpSoupBridge = library('gjsifyhttpsoupbridge', sources,
20
+ dependencies: dependencies,
21
+ vala_gir: meson.project_name() + '-1.0.gir',
22
+ install: true,
23
+ install_dir: [true, true, true, true],
24
+ )
25
+
26
+ g_ir_compiler = find_program('g-ir-compiler')
27
+
28
+ custom_target(meson.project_name() + '-1.0.typelib',
29
+ command: [
30
+ g_ir_compiler,
31
+ '--shared-library', 'libgjsifyhttpsoupbridge.so',
32
+ '--output', '@OUTPUT@',
33
+ meson.current_build_dir() / meson.project_name() + '-1.0.gir',
34
+ ],
35
+ output: meson.project_name() + '-1.0.typelib',
36
+ depends: libGjsifyHttpSoupBridge,
37
+ install: true,
38
+ install_dir: get_option('libdir') / 'girepository-1.0',
39
+ )
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@gjsify/http-soup-bridge",
3
+ "version": "0.2.0",
4
+ "description": "Vala-based main-thread bridge for libsoup HTTP server. Marshals SoupServer + SoupServerMessage signals onto the GLib main context and keeps every libsoup boxed type (MessageBody, MessageHeaders, the message's GMainContext ref, the HTTP1 IO GSource) on the C side, so SpiderMonkey GC has no chance to race a libsoup-side cleanup. Used by @gjsify/http to keep MCP/SSE/long-poll workloads stable on GJS.",
5
+ "type": "module",
6
+ "main": "lib/esm/index.js",
7
+ "module": "lib/esm/index.js",
8
+ "types": "lib/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/types/index.d.ts",
12
+ "default": "./lib/esm/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "prebuilds",
18
+ "meson.build",
19
+ "src/vala"
20
+ ],
21
+ "gjsify": {
22
+ "prebuilds": "prebuilds"
23
+ },
24
+ "scripts": {
25
+ "clear": "rm -rf lib build tsconfig.tsbuildinfo tsconfig.types.tsbuildinfo || exit 0",
26
+ "check": "tsc --noEmit",
27
+ "init:meson": "meson setup build .",
28
+ "init:meson:wipe": "yarn init:meson --wipe",
29
+ "build": "yarn build:gjsify && yarn build:types",
30
+ "build:gjsify": "gjsify build --library 'src/ts/**/*.{ts,js}'",
31
+ "build:meson": "yarn init:meson && meson compile -C build",
32
+ "build:types": "tsc",
33
+ "build:gir-types": "ts-for-gir generate --externalDeps --allowMissingDeps --girDirectories=./prebuilds/linux-x86_64 --girDirectories=/usr/share/gir-1.0 --modules=GjsifyHttpSoupBridge-1.0 --outdir=src/ts --npmScope=@girs --package=false --ignoreVersionConflicts=true",
34
+ "build:prebuilds": "yarn build:meson && mkdir -p prebuilds/linux-x86_64 && cp build/libgjsifyhttpsoupbridge.so build/GjsifyHttpSoupBridge-1.0.gir build/GjsifyHttpSoupBridge-1.0.typelib prebuilds/linux-x86_64/"
35
+ },
36
+ "keywords": [
37
+ "gjs",
38
+ "http",
39
+ "server",
40
+ "libsoup",
41
+ "soup",
42
+ "vala",
43
+ "native",
44
+ "sse",
45
+ "long-poll"
46
+ ],
47
+ "dependencies": {
48
+ "@girs/gio-2.0": "^2.88.0-4.0.0-rc.9",
49
+ "@girs/gjs": "^4.0.0-rc.9",
50
+ "@girs/glib-2.0": "^2.88.0-4.0.0-rc.9",
51
+ "@girs/gobject-2.0": "^2.88.0-4.0.0-rc.9",
52
+ "@girs/soup-3.0": "^3.6.6-4.0.0-rc.9"
53
+ },
54
+ "devDependencies": {
55
+ "@gjsify/cli": "^0.2.0",
56
+ "@ts-for-gir/cli": "^4.0.0-rc.9",
57
+ "@types/node": "^25.6.0",
58
+ "typescript": "^6.0.3"
59
+ }
60
+ }
File without changes