@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 +58 -58
- package/prebuilds/linux-x86_64/libgjsifyhttpsoupbridge.so +0 -0
- package/src/vala/peer-close-watch.vala +123 -0
- package/src/vala/request.vala +169 -0
- package/src/vala/response.vala +276 -0
- package/src/vala/server.vala +120 -0
package/package.json
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
}
|