@gjsify/http-soup-bridge 0.3.21 → 0.4.3

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,60 +1,60 @@
1
1
  {
2
- "name": "@gjsify/http-soup-bridge",
3
- "version": "0.3.21",
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"
2
+ "name": "@gjsify/http-soup-bridge",
3
+ "version": "0.4.3",
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": "gjsify run init:meson --wipe",
29
+ "build": "gjsify run build:gjsify && gjsify run build:types",
30
+ "build:gjsify": "gjsify build --library 'src/ts/**/*.{ts,js}'",
31
+ "build:meson": "gjsify run 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": "gjsify run 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.15",
49
+ "@girs/gjs": "4.0.0-rc.15",
50
+ "@girs/glib-2.0": "2.88.0-4.0.0-rc.15",
51
+ "@girs/gobject-2.0": "2.88.0-4.0.0-rc.15",
52
+ "@girs/soup-3.0": "3.6.6-4.0.0-rc.15"
53
+ },
54
+ "devDependencies": {
55
+ "@gjsify/cli": "workspace:^",
56
+ "@ts-for-gir/cli": "^4.0.0-rc.15",
57
+ "@types/node": "^25.6.2",
58
+ "typescript": "^6.0.3"
13
59
  }
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.14",
49
- "@girs/gjs": "4.0.0-rc.14",
50
- "@girs/glib-2.0": "2.88.0-4.0.0-rc.14",
51
- "@girs/gobject-2.0": "2.88.0-4.0.0-rc.14",
52
- "@girs/soup-3.0": "3.6.6-4.0.0-rc.14"
53
- },
54
- "devDependencies": {
55
- "@gjsify/cli": "^0.3.21",
56
- "@ts-for-gir/cli": "^4.0.0-rc.14",
57
- "@types/node": "^25.6.2",
58
- "typescript": "^6.0.3"
59
- }
60
- }
60
+ }
File without changes
@@ -0,0 +1,123 @@
1
+ /*
2
+ * PeerCloseWatch — detect peer-side TCP half-close while a SoupServerMessage
3
+ * is paused.
4
+ *
5
+ * libsoup destroys its input-polling source on `soup_server_message_pause()`
6
+ * (see refs/libsoup/libsoup/server/http1/soup-server-message-io-http1.c:32,
7
+ * 80–84, 1005–1010), so the `'disconnected'` signal never fires for clients
8
+ * that hang up while we're holding a long-poll/SSE response open. From JS
9
+ * we couldn't fix this: `Gio.Socket.condition_check(HUP|ERR)` only fires on
10
+ * bilateral close, and `Gio.Socket.receive_message(MSG_PEEK)` is not
11
+ * introspectable. From C/Vala both APIs are first-class.
12
+ *
13
+ * What this watcher does on each tick:
14
+ * 1. Wait for `G_IO_IN | G_IO_HUP | G_IO_ERR` on the underlying GSocket.
15
+ * 2. If HUP/ERR fires → peer fully closed, emit `peer_gone()` and exit.
16
+ * 3. If IN fires → peer either sent data (legitimate next request on a
17
+ * keep-alive connection) or closed their write side. We disambiguate
18
+ * with a non-blocking 0-byte `g_socket_receive_message(MSG_PEEK)` —
19
+ * a return of 0 is EOF, > 0 is buffered data we leave for libsoup.
20
+ *
21
+ * The watcher is owned by a Request bridge instance (see request.vala).
22
+ * Source attachment / removal is bookkept here so the JS side never sees
23
+ * a GLib.Source — the typelib only exposes the watcher class itself.
24
+ */
25
+ namespace GjsifyHttpSoupBridge {
26
+
27
+ internal class PeerCloseWatch : GLib.Object {
28
+
29
+ /** Fires once on the main context when peer-side EOF / HUP is detected. */
30
+ public signal void peer_gone();
31
+
32
+ private GLib.Socket _socket;
33
+ private GLib.Source? _source;
34
+ private bool _emitted = false;
35
+
36
+ public PeerCloseWatch(GLib.Socket socket) {
37
+ _socket = socket;
38
+ }
39
+
40
+ /** Begin watching. Idempotent. No-op once peer_gone has fired. */
41
+ public void start() {
42
+ if (_source != null || _emitted)
43
+ return;
44
+
45
+ // GLib.IOCondition.IN catches the half-close case (POLLIN with 0
46
+ // bytes available = EOF). HUP/ERR catch the bilateral-close /
47
+ // socket-error case.
48
+ _source = _socket.create_source(
49
+ GLib.IOCondition.IN | GLib.IOCondition.HUP | GLib.IOCondition.ERR,
50
+ null
51
+ );
52
+ _source.set_callback(() => on_condition());
53
+ _source.attach(GLib.MainContext.@default());
54
+ }
55
+
56
+ /** Stop watching. Safe to call multiple times. */
57
+ public void stop() {
58
+ if (_source != null) {
59
+ _source.destroy();
60
+ _source = null;
61
+ }
62
+ }
63
+
64
+ private bool on_condition() {
65
+ if (_emitted)
66
+ return GLib.Source.REMOVE;
67
+
68
+ // HUP / ERR → unambiguous peer-close.
69
+ var cond = _socket.condition_check(
70
+ GLib.IOCondition.HUP | GLib.IOCondition.ERR
71
+ );
72
+ if ((cond & (GLib.IOCondition.HUP | GLib.IOCondition.ERR)) != 0) {
73
+ fire_peer_gone();
74
+ return GLib.Source.REMOVE;
75
+ }
76
+
77
+ // IN bit set. Probe with a 1-byte MSG_PEEK to disambiguate
78
+ // "peer-half-closed" (recv returns 0) from "data buffered"
79
+ // (recv returns > 0). MSG_PEEK does not consume bytes, so any
80
+ // legitimate buffered data is still available to libsoup.
81
+ uint8 buf[1] = {};
82
+ var vec = GLib.InputVector();
83
+ vec.buffer = buf;
84
+ vec.size = 1;
85
+ GLib.InputVector[] vecs = { vec };
86
+ int flags = (int)GLib.SocketMsgFlags.PEEK;
87
+ try {
88
+ GLib.SocketAddress? src_addr;
89
+ GLib.SocketControlMessage[]? msgs;
90
+ ssize_t n = _socket.receive_message(
91
+ out src_addr, vecs, out msgs, ref flags, null
92
+ );
93
+ if (n == 0) {
94
+ fire_peer_gone();
95
+ return GLib.Source.REMOVE;
96
+ }
97
+ // n > 0 → real data, peer alive. Stay armed.
98
+ return GLib.Source.CONTINUE;
99
+ } catch (GLib.IOError.WOULD_BLOCK e) {
100
+ // Spurious wake-up — keep watching.
101
+ return GLib.Source.CONTINUE;
102
+ } catch (GLib.Error e) {
103
+ // Any other error: treat as peer-gone defensively.
104
+ fire_peer_gone();
105
+ return GLib.Source.REMOVE;
106
+ }
107
+ }
108
+
109
+ private void fire_peer_gone() {
110
+ if (_emitted)
111
+ return;
112
+ _emitted = true;
113
+ // Hop through Idle.add so that the signal is emitted *after*
114
+ // any libsoup callback that may already be running on this
115
+ // mainloop iteration finishes — never re-enter Soup IO from
116
+ // a half-completed callback.
117
+ GLib.Idle.add(() => {
118
+ this.peer_gone();
119
+ return GLib.Source.REMOVE;
120
+ });
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,169 @@
1
+ /*
2
+ * Request — read-side snapshot of one in-flight Soup.ServerMessage.
3
+ *
4
+ * Properties (method, url, headers, body, remote_address, remote_port) are
5
+ * captured at construction time from the SoupServerMessage. Body bytes are
6
+ * pulled from `Soup.ServerMessage.get_request_body()` and copied so JS
7
+ * never holds a reference to the live SoupMessageBody (a refcounted boxed
8
+ * type implicated in the GJS GC race we're working around).
9
+ *
10
+ * `aborted` flips to true and `aborted_signal` fires when:
11
+ * * Soup emits 'disconnected' on the underlying message, OR
12
+ * * the PeerCloseWatch helper detects a peer half-close on a paused
13
+ * long-poll message (the only way we get reliable peer-close
14
+ * detection on libsoup-paused messages, see peer-close-watch.vala).
15
+ *
16
+ * JS API (visible via the typelib):
17
+ * property method : string
18
+ * property url : string
19
+ * property remote_address : string
20
+ * property remote_port : uint
21
+ * property header_pairs : string[] // [name, value, name, value, …]
22
+ * property body : uint8[]
23
+ * property aborted : bool
24
+ * signal aborted_signal()
25
+ * signal close()
26
+ */
27
+ namespace GjsifyHttpSoupBridge {
28
+
29
+ public class Request : GLib.Object {
30
+
31
+ public string method { get; private set; default = ""; }
32
+ public string url { get; private set; default = ""; }
33
+ public string remote_address { get; private set; default = ""; }
34
+ public uint remote_port { get; private set; default = 0; }
35
+ public string[] header_pairs { get; private set; default = new string[0]; }
36
+ public bool aborted { get; private set; default = false; }
37
+
38
+ // Body bytes are exposed as a method (not a property) because
39
+ // GIR-marshalled `uint8[]` properties round-trip through `weak`
40
+ // references that get cleared by the time JS reads them. A method
41
+ // returning `(transfer full)` is the only shape we've found that
42
+ // reliably hands the bytes to JS.
43
+ private uint8[] _body = new uint8[0];
44
+ public uint8[] get_body() {
45
+ var copy = new uint8[_body.length];
46
+ GLib.Memory.copy(copy, _body, _body.length);
47
+ return (owned)copy;
48
+ }
49
+
50
+ public signal void aborted_signal();
51
+ public signal void close();
52
+
53
+ // ---- Internal state ---------------------------------------------
54
+
55
+ private Soup.ServerMessage _msg;
56
+ private PeerCloseWatch? _watch;
57
+ private ulong _disconnected_handler = 0;
58
+ private ulong _finished_handler = 0;
59
+
60
+ internal Request(Soup.ServerMessage msg) {
61
+ _msg = msg;
62
+
63
+ method = msg.get_method();
64
+
65
+ unowned GLib.Uri? uri = msg.get_uri();
66
+ if (uri != null) {
67
+ url = uri.get_path();
68
+ var query = uri.get_query();
69
+ if (query != null && query.length > 0) {
70
+ url = url + "?" + query;
71
+ }
72
+ }
73
+
74
+ var remote_host = msg.get_remote_host();
75
+ if (remote_host != null) remote_address = remote_host;
76
+
77
+ var remote = msg.get_remote_address() as GLib.InetSocketAddress;
78
+ if (remote != null) remote_port = remote.get_port();
79
+
80
+ // Flatten request headers into [name, value, name, value, …].
81
+ // This is the JS-friendliest shape for headers — avoids exposing
82
+ // SoupMessageHeaders boxed handles.
83
+ var pairs = new GLib.GenericArray<string>();
84
+ unowned Soup.MessageHeaders req_hdrs = msg.get_request_headers();
85
+ req_hdrs.foreach((name, value) => {
86
+ pairs.add(name);
87
+ pairs.add(value);
88
+ });
89
+ var arr = new string[pairs.length];
90
+ for (uint i = 0; i < pairs.length; i++) arr[i] = pairs[i];
91
+ header_pairs = arr;
92
+
93
+ // Snapshot the request body. Soup buffers it for us before
94
+ // dispatching the handler (`add_handler` fires after `got-body`).
95
+ // We deep-copy the bytes so JS never holds a SoupMessageBody
96
+ // handle and so the buffer survives Soup's per-message tear-down.
97
+ unowned Soup.MessageBody req_body = msg.get_request_body();
98
+ unowned uint8[] raw = req_body.data;
99
+ if (raw.length > 0) {
100
+ var copy = new uint8[raw.length];
101
+ GLib.Memory.copy(copy, raw, raw.length);
102
+ _body = (owned)copy;
103
+ }
104
+
105
+ // Wire the unambiguous peer-close path: Soup's 'disconnected'
106
+ // signal. This fires reliably while Soup is actively reading
107
+ // (i.e. before the handler pauses the message).
108
+ _disconnected_handler = _msg.disconnected.connect(() => {
109
+ fire_aborted();
110
+ });
111
+
112
+ _finished_handler = _msg.finished.connect(() => {
113
+ fire_close();
114
+ });
115
+
116
+ // Also arm the peer-close watch so we get half-close detection
117
+ // while the response is paused (long-poll / SSE). The watcher
118
+ // does the MSG_PEEK probing C-side so we never expose a
119
+ // GLib.Source to JS.
120
+ var sock = _msg.get_socket();
121
+ if (sock != null) {
122
+ _watch = new PeerCloseWatch(sock);
123
+ _watch.peer_gone.connect(() => {
124
+ fire_aborted();
125
+ });
126
+ _watch.start();
127
+ }
128
+ }
129
+
130
+ // ---- Internal helpers -------------------------------------------
131
+
132
+ private bool _aborted_fired = false;
133
+ private void fire_aborted() {
134
+ if (_aborted_fired) return;
135
+ _aborted_fired = true;
136
+ aborted = true;
137
+ stop_watch();
138
+ GLib.Idle.add(() => {
139
+ this.aborted_signal();
140
+ this.close();
141
+ return GLib.Source.REMOVE;
142
+ });
143
+ }
144
+
145
+ private bool _close_fired = false;
146
+ private void fire_close() {
147
+ if (_close_fired) return;
148
+ _close_fired = true;
149
+ stop_watch();
150
+ disconnect_handlers();
151
+ GLib.Idle.add(() => {
152
+ this.close();
153
+ return GLib.Source.REMOVE;
154
+ });
155
+ }
156
+
157
+ private void stop_watch() {
158
+ if (_watch != null) {
159
+ _watch.stop();
160
+ _watch = null;
161
+ }
162
+ }
163
+
164
+ private void disconnect_handlers() {
165
+ if (_disconnected_handler != 0) { _msg.disconnect(_disconnected_handler); _disconnected_handler = 0; }
166
+ if (_finished_handler != 0) { _msg.disconnect(_finished_handler); _finished_handler = 0; }
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,276 @@
1
+ /*
2
+ * Response — write side of one in-flight Soup.ServerMessage.
3
+ *
4
+ * Owns the SoupServerMessage privately. Keeps every libsoup boxed type
5
+ * (MessageBody, MessageHeaders, Encoding, the message's HTTP1-IO GMain
6
+ * Context ref) on the C side so SpiderMonkey GC has nothing to race
7
+ * against.
8
+ *
9
+ * Replaces the JS-side `SoupMessageLifecycle.ts` machinery from
10
+ * @gjsify/http: `'wrote-chunk'`-tracked re-unpause, `'finished'` /
11
+ * `'disconnected'` signal handling, GC guard, peer-close-driven cleanup —
12
+ * everything moves into this class.
13
+ *
14
+ * JS API (visible via the typelib):
15
+ * set_header / append_header / remove_header / get_header / header_names
16
+ * write_head(code, reason) — flush headers
17
+ * write_chunk(bytes) -> bool — false if already aborted
18
+ * end() — finish chunked or batch mode
19
+ * end_with(bytes) — convenience: write + end
20
+ * abort() — server-side cancel
21
+ * signal close() — fired once on terminal state
22
+ * signal drain() — fired when Soup auto-pause clears
23
+ *
24
+ * Internal state-machine:
25
+ * IDLE → STREAMING (after first write_chunk or write_head with body)
26
+ * IDLE → BATCH (after end() with no prior chunk → set_response)
27
+ * * → FINISHED (Soup 'finished' signal)
28
+ * * → ABORTED (peer-close or abort() call)
29
+ */
30
+ namespace GjsifyHttpSoupBridge {
31
+
32
+ public class Response : GLib.Object {
33
+
34
+ public uint status_code { get; set; default = 200; }
35
+ public string status_message { get; set; default = ""; }
36
+ public bool headers_sent { get; private set; default = false; }
37
+ public bool finished { get; private set; default = false; }
38
+ public bool aborted { get; private set; default = false; }
39
+
40
+ /** Fires once on the main context when the response reaches a terminal state. */
41
+ public signal void close();
42
+
43
+ /** Fires when libsoup auto-pause clears (backpressure relieved). */
44
+ public signal void drain();
45
+
46
+ // ---- Internal state ----------------------------------------------
47
+
48
+ private Soup.ServerMessage _msg;
49
+ private GLib.HashTable<string, GLib.GenericArray<string>> _headers;
50
+ private bool _streaming = false;
51
+ private bool _needs_unpause = false;
52
+
53
+ private ulong _wrote_chunk_handler = 0;
54
+ private ulong _disconnected_handler = 0;
55
+ private ulong _finished_handler = 0;
56
+
57
+ // ---- Construction / wiring ---------------------------------------
58
+
59
+ internal Response(Soup.ServerMessage msg) {
60
+ _msg = msg;
61
+ _headers = new GLib.HashTable<string, GLib.GenericArray<string>>(
62
+ GLib.str_hash, GLib.str_equal
63
+ );
64
+
65
+ // 'wrote-chunk' fires synchronously inside Soup's HTTP1 IO loop
66
+ // right before the auto-pause. By the time we resume in
67
+ // write_chunk()/end(), pause_count is back at 1 and a single
68
+ // unpause() is both safe and necessary.
69
+ _wrote_chunk_handler = _msg.wrote_chunk.connect(() => {
70
+ _needs_unpause = true;
71
+ // Re-emit on the main context so JS subscribers always see
72
+ // 'drain' from the GLib default context (never from Soup's
73
+ // streaming-callback context).
74
+ GLib.Idle.add(() => {
75
+ this.drain();
76
+ return GLib.Source.REMOVE;
77
+ });
78
+ });
79
+
80
+ // 'disconnected' is reliable while Soup is actively reading; for
81
+ // paused long-polls the Request side runs a PeerCloseWatch and
82
+ // calls our `abort()` on its own.
83
+ _disconnected_handler = _msg.disconnected.connect(() => {
84
+ fire_terminal(true);
85
+ });
86
+
87
+ _finished_handler = _msg.finished.connect(() => {
88
+ fire_terminal(false);
89
+ });
90
+ }
91
+
92
+ // ---- Header API --------------------------------------------------
93
+
94
+ public void set_header(string name, string value) {
95
+ var lower = name.down();
96
+ var arr = new GLib.GenericArray<string>();
97
+ arr.add(value);
98
+ _headers.replace(lower, arr);
99
+ }
100
+
101
+ public void append_header(string name, string value) {
102
+ var lower = name.down();
103
+ var arr = _headers.lookup(lower);
104
+ if (arr == null) {
105
+ arr = new GLib.GenericArray<string>();
106
+ _headers.replace(lower, arr);
107
+ }
108
+ arr.add(value);
109
+ }
110
+
111
+ public void remove_header(string name) {
112
+ _headers.remove(name.down());
113
+ }
114
+
115
+ public string? get_header(string name) {
116
+ var arr = _headers.lookup(name.down());
117
+ if (arr == null || arr.length == 0) return null;
118
+ return arr[0];
119
+ }
120
+
121
+ public string[] header_names() {
122
+ var keys = _headers.get_keys_as_array();
123
+ var result = new string[keys.length];
124
+ for (int i = 0; i < keys.length; i++) result[i] = keys[i];
125
+ return result;
126
+ }
127
+
128
+ // ---- Write side --------------------------------------------------
129
+
130
+ /**
131
+ * Flush status + headers and switch into chunked-streaming mode.
132
+ * Idempotent — only fires once.
133
+ */
134
+ public void write_head(uint code, string? reason) {
135
+ if (headers_sent || aborted) return;
136
+ status_code = code;
137
+ if (reason != null) status_message = reason;
138
+ start_streaming();
139
+ }
140
+
141
+ /**
142
+ * Append a body chunk. Returns false if the response is already
143
+ * terminal (so JS callers can stop pumping).
144
+ */
145
+ public bool write_chunk(uint8[] chunk) {
146
+ if (aborted || finished) return false;
147
+ if (!_streaming) start_streaming();
148
+
149
+ _msg.get_response_body().append_bytes(new GLib.Bytes(chunk));
150
+
151
+ // After Soup's HTTP1 IO writes a chunk it auto-pauses
152
+ // (refs/libsoup/libsoup/server/http1/soup-server-message-io-http1.c
153
+ // :32, 80–84, 1005–1010). The 'wrote-chunk' signal sets the
154
+ // ticket; if it's set we owe libsoup an unpause so the *next*
155
+ // chunk we appended just now actually gets written.
156
+ if (_needs_unpause) {
157
+ _needs_unpause = false;
158
+ _msg.unpause();
159
+ }
160
+ return true;
161
+ }
162
+
163
+ /**
164
+ * Finish the response. In streaming mode this calls
165
+ * `Soup.MessageBody.complete()` so libsoup writes the chunked
166
+ * terminator. In batch mode (no prior write_chunk) it calls
167
+ * `Soup.ServerMessage.set_response()` to produce a fixed-length
168
+ * empty body.
169
+ */
170
+ public void end() {
171
+ if (finished || aborted) return;
172
+
173
+ if (_streaming) {
174
+ _msg.get_response_body().complete();
175
+ if (_needs_unpause) {
176
+ _needs_unpause = false;
177
+ _msg.unpause();
178
+ }
179
+ } else {
180
+ send_batch();
181
+ }
182
+ finished = true;
183
+ }
184
+
185
+ public void end_with(uint8[] chunk) {
186
+ if (write_chunk(chunk)) end();
187
+ }
188
+
189
+ public void abort() {
190
+ if (aborted) return;
191
+ aborted = true;
192
+ fire_terminal(true);
193
+ }
194
+
195
+ // ---- Internal helpers --------------------------------------------
196
+
197
+ private void start_streaming() {
198
+ if (_streaming) return;
199
+ _streaming = true;
200
+ headers_sent = true;
201
+
202
+ _msg.set_status(status_code, status_message != "" ? status_message : null);
203
+
204
+ var hdrs = _msg.get_response_headers();
205
+
206
+ // Transfer-encoding selection mirrors @gjsify/http's previous
207
+ // behaviour: CONTENT_LENGTH wins if the caller set one
208
+ // explicitly, CHUNKED otherwise.
209
+ if (_headers.contains("content-length")) {
210
+ hdrs.set_encoding(Soup.Encoding.CONTENT_LENGTH);
211
+ } else {
212
+ hdrs.set_encoding(Soup.Encoding.CHUNKED);
213
+ }
214
+
215
+ if (!_headers.contains("connection")) {
216
+ hdrs.replace("Connection", "close");
217
+ }
218
+
219
+ _headers.foreach((k, vs) => {
220
+ for (uint i = 0; i < vs.length; i++) {
221
+ if (i == 0) hdrs.replace(k, vs[i]);
222
+ else hdrs.append(k, vs[i]);
223
+ }
224
+ });
225
+
226
+ _msg.unpause();
227
+ }
228
+
229
+ private void send_batch() {
230
+ if (headers_sent) return;
231
+ headers_sent = true;
232
+
233
+ _msg.set_status(status_code, status_message != "" ? status_message : null);
234
+
235
+ var hdrs = _msg.get_response_headers();
236
+
237
+ if (!_headers.contains("connection")) {
238
+ hdrs.replace("Connection", "close");
239
+ }
240
+
241
+ _headers.foreach((k, vs) => {
242
+ for (uint i = 0; i < vs.length; i++) {
243
+ if (i == 0) hdrs.replace(k, vs[i]);
244
+ else hdrs.append(k, vs[i]);
245
+ }
246
+ });
247
+
248
+ string content_type = "text/plain";
249
+ var ct_arr = _headers.lookup("content-type");
250
+ if (ct_arr != null && ct_arr.length > 0) content_type = ct_arr[0];
251
+
252
+ uint8[] empty = new uint8[0];
253
+ _msg.set_response(content_type, Soup.MemoryUse.COPY, empty);
254
+ _msg.unpause();
255
+ }
256
+
257
+ private bool _terminal_fired = false;
258
+ private void fire_terminal(bool became_aborted) {
259
+ if (_terminal_fired) return;
260
+ _terminal_fired = true;
261
+ if (became_aborted) aborted = true;
262
+ finished = true;
263
+
264
+ // Disconnect signal handlers so libsoup's own message tear-down
265
+ // doesn't re-enter our class on a recycled signal ID.
266
+ if (_wrote_chunk_handler != 0) { _msg.disconnect(_wrote_chunk_handler); _wrote_chunk_handler = 0; }
267
+ if (_disconnected_handler != 0) { _msg.disconnect(_disconnected_handler); _disconnected_handler = 0; }
268
+ if (_finished_handler != 0) { _msg.disconnect(_finished_handler); _finished_handler = 0; }
269
+
270
+ GLib.Idle.add(() => {
271
+ this.close();
272
+ return GLib.Source.REMOVE;
273
+ });
274
+ }
275
+ }
276
+ }
@@ -0,0 +1,120 @@
1
+ /*
2
+ * Server — wraps Soup.Server with a JS-safe signal-based API.
3
+ *
4
+ * The class owns the SoupServer privately. JS callers see only:
5
+ * listen(port, hostname) -> bool
6
+ * close()
7
+ * property port : uint
8
+ * property address : string
9
+ * property listening : bool
10
+ * property soup_server : Soup.Server // for ws-upgrade callers
11
+ * signal request_received(Request, Response)
12
+ * signal upgrade(Request, GLib.IOStream, GLib.Bytes)
13
+ * signal error_occurred(string)
14
+ *
15
+ * The `soup_server` property is exposed read-only because @gjsify/ws's
16
+ * WebSocketServer needs to call `add_websocket_handler` on the same
17
+ * underlying Soup.Server (port-sharing). Soup.Server *itself* is a
18
+ * GObject (not a Boxed), so exposing it doesn't reintroduce the
19
+ * Boxed-Source GC race the bridge fixes — the race was specifically
20
+ * about boxed types like SoupMessageBody / SoupMessageHeaders / the
21
+ * implicit GLib.Source ref, none of which leak through this surface.
22
+ */
23
+ namespace GjsifyHttpSoupBridge {
24
+
25
+ public class Server : GLib.Object {
26
+
27
+ public uint port { get; private set; default = 0; }
28
+ public string address { get; private set; default = ""; }
29
+ public bool listening { get; private set; default = false; }
30
+
31
+ /** Underlying Soup.Server, exposed so @gjsify/ws can share the port. */
32
+ public Soup.Server soup_server { get; private set; }
33
+
34
+ public signal void request_received(Request req, Response res);
35
+ public signal void upgrade(Request req, GLib.IOStream iostream, GLib.Bytes head);
36
+ public signal void error_occurred(string message);
37
+
38
+ construct {
39
+ // Soup.Server's constructor is the GObject varargs form
40
+ // `Server(string optname1, ...)`. We use the property-bag form
41
+ // via GObject.Object.new() with a zero-property list, which
42
+ // round-trips to `g_object_new(SOUP_TYPE_SERVER, NULL)`.
43
+ soup_server = (Soup.Server) GLib.Object.new(typeof(Soup.Server));
44
+ // ServerCallback signature: (Server, ServerMessage, path, query?)
45
+ soup_server.add_handler(null, (server, msg, path, query) => {
46
+ handle_message(msg);
47
+ });
48
+ }
49
+
50
+ public void listen(uint port_arg, string hostname) throws GLib.Error {
51
+ soup_server.listen_local(port_arg, Soup.ServerListenOptions.IPV4_ONLY);
52
+ var listeners = soup_server.get_listeners();
53
+ if (listeners != null && listeners.length() > 0) {
54
+ var laddr = listeners.nth_data(0).get_local_address() as GLib.InetSocketAddress;
55
+ if (laddr != null) {
56
+ port = laddr.get_port();
57
+ } else {
58
+ port = port_arg;
59
+ }
60
+ } else {
61
+ port = port_arg;
62
+ }
63
+ address = hostname;
64
+ listening = true;
65
+ }
66
+
67
+ public void close() {
68
+ if (!listening) return;
69
+ soup_server.disconnect();
70
+ listening = false;
71
+ }
72
+
73
+ // ---- Per-request dispatch ---------------------------------------
74
+
75
+ private void handle_message(Soup.ServerMessage msg) {
76
+ // Detect WebSocket upgrade BEFORE pausing the message, so
77
+ // steal_connection() returns a usable IOStream. We mirror
78
+ // @gjsify/http's existing logic here so consumers can keep
79
+ // using `Server.on('upgrade', …)` unchanged.
80
+ unowned Soup.MessageHeaders req_hdrs = msg.get_request_headers();
81
+ string? conn = req_hdrs.get_one("Connection");
82
+ string? upg = req_hdrs.get_one("Upgrade");
83
+ bool is_upgrade = conn != null && conn.down().contains("upgrade") && upg != null;
84
+
85
+ if (is_upgrade) {
86
+ // Build a Request snapshot for the upgrade event but skip
87
+ // creating a Response (libsoup will discard any response
88
+ // we'd write after steal_connection).
89
+ var req = new Request(msg);
90
+ GLib.IOStream? io = null;
91
+ try {
92
+ io = msg.steal_connection();
93
+ } catch (GLib.Error e) {
94
+ error_occurred(e.message);
95
+ return;
96
+ }
97
+ if (io != null) {
98
+ var head = new GLib.Bytes(new uint8[0]);
99
+ GLib.Idle.add(() => {
100
+ this.upgrade(req, io, head);
101
+ return GLib.Source.REMOVE;
102
+ });
103
+ }
104
+ return;
105
+ }
106
+
107
+ // Non-upgrade path: pause Soup so the JS handler can take its
108
+ // time before unpause()-ing through Response.write_chunk/end.
109
+ msg.pause();
110
+
111
+ var req = new Request(msg);
112
+ var res = new Response(msg);
113
+
114
+ GLib.Idle.add(() => {
115
+ this.request_received(req, res);
116
+ return GLib.Source.REMOVE;
117
+ });
118
+ }
119
+ }
120
+ }