-
-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathkitwork.go
More file actions
413 lines (371 loc) · 13.8 KB
/
Copy pathkitwork.go
File metadata and controls
413 lines (371 loc) · 13.8 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
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
package engine
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/kitwork/engine/core"
"github.com/kitwork/engine/database"
"github.com/kitwork/engine/domain"
"github.com/kitwork/engine/host"
"github.com/kitwork/engine/logger"
"github.com/kitwork/engine/utilities/compress"
"github.com/kitwork/engine/work"
)
func Run(configFile ...string) (err error) {
// Lưu ý: KHÔNG nạp .env vào môi trường tiến trình toàn cục (sẽ làm mọi tenant
// chung env, rò secret). env là SCOPED: host đọc root .env trong evalConfigJS;
// mỗi tenant đọc .env riêng của nó (work.Tenant.Run → kitwork().env).
// Manifest DUY NHẤT là một file .kitwork.js chạy được: app.kitwork.js (mặc định mới),
// hoặc server.kitwork.js (tên cũ, vẫn đọc). YAML/JSON KHÔNG nạp trực tiếp ở đây — muốn
// dùng chúng thì trỏ từ manifest: server.run("config.kitwork.yaml").
file := ""
if len(configFile) > 0 && configFile[0] != "" {
file = configFile[0]
} else {
for _, candidate := range []string{"app.kitwork.js", "server.kitwork.js"} {
if _, statErr := os.Stat(candidate); statErr == nil {
file = candidate
break
}
}
if file == "" {
return fmt.Errorf("không tìm thấy manifest: cần app.kitwork.js (hoặc server.kitwork.js)")
}
}
if !strings.HasSuffix(strings.ToLower(file), ".js") {
return fmt.Errorf("engine.Run chỉ nhận manifest .kitwork.js, nhận %q — "+
"muốn dùng YAML/JSON thì trỏ từ manifest: server.run(\"config.kitwork.yaml\")", file)
}
if _, statErr := os.Stat(file); statErr != nil {
return fmt.Errorf("không tìm thấy manifest %s: %w", file, statErr)
}
// Chạy manifest trong VM setup tối giản để BẮT các khai báo (surfaces + config chung).
// Engine tự sở hữu stack → config cũng là chính ngôn ngữ Kitwork, không parser ngoài.
builder, err := evalServerBuilder(file)
if err != nil {
return fmt.Errorf("failed to evaluate config %s: %w", file, err)
}
if builder.err != "" {
return fmt.Errorf("failed to evaluate config %s: config validation error: %s", file, builder.err)
}
// DISPATCH THEO MANIFEST: khai báo là DỮ LIỆU, lệnh mới quyết định chạy gì. Không có web
// surface thì cloud host không có gì để phục vụ — nếu app khai desktop/mobile thì đó là
// hợp lệ (chạy shell tương ứng), không phải lỗi.
if !builder.hasWeb {
if _, hasDesktop := builder.config["desktop"]; hasDesktop {
fmt.Printf("%s khai báo app.desktop() nhưng không có web surface — không có gì để phục vụ.\n"+
"→ Chạy `kitwork-desktop` cho app desktop, hoặc thêm `app.web({ port: env.PORT || 8080 })` để phục vụ HTTP.\n", file)
return nil
}
if _, hasMobile := builder.config["mobile"]; hasMobile {
fmt.Printf("%s chỉ khai báo app.mobile() — cloud host không có gì để phục vụ.\n", file)
return nil
}
return fmt.Errorf("failed to evaluate config %s: %w", file, noWebSurfaceErr(builder, file))
}
raw, err := builderToMap(builder, file)
if err != nil {
return fmt.Errorf("failed to evaluate config %s: %w", file, err)
}
fmt.Printf("Loaded configuration from %s (app.web)\n", file)
cfg, err := ParseConfig(raw)
if err != nil {
return fmt.Errorf("failed to process configuration: %w", err)
}
// apps/ is the modern root name (an app = a folder); deployments created before the rename still
// have tenants/. When the configured apps/ is missing but the legacy folder exists, follow it —
// an old server keeps booting untouched, no config edit required.
if cfg.Root == "apps" {
if _, err := os.Stat(cfg.Root); os.IsNotExist(err) {
if _, err := os.Stat("tenants"); err == nil {
fmt.Println("Root apps/ not found — using legacy tenants/ folder")
cfg.Root = "tenants"
}
}
}
// Initialize structured logger
logger.InitLogger(cfg.Logger)
slog.Info("Kitwork Engine starting...", "port", cfg.Port, "root", cfg.Root)
var systemConnected bool
for i := range cfg.Databases {
dbCfg := cfg.Databases[i]
alias := dbCfg.Alias
if alias == "" {
alias = "default"
}
database.Configs[alias] = dbCfg
if dbCfg.Alias == "system" {
dbConn, err := dbCfg.Connect()
if err != nil {
return fmt.Errorf("failed to connect to system database: %w", err)
}
defer dbConn.Close()
database.System = dbConn
// Record the DIALECT alongside the handle: a *sql.DB cannot be asked what SQL it
// speaks, and the background stores need to know rather than assume.
database.SystemDriver = strings.ToLower(strings.TrimSpace(dbCfg.Type))
systemConnected = true
}
}
if !systemConnected {
fmt.Println("System Database is not provided")
}
// Pass global settings to the work package
work.AllowLocal = cfg.AllowLocal
work.ServerPort = cfg.Port
// Scheduler backend is chosen automatically: a connected system Postgres → the SHARED cluster store
// (crons + cron_runs tables, SKIP LOCKED claim, lease/heartbeat, cross-node reclaim); no system DB →
// per-tenant SQLite. No flag — the presence of database.System is the switch (see startPersistedScheduler).
if database.System != nil {
slog.Info("Scheduler: shared Postgres backend (system DB connected)")
}
// Domain whitelist (for AutoSSL HostPolicy) + redirect rules (engine + :80 fallback).
domain.Allows = cfg.Domains
// Single-tenant sites/ convention: every folder under <root>/sites/ is a domain AutoSSL should
// serve, with no identity and no DB. Enable the live HostPolicy folder check, and seed the
// whitelist from the sites present at boot (the live check also covers ones added later).
switch cfg.Root {
case "", "./", "../", "/", ".", "..":
// standalone: no sites/ root
default:
domain.SitesDir = filepath.Join(cfg.Root, work.SitesDirName)
if sites := work.DiscoverSites(cfg.Root); len(sites) > 0 {
domain.Allows = append(domain.Allows, sites...)
slog.Info("Single-tenant sites discovered", "count", len(sites), "dir", domain.SitesDir)
}
}
domain.Configure(cfg.Canonical, cfg.Redirects)
// Initialize and run the engine
handler := core.New(cfg.Root, cfg.MaxEnergy, cfg.HotReload, cfg.Hostname)
defer handler.Close()
if directory := bytecodeCacheDirectory(cfg); directory != "" {
handler.SetBytecodeCache(directory)
}
// Client-IP source: as the edge server Kitwork ignores X-Forwarded-For by default (spoofable);
// trust_proxy: true opts in when running behind your own reverse proxy.
work.TrustProxyHeaders = cfg.TrustProxy
// Host-level rate limits (first gate in ServeHTTP, before tenant resolution). Configured via
// server.kitwork.js .rateLimit({...}) or the YAML rate_limit: block; absent = off.
if cfg.RateLimit != nil {
handler.SetRateLimit(&core.RateLimiter{
Rate: cfg.RateLimit.Rate,
IPRate: cfg.RateLimit.IP,
BrowserRate: cfg.RateLimit.Browser,
UserRate: cfg.RateLimit.User,
Period: cfg.RateLimit.Period,
})
}
// FILESYSTEM-ROUTED is lazy BY DESIGN: nothing is scanned or compiled at startup — the engine is
// idle until the first request, and each folder's router.kitwork.js compiles on first hit. So
// there is NO route prewarm; the old eager route-registration is gone with the flat model.
//
// The ONE deliberate exception is the scheduler: a cron cannot wait for a request. So every app
// (identity) with a _cron/ boots an app runtime NOW that starts its scheduler eagerly.
handler.StartAppSchedulers()
for _, site := range work.DiscoverLegacySites(cfg.Root) {
slog.Warn("Legacy site entry is ignored; migrate to router.kitwork.js", "site", site)
}
// Transport compression wraps the whole handler once. A Kitwork page is markup-heavy — a real
// site measured 174 KB uncompressed — and while the render costs microseconds, shipping those
// bytes costs hundreds of milliseconds. The middleware leaves live streams, already-compressed
// formats and tiny bodies alone; see utilities/compress.
srvHandler := compress.Middleware(handler)
var servers []*http.Server
serverErrors := make(chan error, 2)
if !host.IsLocalhost() && !cfg.AllowLocal {
tlsConfig := domain.AutoSSL(cfg.Domains)
httpsServer := &http.Server{
Addr: ":443",
Handler: srvHandler,
TLSConfig: tlsConfig,
}
servers = append(servers, httpsServer)
go func() {
serverErrors <- httpsServer.ListenAndServeTLS("", "")
}()
}
printBanner(cfg, host.IsLocalhost())
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: srvHandler,
}
servers = append(servers, httpServer)
go func() {
serverErrors <- httpServer.ListenAndServe()
}()
signalCtx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopSignals()
var runErr error
select {
case <-signalCtx.Done():
slog.Info("Shutdown signal received")
case runErr = <-serverErrors:
if !errors.Is(runErr, http.ErrServerClosed) {
slog.Error("HTTP server stopped", "error", runErr)
}
}
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelShutdown()
for _, server := range servers {
if err := server.Shutdown(shutdownCtx); err != nil && runErr == nil {
runErr = err
}
}
if errors.Is(runErr, http.ErrServerClosed) {
return nil
}
return runErr
}
// Check validates the executable manifest and prepares every discovered site
// without opening a listener, publishing a generation, or starting cron.
func Check(configFile ...string) (core.CheckReport, error) {
cfg, err := commandConfig(configFile...)
if err != nil {
return core.CheckReport{}, err
}
for i := range cfg.Databases {
dbConfig := cfg.Databases[i]
alias := dbConfig.Alias
if alias == "" {
alias = "default"
}
database.Configs[alias] = dbConfig
}
work.AllowLocal = cfg.AllowLocal
return core.Check(cfg.Root, cfg.MaxEnergy, bytecodeCacheDirectory(cfg)), nil
}
// ProfileReport is the static bytecode report returned by Profile.
type ProfileReport = core.ProfileReport
// Profile compiles every executable router, cron, and queue entrypoint and
// returns immutable bytecode metrics without executing tenant code.
func Profile(configFile ...string) (core.ProfileReport, error) {
cfg, err := commandConfig(configFile...)
if err != nil {
return core.ProfileReport{}, err
}
return core.Profile(cfg.Root), nil
}
func bytecodeCacheDirectory(cfg *Config) string {
if cfg == nil || !cfg.BytecodeCache {
return ""
}
if cfg.BytecodeCacheDir == "" {
return filepath.Join(cfg.Root, ".kitwork", "cache", "bytecode")
}
if filepath.IsAbs(cfg.BytecodeCacheDir) {
return cfg.BytecodeCacheDir
}
return filepath.Join(cfg.Root, cfg.BytecodeCacheDir)
}
func commandConfig(configFile ...string) (*Config, error) {
file := ""
if len(configFile) > 0 && configFile[0] != "" {
file = configFile[0]
} else {
for _, candidate := range []string{"app.kitwork.js", "server.kitwork.js"} {
if _, err := os.Stat(candidate); err == nil {
file = candidate
break
}
}
}
if file == "" {
return nil, fmt.Errorf(
"không tìm thấy manifest: cần app.kitwork.js (hoặc server.kitwork.js)",
)
}
if !strings.HasSuffix(strings.ToLower(file), ".js") {
return nil, fmt.Errorf(
"engine command chỉ nhận manifest .kitwork.js, nhận %q",
file,
)
}
builder, err := evalServerBuilder(file)
if err != nil {
return nil, fmt.Errorf("failed to evaluate config %s: %w", file, err)
}
if builder.err != "" {
return nil, fmt.Errorf(
"failed to evaluate config %s: config validation error: %s",
file,
builder.err,
)
}
if !builder.hasWeb {
return nil, fmt.Errorf(
"failed to evaluate config %s: %w",
file,
noWebSurfaceErr(builder, file),
)
}
raw, err := builderToMap(builder, file)
if err != nil {
return nil, fmt.Errorf("failed to evaluate config %s: %w", file, err)
}
cfg, err := ParseConfig(raw)
if err != nil {
return nil, fmt.Errorf("failed to process configuration: %w", err)
}
if cfg.Root == "apps" {
if _, statErr := os.Stat(cfg.Root); os.IsNotExist(statErr) {
if _, legacyErr := os.Stat("tenants"); legacyErr == nil {
cfg.Root = "tenants"
}
}
}
return cfg, nil
}
// printBanner renders the Kitwork startup banner: a brand-red "KITWORK" wordmark
// plus honest runtime facts (mode, listen address, TLS, databases). No fake metrics.
func printBanner(cfg *Config, isLocalhost bool) {
const (
red = "\033[38;2;248;34;68m" // brand red #f82244
dim = "\033[2m"
reset = "\033[0m"
)
label := func(name string) string {
return fmt.Sprintf(" %s▸%s %s%-7s%s ", red, reset, dim, name, reset)
}
mode, root := "Multi-Tenant", cfg.Root
switch cfg.Root {
case "", "./", "../", "/", ".", "..":
mode, root = "Standalone", "."
}
fmt.Println("\n" + red + `█ █ █████ █████ █ █ ███ ████ █ █
█ █ █ █ █ █ █ █ █ █ █ █
███ █ █ █ █ █ █ █ ████ ███
█ █ █ █ ██ ██ █ █ █ █ █ █
█ █ █████ █ █ █ ███ █ █ █ █` + reset)
fmt.Println(dim + " sovereign logic engine\n" + reset)
fmt.Printf("%s%s %sroot:%s %s\n", label("mode"), mode, dim, reset, root)
fmt.Printf("%shttp://localhost:%d\n", label("listen"), cfg.Port)
if cfg.AllowLocal || isLocalhost {
fmt.Printf("%s%sdisabled (local dev)%s\n", label("tls"), dim, reset)
} else {
fmt.Printf("%sAutoSSL · :443\n", label("tls"))
}
for _, db := range cfg.Databases {
alias := db.Alias
if alias == "" {
alias = "default"
}
if db.Type == "sqlite" || db.Type == "sqlite3" {
name := db.Name
if name == "" {
name = db.Host
}
fmt.Printf("%ssqlite · %s %s(%s)%s\n", label("db"), name, dim, alias, reset)
} else {
fmt.Printf("%s%s · %s:%d %s(%s)%s\n", label("db"), db.Type, db.Host, db.Port, dim, alias, reset)
}
}
fmt.Println()
}