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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- 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.
|