claude-agent-framework 1.0.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.
- package/README.md +128 -0
- package/bin/claude-framework +3 -0
- package/framework/agents/design-lead.md +240 -0
- package/framework/agents/product-owner.md +179 -0
- package/framework/agents/tech-lead.md +226 -0
- package/framework/commands/ayuda.md +127 -0
- package/framework/commands/a/303/261adir.md +98 -0
- package/framework/commands/backup.md +397 -0
- package/framework/commands/cambiar.md +110 -0
- package/framework/commands/cloud.md +457 -0
- package/framework/commands/code.md +142 -0
- package/framework/commands/debug.md +334 -0
- package/framework/commands/deploy.md +383 -0
- package/framework/commands/deshacer.md +120 -0
- package/framework/commands/estado.md +218 -0
- package/framework/commands/explica.md +227 -0
- package/framework/commands/feature.md +120 -0
- package/framework/commands/git.md +427 -0
- package/framework/commands/historial.md +202 -0
- package/framework/commands/learn.md +408 -0
- package/framework/commands/movil.md +245 -0
- package/framework/commands/nuevo.md +118 -0
- package/framework/commands/plan.md +134 -0
- package/framework/commands/prd.md +113 -0
- package/framework/commands/probar.md +148 -0
- package/framework/commands/revisar.md +208 -0
- package/framework/commands/seeds.md +230 -0
- package/framework/commands/seguridad.md +226 -0
- package/framework/commands/tasks.md +157 -0
- package/framework/skills/architecture/algorithms.md +970 -0
- package/framework/skills/architecture/clean-code.md +1080 -0
- package/framework/skills/architecture/design-patterns.md +1984 -0
- package/framework/skills/architecture/functional-programming.md +972 -0
- package/framework/skills/architecture/solid.md +991 -0
- package/framework/skills/cloud/cloud-aws.md +848 -0
- package/framework/skills/cloud/cloud-azure.md +931 -0
- package/framework/skills/cloud/cloud-gcp.md +848 -0
- package/framework/skills/cloud/message-queues.md +1229 -0
- package/framework/skills/core/accessibility.md +401 -0
- package/framework/skills/core/api.md +474 -0
- package/framework/skills/core/authentication.md +306 -0
- package/framework/skills/core/authorization.md +388 -0
- package/framework/skills/core/background-jobs.md +341 -0
- package/framework/skills/core/caching.md +473 -0
- package/framework/skills/core/code-review.md +341 -0
- package/framework/skills/core/controllers.md +290 -0
- package/framework/skills/core/cua.md +285 -0
- package/framework/skills/core/documentation.md +472 -0
- package/framework/skills/core/file-uploads.md +351 -0
- package/framework/skills/core/hotwire-native.md +296 -0
- package/framework/skills/core/hotwire.md +278 -0
- package/framework/skills/core/i18n.md +334 -0
- package/framework/skills/core/imports-exports.md +750 -0
- package/framework/skills/core/infrastructure.md +337 -0
- package/framework/skills/core/models.md +228 -0
- package/framework/skills/core/notifications.md +672 -0
- package/framework/skills/core/payments.md +581 -0
- package/framework/skills/core/performance.md +361 -0
- package/framework/skills/core/rails-scaffold.md +131 -0
- package/framework/skills/core/search.md +518 -0
- package/framework/skills/core/security.md +565 -0
- package/framework/skills/core/seeds.md +307 -0
- package/framework/skills/core/seo.md +542 -0
- package/framework/skills/core/testing.md +393 -0
- package/framework/skills/core/views.md +260 -0
- package/framework/skills/core/websockets.md +564 -0
- package/framework/skills/data/advanced-sql.md +1204 -0
- package/framework/skills/data/nosql.md +1141 -0
- package/framework/skills/devops/containers-advanced.md +1237 -0
- package/framework/skills/devops/debugging.md +834 -0
- package/framework/skills/devops/git-workflow.md +752 -0
- package/framework/skills/devops/networking.md +932 -0
- package/framework/skills/devops/shell-scripting.md +1132 -0
- package/framework/sub-agents/architecture-patterns-agent.md +1450 -0
- package/framework/sub-agents/cloud-agent.md +677 -0
- package/framework/sub-agents/data.md +504 -0
- package/framework/sub-agents/debugging-agent.md +554 -0
- package/framework/sub-agents/devops.md +483 -0
- package/framework/sub-agents/docs.md +176 -0
- package/framework/sub-agents/frontend-dev.md +349 -0
- package/framework/sub-agents/git-workflow-agent.md +697 -0
- package/framework/sub-agents/integrations.md +630 -0
- package/framework/sub-agents/native-dev.md +434 -0
- package/framework/sub-agents/qa.md +138 -0
- package/framework/sub-agents/rails-dev.md +375 -0
- package/framework/sub-agents/security.md +526 -0
- package/framework/sub-agents/ui.md +437 -0
- package/framework/sub-agents/ux.md +284 -0
- package/framework/templates/api-spec.md +500 -0
- package/framework/templates/component-spec.md +248 -0
- package/framework/templates/feature.json +13 -0
- package/framework/templates/model-spec.md +318 -0
- package/framework/templates/prd-template.md +80 -0
- package/framework/templates/task-plan.md +122 -0
- package/framework/templates/task-user-story.md +52 -0
- package/framework/templates/technical-spec.md +260 -0
- package/framework/templates/user-story.md +95 -0
- package/package.json +42 -0
- package/project-templates/CLAUDE.md +42 -0
- package/project-templates/contexts/architecture.md +25 -0
- package/project-templates/contexts/conventions.md +46 -0
- package/project-templates/contexts/design-system.md +47 -0
- package/project-templates/contexts/requirements.md +38 -0
- package/project-templates/contexts/stack.md +30 -0
- package/project-templates/history/active/models.md +11 -0
- package/project-templates/history/changelog.md +15 -0
- package/project-templates/workspace/.gitkeep +0 -0
- package/src/cli.js +52 -0
- package/src/init.js +104 -0
- package/src/status.js +75 -0
- package/src/update.js +88 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
# Skill: WebSockets (Action Cable)
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Implementar funcionalidades en tiempo real usando Action Cable, el framework de WebSockets integrado en Rails.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
### Configuración básica
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# config/cable.yml
|
|
13
|
+
development:
|
|
14
|
+
adapter: async
|
|
15
|
+
|
|
16
|
+
test:
|
|
17
|
+
adapter: test
|
|
18
|
+
|
|
19
|
+
production:
|
|
20
|
+
adapter: solid_cable # Rails 8
|
|
21
|
+
# O Redis:
|
|
22
|
+
# adapter: redis
|
|
23
|
+
# url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
|
|
24
|
+
# channel_prefix: myapp_production
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# config/environments/production.rb
|
|
29
|
+
config.action_cable.url = "wss://myapp.com/cable"
|
|
30
|
+
config.action_cable.allowed_request_origins = [
|
|
31
|
+
"https://myapp.com",
|
|
32
|
+
/https:\/\/.*\.myapp\.com/
|
|
33
|
+
]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### JavaScript setup
|
|
37
|
+
|
|
38
|
+
```javascript
|
|
39
|
+
// app/javascript/application.js
|
|
40
|
+
import "@hotwired/turbo-rails"
|
|
41
|
+
import "./channels"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```javascript
|
|
45
|
+
// app/javascript/channels/index.js
|
|
46
|
+
import { createConsumer } from "@hotwired/actioncable"
|
|
47
|
+
window.App = { cable: createConsumer() }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Canales básicos
|
|
51
|
+
|
|
52
|
+
### Canal de chat
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# app/channels/chat_channel.rb
|
|
56
|
+
class ChatChannel < ApplicationCable::Channel
|
|
57
|
+
def subscribed
|
|
58
|
+
@room = Room.find(params[:room_id])
|
|
59
|
+
|
|
60
|
+
# Verificar autorización
|
|
61
|
+
reject unless can_access_room?
|
|
62
|
+
|
|
63
|
+
stream_for @room
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unsubscribed
|
|
67
|
+
# Limpiar cuando el usuario se desconecta
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def speak(data)
|
|
71
|
+
message = @room.messages.create!(
|
|
72
|
+
user: current_user,
|
|
73
|
+
body: data["body"]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Broadcast a todos los suscriptores
|
|
77
|
+
ChatChannel.broadcast_to(@room, {
|
|
78
|
+
message: render_message(message),
|
|
79
|
+
user_id: current_user.id
|
|
80
|
+
})
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def typing
|
|
84
|
+
ChatChannel.broadcast_to(@room, {
|
|
85
|
+
type: "typing",
|
|
86
|
+
user_id: current_user.id,
|
|
87
|
+
user_name: current_user.name
|
|
88
|
+
})
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def can_access_room?
|
|
94
|
+
@room.users.include?(current_user)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render_message(message)
|
|
98
|
+
ApplicationController.render(
|
|
99
|
+
partial: "messages/message",
|
|
100
|
+
locals: { message: message }
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```javascript
|
|
107
|
+
// app/javascript/channels/chat_channel.js
|
|
108
|
+
import consumer from "./consumer"
|
|
109
|
+
|
|
110
|
+
document.addEventListener("turbo:load", () => {
|
|
111
|
+
const chatRoom = document.getElementById("chat-room")
|
|
112
|
+
if (!chatRoom) return
|
|
113
|
+
|
|
114
|
+
const roomId = chatRoom.dataset.roomId
|
|
115
|
+
|
|
116
|
+
consumer.subscriptions.create(
|
|
117
|
+
{ channel: "ChatChannel", room_id: roomId },
|
|
118
|
+
{
|
|
119
|
+
connected() {
|
|
120
|
+
console.log("Connected to chat")
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
disconnected() {
|
|
124
|
+
console.log("Disconnected from chat")
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
received(data) {
|
|
128
|
+
if (data.type === "typing") {
|
|
129
|
+
this.showTypingIndicator(data.user_name)
|
|
130
|
+
} else {
|
|
131
|
+
this.appendMessage(data.message)
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
speak(body) {
|
|
136
|
+
this.perform("speak", { body: body })
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
typing() {
|
|
140
|
+
this.perform("typing")
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
appendMessage(html) {
|
|
144
|
+
const messages = document.getElementById("messages")
|
|
145
|
+
messages.insertAdjacentHTML("beforeend", html)
|
|
146
|
+
messages.scrollTop = messages.scrollHeight
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
showTypingIndicator(userName) {
|
|
150
|
+
const indicator = document.getElementById("typing-indicator")
|
|
151
|
+
indicator.textContent = `${userName} is typing...`
|
|
152
|
+
indicator.classList.remove("hidden")
|
|
153
|
+
|
|
154
|
+
clearTimeout(this.typingTimeout)
|
|
155
|
+
this.typingTimeout = setTimeout(() => {
|
|
156
|
+
indicator.classList.add("hidden")
|
|
157
|
+
}, 2000)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Canal de notificaciones
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# app/channels/notifications_channel.rb
|
|
168
|
+
class NotificationsChannel < ApplicationCable::Channel
|
|
169
|
+
def subscribed
|
|
170
|
+
stream_for current_user
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def mark_as_read(data)
|
|
174
|
+
notification = current_user.notifications.find(data["id"])
|
|
175
|
+
notification.mark_as_read!
|
|
176
|
+
|
|
177
|
+
NotificationsChannel.broadcast_to(current_user, {
|
|
178
|
+
type: "read",
|
|
179
|
+
id: notification.id
|
|
180
|
+
})
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Canal de presencia (quién está online)
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# app/channels/presence_channel.rb
|
|
189
|
+
class PresenceChannel < ApplicationCable::Channel
|
|
190
|
+
def subscribed
|
|
191
|
+
@room = Room.find(params[:room_id])
|
|
192
|
+
stream_for @room
|
|
193
|
+
|
|
194
|
+
# Registrar usuario como online
|
|
195
|
+
add_user_to_presence
|
|
196
|
+
|
|
197
|
+
# Notificar a otros
|
|
198
|
+
broadcast_presence
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def unsubscribed
|
|
202
|
+
remove_user_from_presence
|
|
203
|
+
broadcast_presence
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def presence_key
|
|
209
|
+
"room:#{@room.id}:presence"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def add_user_to_presence
|
|
213
|
+
Rails.cache.write(
|
|
214
|
+
"#{presence_key}:#{current_user.id}",
|
|
215
|
+
{ id: current_user.id, name: current_user.name },
|
|
216
|
+
expires_in: 5.minutes
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def remove_user_from_presence
|
|
221
|
+
Rails.cache.delete("#{presence_key}:#{current_user.id}")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def online_users
|
|
225
|
+
keys = Rails.cache.redis.keys("#{presence_key}:*")
|
|
226
|
+
keys.map { |key| Rails.cache.read(key) }.compact
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def broadcast_presence
|
|
230
|
+
PresenceChannel.broadcast_to(@room, {
|
|
231
|
+
type: "presence",
|
|
232
|
+
users: online_users
|
|
233
|
+
})
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Integración con Turbo Streams
|
|
239
|
+
|
|
240
|
+
### Broadcast desde modelos
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
# app/models/message.rb
|
|
244
|
+
class Message < ApplicationRecord
|
|
245
|
+
belongs_to :room
|
|
246
|
+
belongs_to :user
|
|
247
|
+
|
|
248
|
+
# Broadcast automático con Turbo
|
|
249
|
+
broadcasts_to :room
|
|
250
|
+
|
|
251
|
+
# O personalizado:
|
|
252
|
+
after_create_commit -> {
|
|
253
|
+
broadcast_append_to room,
|
|
254
|
+
target: "messages",
|
|
255
|
+
partial: "messages/message",
|
|
256
|
+
locals: { message: self }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
after_update_commit -> {
|
|
260
|
+
broadcast_replace_to room,
|
|
261
|
+
target: dom_id(self),
|
|
262
|
+
partial: "messages/message",
|
|
263
|
+
locals: { message: self }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
after_destroy_commit -> {
|
|
267
|
+
broadcast_remove_to room, target: dom_id(self)
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Vista con Turbo Streams
|
|
273
|
+
|
|
274
|
+
```erb
|
|
275
|
+
<%# app/views/rooms/show.html.erb %>
|
|
276
|
+
<div id="chat-room" data-room-id="<%= @room.id %>">
|
|
277
|
+
<%# Suscribirse a updates de Turbo Stream %>
|
|
278
|
+
<%= turbo_stream_from @room %>
|
|
279
|
+
|
|
280
|
+
<div id="messages" class="h-96 overflow-y-auto">
|
|
281
|
+
<%= render @room.messages.includes(:user).order(created_at: :asc) %>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div id="typing-indicator" class="hidden text-sm text-gray-500 italic"></div>
|
|
285
|
+
|
|
286
|
+
<%= form_with url: room_messages_path(@room),
|
|
287
|
+
data: { controller: "chat", action: "submit->chat#send" } do |f| %>
|
|
288
|
+
<%= f.text_field :body,
|
|
289
|
+
placeholder: t(".type_message"),
|
|
290
|
+
autocomplete: "off",
|
|
291
|
+
data: { chat_target: "input", action: "input->chat#typing" },
|
|
292
|
+
class: "w-full px-4 py-2 border rounded-lg" %>
|
|
293
|
+
<% end %>
|
|
294
|
+
</div>
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Autenticación en canales
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
# app/channels/application_cable/connection.rb
|
|
301
|
+
module ApplicationCable
|
|
302
|
+
class Connection < ActionCable::Connection::Base
|
|
303
|
+
identified_by :current_user
|
|
304
|
+
|
|
305
|
+
def connect
|
|
306
|
+
self.current_user = find_verified_user
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def find_verified_user
|
|
312
|
+
# Opción 1: Session-based (para apps web)
|
|
313
|
+
if verified_user = User.find_by(id: session["user_id"])
|
|
314
|
+
verified_user
|
|
315
|
+
# Opción 2: Token-based (para móvil/API)
|
|
316
|
+
elsif verified_user = User.find_by(auth_token: request.params[:token])
|
|
317
|
+
verified_user
|
|
318
|
+
else
|
|
319
|
+
reject_unauthorized_connection
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def session
|
|
324
|
+
@session ||= cookies.encrypted[Rails.application.config.session_options[:key]]
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Patrones avanzados
|
|
331
|
+
|
|
332
|
+
### Rate limiting
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
# app/channels/concerns/rate_limitable.rb
|
|
336
|
+
module RateLimitable
|
|
337
|
+
extend ActiveSupport::Concern
|
|
338
|
+
|
|
339
|
+
class_methods do
|
|
340
|
+
def rate_limit(method_name, limit:, period:)
|
|
341
|
+
original_method = instance_method(method_name)
|
|
342
|
+
|
|
343
|
+
define_method(method_name) do |data = {}|
|
|
344
|
+
key = "rate_limit:#{self.class.name}:#{method_name}:#{current_user.id}"
|
|
345
|
+
count = Rails.cache.increment(key, 1, expires_in: period)
|
|
346
|
+
|
|
347
|
+
if count > limit
|
|
348
|
+
transmit(error: "Rate limit exceeded. Try again later.")
|
|
349
|
+
return
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
original_method.bind(self).call(data)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Uso en canal
|
|
359
|
+
class ChatChannel < ApplicationCable::Channel
|
|
360
|
+
include RateLimitable
|
|
361
|
+
|
|
362
|
+
rate_limit :speak, limit: 10, period: 1.minute
|
|
363
|
+
end
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Heartbeat para presencia
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
# app/channels/presence_channel.rb
|
|
370
|
+
class PresenceChannel < ApplicationCable::Channel
|
|
371
|
+
periodically :heartbeat, every: 30.seconds
|
|
372
|
+
|
|
373
|
+
def heartbeat
|
|
374
|
+
update_presence
|
|
375
|
+
broadcast_presence
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Broadcast condicional
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
# app/models/comment.rb
|
|
384
|
+
class Comment < ApplicationRecord
|
|
385
|
+
after_create_commit :broadcast_to_relevant_users
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def broadcast_to_relevant_users
|
|
390
|
+
# Solo broadcast a usuarios que pueden ver el comentario
|
|
391
|
+
post.authorized_viewers.each do |user|
|
|
392
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
393
|
+
[user, "notifications"],
|
|
394
|
+
target: "notifications",
|
|
395
|
+
partial: "notifications/new_comment",
|
|
396
|
+
locals: { comment: self }
|
|
397
|
+
)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Stimulus Controller para WebSockets
|
|
404
|
+
|
|
405
|
+
```javascript
|
|
406
|
+
// app/javascript/controllers/chat_controller.js
|
|
407
|
+
import { Controller } from "@hotwired/stimulus"
|
|
408
|
+
import consumer from "../channels/consumer"
|
|
409
|
+
|
|
410
|
+
export default class extends Controller {
|
|
411
|
+
static targets = ["input", "messages", "typing"]
|
|
412
|
+
static values = { roomId: Number }
|
|
413
|
+
|
|
414
|
+
connect() {
|
|
415
|
+
this.subscription = consumer.subscriptions.create(
|
|
416
|
+
{ channel: "ChatChannel", room_id: this.roomIdValue },
|
|
417
|
+
{
|
|
418
|
+
connected: () => this.connected(),
|
|
419
|
+
disconnected: () => this.disconnected(),
|
|
420
|
+
received: (data) => this.received(data)
|
|
421
|
+
}
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
disconnect() {
|
|
426
|
+
this.subscription?.unsubscribe()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
connected() {
|
|
430
|
+
this.element.classList.remove("opacity-50")
|
|
431
|
+
console.log("Chat connected")
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
disconnected() {
|
|
435
|
+
this.element.classList.add("opacity-50")
|
|
436
|
+
console.log("Chat disconnected")
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
received(data) {
|
|
440
|
+
switch (data.type) {
|
|
441
|
+
case "typing":
|
|
442
|
+
this.showTyping(data.user_name)
|
|
443
|
+
break
|
|
444
|
+
case "message":
|
|
445
|
+
this.appendMessage(data.html)
|
|
446
|
+
break
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
send(event) {
|
|
451
|
+
event.preventDefault()
|
|
452
|
+
const body = this.inputTarget.value.trim()
|
|
453
|
+
if (!body) return
|
|
454
|
+
|
|
455
|
+
this.subscription.perform("speak", { body })
|
|
456
|
+
this.inputTarget.value = ""
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
typing() {
|
|
460
|
+
this.subscription.perform("typing")
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
appendMessage(html) {
|
|
464
|
+
this.messagesTarget.insertAdjacentHTML("beforeend", html)
|
|
465
|
+
this.scrollToBottom()
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
showTyping(userName) {
|
|
469
|
+
this.typingTarget.textContent = `${userName} está escribiendo...`
|
|
470
|
+
this.typingTarget.classList.remove("hidden")
|
|
471
|
+
|
|
472
|
+
clearTimeout(this.typingTimeout)
|
|
473
|
+
this.typingTimeout = setTimeout(() => {
|
|
474
|
+
this.typingTarget.classList.add("hidden")
|
|
475
|
+
}, 2000)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
scrollToBottom() {
|
|
479
|
+
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## Testing
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
# spec/channels/chat_channel_spec.rb
|
|
488
|
+
require "rails_helper"
|
|
489
|
+
|
|
490
|
+
RSpec.describe ChatChannel, type: :channel do
|
|
491
|
+
let(:user) { create(:user) }
|
|
492
|
+
let(:room) { create(:room, users: [user]) }
|
|
493
|
+
|
|
494
|
+
before do
|
|
495
|
+
stub_connection current_user: user
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
describe "#subscribed" do
|
|
499
|
+
it "subscribes to the room stream" do
|
|
500
|
+
subscribe(room_id: room.id)
|
|
501
|
+
expect(subscription).to be_confirmed
|
|
502
|
+
expect(subscription).to have_stream_for(room)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
it "rejects unauthorized users" do
|
|
506
|
+
other_room = create(:room)
|
|
507
|
+
subscribe(room_id: other_room.id)
|
|
508
|
+
expect(subscription).to be_rejected
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
describe "#speak" do
|
|
513
|
+
before { subscribe(room_id: room.id) }
|
|
514
|
+
|
|
515
|
+
it "creates a message" do
|
|
516
|
+
expect {
|
|
517
|
+
perform :speak, body: "Hello!"
|
|
518
|
+
}.to change(Message, :count).by(1)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
it "broadcasts the message" do
|
|
522
|
+
expect {
|
|
523
|
+
perform :speak, body: "Hello!"
|
|
524
|
+
}.to have_broadcasted_to(room).with(
|
|
525
|
+
hash_including(user_id: user.id)
|
|
526
|
+
)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# spec/support/action_cable.rb
|
|
532
|
+
RSpec.configure do |config|
|
|
533
|
+
config.include ActionCable::TestHelper
|
|
534
|
+
end
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
## Debugging
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
# config/environments/development.rb
|
|
541
|
+
# Habilitar logs de Action Cable
|
|
542
|
+
config.action_cable.log_tags = [:action_cable]
|
|
543
|
+
config.log_level = :debug
|
|
544
|
+
|
|
545
|
+
# Para ver mensajes en consola del navegador
|
|
546
|
+
# En app/channels/application_cable/channel.rb
|
|
547
|
+
class Channel < ActionCable::Channel::Base
|
|
548
|
+
rescue_from Exception do |exception|
|
|
549
|
+
Rails.logger.error "Channel error: #{exception.message}"
|
|
550
|
+
transmit(error: exception.message)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
## Checklist
|
|
556
|
+
|
|
557
|
+
- [ ] cable.yml configurado para cada environment
|
|
558
|
+
- [ ] CORS configurado en producción
|
|
559
|
+
- [ ] Autenticación en Connection
|
|
560
|
+
- [ ] Autorización en cada Canal
|
|
561
|
+
- [ ] Turbo Streams integrado donde aplica
|
|
562
|
+
- [ ] Rate limiting implementado
|
|
563
|
+
- [ ] Tests de canales escritos
|
|
564
|
+
- [ ] Manejo de errores y reconexión
|