-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmountable_apps.mdc
More file actions
285 lines (210 loc) · 9.54 KB
/
mountable_apps.mdc
File metadata and controls
285 lines (210 loc) · 9.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
Awesome request. I analyzed Azu’s repo and docs to see how it composes apps today (handlers, router, middleware, templates) and what gaps exist for Rails-style “mountable engines.” Azu already leans on explicit handler chains (Crystal HTTP::Handler) and a flexible router, which makes clean delegation to sub-apps very feasible. 
Below is a ready-to-drop .mdc design doc you can paste into Cursor. It includes motivation, APIs, architecture, code stubs, acceptance criteria, and a migration plan.
⸻
Mountable Azu Applications (“Engines”) — Design (.mdc)
Title
Mounting Other Azu Applications on a Main Application (Rails Engine–style)
Author
eperez
Status
Draft → Review → Pilot in azu-examples → Release in azu >= 0.6.0
Motivation / Problem
We want to compose a main Azu app from smaller, independently developed Azu apps (admin panel, billing UI, blog, auth UI, etc.), each with its own routes, templates, middleware, and configuration. Today, Azu exposes a handler chain and router primitives but lacks a first-class engine/mount abstraction with:
• Path and host-based mounting
• Per-engine middleware stacks
• Isolated config (templates, static assets, websockets)
• URL helpers aware of mount prefixes
• Shared session/cookie store with namespacing to avoid clashes
Azu’s existing concepts (handler chain, endpoints, middleware) make this straightforward to implement without invasive changes. 
Goals (MVP) 1. Mount any Azu app under a path prefix (e.g., /admin, /blog). 2. Optional host constraint (e.g., admin.example.com). 3. Per-engine middleware that runs before the engine’s router. 4. Prefix-aware URL helpers so path generation includes the engine base path. 5. Shared session compatibility with optional key scoping. 6. Graceful 404/405 propagation when the path doesn’t match.
Non-Goals (for v1)
• Asset compilation pipeline.
• Cross-engine constant autoloading.
• Cross-engine dependency management.
(These can come in v2 with “Engine Packs.”)
High-Level Architecture
We introduce two core types:
• Azu::Engine: a small wrapper around an Azu application providing:
• #call(context) (implements HTTP::Handler)
• Its own router, endpoints, templates, channels, etc.
• Local middleware chain for pre-routing concerns.
• Azu::MountTable: resides in the main app and routes requests to mounted engines by (host, path_prefix). Each entry holds:
• base_path : String
• host : String?
• handler_chain : Array(HTTP::Handler) (engine middleware + engine)
• engine : Azu::Engine
Mounting is implemented as an early handler in the main app’s chain. If a request matches an engine’s constraint, we strip the prefix and delegate to the engine’s chain; otherwise, we call_next.
Request Path Rewriting
To keep engines oblivious of their mount path, we present them a “virtual root” by adjusting:
• context.request.path (or store X-Original-Path and provide a helper to read base).
• Provide Azu::Mount.env_base_path(context) to recover the mount base when generating URLs.
URL Helpers
Azu::Router#url_for gains an optional base: param. In engines, a tiny helper fetches the base from the context and preprends it.
Public API (Proposed)
module Azu
# Engine contract
abstract class Engine
include HTTP::Handler
getter name : String
getter router : Azu::Router
getter middleware : Array(HTTP::Handler)
def initialize(@name : String, @router : Azu::Router, @middleware = [] of HTTP::Handler)
end
# Engines build their own handler chain ending in router
def call(ctx : HTTP::Server::Context)
chain = Azu::Handler.build_chain(@middleware + [router])
chain.call(ctx)
end
end
# Mount descriptor
struct Mount
getter base_path : String
getter host : String?
getter app : Engine
getter handler : HTTP::Handler # cached composed chain
def initialize(@base_path, @app, @host = nil, middleware = [] of HTTP::Handler)
@handler = Azu::Handler.build_chain(middleware + [StripPrefix.new(@base_path), @app])
end
end
# Central registry & dispatcher
class MountTable
def initialize
@mounts = [] of Mount
end
def add(mount : Mount); @mounts << mount; end
# Finds a matching mount by host and path prefix
def find(ctx) : Mount?
req_host = ctx.request.headers["Host"]?
req_path = ctx.request.path
@mounts.find do |m|
host_ok = m.host.nil? || req_host.try(&.starts_with?(m.host.not_nil!))
path_ok = req_path.starts_with?(m.base_path)
host_ok && path_ok
end
end
end
# Main-app handler that dispatches to engines when matched
class MountDispatcher
include HTTP::Handler
def initialize(@table : MountTable)
end
def call(ctx)
if mount = @table.find(ctx)
mount.handler.call(ctx)
else
call_next(ctx)
end
end
end
# Responsible for presenting a rewritten path to the engine
class StripPrefix
include HTTP::Handler
def initialize(@prefix : String); end
def call(ctx)
if ctx.request.path.starts_with?(@prefix)
original = ctx.request.path
ctx.request.path = original[@prefix.size..-1] || "/"
ctx.request.headers.add("X-AZU-Mount-Base", @prefix)
end
call_next(ctx)
end
end
end
Notes: The shapes align with Azu’s current handler-chain approach and router model described in the repo/docs. 
Engine Authoring Pattern
# src/admin/app.cr
module Admin
class App < Azu::Engine
def self.build
router = Azu::Router.new.tap do |r|
r.get("/", Dashboard::Index)
r.get("/users", Users::Index) # ...
end
middleware = [
Admin::AuthMiddleware.new,
Azu::Handler::CSRF.new,
]
new("admin", router, middleware)
end
end
end
Mounting in Main App
# server.cr (main app)
require "azu"
require "./src/my_app"
require "./engines/admin" # brings Admin::App
table = Azu::MountTable.new
table.add(
Azu::Mount.new(
base_path: "/admin",
app: Admin::App.build,
host: ENV["ADMIN_HOST"]? # optional host constraint
)
)
main_chain = Azu::Handler.build_chain([
Azu::Handler::RequestId.new,
Azu::Handler::Rescuer.new,
Azu::MountDispatcher.new(table), # 👈 dispatch early
MyApp.router # main app router at the end
])
HTTP::Server.new(main_chain).bind_tcp(MyApp::CONFIG.host, MyApp::CONFIG.port).listen
URL Helper (Prefix-Aware)
module Azu::Mount
def self.base_path(ctx : HTTP::Server::Context) : String
ctx.request.headers["X-AZU-Mount-Base"]? || ""
end
end
# Example in an Admin endpoint:
def index(ctx)
base = Azu::Mount.base_path(ctx)
json({ users_url: MyRoutes.url_for(:users_index, base: base) })
end
Sessions & Cookies
• Engines use the main app’s Azu::Handler::Session instance by default (shared in the composed chain).
• Optional namespacing via a cookie/session key prefix set at mount time:
• Azu::Mount.new(..., middleware: [Azu::Handler::Session.new(namespace: "admin")])
WebSockets / Channels
• If an engine defines channels, the engine’s router can include ws "/socket", Admin::ChannelHub. The mount prefix rules still apply so /admin/socket is exposed externally while the engine sees /socket.
Error Handling
• The MountDispatcher only delegates when path matches. Otherwise it call_next(ctx).
• Inside an engine, unmatched routes should emit 404 which bubble up normally.
Security
• Host constraints prevent accidental exposure on the wrong domain.
• Path stripping is tight and only occurs when prefix matches.
• Session namespace avoids key collisions between engines.
Configuration Surface
• Azu::Engine accepts its own template/search paths and static directories (via engine-local config). The main app’s Static handler should include the engine’s public dir via multi-root option (future optimization).
Tests (Outline)
• Mount match and non-match routing.
• Prefix stripping behavior (/admin/users → engine sees /users).
• URL helper includes base.
• Separate middleware stacks (engine auth vs main).
• Session namespacing works.
• Host-constrained mount works.
Examples Repo
• Add examples/mounting/ showing:
• MainApp with / routes.
• Admin::App mounted at /admin.
• Blog::App mounted at /blog with host constraint blog.local.
Migration Plan 1. Implement Azu::Engine, Azu::Mount, Azu::MountTable, Azu::MountDispatcher. 2. Add helper Azu::Mount.base_path(ctx) and extend URL helpers with base:. 3. Provide examples/mounting. 4. Write spec coverage for delegation, middleware, and URL generation. 5. Release behind azu >= 0.6.0 with changelog and docs page “Mountable Apps”.
Acceptance Criteria
• ✅ Can mount two independent Azu apps at /admin and /blog.
• ✅ Engine routes render correctly and do not need to know their mount path.
• ✅ Per-engine middleware runs only for its path.
• ✅ URL helpers generate prefix-aware links.
• ✅ Sessions are shared by default; optional namespace supported.
• ✅ 100% of example scenarios pass specs.
Open Questions
• Should engines be able to export routes for the main app to import (advanced composition)?
• Asset/static directory multi-root in Azu::Handler::Static (add now or later)?
• Do we need a compile-time macro to auto-register engines?
References
• Azu handler/chain and router usage in the repo and docs. 
• Crystal HTTP::Handler composition patterns (compare with Lucky’s docs for handler chains). 
⸻
Implementation Sketch (PR checklist)
• Add src/azu/engine.cr, src/azu/mount.cr, src/azu/mount_table.cr, src/azu/handlers/mount_dispatcher.cr, src/azu/handlers/strip_prefix.cr.
• Extend router URL helpers to accept base: (non-breaking default "").
• Specs in spec/mounting/.
• examples/mounting/ with Admin & Blog engines.
• Docs page “Mountable Applications” with copy/paste snippets.