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
@@ -0,0 +1,320 @@
1
+ /* tep_sqlite.c - thin libsqlite3 wrapper for spinel-AOT'd tep apps.
2
+ *
3
+ * Pattern mirrors sphttp.c: spinel can't load CRuby C extensions
4
+ * (the `sqlite3` gem ships native code via Ruby's MRI ABI), so we
5
+ * write a small C shim with stable, str/int-typed entry points and
6
+ * expose them via spinel's `ffi_func` DSL.
7
+ *
8
+ * Scope of v1
9
+ * -----------
10
+ * - `open(path)` / `close(h)` -- multiple DB
11
+ * handles (up to TEP_SQLITE_MAX_HANDLES) tracked in a static
12
+ * slot table so handles fit in spinel's :int FFI return.
13
+ * - `exec(h, sql)` -- run a
14
+ * statement that returns no rows (CREATE / INSERT / UPDATE /
15
+ * DELETE / PRAGMA). No bind in this form; for parameterised
16
+ * writes use prepare + bind + step + finalize.
17
+ * - `prepare(h, sql)` / `bind_{str,int}(idx, v)` / `step()` /
18
+ * `col_{str,int}(idx)` / `col_count()` / `finalize()`
19
+ * -- a single
20
+ * global cursor (one in-flight statement per process). Spinel
21
+ * workers are single-threaded, and the framework never holds
22
+ * two cursors at once -- handlers run serially per worker.
23
+ * - `last_insert_rowid(h)` -- for INSERT
24
+ * ... RETURNING-less workflows.
25
+ *
26
+ * Out of scope (v1)
27
+ * -----------------
28
+ * - Multi-cursor: nested queries (open one cursor, iterate, run
29
+ * another query inside). Document and revisit.
30
+ * - Blob / NULL columns: col_str returns "" for NULL, callers
31
+ * can't distinguish empty-string from NULL.
32
+ * - Transactions: works via `exec("BEGIN")` / `exec("COMMIT")`.
33
+ *
34
+ * Errors
35
+ * ------
36
+ * Most functions return 0 on success and -1 on error. Detailed
37
+ * errmsg surfacing isn't wired through (spinel's :str return
38
+ * lifetime would make it awkward); the C shim could expose
39
+ * `tep_sqlite_errmsg()` as a static-buffer copy if needed.
40
+ */
41
+
42
+ #include <stdlib.h>
43
+ #include <string.h>
44
+ #include <sqlite3.h>
45
+
46
+ #define TEP_SQLITE_MAX_HANDLES 16
47
+ #define TEP_SQLITE_COL_BUFSIZE 65536
48
+ #define TEP_SQLITE_COL_BUF_SLOTS 16
49
+ #define TEP_SQLITE_CACHE_SLOTS 64 /* prepare-statement cache */
50
+ #define TEP_SQLITE_CACHE_SQL_MAX 512 /* longest cached SQL */
51
+
52
+ static sqlite3 *tep_sqlite_handles[TEP_SQLITE_MAX_HANDLES] = {0};
53
+ static sqlite3_stmt *tep_sqlite_stmt = NULL;
54
+ /* When the current cursor came from the prepare-statement cache, we
55
+ * must NOT sqlite3_finalize it on tep_sqlite_finalize -- the slot
56
+ * stays alive for reuse, and finalize becomes reset+clear_bindings.
57
+ * Same applies to tep_sqlite_prepare's stmt-swap path. */
58
+ static int tep_sqlite_stmt_cached = 0;
59
+
60
+ /* prepare-statement cache (chunk per #75). Linear scan by (h, sql);
61
+ * n is small so O(n) is fine. Bounded by TEP_SQLITE_CACHE_SLOTS;
62
+ * if full, prepare_cached falls through to an uncached prepare. */
63
+ typedef struct {
64
+ int h; /* db handle index (1-based), 0 = empty */
65
+ sqlite3_stmt *stmt;
66
+ char sql[TEP_SQLITE_CACHE_SQL_MAX];
67
+ } tep_sqlite_cache_entry;
68
+ static tep_sqlite_cache_entry tep_sqlite_cache[TEP_SQLITE_CACHE_SLOTS] = {0};
69
+ /* Rotating return buffers for col_str. spinel's `:str` return type
70
+ * wants a pointer that stays valid until "the caller is done with
71
+ * it", but in practice callers stash multiple col_str results into
72
+ * variables / hashes / array entries before the buffer would
73
+ * otherwise rotate. A single static buf would alias all those
74
+ * entries to whatever the most recent call wrote.
75
+ *
76
+ * We rotate across SLOTS buffers; each call lands in the next
77
+ * slot. With 16 slots a typical handler doing
78
+ *
79
+ * a = first_str(...); b = first_str(...); c = first_str(...)
80
+ *
81
+ * sees three independent strings.
82
+ *
83
+ * The aliasing window only collapses for callers who hold > 16
84
+ * live col_str references concurrently -- e.g. iterating a query
85
+ * and pushing every row into an array without copying. Document
86
+ * that lifetime in `Tep::SQLite#col_str`. */
87
+ static char tep_sqlite_col_buf[TEP_SQLITE_COL_BUF_SLOTS][TEP_SQLITE_COL_BUFSIZE];
88
+ static int tep_sqlite_col_slot = 0;
89
+
90
+ int tep_sqlite_open(const char *path) {
91
+ int i;
92
+ for (i = 0; i < TEP_SQLITE_MAX_HANDLES; i++) {
93
+ if (tep_sqlite_handles[i] == NULL) {
94
+ sqlite3 *db = NULL;
95
+ if (sqlite3_open(path, &db) != SQLITE_OK) {
96
+ if (db) sqlite3_close(db);
97
+ return -1;
98
+ }
99
+ tep_sqlite_handles[i] = db;
100
+ return i + 1; /* 1-indexed: 0 reserved for "uninitialised" */
101
+ }
102
+ }
103
+ return -1;
104
+ }
105
+
106
+ int tep_sqlite_close(int h) {
107
+ if (h < 1 || h > TEP_SQLITE_MAX_HANDLES) return -1;
108
+ sqlite3 *db = tep_sqlite_handles[h - 1];
109
+ if (db == NULL) return -1;
110
+ /* Finalize any cursor that was on this handle to avoid
111
+ * sqlite_close returning SQLITE_BUSY. */
112
+ if (tep_sqlite_stmt && sqlite3_db_handle(tep_sqlite_stmt) == db) {
113
+ if (!tep_sqlite_stmt_cached) {
114
+ sqlite3_finalize(tep_sqlite_stmt);
115
+ }
116
+ /* Cached stmts get finalized below by the cache walk. */
117
+ tep_sqlite_stmt = NULL;
118
+ tep_sqlite_stmt_cached = 0;
119
+ }
120
+ /* Finalize every cached statement that belongs to this handle
121
+ * before closing the db. Cache slots are owner-keyed by `h`. */
122
+ int i;
123
+ for (i = 0; i < TEP_SQLITE_CACHE_SLOTS; i++) {
124
+ if (tep_sqlite_cache[i].h == h && tep_sqlite_cache[i].stmt) {
125
+ sqlite3_finalize(tep_sqlite_cache[i].stmt);
126
+ tep_sqlite_cache[i].stmt = NULL;
127
+ tep_sqlite_cache[i].h = 0;
128
+ tep_sqlite_cache[i].sql[0] = '\0';
129
+ }
130
+ }
131
+ sqlite3_close(db);
132
+ tep_sqlite_handles[h - 1] = NULL;
133
+ return 0;
134
+ }
135
+
136
+ int tep_sqlite_exec(int h, const char *sql) {
137
+ if (h < 1 || h > TEP_SQLITE_MAX_HANDLES) return -1;
138
+ sqlite3 *db = tep_sqlite_handles[h - 1];
139
+ if (db == NULL) return -1;
140
+ char *err = NULL;
141
+ int rc = sqlite3_exec(db, sql, NULL, NULL, &err);
142
+ if (err) sqlite3_free(err);
143
+ return rc == SQLITE_OK ? 0 : -1;
144
+ }
145
+
146
+ /* Drop or reset whatever's currently on the singleton cursor before
147
+ * we install a new one. Cached stmts get reset+clear_bindings (so
148
+ * the cached slot stays valid); uncached stmts get finalized. */
149
+ static void tep_sqlite_release_current(void) {
150
+ if (!tep_sqlite_stmt) return;
151
+ if (tep_sqlite_stmt_cached) {
152
+ sqlite3_reset(tep_sqlite_stmt);
153
+ sqlite3_clear_bindings(tep_sqlite_stmt);
154
+ } else {
155
+ sqlite3_finalize(tep_sqlite_stmt);
156
+ }
157
+ tep_sqlite_stmt = NULL;
158
+ tep_sqlite_stmt_cached = 0;
159
+ }
160
+
161
+ int tep_sqlite_prepare(int h, const char *sql) {
162
+ if (h < 1 || h > TEP_SQLITE_MAX_HANDLES) return -1;
163
+ sqlite3 *db = tep_sqlite_handles[h - 1];
164
+ if (db == NULL) return -1;
165
+ tep_sqlite_release_current();
166
+ if (sqlite3_prepare_v2(db, sql, -1, &tep_sqlite_stmt, NULL) != SQLITE_OK) {
167
+ tep_sqlite_stmt = NULL;
168
+ return -1;
169
+ }
170
+ /* uncached */
171
+ return 0;
172
+ }
173
+
174
+ /* Cached variant: looks up `sql` for db handle `h` in the cache;
175
+ * on hit reuses the prepared statement (with reset + clear_bindings);
176
+ * on miss prepares + stashes in the first free slot; if the cache is
177
+ * full, falls back to an uncached prepare so the caller still works.
178
+ *
179
+ * SQL is matched literally (no normalization). Apps that generate
180
+ * SQL with varying whitespace miss the cache; format consistently. */
181
+ int tep_sqlite_prepare_cached(int h, const char *sql) {
182
+ if (h < 1 || h > TEP_SQLITE_MAX_HANDLES) return -1;
183
+ sqlite3 *db = tep_sqlite_handles[h - 1];
184
+ if (db == NULL) return -1;
185
+ /* SQL longer than the cache's per-slot buffer: just do an
186
+ * uncached prepare. The caller still gets correct behavior. */
187
+ size_t sql_len = strlen(sql);
188
+ if (sql_len >= TEP_SQLITE_CACHE_SQL_MAX) {
189
+ return tep_sqlite_prepare(h, sql);
190
+ }
191
+ tep_sqlite_release_current();
192
+ /* Cache lookup. */
193
+ int i;
194
+ for (i = 0; i < TEP_SQLITE_CACHE_SLOTS; i++) {
195
+ if (tep_sqlite_cache[i].h == h &&
196
+ tep_sqlite_cache[i].stmt &&
197
+ strcmp(tep_sqlite_cache[i].sql, sql) == 0) {
198
+ tep_sqlite_stmt = tep_sqlite_cache[i].stmt;
199
+ sqlite3_reset(tep_sqlite_stmt);
200
+ sqlite3_clear_bindings(tep_sqlite_stmt);
201
+ tep_sqlite_stmt_cached = 1;
202
+ return 0;
203
+ }
204
+ }
205
+ /* Cache miss -- find an empty slot. */
206
+ int empty = -1;
207
+ for (i = 0; i < TEP_SQLITE_CACHE_SLOTS; i++) {
208
+ if (tep_sqlite_cache[i].h == 0) { empty = i; break; }
209
+ }
210
+ if (empty < 0) {
211
+ /* Cache full: prepare uncached so the caller works. The
212
+ * existing tep_sqlite_finalize path will sqlite3_finalize
213
+ * this one as today. */
214
+ if (sqlite3_prepare_v2(db, sql, -1, &tep_sqlite_stmt, NULL) != SQLITE_OK) {
215
+ tep_sqlite_stmt = NULL;
216
+ return -1;
217
+ }
218
+ tep_sqlite_stmt_cached = 0;
219
+ return 0;
220
+ }
221
+ /* Prepare + stash. */
222
+ sqlite3_stmt *new_stmt = NULL;
223
+ if (sqlite3_prepare_v2(db, sql, -1, &new_stmt, NULL) != SQLITE_OK) {
224
+ tep_sqlite_stmt = NULL;
225
+ return -1;
226
+ }
227
+ tep_sqlite_cache[empty].h = h;
228
+ tep_sqlite_cache[empty].stmt = new_stmt;
229
+ memcpy(tep_sqlite_cache[empty].sql, sql, sql_len);
230
+ tep_sqlite_cache[empty].sql[sql_len] = '\0';
231
+ tep_sqlite_stmt = new_stmt;
232
+ tep_sqlite_stmt_cached = 1;
233
+ return 0;
234
+ }
235
+
236
+ int tep_sqlite_bind_str(int idx, const char *value) {
237
+ if (!tep_sqlite_stmt) return -1;
238
+ /* SQLITE_TRANSIENT -> sqlite copies the string before returning,
239
+ * so the caller's pointer doesn't need to outlive the bind. */
240
+ return sqlite3_bind_text(tep_sqlite_stmt, idx, value, -1, SQLITE_TRANSIENT)
241
+ == SQLITE_OK ? 0 : -1;
242
+ }
243
+
244
+ /* 64-bit bind. `long` is the FFI `:long` type (64-bit on the LP64
245
+ * targets Spinel compiles for); routed through sqlite3_bind_int64 so a
246
+ * value > 2^31 isn't truncated on the way in (issue #171). */
247
+ int tep_sqlite_bind_int(int idx, long value) {
248
+ if (!tep_sqlite_stmt) return -1;
249
+ return sqlite3_bind_int64(tep_sqlite_stmt, idx, (sqlite3_int64)value) == SQLITE_OK ? 0 : -1;
250
+ }
251
+
252
+ /* 1 -> row available, 0 -> done (no more rows), -1 -> error */
253
+ int tep_sqlite_step(void) {
254
+ if (!tep_sqlite_stmt) return -1;
255
+ int rc = sqlite3_step(tep_sqlite_stmt);
256
+ if (rc == SQLITE_ROW) return 1;
257
+ if (rc == SQLITE_DONE) return 0;
258
+ return -1;
259
+ }
260
+
261
+ const char *tep_sqlite_col_str(int idx) {
262
+ if (!tep_sqlite_stmt) return "";
263
+ const unsigned char *t = sqlite3_column_text(tep_sqlite_stmt, idx);
264
+ if (!t) return "";
265
+ size_t n = strlen((const char *)t);
266
+ if (n >= TEP_SQLITE_COL_BUFSIZE) n = TEP_SQLITE_COL_BUFSIZE - 1;
267
+ char *buf = tep_sqlite_col_buf[tep_sqlite_col_slot];
268
+ tep_sqlite_col_slot = (tep_sqlite_col_slot + 1) % TEP_SQLITE_COL_BUF_SLOTS;
269
+ memcpy(buf, t, n);
270
+ buf[n] = '\0';
271
+ return buf;
272
+ }
273
+
274
+ /* 64-bit column read. sqlite3_column_int (32-bit) silently wrapped a
275
+ * value > 2^31 negative -- e.g. a 3.4e9 download count read back as
276
+ * -867422609 (issue #171). sqlite3_column_int64 + a `long` (FFI
277
+ * `:long`, 64-bit) return path preserves the full range; mrb_int is
278
+ * pointer-width so the Ruby side holds it losslessly. */
279
+ long tep_sqlite_col_int(int idx) {
280
+ if (!tep_sqlite_stmt) return 0;
281
+ return (long)sqlite3_column_int64(tep_sqlite_stmt, idx);
282
+ }
283
+
284
+ int tep_sqlite_col_count(void) {
285
+ if (!tep_sqlite_stmt) return 0;
286
+ return sqlite3_column_count(tep_sqlite_stmt);
287
+ }
288
+
289
+ /* Finalize semantics depend on whether the current stmt came from
290
+ * the prepare-statement cache. Cached stmts get reset + clear_bindings
291
+ * and stay alive in their slot (the whole point of the cache); only
292
+ * uncached stmts actually sqlite3_finalize. The Ruby-side API
293
+ * (`db.finalize`) is unchanged either way. */
294
+ int tep_sqlite_finalize(void) {
295
+ if (!tep_sqlite_stmt) return 0;
296
+ if (tep_sqlite_stmt_cached) {
297
+ sqlite3_reset(tep_sqlite_stmt);
298
+ sqlite3_clear_bindings(tep_sqlite_stmt);
299
+ } else {
300
+ sqlite3_finalize(tep_sqlite_stmt);
301
+ }
302
+ tep_sqlite_stmt = NULL;
303
+ tep_sqlite_stmt_cached = 0;
304
+ return 0;
305
+ }
306
+
307
+ int tep_sqlite_last_insert_rowid(int h) {
308
+ if (h < 1 || h > TEP_SQLITE_MAX_HANDLES) return -1;
309
+ sqlite3 *db = tep_sqlite_handles[h - 1];
310
+ if (db == NULL) return -1;
311
+ return (int)sqlite3_last_insert_rowid(db);
312
+ }
313
+
314
+ /* Reset the current statement so it can be re-stepped (e.g. inside
315
+ * a loop where bound params change between iterations). Returns 0
316
+ * on success. */
317
+ int tep_sqlite_reset(void) {
318
+ if (!tep_sqlite_stmt) return -1;
319
+ return sqlite3_reset(tep_sqlite_stmt) == SQLITE_OK ? 0 : -1;
320
+ }
data/lib/tep/url.rb ADDED
@@ -0,0 +1,161 @@
1
+ # Percent-decoding + form-urlencoded query parser.
2
+ module Tep
3
+ class Url
4
+ # "%41+b" -> "A b"
5
+ def self.unescape(s)
6
+ out = ""
7
+ i = 0
8
+ n = s.length
9
+ while i < n
10
+ c = s[i]
11
+ if c == "+"
12
+ out = out + " "
13
+ i += 1
14
+ elsif c == "%" && i + 2 < n
15
+ hi = Url.hex_nibble(s[i + 1])
16
+ lo = Url.hex_nibble(s[i + 2])
17
+ if hi >= 0 && lo >= 0
18
+ out = out + ((hi * 16 + lo).chr)
19
+ i += 3
20
+ else
21
+ out = out + c
22
+ i += 1
23
+ end
24
+ else
25
+ out = out + c
26
+ i += 1
27
+ end
28
+ end
29
+ out
30
+ end
31
+
32
+ # Percent-encode the bytes that are unsafe in cookie values, query
33
+ # strings, and similar contexts. RFC 3986 unreserved set:
34
+ # ALPHA / DIGIT / `-._~`. Everything else gets `%XX` (uppercase hex).
35
+ def self.escape(s)
36
+ out = ""
37
+ i = 0
38
+ while i < s.length
39
+ c = s[i]
40
+ if (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") ||
41
+ (c >= "0" && c <= "9") || c == "-" || c == "." ||
42
+ c == "_" || c == "~"
43
+ out = out + c
44
+ else
45
+ b = c.getbyte(0)
46
+ hi = b / 16
47
+ lo = b % 16
48
+ out = out + "%" + Url.hex_char(hi) + Url.hex_char(lo)
49
+ end
50
+ i += 1
51
+ end
52
+ out
53
+ end
54
+
55
+ def self.hex_char(n)
56
+ if n < 10
57
+ return ("0".getbyte(0) + n).chr
58
+ end
59
+ ("A".getbyte(0) + n - 10).chr
60
+ end
61
+
62
+ def self.hex_nibble(c)
63
+ if c >= "0" && c <= "9"
64
+ return c.getbyte(0) - "0".getbyte(0)
65
+ end
66
+ if c >= "a" && c <= "f"
67
+ return c.getbyte(0) - "a".getbyte(0) + 10
68
+ end
69
+ if c >= "A" && c <= "F"
70
+ return c.getbyte(0) - "A".getbyte(0) + 10
71
+ end
72
+ -1
73
+ end
74
+
75
+ # Split a URL into a Hash with str=>str entries:
76
+ # "scheme" "host" "port" "path" "query"
77
+ #
78
+ # Recognises `http://host[:port]/path?query` and the same shape
79
+ # with `https://`. Without a scheme, the input is treated as a
80
+ # path (host stays empty); useful for routing relative paths
81
+ # through the same parser. Default ports follow the scheme:
82
+ # 80 for http, 443 for https. Path defaults to "/". `query` is
83
+ # the raw substring after `?`, no further decoding.
84
+ #
85
+ # Inlined as one method on purpose: spinel's analyzer widens
86
+ # Hash-typed parameters when a helper mutates them and the
87
+ # caller then keeps reading; sticking to a single body keeps
88
+ # `out` narrowed to StrStrHash throughout.
89
+ def self.split_url(u)
90
+ out = Tep.str_hash
91
+ out["scheme"] = ""
92
+ out["host"] = ""
93
+ out["port"] = ""
94
+ out["path"] = "/"
95
+ out["query"] = ""
96
+
97
+ rest = u
98
+ if rest.length >= 7 && rest[0, 7] == "http://"
99
+ out["scheme"] = "http"
100
+ out["port"] = "80"
101
+ rest = rest[7, rest.length - 7]
102
+ elsif rest.length >= 8 && rest[0, 8] == "https://"
103
+ out["scheme"] = "https"
104
+ out["port"] = "443"
105
+ rest = rest[8, rest.length - 8]
106
+ end
107
+
108
+ if out["scheme"].length > 0
109
+ slash = Tep.str_find(rest, "/", 0)
110
+ hostport = rest
111
+ tail = "/"
112
+ if slash >= 0
113
+ hostport = rest[0, slash]
114
+ tail = rest[slash, rest.length - slash]
115
+ end
116
+ colon = Tep.str_find(hostport, ":", 0)
117
+ if colon >= 0
118
+ out["host"] = hostport[0, colon]
119
+ out["port"] = hostport[colon + 1, hostport.length - colon - 1]
120
+ else
121
+ out["host"] = hostport
122
+ end
123
+ rest = tail
124
+ end
125
+
126
+ qi = Tep.str_find(rest, "?", 0)
127
+ if qi >= 0
128
+ out["path"] = rest[0, qi]
129
+ out["query"] = rest[qi + 1, rest.length - qi - 1]
130
+ else
131
+ out["path"] = rest
132
+ end
133
+ if out["path"].length == 0
134
+ out["path"] = "/"
135
+ end
136
+ out
137
+ end
138
+
139
+ # "a=1&b=2&c" -> Hash {"a"=>"1","b"=>"2","c"=>""}
140
+ def self.parse_query(s)
141
+ h = Tep.str_hash
142
+ if s.length == 0
143
+ return h
144
+ end
145
+ pairs = s.split("&")
146
+ pairs.each do |pair|
147
+ if pair.length > 0
148
+ eq = Tep.str_find(pair, "=", 0)
149
+ if eq < 0
150
+ h[Url.unescape(pair)] = ""
151
+ else
152
+ k = pair[0, eq]
153
+ v = pair[eq + 1, pair.length - eq - 1]
154
+ h[Url.unescape(k)] = Url.unescape(v)
155
+ end
156
+ end
157
+ end
158
+ h
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,3 @@
1
+ module Tep
2
+ VERSION = "0.11.0"
3
+ end
@@ -0,0 +1,171 @@
1
+ # Tep::WebSocket::Connection -- per-connection recv loop.
2
+ #
3
+ # Designed to run inside a Tep::Scheduler-managed fiber spawned by
4
+ # the upgrade route after the 101 response is written. The fiber:
5
+ # 1. Parks on Tep::Scheduler.io_wait(fd, READ, timeout) for bytes.
6
+ # 2. Reads via Sock.sphttp_recv_into_frame into the binary frame buf.
7
+ # 3. Walks the accumulated buffer with Frame.parse_from_buf,
8
+ # dispatching events to the Driver's handlers.
9
+ # 4. On close (sent OR received), exits cleanly + closes the fd.
10
+ #
11
+ # The recv buffer (sphttp_frame_buf, 64 KiB) is the per-fork static
12
+ # from Phase 0.5; cross-fiber sharing within one worker process is
13
+ # bounded by the worker's cooperative scheduling -- only one fiber
14
+ # parses at a time. A future Phase 2.1 (or whenever multi-fiber WS
15
+ # concurrency-per-worker becomes a goal) replaces this with
16
+ # per-fiber buffers via Fiber.storage (matz/spinel#578).
17
+ module Tep
18
+ module WebSocket
19
+ class Connection
20
+ attr_accessor :driver, :fd, :idle_timeout_seconds
21
+
22
+ def initialize(driver)
23
+ @driver = driver
24
+ @fd = driver.fd
25
+ @idle_timeout_seconds = 300
26
+ end
27
+
28
+ def set_idle_timeout(seconds)
29
+ @idle_timeout_seconds = seconds
30
+ end
31
+
32
+ # Drive the recv loop. Returns 0 on clean close, -1 on error.
33
+ # Idempotent across multiple frames per recv: a single
34
+ # sphttp_recv_into_frame fill may contain several complete
35
+ # frames; Connection consumes them all before parking again.
36
+ #
37
+ # The caller (Tep::Server::Scheduled.write_response) owns the
38
+ # fd lifecycle -- run() never calls sphttp_close. On clean
39
+ # close OR error the server's handle_connection closes the fd
40
+ # via its usual exit path.
41
+ def run
42
+ # Synthetic open event before the first recv -- handlers
43
+ # often want to send a welcome message.
44
+ Connection.dispatch_open(@driver)
45
+
46
+ while true
47
+ ready = Tep::Scheduler.io_wait(@fd, Tep::Scheduler::READ, @idle_timeout_seconds)
48
+ if ready == 0
49
+ # Timeout: close 1001 going-away.
50
+ @driver.close(Tep::WebSocket::CLOSE_GOING_AWAY, "idle timeout")
51
+ return 0
52
+ end
53
+
54
+ n = Sock.sphttp_recv_into_frame(@fd)
55
+ if n <= 0
56
+ # EOF or error: dispatch close without sending one back
57
+ # (peer already gone) and exit.
58
+ Connection.dispatch_close(@driver, Tep::WebSocket::CLOSE_GOING_AWAY, "")
59
+ if n == 0
60
+ return 0
61
+ end
62
+ return -1
63
+ end
64
+
65
+ # Parse + dispatch as many complete frames as possible
66
+ # from this recv.
67
+ state = Tep::WebSocket::ConnectionState.new
68
+ state.start = 0
69
+ state.avail = n
70
+ while true
71
+ r = Tep::WebSocket::Frame.parse_from_buf(state.start, state.avail)
72
+ if r.outcome == "need"
73
+ break
74
+ end
75
+ if r.outcome == "close"
76
+ @driver.close(r.close_code, "protocol error")
77
+ return 0
78
+ end
79
+ Connection.dispatch_frame(@driver, r.frame)
80
+ state.start = state.start + r.consumed
81
+ if state.start >= state.avail
82
+ break
83
+ end
84
+ end
85
+ end
86
+ 0
87
+ end
88
+
89
+ # Route a parsed frame to the right handler.
90
+ def self.dispatch_frame(driver, frame)
91
+ op = frame.opcode
92
+ if op == Tep::WebSocket::OPCODE_TEXT
93
+ Connection.dispatch_message(driver, frame.payload, true)
94
+ elsif op == Tep::WebSocket::OPCODE_BINARY
95
+ Connection.dispatch_message(driver, frame.payload, false)
96
+ elsif op == Tep::WebSocket::OPCODE_PING
97
+ # Auto-pong with the ping's payload (§5.5.3).
98
+ driver.pong(frame.payload)
99
+ Connection.dispatch_ping(driver, frame.payload)
100
+ elsif op == Tep::WebSocket::OPCODE_PONG
101
+ Connection.dispatch_pong(driver, frame.payload)
102
+ elsif op == Tep::WebSocket::OPCODE_CLOSE
103
+ code = 0
104
+ reason = ""
105
+ if frame.payload.length >= 2
106
+ code = (frame.payload[0].ord << 8) | frame.payload[1].ord
107
+ if frame.payload.length > 2
108
+ reason = frame.payload[2, frame.payload.length - 2]
109
+ end
110
+ end
111
+ # Echo the close back (§5.5.1) then dispatch.
112
+ driver.close(code == 0 ? Tep::WebSocket::CLOSE_NORMAL : code, reason)
113
+ Connection.dispatch_close(driver, code, reason)
114
+ end
115
+ 0
116
+ end
117
+
118
+ def self.dispatch_open(driver)
119
+ evt = Tep::WebSocket::Event.new
120
+ driver.h_open.handle_event(evt)
121
+ 0
122
+ end
123
+
124
+ def self.dispatch_message(driver, data, text)
125
+ evt = Tep::WebSocket::Event.new
126
+ evt.data = data
127
+ driver.h_message.handle_event(evt)
128
+ 0
129
+ end
130
+
131
+ def self.dispatch_ping(driver, data)
132
+ evt = Tep::WebSocket::Event.new
133
+ evt.data = data
134
+ driver.h_ping.handle_event(evt)
135
+ 0
136
+ end
137
+
138
+ def self.dispatch_pong(driver, data)
139
+ evt = Tep::WebSocket::Event.new
140
+ evt.data = data
141
+ driver.h_pong.handle_event(evt)
142
+ 0
143
+ end
144
+
145
+ def self.dispatch_close(driver, code, reason)
146
+ evt = Tep::WebSocket::Event.new
147
+ evt.code = code
148
+ evt.reason = reason
149
+ driver.h_close.handle_event(evt)
150
+ # Auto-cleanup: any Broadcast subscription or Presence row
151
+ # keyed on this connection's fd gets dropped. Both calls
152
+ # are no-op-safe when nothing was tracked (zero matches).
153
+ # Apps that still call unsubscribe_fd / untrack_by_fd
154
+ # explicitly stay correct -- the second call finds 0 matches.
155
+ Tep::Broadcast.unsubscribe_fd(driver.fd)
156
+ Tep::Presence.untrack_by_fd(driver.fd)
157
+ 0
158
+ end
159
+ end
160
+
161
+ # Per-recv-loop iteration state. Avoids tuple-returns from
162
+ # parse_from_buf calls (spinel multi-return support is uneven).
163
+ class ConnectionState
164
+ attr_accessor :start, :avail
165
+ def initialize
166
+ @start = 0
167
+ @avail = 0
168
+ end
169
+ end
170
+ end
171
+ end