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
|
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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>
|