Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sh text eol=lf
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
contents: read

env:
OSCAN_VERSION: 0.0.37
OSCAN_VERSION: 0.0.39

concurrency:
group: ci-${{ github.ref }}
Expand Down Expand Up @@ -50,6 +50,11 @@ jobs:
shell: pwsh
run: ./tests/test_build_common.ps1

- name: POSIX net bridge tests
if: runner.os == 'Linux'
shell: bash
run: bash ./tests/test_posix_net_bridge.sh

- name: Install Oscan
shell: bash
env:
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ permissions:
contents: write

env:
OSCAN_VERSION: 0.0.37
OSCAN_VERSION: 0.0.39

jobs:
build:
Expand Down Expand Up @@ -52,6 +52,11 @@ jobs:
shell: pwsh
run: ./tests/test_build_common.ps1

- name: POSIX net bridge tests
if: runner.os == 'Linux'
shell: bash
run: bash ./tests/test_posix_net_bridge.sh

- name: Resolve version
id: version
shell: bash
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Switch to a TrueType font (`TT`) and bump the size (`16pt`) for a clean, modern

## Prerequisites

- **[Oscan compiler](https://github.com/lucabol/Oscan)** — required (includes TLS support). Native Windows/Linux builds require Oscan `v0.0.37` or newer with `--backend c|native`, `--native-target`, `--extra-obj`, `--extra-lib`, `--allow-elevated-native-link`, native user-extern `str` parameter/return shims, and native hosted Windows canvas/gfx/clipboard/img/svg/tt runtime support.
- **[Oscan compiler](https://github.com/lucabol/Oscan)** — required (includes TLS support). Native Windows/Linux builds require Oscan `v0.0.39` or newer with `--backend c|native`, `--native-target`, `--extra-obj`, `--extra-lib`, `--allow-elevated-native-link`, native user-extern `str` parameter/return shims, and native hosted Windows/Linux TLS plus Windows canvas/gfx/clipboard/img/svg/tt runtime support.
- **PowerShell** — for the build script

## Build backends
Expand Down Expand Up @@ -382,7 +382,7 @@ supported CSS feature.
do one request per socket, but servers that don't send a
`Content-Length` and instead stream chunked responses now work)
- **TLS built-in** — SChannel on Windows, BearSSL on Linux (zero external dependencies)
- **Redirects** — automatic follow of 301/302/307/308 (up to 5 hops)
- **Redirects** — automatic follow of 301/302/303/307/308 (up to 5 hops)
- **Default ports** — 80 for HTTP, 443 for HTTPS
- **Persistent HTTP cache** — text responses (HTML, CSS, JS, JSON, XML)
survive browser restarts; saved to
Expand Down Expand Up @@ -433,9 +433,14 @@ Refresh the captured snapshots when sites change layout:
.\tools\smoke.ps1
```

Hits the public internet to verify the full HTTP→gzip→parse pipeline
end-to-end. Flaky by design; not part of CI. Use after touching
`http.osc`, `gzip_bridge.c`, or TLS-related code.
Hits the public internet to verify HTTPS/TLS, SNI hostname handling,
redirects, `Host` header construction, gzip/chunked/content-length body
handling, and HTML parsing end-to-end, including the lucabol.com regression
URL. Flaky by design; not part of CI. A self-signed local TLS fixture would
require disabling certificate validation or changing host trust stores, so
offline tests keep coverage to deterministic request construction and
response parsing. Use after touching `http.osc`, `gzip_bridge.c`, or
TLS-related code.

## Reader Mode, Bookmarks & Zoom

Expand Down
12 changes: 12 additions & 0 deletions docs/MANUAL_TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ end-to-end. All can be run against a local Python server
Wikipedia streams responses as `Transfer-Encoding: chunked` without
`Content-Length`.

## 1a. Native hosted HTTPS smoke

```powershell
.\tools\smoke.ps1 -Backend native
```

**Expect:** the public live-smoke URLs pass, including
`https://www.lucabol.com/posts/2026-03-27-notes-the-metamorphosis-franz-kafka`.
That URL should connect with SNI `www.lucabol.com`, send `Host:
www.lucabol.com`, request the `/posts/...` path, receive a `Content-Length`
response, and render text containing `Gregor`.

## 2. External `<script src>`

Create `tests/scratch_script.html`:
Expand Down
71 changes: 44 additions & 27 deletions http.osc
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,39 @@ fn! http_make_error(msg: str) -> HttpResponse {
}
}

fn! http_host_header(host: str, port: i32, use_tls: bool) -> str {
let is_default_port: bool =
(use_tls and port == 443) or
(not use_tls and port == 80);
if is_default_port { host } else { "{host}:{port}" }
}

fn! http_build_get_request(host: str, port: i32, path: str, use_tls: bool,
extra_headers: str) -> str {
let host_header: str = http_host_header(host, port, use_tls);
"GET {path} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\nUser-Agent: OscaWeb/0.1\r\nAccept: text/html,application/xhtml+xml,image/*,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nAccept-Encoding: gzip\r\n{extra_headers}\r\n"
}

fn! http_build_post_request(host: str, port: i32, path: str, use_tls: bool,
body: str, content_type: str, extra_headers: str,
page_url: str) -> str {
let body_len: i32 = str_len(body);
let host_header: str = http_host_header(host, port, use_tls);
let mut origin_hdr: str = "";
let mut referer_hdr: str = "";
if str_len(page_url) > 0 {
let pu: Url = url_parse(page_url);
if str_len(pu.host) > 0 and str_len(pu.scheme) > 0 {
let default_port: bool = (str_eq(pu.scheme, "https") and pu.port == 443) or
(str_eq(pu.scheme, "http") and pu.port == 80);
let port_part: str = if default_port { "" } else { ":{pu.port}" };
origin_hdr = "Origin: {pu.scheme}://{pu.host}{port_part}\r\n";
referer_hdr = "Referer: {page_url}\r\n";
};
};
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\nUser-Agent: OscaWeb/0.1\r\nAccept: text/html,application/xhtml+xml,image/*,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nAccept-Encoding: gzip\r\nContent-Type: {content_type}\r\nContent-Length: {body_len}\r\n{origin_hdr}{referer_hdr}{extra_headers}\r\n{body}"
}

// Collect every `Set-Cookie:` header value (without the key prefix)
// from a CRLF-separated header block. Case-insensitive on the key.
// Kept separate from http_parse_headers because a map collapses the
Expand Down Expand Up @@ -169,15 +202,15 @@ fn! http_get_tls(host: str, port: i32, request: str) -> HttpResponse {
let mut handle: i32 = 0;
match conn_result {
Result::Ok(h) => { handle = h; },
Result::Err(e) => { return http_make_error("TLS connection failed: {e}"); },
Result::Err(e) => { return http_make_error("TLS connection to {host}:{port} failed: {e}"); },
};
defer tls_close(handle);
vlog_msg("tls_connected", "");

let send_result: Result<i32, str> = tls_send(handle, request);
match send_result {
Result::Ok(_n) => {},
Result::Err(e) => { return http_make_error("TLS send failed: {e}"); },
Result::Err(e) => { return http_make_error("TLS send to {host}:{port} failed: {e}"); },
};
vlog_msg("tls_sent", "");

Expand Down Expand Up @@ -326,7 +359,7 @@ fn! http_get_plain(host: str, port: i32, request: str, timeout_ms: i32) -> HttpR
let mut sock: i32 = 0;
match sock_result {
Result::Ok(s) => { sock = s; },
Result::Err(e) => { return http_make_error("Socket creation failed: {e}"); },
Result::Err(e) => { return http_make_error("Socket creation for {host}:{port} failed: {e}"); },
};
defer socket_close(sock);

Expand All @@ -340,14 +373,14 @@ fn! http_get_plain(host: str, port: i32, request: str, timeout_ms: i32) -> HttpR
let conn_result: Result<str, str> = socket_connect(sock, host, port);
match conn_result {
Result::Ok(_s) => {},
Result::Err(e) => { return http_make_error("Connection failed: {e}"); },
Result::Err(e) => { return http_make_error("Connection to {host}:{port} failed: {e}"); },
};
vlog_msg("plain connected", "");

let send_result: Result<i32, str> = socket_send(sock, request);
match send_result {
Result::Ok(_n) => {},
Result::Err(e) => { return http_make_error("Send failed: {e}"); },
Result::Err(e) => { return http_make_error("Send to {host}:{port} failed: {e}"); },
};
vlog_msg("plain sent", "");

Expand All @@ -362,7 +395,7 @@ fn! http_get_plain(host: str, port: i32, request: str, timeout_ms: i32) -> HttpR
// got any data treat it as an error so the caller can fall
// back to TLS.
if not got_any {
return http_make_error("recv timeout or empty response");
return http_make_error("Receive from {host}:{port} timed out or returned empty response");
};
done = true;
} else {
Expand Down Expand Up @@ -396,7 +429,7 @@ fn! http_get(host: str, port: i32, path: str, use_tls: bool, extra_headers: str)
// HTTP/1.1 + Connection: close — we advertise 1.1 so servers unlock
// chunked transfer-encoding and modern header behaviour, but we still
// close the socket after one response (no keep-alive pipelining).
let request: str = "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: OscaWeb/0.1\r\nAccept: text/html,application/xhtml+xml,image/*,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nAccept-Encoding: gzip\r\n{extra_headers}\r\n";
let request: str = http_build_get_request(host, port, path, use_tls, extra_headers);

let mut raw_response: str = "";

Expand Down Expand Up @@ -442,7 +475,7 @@ fn! http_get(host: str, port: i32, path: str, use_tls: bool, extra_headers: str)
};

if str_len(raw_response) == 0 {
return http_make_error("Empty response");
return http_make_error("Empty response from {host}:{port}");
};

http_parse_raw_response(raw_response)
Expand Down Expand Up @@ -583,24 +616,8 @@ fn! http_parse_raw_response(raw_response: str) -> HttpResponse {
fn! http_post(host: str, port: i32, path: str, use_tls: bool,
body: str, content_type: str, extra_headers: str,
page_url: str) -> HttpResponse {
let body_len: i32 = str_len(body);
let mut origin_hdr: str = "";
let mut referer_hdr: str = "";
if str_len(page_url) > 0 {
let pu: Url = url_parse(page_url);
if str_len(pu.host) > 0 and str_len(pu.scheme) > 0 {
// Origin is scheme + host (+ non-default port). No path.
let default_port: bool = (str_eq(pu.scheme, "https") and pu.port == 443) or
(str_eq(pu.scheme, "http") and pu.port == 80);
let port_part: str = if default_port { "" } else { ":{pu.port}" };
origin_hdr = "Origin: {pu.scheme}://{pu.host}{port_part}\r\n";
// Referer can include the path (but never the fragment, which
// url_parse already stripped). Skip when the page URL is
// suspicious (no host).
referer_hdr = "Referer: {page_url}\r\n";
};
};
let request: str = "POST {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: OscaWeb/0.1\r\nAccept: text/html,application/xhtml+xml,image/*,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nAccept-Encoding: gzip\r\nContent-Type: {content_type}\r\nContent-Length: {body_len}\r\n{origin_hdr}{referer_hdr}{extra_headers}\r\n{body}";
let request: str = http_build_post_request(host, port, path, use_tls,
body, content_type, extra_headers, page_url);

let mut raw_response: str = "";
if use_tls {
Expand All @@ -623,7 +640,7 @@ fn! http_post(host: str, port: i32, path: str, use_tls: bool,
};

if str_len(raw_response) == 0 {
return http_make_error("Empty response");
return http_make_error("Empty response from {host}:{port}");
};

http_parse_raw_response(raw_response)
Expand Down
86 changes: 80 additions & 6 deletions js_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@
#include <ws2tcpip.h>
#include <windows.h>
#include <winhttp.h>
#else
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#endif

/* ── osc_str helpers ──────────────────────────────────────── */
Expand Down Expand Up @@ -1103,8 +1112,15 @@ int32_t net_set_recv_timeout(int32_t sock, int32_t ms) {
(const char *)&tv, sizeof(tv));
return (int32_t)(r1 | r2);
#else
(void)sock; (void)ms;
return -1;
if (ms < 0) return -1;
struct timeval tv;
tv.tv_sec = ms / 1000;
tv.tv_usec = (ms % 1000) * 1000;
int r1 = setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO,
(const void *)&tv, sizeof(tv));
int r2 = setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO,
(const void *)&tv, sizeof(tv));
return (int32_t)(r1 | r2);
#endif
}

Expand Down Expand Up @@ -1202,8 +1218,51 @@ int32_t net_connect_timeout_ipv4(int32_t sock, int32_t ip_be, int32_t port, int3
ioctlsocket(s, FIONBIO, &nb);
return rc;
#else
(void)sock; (void)ip_be; (void)port; (void)ms;
return -1;
if (ms < 0) return -1;

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
addr.sin_addr.s_addr = (in_addr_t)ip_be; /* already network byte order */

int old_flags = fcntl(sock, F_GETFL, 0);
if (old_flags < 0) return -1;
if (fcntl(sock, F_SETFL, old_flags | O_NONBLOCK) != 0) return -1;

int rc = 0;
if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
int err = errno;
if (err != EINPROGRESS) {
rc = -2;
} else {
struct pollfd pfd;
memset(&pfd, 0, sizeof(pfd));
pfd.fd = sock;
pfd.events = POLLOUT;

int pr;
do {
pr = poll(&pfd, 1, ms);
} while (pr < 0 && errno == EINTR);

if (pr <= 0) {
rc = -3;
} else {
int so_error = 0;
socklen_t opt_len = (socklen_t)sizeof(so_error);
if (getsockopt(sock, SOL_SOCKET, SO_ERROR,
(void *)&so_error, &opt_len) != 0) {
rc = -4;
} else if (so_error != 0) {
rc = -5;
}
}
}
}

if (fcntl(sock, F_SETFL, old_flags) != 0) return -6;
return rc;
#endif
}

Expand Down Expand Up @@ -1246,8 +1305,23 @@ int32_t net_resolve_ipv4(osc_str host) {
freeaddrinfo(res);
return ip;
#else
(void)host;
return 0;
char *h = osc_str_to_cstr_alloc(host);
if (!h) return 0;
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
int r = getaddrinfo(h, NULL, &hints, &res);
free(h);
if (r != 0 || !res) return 0;
if (!res->ai_addr || res->ai_addrlen < (socklen_t)sizeof(struct sockaddr_in)) {
freeaddrinfo(res);
return 0;
}
struct sockaddr_in *sa = (struct sockaddr_in *)res->ai_addr;
int32_t ip = (int32_t)sa->sin_addr.s_addr;
freeaddrinfo(res);
return ip;
#endif
}

Expand Down
Loading