tep 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
data/SINATRA_COMPAT.md ADDED
@@ -0,0 +1,376 @@
1
+ # Sinatra compatibility (tep)
2
+
3
+ Generated from the curated checklist suite under `test/` plus
4
+ real-world apps under `test/real_world/`. Run `make test` to refresh.
5
+
6
+ **Headline**: ~170 checklist tests pass + 6 real-world apps build
7
+ and serve correctly (smoke-tested through `Net::HTTP`).
8
+ 4 documented skips remain. v0.2 brought cookies, sessions,
9
+ streaming, regex routes, modular `Sinatra::Base`, ERB. v0.3 added
10
+ `send_file 'path'`, `configure { ... }` (incl. `:env`), `__END__`
11
+ inline templates, `pass`, multiple chained `before`/`after`,
12
+ optional path segments, full Rack::Request method surface, ERB
13
+ ivar locals, a Mustache subset, Tep::SQLite, Tep::Json,
14
+ Tep::Logger, Tep::Jwt, Tep::Password, Tep::Security
15
+ (CORS + secure headers), Tep::Assets (compile-time asset
16
+ bundling), Tep::Scheduler (cooperative fiber scheduler with
17
+ poll(2)-backed `io_wait`), Tep::Shell (popen + small-file
18
+ reader), and Tep::Http (Faraday-shaped outbound HTTP/1.0 client).
19
+ v0.5 added Tep::Llm (chat completions + SSE streaming), Tep::Job
20
+ (sidekiq-shaped SQLite-backed queue), Tep::Parallel (grosser/parallel
21
+ fork fan-out), and Tep::WebSocket (RFC 6455 server-side, Faye-driver
22
+ shape, lowered by the `websocket '/p' do |ws| ... end` DSL hook).
23
+
24
+ ## Phase A — Curated checklist
25
+
26
+ | Feature | Tests | Notes |
27
+ |--------------------------------------|--------:|---|
28
+ | `get`/`post`/`put`/`patch`/`delete` | ✅ 8 | All five verbs round-trip |
29
+ | Path parameters (`/hi/:name`) | ✅ 4 | Single-segment captures |
30
+ | Two+ path parameters | ✅ 1 | `/users/:id/posts/:post_id` |
31
+ | Splat (`*`) | ✅ 1 | Last-segment only |
32
+ | Query string | ✅ 4 | `params[:q]` reads through |
33
+ | Form-urlencoded body | ✅ 2 | Auto-merged into params |
34
+ | Multipart/form-data body | ✅ 1 | Text fields auto-merged into params; file-upload parts skipped (follow-up `req.files`) |
35
+ | URL-decoding (`%xx`, `+`) | ✅ 2 | Path captures and query both |
36
+ | Custom status (`status N`) | ✅ 5 | 201, 204, 401, 418, 500, ... |
37
+ | Default `text/html` Content-Type | ✅ 1 | |
38
+ | Explicit `content_type 'x'` | ✅ 2 | Plain, JSON |
39
+ | Custom `headers["X"] = "y"` | ✅ 1 | |
40
+ | `redirect 'x'` (302) | ✅ 1 | Location header set |
41
+ | `redirect 'x', code` | ✅ 1 | Honors override (301) |
42
+ | `halt code, "body"` | ✅ 1 | |
43
+ | `halt code` (no body) | ✅ 1 | |
44
+ | `before do ... end` | ✅ 2 | Single slot, runs before route |
45
+ | `after do ... end` | ✅ 1 | Runs after route, sees mutated res |
46
+ | Default 404 | ✅ 1 | |
47
+ | Custom `not_found do ... end` | ✅ 2 | Body and `request.path` access |
48
+ | Static files (`set :public_dir`) | ✅ 4 | Mime-type sniffing, X-Tep-Static |
49
+ | Path-traversal rejection | ✅ 1 | `..` segments blocked |
50
+ | Route precedence over static | ✅ 1 | Defined route wins |
51
+ | `Content-Length` correctness | ✅ 1 | |
52
+ | 404 on method mismatch | ✅ 1 | `POST /` when only GET defined |
53
+ | `on_start do ... end` | ✅ 1 | Body runs at top of program |
54
+ | `request.headers["X"]` | ✅ 1 | Read alias |
55
+ | **Cookies**: `cookies["x"]` (read) | ✅ 4 | Parsed from Cookie: header |
56
+ | **Cookies**: `set_cookie "k", "v"` | ✅ 2 | Set-Cookie line written |
57
+ | **Sessions**: signed cookie store | ✅ 4 | HMAC-SHA256, tampered cookies rejected |
58
+ | **Streaming**: `stream X.new` | ✅ 4 | Chunked Transfer-Encoding via Streamer subclass |
59
+ | **Regex routes**: `get %r{...}` | ✅ 5 | Up to 9 captures bound to params["1"]..params["9"] |
60
+ | **Modular**: `class A < Sinatra::Base` | ✅ 3 | Routes fold into the global app; multiple modular classes coexist |
61
+ | **ERB**: `erb :name` + `locals: {}` | ✅ 4 | Build-time compiled; `<%= %>`, `<% %>`, `<%# %>` |
62
+ | **ERB ivar locals (`@name`)** | ✅ 3 | Sinatra-style: `@x = v` in handler / `before` filter, `<%= @x %>` in template. Translator stores on a per-request `req.ivars` String=>String bag; templates take `(locals, ivars)`. Values are `(...).to_s`-coerced on write. |
63
+ | **Mustache (subset)** | ✅ 3 | Build-time compiled; `mustache :name` DSL parallel to `erb :name`. See "Mustache subset" below. |
64
+ | **SQLite (libsqlite3 wrapper)** | ✅ 5 | `Tep::SQLite` class wrapping libsqlite3 via a thin C shim (tep_sqlite.c). Same FFI pattern as sphttp.c -- spinel can't load gem-style native extensions, so we link a static .o instead. See "SQLite" below. |
65
+ | **JSON (subset)** | ✅ 13 | Pure-Ruby `Tep::Json`: encode primitives + flat-key decoder. See "JSON subset" below. |
66
+ | **Logger** | ✅ 3 | `Tep::Logger` with debug/info/warn/error levels. stderr by default; `to_file(path)` appends. Format: `[<unix_seconds>] [<level>] <msg>`. |
67
+ | **JWT (HS256)** | ✅ 10 | `Tep::Jwt` -- encode/verify/decode. HS256 only (asymmetric algs would need OpenSSL); `none` deliberately not supported (RFC 8725 §3.1). Tokens verify cleanly against the canonical `jwt` Ruby gem (interop test included). New base64url helpers (`sphttp_b64url_encode/decode`, `sphttp_hmac_sha256_b64url`) ride on top of the existing HMAC-SHA256 used by the session store. |
68
+ | **Password hashing (PBKDF2)** | ✅ 9 | `Tep::Password.hash` / `verify`. PBKDF2-SHA256, 200k iters by default, 16-byte CSPRNG salt. Self-describing storage format (`pbkdf2-sha256$<iters>$<salt>$<derived>`) so iter rotation can land later without breaking old hashes. New `sphttp_pbkdf2_sha256_b64url` + `sphttp_random_b64url` C helpers. (`Klass.hash(plain)` factory shape resolved via spinel #407.) |
69
+ | **CORS + secure headers** | ✅ 4 | `Tep::Security::Cors` (before-filter; configurable origin / verbs / headers / max-age; OPTIONS preflight short-circuits with 204) and `Tep::Security::Headers` (after-filter; `nosniff`, `SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-XSS-Protection: 0`, optional HSTS via `set_hsts(seconds)`). |
70
+ | **Cooperative scheduler** | ✅ 4 | `Tep::Scheduler` -- spawn fibers, drain via tick / `run_until_empty` / `run_for(seconds)`, cooperative `pause(seconds)` and `io_wait(fd, mode, timeout)` that yield back to the scheduler root. Each `tick` runs a poll(2) round (`sphttp_poll_*` C helpers) to mark socket-ready fibers, then resumes whichever wake_at (time- or I/O-) is soonest-due. Spinel ships Fiber natively (ucontext-based, GC-aware); the scheduler is the layer above. |
71
+ | **Compile-time asset bundling** | ✅ 1 | `<app>/assets/**` auto-discovered by `bin/tep`, emitted as `Tep::Assets._add` registrations. Body bytes ride in the binary as Ruby string literals. `Tep::Assets.serve(path, res)` runs in `App#dispatch` before route matching; `Cache-Control: public, max-age=3600` on every response. |
72
+ | **send_file `'path'`** | ✅ 1 | Reuses Tep::Response#send_file streaming path |
73
+ | **configure { ... }** / **:env** | ✅ 1 | Body runs at module load; env-keyed form gates on `ENV["TEP_ENV"]` (default "development") |
74
+ | **`__END__` inline templates** | ✅ 1 | `@@ name` blocks compile through the same ERB pipeline as files; file-based views still win when both exist |
75
+ | **`pass`** / **`pass if cond`** | ✅ 3 | `req.passed` flag; dispatcher walks to next matching route or 404s |
76
+ | **Multiple `before` / `after`** | ✅ 2 | Translator merges N blocks into one composite Filter subclass |
77
+ | **Optional path segments `(/:foo)`** | ✅ 5 | Translator expands to the Cartesian product of include/skip; up to N optionals |
78
+ | **Rack::Request-style methods** | ✅ 6 | `.host`, `.user_agent`, `.referer`/`.referrer`, `.accept`, `.content_type`, `.scheme`/`.ssl?` (via `X-Forwarded-Proto`) |
79
+ | **WebSocket** (`websocket '/p' do \|ws\|`) | ✅ 11 | RFC 6455 server-side. `on_open` / `on_message` / `on_close` / `on_ping` / `on_pong` / `on_error` block events; `ws.text` / `ws.binary` / `ws.ping` / `ws.pong` / `ws.close(code, reason)` writers. Each `on_X` block has access to `req` (the upgrade-time `Tep::Request` — same `req.identity`, `req.session`, headers as a normal route) plus `ws` (the Driver). 10 codec/handshake unit tests + 1 end-to-end echo round-trip (raw-socket RFC 6455 handshake + masked TEXT → unmasked echo). Requires `set :scheduler, :scheduled` (recv loop parks on `Tep::Scheduler.io_wait`); the blocking server returns 501. Text payload paths are NUL-safe via a C-side send accumulator (`sphttp_send_append_byte` / `_flush`) since spinel Ruby Strings are NUL-bound at the value level; full binary payloads with embedded `0x00` are a follow-up. |
80
+
81
+ ## Phase B — Real-world apps
82
+
83
+ `test/real_world/`:
84
+
85
+ | # | Source | Build | Serve | Notes |
86
+ |---|---------------------------------------------------|:-----:|:-----:|---|
87
+ | 01 | sinatra/examples/simple.rb | ✅ | ✅ | First-try pass |
88
+ | 02 | sinatra/examples/lifecycle_events.rb | ✅ | ✅ | Triggered translator support for `on_start`; `on_stop` ignored |
89
+ | 03 | sinatra/examples/chat.rb | ⚠️ | — | ERB now works, but `stream do |out|` block syntax + `Set.new` top-level + `__END__` data section still don't translate |
90
+ | 04 | synthesized: tiny health/version JSON API | ✅ | ✅ | |
91
+ | 05 | synthesized: in-memory todo CRUD | ✅ | ✅ | Required `[0].delete_at(0)` seed for typed arrays |
92
+ | 06 | synthesized: before-filter Bearer auth | ✅ | ✅ | |
93
+ | 07-bbc | github.com/bbc/REST-API-example | ❌ | — | DataMapper ORM, dm-types, dm-validations |
94
+ | 07-sklise | github.com/sklise/sinatra-api-example | ❌ | — | DataMapper, `to_json`, `send_file` |
95
+ | 08 | github.com/jwd83/sinatra-helloworld | ⚠️ | ⚠️ | Uses `__END__` inline templates; ERB itself works for view-files now |
96
+
97
+ ## Inline fixes shipped
98
+
99
+ - **Translator**: `on_start`, top-level passthrough (constants, classes, defs), receiver-aware top-call, `Sinatra::Base` modular unwrapping.
100
+ - **Translator**: rewrites for `cookies[]`, `set_cookie`, `session[]=` / `session[]` (via `.set` / `.get`), `stream X`, `erb :name`, `set :views`, `set :public_dir`.
101
+ - **Tep::Request**: `headers` / `body` read aliases.
102
+ - **Tep::Response**: `set_cookie`, `start_stream`.
103
+ - **Tep::Session**: HMAC-SHA256-signed cookie store; tampered cookies rejected via timing-safe compare.
104
+ - **Tep::Streamer**: subclass-style streaming with chunked frames written via `Stream#write`.
105
+ - **C helper**: `sphttp_hmac_sha256_hex`, `sphttp_write_chunk`, `sphttp_write_chunk_end`.
106
+
107
+ ## Not yet supported (skipped tests)
108
+
109
+ | Feature | Effort | Notes |
110
+ |------------------------|--------|---|
111
+ | Haml / Slim / etc. | n/a | Out of scope -- those are CRuby gems |
112
+ | `helpers do ... end` | medium | Closures not first-class in spinel; would need translator-level "extract methods to Handler base" pass |
113
+ | `request.ip` / `request.remote_ip` | medium | Needs an sphttp_accept variant that returns the peer addr from the kernel; the rest of Rack::Request lands without C changes |
114
+
115
+ ## Showcases
116
+
117
+ Two flagship examples that put the framework through its paces.
118
+
119
+ ### `examples/blog/`
120
+
121
+ Posts + users persisted in SQLite, web login via sessions +
122
+ `Tep::Password`, JSON API with `Tep::Json`, JWT-authed writes via
123
+ `Tep::Jwt`, ERB views with Sinatra-style `@ivar` locals, request
124
+ logging via `Tep::Logger`, CORS + secure headers via
125
+ `Tep::Security`. First boot seeds `alice / hunter2` and an intro
126
+ post explaining what tep is.
127
+
128
+ bin/tep build examples/blog/app.rb -o /tmp/blog
129
+ /tmp/blog -p 4567
130
+
131
+ ### `examples/chat/`
132
+
133
+ Live multi-user chat with **presence** and **bundled assets**.
134
+ The view ships an SVG logo + a polished CSS file from
135
+ `examples/chat/assets/`; both are baked into the binary by
136
+ `bin/tep` (see "Compile-time asset bundling" below) and served
137
+ directly from memory.
138
+
139
+ By default the JS client polls `GET /chat/recent?since=N` once
140
+ per second. The Server-Sent Events transport (the
141
+ `ChatStreamer` + `GET /chat/stream`) is also wired -- flip
142
+ `window.USE_SSE = true` in the page to switch. SSE works fine on
143
+ Linux (prefork distributes accepts across workers); on macOS dev
144
+ machines `SO_REUSEPORT` doesn't load-balance the same way, so a
145
+ held SSE connection on the only-accepting worker blocks every
146
+ other request on the same listener until the stream self-closes
147
+ (`STREAM_MAX`, 30 s). Polling-by-default keeps the dev experience
148
+ identical across the two.
149
+
150
+ `set :workers, 4` is wired in the app source so prefork is the
151
+ default.
152
+
153
+ bin/tep build examples/chat/app.rb -o /tmp/chat
154
+ /tmp/chat -p 4567
155
+
156
+ Open in two browsers; messages from one show up in the other
157
+ within a second.
158
+
159
+ ## Compile-time asset bundling
160
+
161
+ Anything under `<app_dir>/assets/` is auto-discovered by
162
+ `bin/tep` and emitted as `Tep::Assets._add` registrations in the
163
+ generated source. The body bytes ride in the binary as Ruby
164
+ string literals (which spinel passes through to the C compile as
165
+ `const char *`); MIME is inferred from extension at build time.
166
+
167
+ ```
168
+ examples/chat/
169
+ app.rb
170
+ assets/
171
+ style.css -> GET /style.css (text/css)
172
+ logo.svg -> GET /logo.svg (image/svg+xml)
173
+ ```
174
+
175
+ The `Tep::Assets.serve(path, res)` check runs in `App#dispatch`
176
+ before route matching, so a route at `/foo` and an asset at
177
+ `/foo` -- the asset wins. Each response gets
178
+ `Cache-Control: public, max-age=3600`.
179
+
180
+ Limitations:
181
+
182
+ - Files containing NUL bytes are skipped (warned at build time).
183
+ Spinel's `:str` type doesn't track length alongside the
184
+ pointer, so a NUL truncates the served body. For binary
185
+ assets that need exact byte round-trip (PNG, fonts, ...),
186
+ use `Tep.public_dir` to serve from disk at runtime instead.
187
+ - No content-hash etag yet; the bytes are immutable for the
188
+ life of the binary, so a fingerprint-in-filename strategy
189
+ would be a clean follow-up.
190
+
191
+ ### Smoke-tested end-to-end
192
+
193
+ `test/test_real_world.rb` builds each "claimed working" example
194
+ and the two showcases on a fresh port, drives Net::HTTP requests
195
+ through them, and asserts on the response shape (incl. raw
196
+ TCP-socket reads on the SSE pipe to verify backlog + keepalive
197
+ chunks land before `Net::HTTP` would have stopped reading). A
198
+ build-passes-but-doesn't-actually-serve regression fails CI now,
199
+ not "later, when someone curls it by hand."
200
+
201
+ ## Mustache subset
202
+
203
+ Tep ships a build-time Mustache compiler with a deliberately
204
+ narrow surface. The DSL mirrors ERB:
205
+
206
+ ```ruby
207
+ get '/' do
208
+ mustache :hello, locals: { name: "alice", snippet: "<b>BOLD</b>" }
209
+ end
210
+ ```
211
+
212
+ Supported tags:
213
+
214
+ | Tag | Compiles to | Notes |
215
+ |-----------------|--------------------------------------|---|
216
+ | `{{name}}` | `out += Tep.h(locals["name"])` | Default. HTML-escaped. |
217
+ | `{{{name}}}` | `out += locals["name"]` | Raw / unescaped. |
218
+ | `{{& name}}` | `out += locals["name"]` | Spec alias for the triple-stache form. |
219
+ | `{{@name}}` | `out += Tep.h(ivars["name"])` | Reads from the per-request ivars bag (same `@x = v` pattern as ERB). Escaped. |
220
+ | `{{{@name}}}` | `out += ivars["name"]` | Raw ivar form. |
221
+ | `{{! comment}}` | dropped at compile | |
222
+
223
+ Out of scope (compiler raises with a `mustache ... unsupported`
224
+ message if reached, so build fails fast instead of silently
225
+ mis-rendering):
226
+
227
+ - `{{#section}}...{{/section}}` and inverted `{{^section}}` --
228
+ sections need iterable locals; tep's view args are
229
+ `String=>String` hashes.
230
+ - `{{>partial}}` -- call `mustache :partial` from the handler
231
+ instead, or compose at the handler level.
232
+ - `{{=<% %>=}}` delimiter swaps -- niche, no plan.
233
+ - Lambdas / Proc-valued locals -- spinel has no Proc.
234
+
235
+ File resolution mirrors ERB: `views/<name>.mustache` first, then
236
+ the inline `__END__ \n @@ name` block. Tep's compiler emits a
237
+ distinct `tep_mustache_<name>(locals, ivars)` function, so a
238
+ project can mix ERB and Mustache views without name collisions.
239
+
240
+ ## SQLite
241
+
242
+ `Tep::SQLite` exposes libsqlite3 through a thin C shim (`lib/tep/tep_sqlite.c`).
243
+ Spinel can't load CRuby's native-extension gems (the `sqlite3` gem
244
+ ships an `.so`/`.bundle` against MRI's ABI), so the binding shape
245
+ is "static link to a small C wrapper" rather than "load a gem at
246
+ runtime". The Makefile builds `tep_sqlite.o` and `bin/tep`
247
+ substitutes its absolute path into `sqlite.rb`'s `ffi_cflags`.
248
+ `-lsqlite3` is added via `ffi_lib`.
249
+
250
+ ```ruby
251
+ db = Tep::SQLite.new
252
+ db.open("./app.db")
253
+ db.exec("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)")
254
+
255
+ # Parameterised insert.
256
+ db.prepare("INSERT INTO notes (body) VALUES (?)")
257
+ db.bind_str(1, "hello")
258
+ db.step
259
+ db.finalize
260
+ id = db.last_rowid
261
+
262
+ # Single-row read with one bound param. The convenience first_str /
263
+ # first_int wrap prepare + bind + step + col + finalize.
264
+ body = db.first_str("SELECT body FROM notes WHERE id = ?", id.to_s)
265
+
266
+ # Multi-row iteration.
267
+ db.prepare("SELECT id, body FROM notes ORDER BY id")
268
+ while db.step == 1
269
+ puts db.col_int(0).to_s + ": " + db.col_str(1)
270
+ end
271
+ db.finalize
272
+ ```
273
+
274
+ API surface:
275
+
276
+ | Method | Returns | Notes |
277
+ |-----------------------|---------|---|
278
+ | `open(path)` | bool | `path` may be `:memory:` for an anonymous in-memory db. |
279
+ | `close` | int | |
280
+ | `exec(sql)` | bool | DDL / non-bound writes / `BEGIN`+`COMMIT`. |
281
+ | `prepare(sql)` | bool | Opens the cursor; `?` markers bind 1-indexed. |
282
+ | `bind_str(idx, v)` | int | |
283
+ | `bind_int(idx, v)` | int | |
284
+ | `step` | int | 1 -> row, 0 -> done, -1 -> error. |
285
+ | `col_str(idx)` | str | NULL columns return `""`. |
286
+ | `col_int(idx)` | int | |
287
+ | `col_count` | int | |
288
+ | `reset` | int | Re-step the same prepared statement (e.g. inside a binding loop). |
289
+ | `finalize` | int | |
290
+ | `last_rowid` | int | |
291
+ | `first_str(sql, p1)` | str | Convenience for "single-row, single-column read with one param." Pass `""` for "no param". |
292
+ | `first_int(sql, p1)` | int | Same. |
293
+
294
+ Constraints:
295
+
296
+ - **One in-flight cursor per process.** `prepare` / `step` /
297
+ `finalize` share a single `sqlite3_stmt *`. Tep runs handlers
298
+ serially per worker so this is fine for "one DB call per
299
+ request"; nested queries (open one cursor, run another query
300
+ inside its `while step == 1` loop) would clobber the parent
301
+ cursor.
302
+ - **Up to 16 open DB handles per process** (a static slot table).
303
+ Increase `TEP_SQLITE_MAX_HANDLES` in `tep_sqlite.c` if needed.
304
+ - **String / int columns only.** Floats and blobs aren't first-
305
+ class. NULL is indistinguishable from empty-string.
306
+ - **64 KiB cap on a single col_str result.** Bump
307
+ `TEP_SQLITE_COL_BUFSIZE` for larger row fields.
308
+
309
+ ## JSON subset
310
+
311
+ `Tep::Json` is a pure-Ruby JSON shim covering the encode + decode
312
+ shapes that JSON-over-HTTP APIs use in practice. It deliberately
313
+ trades full library breadth for spinel-friendly code paths.
314
+
315
+ ### Encode
316
+
317
+ ```ruby
318
+ # Primitives.
319
+ Tep::Json.escape(s) # body of a JSON string literal (no quotes)
320
+ Tep::Json.quote(s) # "<escaped s>"
321
+
322
+ # Object building blocks (fixed-arity; compose by concatenation).
323
+ Tep::Json.encode_pair_str("k", v_string) # "k":"v"
324
+ Tep::Json.encode_pair_int("k", v_int) # "k":N
325
+
326
+ # Build a full object literal:
327
+ "{" + Tep::Json.encode_pair_str("name", name) + "," +
328
+ Tep::Json.encode_pair_int("age", age) + "}"
329
+
330
+ # Arrays.
331
+ Tep::Json.from_str_array(["a", "b"]) # ["a","b"]
332
+ Tep::Json.from_int_array([1, 2, 3]) # [1,2,3]
333
+
334
+ # Hashes (String=>String, String=>Int).
335
+ Tep::Json.from_str_hash({"name" => "alice"}) # {"name":"alice"}
336
+ Tep::Json.from_int_hash({"age" => 30}) # {"age":30}
337
+ ```
338
+
339
+ The hash forms `each`-iterate and inline `Json.quote` on the
340
+ narrowed `k:str` / `v:str` (or `v:int`) loop locals. The earlier
341
+ `encode_pair_*` building blocks remain useful when the caller
342
+ wants to compose an object from mixed-type pairs without building
343
+ a Hash first.
344
+
345
+ ### Decode (flat-key, top-level only)
346
+
347
+ ```ruby
348
+ Tep::Json.get_str(body, "name") # value of top-level "name", or "" if absent / non-string
349
+ Tep::Json.get_int(body, "age") # 0 if absent / non-numeric
350
+ Tep::Json.has_key?(body, "x") # boolean
351
+ ```
352
+
353
+ The hand-rolled state-machine parser walks one `{ "k": <value>, ... }`
354
+ pair at a time and skips over values it doesn't need (including
355
+ nested objects / arrays / strings with `"` and `{` / `}` inside
356
+ them). Returns 0 / "" on parse failure rather than raising --
357
+ suits API code that wants "no key" and "wrong type" to behave
358
+ the same way.
359
+
360
+ ### Out of scope (deliberately)
361
+
362
+ - **Floats.** Numbers parse / emit as int (`.to_s`). For
363
+ fractional values, transport as strings.
364
+ - **Path traversal** in the decoder (`payload.user.email`-style).
365
+ Use a flatter API contract or do the nested decode manually.
366
+ - **`\uXXXX` decoding past 00XX.** ASCII round-trips; non-ASCII
367
+ bytes pass through verbatim in encode and on parse-time
368
+ \u escapes in input we keep the low byte only.
369
+ - **Streaming** parsers. Loads the whole string.
370
+
371
+ ## Reading the matrix
372
+
373
+ A "supported" feature has at least one passing test through the
374
+ full pipeline (HTTP -> tep binary -> response). "Not yet supported"
375
+ rows have a `skip` in `test/test_unsupported.rb` or a fail-row in
376
+ the real-world table.