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,278 @@
1
+ # tep chat -- live multi-user chat with presence + Server-Sent
2
+ # Events streaming. A second flagship demo (alongside examples/blog/)
3
+ # that pushes tep into less-trodden corners:
4
+ #
5
+ # - Tep::Streamer long-running SSE pump per client
6
+ # - polling SSE while-loop with sleep + sphttp_write_chunk
7
+ # - SQLite as a fanout each streamer polls a `messages` table for
8
+ # rows newer than its last seen id; the
9
+ # single-cursor-per-process rule means each
10
+ # worker process holds one streamer + one
11
+ # DB cursor at a time, but the prefork model
12
+ # (-w N) gives N concurrent listeners
13
+ # - Presence heartbeat table refreshed via POST every
14
+ # few seconds; `who` query lists rows touched
15
+ # in the last 30 s
16
+ # - Tep::Json wire format for the SSE event payloads
17
+ # and the /who endpoint
18
+ # - Tep::Logger per-connection trace
19
+ # - Tep::Security CORS + secure-headers
20
+ # - ERB + @ivar locals the chat UI page
21
+ #
22
+ # Build + run:
23
+ #
24
+ # bin/tep build examples/chat/app.rb -o /tmp/chat
25
+ # /tmp/chat -p 4567 -w 4
26
+ #
27
+ # Open http://localhost:4567/ in two browser windows; watch
28
+ # messages from one show up in the other within ~1 s. The `-w 4`
29
+ # matters: each open SSE connection occupies a worker.
30
+
31
+ require 'sinatra'
32
+
33
+ # Concurrency model
34
+ # -----------------
35
+ # tep handlers are blocking inside their worker; a long-running
36
+ # stream pins that worker until it returns. macOS's SO_REUSEPORT
37
+ # does not load-balance new connections across listening
38
+ # processes (only Linux 3.9+ does), so on macOS even with
39
+ # `-w 4` a single SSE connection effectively blocks every other
40
+ # request. Linux behaves correctly.
41
+ #
42
+ # To make this demo work across platforms we ship the polling
43
+ # variant by default (each browser hits `GET /chat/recent` once
44
+ # per second). The SSE streamer survives in the codebase as
45
+ # `ChatStreamer` + `GET /chat/stream`; on Linux you can set
46
+ # TEP_CHAT_USE_SSE=1 in the page's JS layer (see views/index.erb)
47
+ # to switch back to the streaming path with sub-second latency.
48
+ set :workers, 4
49
+
50
+ DB_PATH = ENV.fetch("TEP_CHAT_DB", "/tmp/tep_chat.db")
51
+ PRESENCE_TTL = 30 # seconds; users not seen in this window drop
52
+ # out of /who
53
+ STREAM_MAX = 30 # seconds; streamers self-close after this and
54
+ # the client reconnects (so we don't pile up
55
+ # connection-state forever in any one worker)
56
+
57
+ LOGGER = Tep::Logger.new
58
+ LOGGER.set_level("info")
59
+
60
+ CORS = Tep::Security::Cors.new
61
+ CORS.set_origin("*")
62
+ CORS.set_allowed_verbs("GET,POST,OPTIONS")
63
+ CORS.set_allowed_headers("Content-Type")
64
+ Tep.before CORS
65
+
66
+ HEADERS = Tep::Security::Headers.new
67
+ Tep.after HEADERS
68
+
69
+ set :views, File.expand_path("views", __dir__)
70
+
71
+ # -------------------------------------------------------------------
72
+ # Schema
73
+ # -------------------------------------------------------------------
74
+
75
+ on_start do
76
+ db = Tep::SQLite.new
77
+ if db.open(DB_PATH)
78
+ db.exec("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, room TEXT, author TEXT, body TEXT, created_at INTEGER)")
79
+ db.exec("CREATE TABLE IF NOT EXISTS presence (user TEXT PRIMARY KEY, last_seen INTEGER)")
80
+ LOGGER.info("chat ready, db at " + DB_PATH)
81
+ db.close
82
+ end
83
+ end
84
+
85
+ before do
86
+ LOGGER.info(req.verb + " " + req.path)
87
+ end
88
+
89
+ # -------------------------------------------------------------------
90
+ # Web UI
91
+ # -------------------------------------------------------------------
92
+
93
+ get '/' do
94
+ @last_id = current_max_id
95
+ erb :index
96
+ end
97
+
98
+ # Helper: read max(messages.id) so the page joins mid-stream.
99
+ def current_max_id
100
+ db = Tep::SQLite.new
101
+ db.open(DB_PATH)
102
+ n = db.first_int("SELECT IFNULL(MAX(id), 0) FROM messages", "")
103
+ db.close
104
+ n
105
+ end
106
+
107
+ # -------------------------------------------------------------------
108
+ # Send / heartbeat / who -- the JSON corners
109
+ # -------------------------------------------------------------------
110
+
111
+ post '/chat/send' do
112
+ res.headers["Content-Type"] = "application/json"
113
+ author = params[:author]
114
+ body = params[:body]
115
+ room = "main"
116
+ if author.length == 0 || body.length == 0
117
+ res.set_status(400)
118
+ return "{\"error\":\"author and body required\"}"
119
+ end
120
+
121
+ db = Tep::SQLite.new
122
+ db.open(DB_PATH)
123
+ db.prepare("INSERT INTO messages (room, author, body, created_at) VALUES (?, ?, ?, ?)")
124
+ db.bind_str(1, room)
125
+ db.bind_str(2, author)
126
+ db.bind_str(3, body)
127
+ db.bind_int(4, Time.now.to_i)
128
+ db.step
129
+ db.finalize
130
+ id = db.last_rowid
131
+
132
+ # Fold the send into the sender's presence too.
133
+ db.prepare("INSERT OR REPLACE INTO presence (user, last_seen) VALUES (?, ?)")
134
+ db.bind_str(1, author)
135
+ db.bind_int(2, Time.now.to_i)
136
+ db.step
137
+ db.finalize
138
+ db.close
139
+
140
+ LOGGER.info("send id=" + id.to_s + " by " + author + ": " + body)
141
+ "{" + Tep::Json.encode_pair_int("id", id) + "}"
142
+ end
143
+
144
+ post '/chat/heartbeat' do
145
+ res.headers["Content-Type"] = "application/json"
146
+ user = params[:user]
147
+ if user.length == 0
148
+ res.set_status(400)
149
+ return "{\"error\":\"user required\"}"
150
+ end
151
+ db = Tep::SQLite.new
152
+ db.open(DB_PATH)
153
+ db.prepare("INSERT OR REPLACE INTO presence (user, last_seen) VALUES (?, ?)")
154
+ db.bind_str(1, user)
155
+ db.bind_int(2, Time.now.to_i)
156
+ db.step
157
+ db.finalize
158
+ db.close
159
+ "{\"ok\":1}"
160
+ end
161
+
162
+ get '/chat/who' do
163
+ res.headers["Content-Type"] = "application/json"
164
+ cutoff = Time.now.to_i - PRESENCE_TTL
165
+
166
+ db = Tep::SQLite.new
167
+ db.open(DB_PATH)
168
+ out = "["
169
+ first = true
170
+ db.prepare("SELECT user, last_seen FROM presence WHERE last_seen >= ? ORDER BY last_seen DESC")
171
+ db.bind_int(1, cutoff)
172
+ while db.step == 1
173
+ if !first
174
+ out = out + ","
175
+ end
176
+ first = false
177
+ out = out + "{" +
178
+ Tep::Json.encode_pair_str("user", db.col_str(0)) + "," +
179
+ Tep::Json.encode_pair_int("last_seen", db.col_int(1)) + "}"
180
+ end
181
+ db.finalize
182
+ db.close
183
+ out + "]"
184
+ end
185
+
186
+ # Non-streaming fallback for clients that don't grok SSE.
187
+ get '/chat/recent' do
188
+ res.headers["Content-Type"] = "application/json"
189
+ since = (params[:since].length > 0 ? params[:since] : "0").to_i
190
+
191
+ db = Tep::SQLite.new
192
+ db.open(DB_PATH)
193
+ out = "["
194
+ first = true
195
+ db.prepare("SELECT id, author, body FROM messages WHERE id > ? ORDER BY id LIMIT 200")
196
+ db.bind_int(1, since)
197
+ while db.step == 1
198
+ if !first
199
+ out = out + ","
200
+ end
201
+ first = false
202
+ out = out + "{" +
203
+ Tep::Json.encode_pair_int("id", db.col_int(0)) + "," +
204
+ Tep::Json.encode_pair_str("author", db.col_str(1)) + "," +
205
+ Tep::Json.encode_pair_str("body", db.col_str(2)) + "}"
206
+ end
207
+ db.finalize
208
+ db.close
209
+ out + "]"
210
+ end
211
+
212
+ # -------------------------------------------------------------------
213
+ # SSE stream
214
+ # -------------------------------------------------------------------
215
+ #
216
+ # Polls the messages table once per second, emits any rows with id
217
+ # greater than the last one we sent, plus an SSE comment keepalive
218
+ # on every tick so an idle connection still proves it's alive.
219
+ # After STREAM_MAX seconds the pump returns; the client reconnects
220
+ # (?since=<last_id>) to keep going.
221
+ #
222
+ # Single-cursor-per-process: each pump tick opens its own SQLite
223
+ # handle, runs the SELECT to completion, and closes the handle
224
+ # before sleeping. That keeps the cursor lifetime short and lets a
225
+ # concurrent /chat/send on the same worker (none, since workers are
226
+ # single-threaded) or a different worker run uncontested.
227
+
228
+ class ChatStreamer < Tep::Streamer
229
+ attr_accessor :since_id
230
+
231
+ def initialize
232
+ @since_id = 0
233
+ end
234
+
235
+ def pump(out)
236
+ last_id = @since_id
237
+ ticks = 0
238
+ while ticks < STREAM_MAX
239
+ db = Tep::SQLite.new
240
+ db.open(DB_PATH)
241
+ db.prepare("SELECT id, author, body FROM messages WHERE id > ? ORDER BY id LIMIT 50")
242
+ db.bind_int(1, last_id)
243
+ while db.step == 1
244
+ id = db.col_int(0)
245
+ author = db.col_str(1)
246
+ body = db.col_str(2)
247
+ line = "data: {" +
248
+ Tep::Json.encode_pair_int("id", id) + "," +
249
+ Tep::Json.encode_pair_str("author", author) + "," +
250
+ Tep::Json.encode_pair_str("body", body) + "}\n\n"
251
+ out.write(line)
252
+ if id > last_id
253
+ last_id = id
254
+ end
255
+ end
256
+ db.finalize
257
+ db.close
258
+
259
+ # SSE comment keepalive -- the browser EventSource ignores it
260
+ # but the byte arriving on the socket is what we use to detect
261
+ # a half-closed peer (writes start failing once the kernel
262
+ # learns the other side is gone).
263
+ out.write(": tick\n\n")
264
+
265
+ sleep 1
266
+ ticks += 1
267
+ end
268
+ 0
269
+ end
270
+ end
271
+
272
+ get '/chat/stream' do
273
+ res.headers["Content-Type"] = "text/event-stream"
274
+ res.headers["Cache-Control"] = "no-cache"
275
+ s = ChatStreamer.new
276
+ s.since_id = (params[:since].length > 0 ? params[:since] : "0").to_i
277
+ stream s
278
+ end
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#6e7df0"/>
5
+ <stop offset="1" stop-color="#3a4ec9"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="url(#g)"/>
9
+ <path d="M16 22h32a4 4 0 0 1 4 4v14a4 4 0 0 1-4 4H30l-8 8v-8h-6a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="#fff" opacity=".95"/>
10
+ <circle cx="24" cy="33" r="2.5" fill="#3a4ec9"/>
11
+ <circle cx="32" cy="33" r="2.5" fill="#3a4ec9"/>
12
+ <circle cx="40" cy="33" r="2.5" fill="#3a4ec9"/>
13
+ </svg>
@@ -0,0 +1,209 @@
1
+ :root {
2
+ --bg-grad-from: #f5f7fb;
3
+ --bg-grad-to: #e8ecf5;
4
+ --card-bg: #ffffff;
5
+ --ink: #1f2330;
6
+ --ink-soft: #5b6479;
7
+ --ink-faint: #9aa3b6;
8
+ --accent: #4a5fde;
9
+ --accent-soft: #e8ebff;
10
+ --me-bg: #4a5fde;
11
+ --me-ink: #ffffff;
12
+ --them-bg: #f1f3f9;
13
+ --them-ink: #1f2330;
14
+ --shadow: 0 8px 30px rgba(31, 35, 48, .06);
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+
19
+ html, body {
20
+ height: 100%;
21
+ margin: 0;
22
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
23
+ color: var(--ink);
24
+ background: linear-gradient(135deg, var(--bg-grad-from), var(--bg-grad-to));
25
+ }
26
+
27
+ .shell {
28
+ max-width: 760px;
29
+ margin: 0 auto;
30
+ padding: 24px 16px 16px;
31
+ display: flex;
32
+ flex-direction: column;
33
+ height: 100vh;
34
+ }
35
+
36
+ nav {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 16px;
40
+ padding-bottom: 12px;
41
+ border-bottom: 1px solid rgba(31, 35, 48, .08);
42
+ }
43
+
44
+ nav h1 {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 10px;
48
+ font-size: 20px;
49
+ font-weight: 600;
50
+ margin: 0;
51
+ letter-spacing: -.01em;
52
+ }
53
+
54
+ nav h1 .logo {
55
+ width: 28px;
56
+ height: 28px;
57
+ display: block;
58
+ }
59
+
60
+ nav h1 .stripe {
61
+ font-weight: 500;
62
+ color: var(--ink-faint);
63
+ font-size: 13px;
64
+ margin-left: 8px;
65
+ }
66
+
67
+ #who {
68
+ flex: 1;
69
+ color: var(--ink-soft);
70
+ font-size: 13px;
71
+ }
72
+
73
+ #who .pill {
74
+ display: inline-flex;
75
+ align-items: center;
76
+ gap: 6px;
77
+ background: var(--accent-soft);
78
+ color: var(--accent);
79
+ padding: 3px 10px;
80
+ border-radius: 999px;
81
+ font-weight: 500;
82
+ margin-right: 6px;
83
+ margin-bottom: 4px;
84
+ }
85
+
86
+ #who .pill::before {
87
+ content: "";
88
+ width: 6px;
89
+ height: 6px;
90
+ border-radius: 50%;
91
+ background: #2bb673;
92
+ display: inline-block;
93
+ }
94
+
95
+ #me {
96
+ background: var(--card-bg);
97
+ border: 1px solid rgba(31, 35, 48, .12);
98
+ padding: 7px 12px;
99
+ border-radius: 8px;
100
+ font: inherit;
101
+ width: 160px;
102
+ outline: none;
103
+ transition: border-color .15s;
104
+ }
105
+
106
+ #me:focus {
107
+ border-color: var(--accent);
108
+ }
109
+
110
+ #log {
111
+ flex: 1;
112
+ overflow-y: auto;
113
+ margin: 16px 0;
114
+ padding: 16px;
115
+ background: var(--card-bg);
116
+ border-radius: 12px;
117
+ box-shadow: var(--shadow);
118
+ }
119
+
120
+ .msg {
121
+ display: flex;
122
+ flex-direction: column;
123
+ margin: 10px 0;
124
+ max-width: 78%;
125
+ }
126
+
127
+ .msg.me {
128
+ align-self: flex-end;
129
+ margin-left: auto;
130
+ }
131
+
132
+ .msg .meta {
133
+ font-size: 11px;
134
+ color: var(--ink-faint);
135
+ letter-spacing: .02em;
136
+ text-transform: uppercase;
137
+ margin-bottom: 3px;
138
+ padding: 0 4px;
139
+ }
140
+
141
+ .msg.me .meta { text-align: right; }
142
+
143
+ .msg .body {
144
+ padding: 9px 14px;
145
+ border-radius: 14px;
146
+ background: var(--them-bg);
147
+ color: var(--them-ink);
148
+ white-space: pre-wrap;
149
+ word-wrap: break-word;
150
+ }
151
+
152
+ .msg.me .body {
153
+ background: var(--me-bg);
154
+ color: var(--me-ink);
155
+ border-bottom-right-radius: 4px;
156
+ }
157
+
158
+ .msg.them .body {
159
+ border-bottom-left-radius: 4px;
160
+ }
161
+
162
+ #log:empty::before {
163
+ content: "no messages yet -- pick a name and say hi";
164
+ color: var(--ink-faint);
165
+ font-style: italic;
166
+ }
167
+
168
+ .status {
169
+ color: var(--ink-faint);
170
+ font-size: 12px;
171
+ text-align: center;
172
+ margin: 6px 0 12px;
173
+ }
174
+
175
+ #f {
176
+ display: flex;
177
+ gap: 8px;
178
+ padding: 10px;
179
+ background: var(--card-bg);
180
+ border-radius: 12px;
181
+ box-shadow: var(--shadow);
182
+ }
183
+
184
+ #body {
185
+ flex: 1;
186
+ padding: 11px 14px;
187
+ border: none;
188
+ background: transparent;
189
+ font: inherit;
190
+ outline: none;
191
+ color: var(--ink);
192
+ }
193
+
194
+ #body::placeholder { color: var(--ink-faint); }
195
+
196
+ button {
197
+ padding: 9px 18px;
198
+ border: none;
199
+ border-radius: 8px;
200
+ background: var(--accent);
201
+ color: white;
202
+ font: 500 14px/1 -apple-system, sans-serif;
203
+ cursor: pointer;
204
+ transition: opacity .12s;
205
+ }
206
+
207
+ button:hover { opacity: .9; }
208
+ button:active { transform: translateY(1px); }
209
+ button:disabled { opacity: .4; cursor: default; }
@@ -0,0 +1,142 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>tep chat</title>
7
+ <link rel="icon" type="image/svg+xml" href="/logo.svg">
8
+ <link rel="stylesheet" href="/style.css">
9
+ </head>
10
+ <body>
11
+ <div class="shell">
12
+ <nav>
13
+ <h1>
14
+ <img src="/logo.svg" alt="" class="logo">
15
+ tep chat
16
+ <span class="stripe">single static binary</span>
17
+ </h1>
18
+ <span id="who">connecting...</span>
19
+ <input id="me" placeholder="your name" autocomplete="off">
20
+ </nav>
21
+
22
+ <div id="log" role="log" aria-live="polite"></div>
23
+ <p class="status" id="conn">starting...</p>
24
+
25
+ <form id="f" autocomplete="off">
26
+ <input id="body" placeholder="say something..." autocomplete="off">
27
+ <button>send</button>
28
+ </form>
29
+ </div>
30
+
31
+ <script>
32
+ let sinceId = <%= @last_id %>;
33
+ let me = localStorage.getItem('tep_chat_user') || '';
34
+ document.getElementById('me').value = me;
35
+ document.getElementById('me').addEventListener('change', e => {
36
+ me = e.target.value.trim();
37
+ localStorage.setItem('tep_chat_user', me);
38
+ heartbeat();
39
+ });
40
+
41
+ const log = document.getElementById('log');
42
+ const conn = document.getElementById('conn');
43
+ const who = document.getElementById('who');
44
+
45
+ function escapeHtml(s) {
46
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
47
+ }
48
+
49
+ function append(m) {
50
+ if (m.id <= sinceId) return;
51
+ const row = document.createElement('div');
52
+ row.className = 'msg ' + (m.author === me ? 'me' : 'them');
53
+ row.innerHTML =
54
+ '<div class="meta">' + escapeHtml(m.author) + '</div>' +
55
+ '<div class="body">' + escapeHtml(m.body) + '</div>';
56
+ log.appendChild(row);
57
+ log.scrollTop = log.scrollHeight;
58
+ sinceId = Math.max(sinceId, m.id);
59
+ }
60
+
61
+ // Two transports for the message stream:
62
+ // * polling -- /chat/recent?since=N every 1s (default).
63
+ // Works everywhere, including macOS where prefork
64
+ // workers don't load-balance new connections under a
65
+ // long-running SSE.
66
+ // * SSE -- /chat/stream?since=N, sub-second latency.
67
+ // Set window.USE_SSE = true (or have your deploy template
68
+ // flip it) to switch back on Linux where SO_REUSEPORT
69
+ // actually distributes accept calls across workers.
70
+ async function pollOnce() {
71
+ try {
72
+ const r = await fetch('/chat/recent?since=' + sinceId);
73
+ const list = await r.json();
74
+ list.forEach(append);
75
+ conn.textContent = 'live (polling, since=' + sinceId + ')';
76
+ } catch (e) {
77
+ conn.textContent = 'polling: error -- retrying';
78
+ }
79
+ }
80
+ function startPolling() {
81
+ pollOnce();
82
+ setInterval(pollOnce, 1000);
83
+ }
84
+ function startSse() {
85
+ conn.textContent = 'connecting (since=' + sinceId + ')';
86
+ const es = new EventSource('/chat/stream?since=' + sinceId);
87
+ es.onopen = () => { conn.textContent = 'live (SSE)'; };
88
+ es.onmessage = (e) => {
89
+ try { append(JSON.parse(e.data)); } catch(_) {}
90
+ };
91
+ es.onerror = () => {
92
+ conn.textContent = 'reconnecting...';
93
+ es.close();
94
+ setTimeout(startSse, 250);
95
+ };
96
+ }
97
+ function connectStream() {
98
+ if (window.USE_SSE) startSse(); else startPolling();
99
+ }
100
+
101
+ async function heartbeat() {
102
+ if (!me) return;
103
+ const fd = new FormData();
104
+ fd.set('user', me);
105
+ try { await fetch('/chat/heartbeat', { method: 'POST', body: fd }); } catch(_) {}
106
+ }
107
+
108
+ async function refreshWho() {
109
+ try {
110
+ const r = await fetch('/chat/who');
111
+ const list = await r.json();
112
+ if (list.length === 0) {
113
+ who.innerHTML = '<span style="opacity:.6">no one online</span>';
114
+ } else {
115
+ who.innerHTML = list.map(p =>
116
+ '<span class="pill">' + escapeHtml(p.user) + '</span>'
117
+ ).join('');
118
+ }
119
+ } catch(_) {}
120
+ }
121
+
122
+ document.getElementById('f').addEventListener('submit', async (e) => {
123
+ e.preventDefault();
124
+ if (!me) { document.getElementById('me').focus(); return; }
125
+ const body = document.getElementById('body');
126
+ const text = body.value.trim();
127
+ if (!text) return;
128
+ const fd = new FormData();
129
+ fd.set('author', me);
130
+ fd.set('body', text);
131
+ await fetch('/chat/send', { method: 'POST', body: fd });
132
+ body.value = '';
133
+ });
134
+
135
+ connectStream();
136
+ setInterval(heartbeat, 5000);
137
+ setInterval(refreshWho, 5000);
138
+ heartbeat();
139
+ refreshWho();
140
+ </script>
141
+ </body>
142
+ </html>