diff --git a/README.md b/README.md
index 2d43dd1..d5c5df2 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@
In one sentence: **Fun is a scripting language that goes all the way from JSON to machine code.**
- One runtime spans the whole spectrum, from high-level data work (JSON/FD) down to low-level system programming (FFI, C at runtime, JIT, machine code);
-- 66 standard-library modules written in Fun itself ship together as one embeddable `fun.dll`;
+- 67 standard-library modules written in Fun itself ship together as one embeddable `fun.dll`;
- in AI-agent and domestic-IT scenarios, it is the lightweight glue connecting OSes, databases, chips, and business logic.
Fun is designed around a simple premise: **data and code should be equally first-class, and a script should be able to reach down to the machine without leaving the language.**
@@ -70,7 +70,7 @@ Fun is written in Pascal (Delphi / Free Pascal). The core is a small tree-walkin
- **Keyword aliasing** — a canonical English keyword set with pluggable alias packs (Chinese, French, custom) selected at runtime (see [below](#keyword-aliasing)).
- **Cross-platform** — Windows (32/64-bit), Linux, ARM, and Windows CE.
- **Dual distribution** — command-line `fun.exe` and embeddable runtime `fun.dll` exporting a simple `Run` interface.
-- **Self-hosted standard library** — 66 modules written in Fun itself, from JSON/YAML/XML to ORM, async, UI, and native bindings.
+- **Self-hosted standard library** — 67 modules written in Fun itself, from JSON/YAML/XML to ORM, async, UI, and native bindings.
---
@@ -372,7 +372,7 @@ fun/
│ ├── utils/ # Utility functions
│ └── prj/fun/ # Projects & build scripts (funcmd.dpr, Delphi/FPC)
├── fun/
-│ ├── lib/ # Standard library (pure Fun, 66 modules)
+│ ├── lib/ # Standard library (pure Fun, 67 modules)
│ ├── demo/ # Language syntax demos
│ ├── demos/ # Full applications & benchmarks
│ └── key_*.ini # Keyword alias packs (CN / FR / PUA)
@@ -497,16 +497,16 @@ win64/aarch64 target, so there is nothing to build with.
## Standard library
-The standard library in [`fun/lib`](fun/lib) is written in pure Fun (66 modules):
+The standard library in [`fun/lib`](fun/lib) is written in pure Fun (67 modules):
-> In total roughly 6,000 lines of logical code (excluding comments), about 100 lines per module — all 66 modules written in Fun itself.
+> In total roughly 6,000 lines of logical code (excluding comments), about 100 lines per module — all 67 modules written in Fun itself.
- **Data**: `lib-json`, `lib-yaml`, `lib-xml`, `lib-base64`, `lib-cstruct`, `lib-md5`, `lib-crypt`
- **Utility**: `uuid`
- **Collections / algorithms**: `lib-set`, `lib-tree`, `lib-stack`, `lib-dyns`, `lib-math`
- **Text**: `lib-string`, `lib-regex`, `lib-match`, `lib-unicode`
- **System / IO**: `lib-file`, `lib-os`, `lib-host`, `lib-time`, `lib-cmdline`, `lib-proc`
-- **Network**: `lib-winsock`, `lib-ajax`, `lib-jsonrpc`, `http-server`, `http-client`, `http-auth`, `http-bearer`, `http-cors`
+- **Network**: `lib-winsock`, `lib-ajax`, `lib-jsonrpc`, `http-server`, `http-client`, `http-auth`, `http-bearer`, `http-cors`, `http-session`
- **Databases**: `lib-orm`, `lib-orm-pro`, `lib-orm-gen`, `lib-orm-rpc`, `lib-orm-cte`, `lib-ado`, `lib-ado-schema`
- **Native / low-level**: `lib-winapi`, `lib-winole`, `lib-tcc` (C compile), `lib-jit`, `lib-asm`, `lib-asm-pro`
- **UI**: `lib-ui`, `lib-ui-base`, `lib-dialog`, `lib-trayicon`
@@ -533,6 +533,7 @@ The standard library in [`fun/lib`](fun/lib) is written in pure Fun (66 modules)
- [`fun/demos/test-demo`](fun/demos/test-demo) — a lib-only package with tests, a shared helper, and a failing-assertion example.
- [`fun/demos/http-cors-demo`](fun/demos/http-cors-demo) — CORS preflight, credentials, exposed headers, and a browser page.
- [`fun/demos/http-bearer-demo`](fun/demos/http-bearer-demo) — Bearer token auth, 401 challenges, and a same-origin page.
+- [`fun/demos/http-session-demo`](fun/demos/http-session-demo) — cookie session login/logout, SID rotation, and server-side state.
---
diff --git a/README.zh.md b/README.zh.md
index 29f8184..dbb9b1a 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -39,7 +39,7 @@
一句话定位:**Fun 是一门从 JSON 一路写到机器码的脚本语言。**
- 用同一个运行时,覆盖从高层数据处理(JSON/FD)到底层系统编程(FFI、运行时 C、JIT、机器码)的全谱系;
-- 自带 66 个用 Fun 自身编写的标准库模块,全栈能力打包进单个可嵌入的 `fun.dll`;
+- 自带 67 个用 Fun 自身编写的标准库模块,全栈能力打包进单个可嵌入的 `fun.dll`;
- 在信创与 AI Agent 场景中,作为连接国产 OS、数据库、芯片与业务逻辑的轻量级胶水。
Fun 的设计出发点很简单:**数据与代码都应该是头等公民,而且一段脚本应该能在不离开语言的情况下一路触达机器底层。**
@@ -70,7 +70,7 @@ Fun 使用 Pascal(Delphi / Free Pascal)编写。核心是一个小巧的树
- **关键字别名** —— 规范英文关键字 + 可插拔的别名包(中文、法文、自定义),运行时选择(见[下文](#关键字别名))。
- **跨平台** —— Windows(32/64 位)、Linux、ARM 与 Windows CE。
- **双形态分发** —— 命令行 `fun.exe` 与嵌入运行时 `fun.dll`(导出简单的 `Run` 接口)。
-- **自托管的标准库** —— 66 个用 Fun 自身编写的模块,覆盖 JSON/YAML/XML 到 ORM、异步、UI 与原生绑定。
+- **自托管的标准库** —— 67 个用 Fun 自身编写的模块,覆盖 JSON/YAML/XML 到 ORM、异步、UI 与原生绑定。
---
@@ -365,7 +365,7 @@ fun/
│ ├── utils/ # 工具函数
│ └── prj/fun/ # 工程与构建脚本(funcmd.dpr,Delphi/FPC)
├── fun/
-│ ├── lib/ # 标准库(纯 Fun 编写,66 个模块)
+│ ├── lib/ # 标准库(纯 Fun 编写,67 个模块)
│ ├── demo/ # 语言语法演示
│ ├── demos/ # 完整应用与基准测试
│ └── key_*.ini # 关键字别名包(CN / FR / PUA)
@@ -477,16 +477,16 @@ float/混合参数与 `@toCallback` 闭包在 win64 上也有覆盖;`ffi-scala
## 标准库
-标准库位于 [`fun/lib`](fun/lib),以纯 Fun 编写(66 个模块):
+标准库位于 [`fun/lib`](fun/lib),以纯 Fun 编写(67 个模块):
-> 总代码量约 6,000 行逻辑代码(不含注释),平均每个模块约 100 行——66 个模块全部用 Fun 自身写成。
+> 总代码量约 6,000 行逻辑代码(不含注释),平均每个模块约 100 行——67 个模块全部用 Fun 自身写成。
- **数据**:`lib-json`、`lib-yaml`、`lib-xml`、`lib-base64`、`lib-cstruct`、`lib-md5`、`lib-crypt`
- **工具**:`uuid`
- **集合 / 算法**:`lib-set`、`lib-tree`、`lib-stack`、`lib-dyns`、`lib-math`
- **文本**:`lib-string`、`lib-regex`、`lib-match`、`lib-unicode`
- **系统 / IO**:`lib-file`、`lib-os`、`lib-host`、`lib-time`、`lib-cmdline`、`lib-proc`
-- **网络**:`lib-winsock`、`lib-ajax`、`lib-jsonrpc`、`http-server`、`http-client`、`http-auth`、`http-bearer`、`http-cors`
+- **网络**:`lib-winsock`、`lib-ajax`、`lib-jsonrpc`、`http-server`、`http-client`、`http-auth`、`http-bearer`、`http-cors`、`http-session`
- **数据库**:`lib-orm`、`lib-orm-pro`、`lib-orm-gen`、`lib-orm-rpc`、`lib-orm-cte`、`lib-ado`、`lib-ado-schema`
- **原生 / 底层**:`lib-winapi`、`lib-winole`、`lib-tcc`(C 编译)、`lib-jit`、`lib-asm`、`lib-asm-pro`
- **UI**:`lib-ui`、`lib-ui-base`、`lib-dialog`、`lib-trayicon`
@@ -510,6 +510,7 @@ float/混合参数与 `@toCallback` 闭包在 win64 上也有覆盖;`ffi-scala
- [`fun/demos/test-demo`](fun/demos/test-demo) —— 只有库的包及测试:共享 helper、通过用例与失败断言示例。
- [`fun/demos/http-cors-demo`](fun/demos/http-cors-demo) —— CORS 预检、credentials、暴露响应头与浏览器页面示例。
- [`fun/demos/http-bearer-demo`](fun/demos/http-bearer-demo) —— Bearer token 认证、401 挑战与同源页面示例。
+- [`fun/demos/http-session-demo`](fun/demos/http-session-demo) —— cookie session 登录/登出、SID 轮换与服务端状态示例。
---
diff --git a/fun/demos/http-session-demo/README.md b/fun/demos/http-session-demo/README.md
new file mode 100644
index 0000000..abd55d5
--- /dev/null
+++ b/fun/demos/http-session-demo/README.md
@@ -0,0 +1,68 @@
+# http-session-demo
+
+A small login/session page:
+
+- `POST /api/login` stores the user in `req.session` and rotates the SID;
+- `GET /api/me` reads the session;
+- `GET /api/count` increments a value stored server-side;
+- `POST /api/logout` sets `req.session = nil`, destroying the session and cookie.
+
+```text
+package.fun # manifest
+src/main.fun # API + static page on :8080
+web/index.html # same-origin page
+```
+
+## Run
+
+`fun` below is the command-line binary (`fun.exe` on Windows). From the
+repository root, after building, the in-tree binary is
+`src/prj/fun/funcmd`; from this directory that is
+`../../../src/prj/fun/funcmd`.
+
+```text
+cd fun/demos/http-session-demo
+fun run
+# or: ../../../src/prj/fun/funcmd run
+```
+
+Open . Login, then click `GET /api/me` and
+`GET /api/count`; the browser sends the `sid` cookie automatically.
+
+## Curl
+
+Use a cookie jar:
+
+```text
+curl -i -c /tmp/fun-sid.txt -b /tmp/fun-sid.txt \
+ -X POST http://127.0.0.1:8080/api/login \
+ -H 'Content-Type: application/json' \
+ -d '{"user":"alice"}'
+```
+
+The response sets `sid=...; Path=/; HttpOnly; SameSite=Lax`. Then:
+
+```text
+curl -i -c /tmp/fun-sid.txt -b /tmp/fun-sid.txt http://127.0.0.1:8080/api/me
+curl -i -c /tmp/fun-sid.txt -b /tmp/fun-sid.txt http://127.0.0.1:8080/api/count
+curl -i -c /tmp/fun-sid.txt -b /tmp/fun-sid.txt -X POST http://127.0.0.1:8080/api/logout
+```
+
+After logout the cookie is cleared and `/api/me` returns `{"user":null}`.
+
+## Notes
+
+- Only a random SID is stored in the cookie; session data is in the server.
+- `SessionRegenerate(req)` after login prevents session fixation; the old SID
+ is destroyed.
+- Session data must be JSON-serializable (it is compared before/after the
+ handler to decide whether to save).
+- The default cookie is `HttpOnly`, `SameSite=Lax`, `Path=/`; set `secure: true`
+ behind HTTPS.
+- SameSite helps with CSRF but is not a complete CSRF defense. State-changing
+ routes should still use a CSRF token.
+- The in-memory store is per process. For multiple instances, pass a shared
+ `store` implementing `get(sid)`, `set(sid, data, ttl)`, `destroy(sid)`.
+- For a cross-site SPA, CORS must allow credentials with an exact origin, the
+ cookie needs `secure: true` and `sameSite: 'None'`, and browser third-party
+ cookie restrictions may still block it; a same-site/BFF deployment is safer.
diff --git a/fun/demos/http-session-demo/package.fun b/fun/demos/http-session-demo/package.fun
new file mode 100644
index 0000000..d5c0881
--- /dev/null
+++ b/fun/demos/http-session-demo/package.fun
@@ -0,0 +1,15 @@
+fun package()
+ result = [
+ name: 'http-session-demo',
+ version: '0.1.0',
+ bin: 'src/main.fun',
+ lib: '',
+ 'fun': '9.4',
+
+ dependencies: [],
+
+ // >>> generated - do not edit; run 'fun package tidy' to refresh
+ resolved: []
+ // <<< generated
+ ];
+end fun;
diff --git a/fun/demos/http-session-demo/src/main.fun b/fun/demos/http-session-demo/src/main.fun
new file mode 100644
index 0000000..5986b45
--- /dev/null
+++ b/fun/demos/http-session-demo/src/main.fun
@@ -0,0 +1,42 @@
+use '../../../lib/http-server.fun';
+use '../../../lib/http-session.fun';
+
+fun meH(req, res)
+ if req.session['user'] = nil then
+ res.json([user: nil]);
+ else
+ res.json([user: req.session['user']]);
+ end if;
+end fun;
+
+fun loginH(req, res)
+ var body = req.json();
+ var user = body['user'];
+ if user = nil or user = '' then
+ res.status(400).json([error: 'user required']);
+ return;
+ end if;
+ req.session['user'] = user;
+ SessionRegenerate(req);
+ res.json([user: user]);
+end fun;
+
+fun countH(req, res)
+ req.session['count'] = (req.session['count'] or 0) + 1;
+ res.json([count: req.session['count']]);
+end fun;
+
+fun logoutH(req, res)
+ req.session = nil;
+ res.json([ok: true]);
+end fun;
+
+var s = HttpServer(8080);
+s.Use(HttpLogger());
+s.Use(HttpSession([name: 'sid', ttl: 86400, maxAge: 0, sameSite: 'Lax']));
+s.get('/api/me', meH);
+s.post('/api/login', loginH);
+s.get('/api/count', countH);
+s.post('/api/logout', logoutH);
+s.static('/', 'web');
+s.run();
diff --git a/fun/demos/http-session-demo/web/index.html b/fun/demos/http-session-demo/web/index.html
new file mode 100644
index 0000000..cee1b0b
--- /dev/null
+++ b/fun/demos/http-session-demo/web/index.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+ Fun session demo
+
+
+
+
Fun session demo
+
Cookie sid carries only a random session id; data stays on the server.
+
+
+
+
+
+
Click a button.
+
+
+
+
diff --git a/fun/lib/http-server.fun b/fun/lib/http-server.fun
index cecea49..6341fa7 100644
--- a/fun/lib/http-server.fun
+++ b/fun/lib/http-server.fun
@@ -46,6 +46,8 @@
// * HttpServer.Use runs before route matching (all requests, including
// 404/405); HttpGroup.Use runs after a route matches. Put request-wide
// middleware such as CORS on the server, before authentication.
+// * res.beforeSend(fn) runs fn(res, body) just before headers are written;
+// session/cookie middleware uses it to add or clear cookies.
// * the middleware continuation parameter is named `nx` because `next`
// (like `step`) is reserved by the language.
// * routing rules: literal segments beat `:name` parameters, which beat the
@@ -438,6 +440,7 @@ class HttpRes()
var code = 0;
var hdrs = nil;
var sent = false;
+ var preSend = nil;
fun status(c)
code = c;
@@ -472,10 +475,26 @@ class HttpRes()
result = this;
end fun;
+ // Run before the response headers are written; used by session/cookie
+ // middleware to add or clear cookies after the handler has run.
+ fun beforeSend(fx)
+ if preSend = nil then preSend = new []; end if;
+ preSend.@add(fx);
+ result = this;
+ end fun;
+
fun send(s)
if sent then
raise 'http-server: response already sent';
end if;
+ if preSend <> nil then
+ var ps = preSend;
+ preSend = nil;
+ for i = 0 to ps.@count() - 1 do
+ var f = ps[i];
+ f(this, s);
+ end do;
+ end if;
sent = true;
if code <= 0 then
code = 200;
diff --git a/fun/lib/http-session.fun b/fun/lib/http-session.fun
new file mode 100644
index 0000000..92622a0
--- /dev/null
+++ b/fun/lib/http-session.fun
@@ -0,0 +1,227 @@
+// Copyright (c) 2010-2026 Zhang Weidong
+// SPDX-License-Identifier: MIT
+//
+// http-session: cookie-backed server-side sessions (RFC 6265).
+//
+// s.Use(HttpSession([
+// name: 'sid', // cookie name
+// ttl: 86400, // server-side seconds
+// maxAge: 0, // cookie seconds; 0 = browser session cookie
+// secure: true, // HTTPS only
+// sameSite: 'Lax'
+// ]));
+//
+// req.session['user'] = 'alice';
+// SessionRegenerate(req); // after login (new SID)
+// req.session = nil; // logout
+//
+// Options: name, ttl, maxAge, secure, sameSite, domain, path, httpOnly, and
+// store (get(sid), set(sid, data, ttl), destroy(sid)).
+// The cookie carries only a random SID; data stays in the store. Session data
+// must be JSON-serializable (it is compared before/after the handler). TTL is
+// refreshed when the session is saved. CSRF is not included: SameSite helps,
+// but state-changing routes still need a CSRF token.
+use 'uuid.fun';
+
+fun _sessNow()
+ var p = -2; // QPC counter
+ var f = -1; // QPC frequency
+ result = p.time() div (f.time() div 1000);
+end fun;
+
+class CMemStore()
+ var map = new [];
+ var lastGc = 0;
+
+ fun gc()
+ var now = _sessNow();
+ var m = new [];
+ for k: v in map do
+ if v['expires'] > now then m[k] = v; end if;
+ end do;
+ map = m;
+ lastGc = now;
+ end fun;
+
+ fun drop(sid)
+ var m = new [];
+ for k: v in map do
+ if k <> sid then m[k] = v; end if;
+ end do;
+ map = m;
+ end fun;
+
+ fun get(sid)
+ var e = map[sid];
+ if e = nil then result = nil; return; end if;
+ if e['expires'] <= _sessNow() then
+ this.drop(sid);
+ result = nil;
+ return;
+ end if;
+ result = e['data'];
+ end fun;
+
+ fun set(sid, data, ttl)
+ var now = _sessNow();
+ if map.@count() > 128 and now - lastGc > 60000 then this.gc(); end if;
+ var e = new [];
+ e['data'] = data;
+ e['expires'] = now + ttl * 1000;
+ map[sid] = e;
+ result = true;
+ end fun;
+
+ fun destroy(sid)
+ if map[sid] <> nil then this.drop(sid); end if;
+ result = true;
+ end fun;
+end class;
+
+fun _sessionFlush(res, body)
+ var ctx = res.sessionCtx;
+ if ctx = nil then return; end if;
+ res.sessionCtx = nil;
+
+ var req = ctx['req'];
+ var store = ctx['store'];
+ var name = ctx['name'];
+ var cookieOpts = ctx['cookieOpts'];
+ var clearOpts = ctx['clearOpts'];
+ var ttl = ctx['ttl'];
+ var sid = ctx['sid'];
+ var stale = ctx['stale'];
+ var had = ctx['had'];
+ var sess = req.session;
+ var regen = req.sessionRegen = true;
+ req.sessionRegen = false;
+
+ if sess = nil then
+ if sid <> nil then store.destroy(sid); end if;
+ if had then res.clearCookie(name, clearOpts); end if;
+ return;
+ end if;
+
+ var after = sess.@toJson(json: true);
+ if regen then
+ if sid <> nil then store.destroy(sid); end if;
+ sid = UUID();
+ store.set(sid, sess, ttl);
+ res.cookie(name, sid, cookieOpts);
+ elsif after <> ctx['before'] then
+ if sid = nil then
+ sid = UUID();
+ store.set(sid, sess, ttl);
+ res.cookie(name, sid, cookieOpts);
+ else
+ store.set(sid, sess, ttl);
+ end if;
+ elsif stale and had then
+ res.clearCookie(name, clearOpts);
+ end if;
+end fun;
+
+class CSession(o)
+ var name = 'sid';
+ var ttl = 86400;
+ var maxAge = 0;
+ var secure = false;
+ var sameSite = 'Lax';
+ var domain = nil;
+ var path = '/';
+ var httpOnly = true;
+ var store = nil;
+ var cookieOpts = new [];
+ var clearOpts = new [];
+
+ if o <> nil then
+ if o.@has('name') and o['name'] <> nil and o['name'] <> '' then name = '' & o['name']; end if;
+ if o.@has('ttl') then
+ ttl = o['ttl'];
+ if ttl < 0 then raise 'http-session: ttl must be >= 0'; end if;
+ end if;
+ if o.@has('maxAge') then
+ maxAge = o['maxAge'];
+ if maxAge < 0 then raise 'http-session: maxAge must be >= 0'; end if;
+ end if;
+ if o.@has('secure') and o['secure'] = true then secure = true; end if;
+ if o.@has('httpOnly') then httpOnly = (o['httpOnly'] = true); end if;
+ if o.@has('domain') and o['domain'] <> nil and o['domain'] <> '' then domain = '' & o['domain']; end if;
+ if o.@has('path') and o['path'] <> nil and o['path'] <> '' then path = '' & o['path']; end if;
+ if o.@has('sameSite') and o['sameSite'] <> nil then
+ var ss = ('' & o['sameSite']).upper();
+ if ss = 'LAX' then sameSite = 'Lax';
+ elsif ss = 'STRICT' then sameSite = 'Strict';
+ elsif ss = 'NONE' then sameSite = 'None';
+ else raise 'http-session: sameSite must be Lax, Strict or None'; end if;
+ end if;
+ if o.@has('store') then store = o['store']; end if;
+ end if;
+ if sameSite = 'None' and not secure then
+ raise 'http-session: sameSite None requires secure';
+ end if;
+ if store = nil then store = CMemStore(); end if;
+
+ var co = new [];
+ co['path'] = path;
+ co['httpOnly'] = httpOnly;
+ co['sameSite'] = sameSite;
+ if secure then co['secure'] = true; end if;
+ if domain <> nil then co['domain'] = domain; end if;
+ if maxAge > 0 then co['maxAge'] = maxAge; end if;
+ cookieOpts = co;
+
+ var cl = new [];
+ cl['path'] = path;
+ cl['httpOnly'] = httpOnly;
+ cl['sameSite'] = sameSite;
+ if secure then cl['secure'] = true; end if;
+ if domain <> nil then cl['domain'] = domain; end if;
+ clearOpts = cl;
+
+ fun run(req, res, nx)
+ var h = req.cookie(name);
+ var sid = nil;
+ var data = nil;
+ var stale = false;
+ var had = false;
+ if h <> nil and h <> '' then
+ had = true;
+ sid = h;
+ data = store.get(sid);
+ if data = nil then
+ sid = nil;
+ stale = true;
+ else
+ data = data.@toJson(json: true).getJson(json: true);
+ end if;
+ end if;
+ if data = nil then data = new []; end if;
+ req.session = data;
+ req.sessionRegen = false;
+
+ var ctx = new [];
+ ctx['req'] = req;
+ ctx['store'] = store;
+ ctx['name'] = name;
+ ctx['cookieOpts'] = cookieOpts;
+ ctx['clearOpts'] = clearOpts;
+ ctx['ttl'] = ttl;
+ ctx['sid'] = sid;
+ ctx['stale'] = stale;
+ ctx['had'] = had;
+ ctx['before'] = data.@toJson(json: true);
+ res.sessionCtx = ctx;
+ res.beforeSend(_sessionFlush);
+ nx();
+ end fun;
+end class;
+
+fun HttpSession(opts)
+ result = CSession(opts);
+end fun;
+
+fun SessionRegenerate(req)
+ req.sessionRegen = true;
+ result = true;
+end fun;
diff --git a/fun/test/expected/lib-http-session-test.fun.txt b/fun/test/expected/lib-http-session-test.fun.txt
new file mode 100644
index 0000000..ad58053
--- /dev/null
+++ b/fun/test/expected/lib-http-session-test.fun.txt
@@ -0,0 +1,26 @@
+session read empty: True
+session read no cookie: True
+session write cookie: True
+session cookie httponly: True
+session cookie samesite: True
+session cookie path: True
+session read user: True
+session read no resend: True
+session inc 1: True
+session inc no cookie: True
+session inc 2: True
+session isolated: True
+session regen cookie: True
+session old sid dead: True
+session new sid alive: True
+session logout clears: True
+session logout dead: True
+session stale clear: True
+session secure: True
+session strict: True
+session maxage: True
+session ttl expired: True
+session ttl stale clear: True
+session custom store: True
+session none requires secure: True
+ok
diff --git a/fun/test/expected/lib-loadcheck.sh.txt b/fun/test/expected/lib-loadcheck.sh.txt
index 4e06b2c..0b1e56e 100644
--- a/fun/test/expected/lib-loadcheck.sh.txt
+++ b/fun/test/expected/lib-loadcheck.sh.txt
@@ -3,6 +3,7 @@ LOAD http-bearer
LOAD http-client
LOAD http-cors
LOAD http-server
+LOAD http-session
LOAD lib-ado-schema
LOAD lib-ado
LOAD lib-ajax
@@ -67,4 +68,4 @@ LOAD lib-zlib
LOAD test
LOAD uuid
-loaded=62 failed=6
\ No newline at end of file
+loaded=63 failed=6
\ No newline at end of file
diff --git a/fun/test/expected/macos/lib-loadcheck.sh.txt b/fun/test/expected/macos/lib-loadcheck.sh.txt
index 6080b73..ef4c8ec 100644
--- a/fun/test/expected/macos/lib-loadcheck.sh.txt
+++ b/fun/test/expected/macos/lib-loadcheck.sh.txt
@@ -3,6 +3,7 @@ LOAD http-bearer
LOAD http-client
LOAD http-cors
LOAD http-server
+LOAD http-session
LOAD lib-ado-schema
LOAD lib-ado
LOAD lib-ajax
@@ -67,4 +68,4 @@ LOAD lib-zlib
LOAD test
LOAD uuid
-loaded=62 failed=6
\ No newline at end of file
+loaded=63 failed=6
\ No newline at end of file
diff --git a/fun/test/lib-http-session-test.fun b/fun/test/lib-http-session-test.fun
new file mode 100644
index 0000000..8f4b96b
--- /dev/null
+++ b/fun/test/lib-http-session-test.fun
@@ -0,0 +1,256 @@
+// fun/lib/http-session: self-driven checks. Raw requests go out over
+// ConnectSocket and the server is pumped with runOnce, so every assertion is
+// a stable boolean snapshot.
+use 'fun/lib/http-server.fun';
+use 'fun/lib/http-session.fun';
+
+fun hasRe(s, re)
+ result = false;
+ if s =~ re then result = true; end if;
+end fun;
+
+fun pump(s, n)
+ for i = 1 to n loop
+ exit when s.runOnce(300) = 0;
+ end loop;
+end fun;
+
+fun rq(port, raw)
+ var c = ConnectSocket('127.0.0.1', port);
+ SendAll(c, raw.escape());
+ result = c;
+end fun;
+
+fun getReq(port, path)
+ result = rq(port, 'GET ' & path & ' HTTP/1.1\r\nHost: t\r\n\r\n');
+end fun;
+
+fun getSid(port, path, sid)
+ result = rq(port, 'GET ' & path & ' HTTP/1.1\r\nHost: t\r\nCookie: sid=' & sid & '\r\n\r\n');
+end fun;
+
+// Set-Cookie: sid=; ...
+fun cookieSid(resp, name)
+ result = '';
+ var lines = split(/\r?\n/, resp);
+ var key = 'Set-Cookie: ' & name & '=';
+ for i = 0 to lines.@count() - 1 do
+ var ln = lines[i];
+ if ln.subpos(key) = 0 then
+ var v = ln.substr(key.length());
+ var e = v.subpos(';');
+ if e >= 0 then v = v.substr(0, e); end if;
+ result = v;
+ return;
+ end if;
+ end do;
+end fun;
+
+fun readH(req, res)
+ if req.session['user'] = nil then
+ res.text('none');
+ else
+ res.text('user:' & req.session['user']);
+ end if;
+end fun;
+
+fun writeH(req, res)
+ req.session['user'] = 'alice';
+ res.text('written');
+end fun;
+
+fun incH(req, res)
+ req.session['count'] = (req.session['count'] or 0) + 1;
+ res.text('count:' & req.session['count']);
+end fun;
+
+fun logoutH(req, res)
+ req.session = nil;
+ res.text('out');
+end fun;
+
+fun regenH(req, res)
+ req.session['user'] = 'alice';
+ SessionRegenerate(req);
+ res.text('regen');
+end fun;
+
+class CTestStore()
+ var map = new [];
+
+ fun get(sid)
+ result = map[sid];
+ end fun;
+
+ fun set(sid, data, ttl)
+ map[sid] = data;
+ result = true;
+ end fun;
+
+ fun destroy(sid)
+ map[sid] = nil;
+ result = true;
+ end fun;
+end class;
+
+var s = HttpServer(0);
+s.Use(HttpSession([name: 'sid', ttl: 60, maxAge: 0, sameSite: 'Lax']));
+s.get('/read', readH);
+s.get('/write', writeH);
+s.get('/inc', incH);
+s.get('/logout', logoutH);
+s.get('/regen', regenH);
+var p = s.listen();
+
+// 1. read-only request: no session, no cookie
+var c1 = getReq(p, '/read');
+pump(s, 5);
+var r1 = RecvSome(c1, 4096);
+?. 'session read empty: ' & hasRe(r1, %\r\n\r\nnone$%);
+?. 'session read no cookie: ' & not hasRe(r1, %Set-Cookie%);
+CloseSocket(c1);
+
+// 2. write creates a cookie with secure defaults
+var c2 = getReq(p, '/write');
+pump(s, 5);
+var r2 = RecvSome(c2, 4096);
+var sid1 = cookieSid(r2, 'sid');
+?. 'session write cookie: ' & (sid1.length() = 36);
+?. 'session cookie httponly: ' & hasRe(r2, %HttpOnly%);
+?. 'session cookie samesite: ' & hasRe(r2, %SameSite=Lax%);
+?. 'session cookie path: ' & hasRe(r2, %Path=/%);
+CloseSocket(c2);
+
+// 3. next request loads the session
+var c3 = getSid(p, '/read', sid1);
+pump(s, 5);
+var r3 = RecvSome(c3, 4096);
+?. 'session read user: ' & hasRe(r3, %\r\n\r\nuser:alice$%);
+?. 'session read no resend: ' & not hasRe(r3, %Set-Cookie%);
+CloseSocket(c3);
+
+// 4. existing session mutation persists without a new cookie
+var c4 = getSid(p, '/inc', sid1);
+pump(s, 5);
+var r4 = RecvSome(c4, 4096);
+?. 'session inc 1: ' & hasRe(r4, %\r\n\r\ncount:1$%);
+?. 'session inc no cookie: ' & not hasRe(r4, %Set-Cookie%);
+CloseSocket(c4);
+
+var c5 = getSid(p, '/inc', sid1);
+pump(s, 5);
+var r5 = RecvSome(c5, 4096);
+?. 'session inc 2: ' & hasRe(r5, %\r\n\r\ncount:2$%);
+CloseSocket(c5);
+
+// 5. another client has no session
+var c6 = getReq(p, '/read');
+pump(s, 5);
+var r6 = RecvSome(c6, 4096);
+?. 'session isolated: ' & hasRe(r6, %\r\n\r\nnone$%);
+CloseSocket(c6);
+
+// 6. regenerate rotates the SID, old SID dies, data survives
+var c7 = getSid(p, '/regen', sid1);
+pump(s, 5);
+var r7 = RecvSome(c7, 4096);
+var sid2 = cookieSid(r7, 'sid');
+?. 'session regen cookie: ' & (sid2.length() = 36 and sid2 <> sid1);
+CloseSocket(c7);
+
+var c8 = getSid(p, '/read', sid1);
+pump(s, 5);
+var r8 = RecvSome(c8, 4096);
+?. 'session old sid dead: ' & hasRe(r8, %\r\n\r\nnone$%);
+CloseSocket(c8);
+
+var c9 = getSid(p, '/read', sid2);
+pump(s, 5);
+var r9 = RecvSome(c9, 4096);
+?. 'session new sid alive: ' & hasRe(r9, %\r\n\r\nuser:alice$%);
+CloseSocket(c9);
+
+// 7. logout clears the cookie and the store
+var c10 = getSid(p, '/logout', sid2);
+pump(s, 5);
+var r10 = RecvSome(c10, 4096);
+?. 'session logout clears: ' & hasRe(r10, %Set-Cookie:\x20sid=;\x20Path=/;\x20Max-Age=0%);
+CloseSocket(c10);
+
+var c11 = getSid(p, '/read', sid2);
+pump(s, 5);
+var r11 = RecvSome(c11, 4096);
+?. 'session logout dead: ' & hasRe(r11, %\r\n\r\nnone$%);
+CloseSocket(c11);
+
+// 8. unknown SID is treated as stale and cleared
+var c12 = getSid(p, '/read', 'bogus');
+pump(s, 5);
+var r12 = RecvSome(c12, 4096);
+?. 'session stale clear: ' & hasRe(r12, %Set-Cookie:\x20sid=;\x20Path=/;\x20Max-Age=0%);
+CloseSocket(c12);
+
+s.stop();
+
+// 9. secure + sameSite Strict + cookie maxAge
+var s2 = HttpServer(0);
+s2.Use(HttpSession([secure: true, sameSite: 'Strict', maxAge: 120]));
+s2.get('/write', writeH);
+var p2 = s2.listen();
+var c13 = getReq(p2, '/write');
+pump(s2, 5);
+var r13 = RecvSome(c13, 4096);
+?. 'session secure: ' & hasRe(r13, %Secure%);
+?. 'session strict: ' & hasRe(r13, %SameSite=Strict%);
+?. 'session maxage: ' & hasRe(r13, %Max-Age=120%);
+CloseSocket(c13);
+s2.stop();
+
+// 10. TTL expiry
+var s3 = HttpServer(0);
+s3.Use(HttpSession([ttl: 0]));
+s3.get('/write', writeH);
+s3.get('/read', readH);
+var p3 = s3.listen();
+var c14 = getReq(p3, '/write');
+pump(s3, 5);
+var r14 = RecvSome(c14, 4096);
+var sid3 = cookieSid(r14, 'sid');
+CloseSocket(c14);
+var c15 = getSid(p3, '/read', sid3);
+pump(s3, 5);
+var r15 = RecvSome(c15, 4096);
+?. 'session ttl expired: ' & hasRe(r15, %\r\n\r\nnone$%);
+?. 'session ttl stale clear: ' & hasRe(r15, %Set-Cookie:\x20sid=;%);
+CloseSocket(c15);
+s3.stop();
+
+// 11. custom store interface
+var st = CTestStore();
+var s4 = HttpServer(0);
+s4.Use(HttpSession([store: st]));
+s4.get('/write', writeH);
+s4.get('/read', readH);
+var p4 = s4.listen();
+var c16 = getReq(p4, '/write');
+pump(s4, 5);
+var r16 = RecvSome(c16, 4096);
+var sid4 = cookieSid(r16, 'sid');
+CloseSocket(c16);
+var c17 = getSid(p4, '/read', sid4);
+pump(s4, 5);
+var r17 = RecvSome(c17, 4096);
+?. 'session custom store: ' & hasRe(r17, %\r\n\r\nuser:alice$%);
+CloseSocket(c17);
+s4.stop();
+
+// 12. SameSite=None requires Secure
+var bad = false;
+try
+ var x = HttpSession([sameSite: 'None']);
+except
+ bad = true;
+end try;
+?. 'session none requires secure: ' & bad;
+
+?. 'ok';
diff --git a/fun/test/regression-list.txt b/fun/test/regression-list.txt
index 51fc4b4..46e10f9 100644
--- a/fun/test/regression-list.txt
+++ b/fun/test/regression-list.txt
@@ -57,6 +57,7 @@ lib-hash-test.fun test/lib-hash-test.fun snap
lib-http-test.fun test/lib-http-test.fun snap
lib-http-bearer-test.fun test/lib-http-bearer-test.fun snap
lib-http-cors-test.fun test/lib-http-cors-test.fun snap
+lib-http-session-test.fun test/lib-http-session-test.fun snap
lib-proc-test.fun test/lib-proc-test.fun snap
lib-unicode-test.fun test/lib-unicode-test.fun snap
lib-bnf-dedup.fun test/lib-bnf-dedup.fun snap
diff --git a/fun/test/run-windows-regression.bat b/fun/test/run-windows-regression.bat
index 6c5ff51..66211f5 100644
--- a/fun/test/run-windows-regression.bat
+++ b/fun/test/run-windows-regression.bat
@@ -63,7 +63,7 @@ rem The four portable FFI cases (ffi-int64/float/float-cb/callbacks) are NOT
rem listed here: they only need getapi plus libffitest.dll, so they run
rem when both are there and are added below when they are not. ffi-scalar
rem stays: it calls libc/libm directly (getpid does not exist on Windows).
-set "HOSTDEP= builtin-dispatch.fun lib-host-test.fun lib-param-linux.fun lib-cmdline-linux.fun lib-loadcheck.sh libc-outbuf.fun ptr-primitives.fun raw-address-getapi.fun lib-tcc-test.fun lib-jit-test.fun lib-asm-lnx.fun lib-asm-a64.fun lib-asm-x64.fun fractal-asm-x64.fun lib-hash-test.fun lib-http-test.fun lib-http-bearer-test.fun lib-http-cors-test.fun lib-proc-test.fun lib-unicode-test.fun run-benchmarks.sh ffi-scalar.fun lib-os-dispatch-prototype.fun lib-crypt-lnx.fun lib-e2e-lnx.fun lib-ajax-lnx-test.fun run-ajax-lnx.sh lib-winsock-lnx-test.fun lib-netif-test.fun lib-ado-lnx-test.fun lib-ado-schema-lnx-test.fun lib-xml-lnx-test.fun lib-jsonrpc-lnx-test.fun lib-orm-rpc-lnx-test.fun lib-os-lnx-test.fun lib-cmdline-lnx-test.fun lib-zlib-test.fun lib-orm-rows.fun lib-file-test.fun lib-dyns-test.fun "
+set "HOSTDEP= builtin-dispatch.fun lib-host-test.fun lib-param-linux.fun lib-cmdline-linux.fun lib-loadcheck.sh libc-outbuf.fun ptr-primitives.fun raw-address-getapi.fun lib-tcc-test.fun lib-jit-test.fun lib-asm-lnx.fun lib-asm-a64.fun lib-asm-x64.fun fractal-asm-x64.fun lib-hash-test.fun lib-http-test.fun lib-http-bearer-test.fun lib-http-cors-test.fun lib-http-session-test.fun lib-proc-test.fun lib-unicode-test.fun run-benchmarks.sh ffi-scalar.fun lib-os-dispatch-prototype.fun lib-crypt-lnx.fun lib-e2e-lnx.fun lib-ajax-lnx-test.fun run-ajax-lnx.sh lib-winsock-lnx-test.fun lib-netif-test.fun lib-ado-lnx-test.fun lib-ado-schema-lnx-test.fun lib-xml-lnx-test.fun lib-jsonrpc-lnx-test.fun lib-orm-rpc-lnx-test.fun lib-os-lnx-test.fun lib-cmdline-lnx-test.fun lib-zlib-test.fun lib-orm-rows.fun lib-file-test.fun lib-dyns-test.fun "
rem Tests gated only by the regex engine's absence: probed below, skipped while
rem REGEXOK=0. The CI win64 build links PCRE dynamically (pcre3.dll next to