feat(11.1-03): add the Architecture and Plugins docs sections
- architecture: introduction, Go modules and workspaces, application lifecycle, request lifecycle - plugins: registration, scheduling, extending, testing - verified Examples for backpack services, towel request context, pact schedules and festival events - TestDocsRequiredPages lists the eight new pages
This commit is contained in:
75
docs/architecture/application-lifecycle.md
Normal file
75
docs/architecture/application-lifecycle.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Application lifecycle
|
||||
description: "What happens when an application binary starts: configuration, the backpack container, plugin ordering, Register and Boot, and database-dependent boot work."
|
||||
section: architecture
|
||||
order: 30
|
||||
---
|
||||
# Application lifecycle
|
||||
|
||||
Every run of an application binary, whether it serves HTTP or runs a single console command, goes through the same start-up. `summer build` generates the `main.go` that performs it, so you never write it by hand.
|
||||
|
||||
## Start-up sequence
|
||||
|
||||
The generated `main` does the following, in order:
|
||||
|
||||
1. Loads configuration with `compass.Load` from the `config/` directory, applying the environment directory and `SUMMER_` variables.
|
||||
2. Creates the application container with `backpack.New`.
|
||||
3. Activates the plugins listed in `summer.yaml` with `party.Activate`.
|
||||
4. Collects the console commands: the framework's runtime commands (from `lagoon.RuntimeCommands`, `conga.RuntimeCommands`, `surf.ServeCommand`, `surf.RouteListCommand` and `cabana.RuntimeCommands`), then the commands of every plugin that implements `pact.HasCommands`.
|
||||
5. Publishes the command set as a `bonfire.Catalog`, so the scheduler can run commands in-process.
|
||||
6. Runs the command named on the command line.
|
||||
|
||||
## Plugin ordering
|
||||
|
||||
`party.Activate` selects the plugins by ID and orders them so that every plugin comes after the plugins its `party.Plugin.Requires` lists. It fails before any plugin code runs when an ID is empty, duplicated or not compiled in, when a required plugin is missing, or when the requirements form a cycle.
|
||||
|
||||
## Register, then Boot
|
||||
|
||||
Activation runs in phases, and each phase finishes for every plugin before the next begins:
|
||||
|
||||
1. `backpack.App.SetPlugins` records the complete plugin set, so `backpack.App.HasPlugin` answers correctly from the first Register onwards.
|
||||
2. The embedded defaults of every plugin that implements `pact.HasConfig` are merged into the configuration under the plugin ID.
|
||||
3. `party.Plugin.Register` runs for every plugin. Publish services here; do not use other plugins' services yet.
|
||||
4. The framework publishes the translator and the mailer, and registers each plugin's translations and mail templates.
|
||||
5. `party.Plugin.Boot` runs for every plugin. Look up services, register event listeners and extend other plugins here.
|
||||
|
||||
This is the WinterCMS `register` and `boot` split: when any Boot runs, every plugin has already registered.
|
||||
|
||||
## The container
|
||||
|
||||
`backpack.App` is the application container that Register and Boot receive. It holds the configuration in `backpack.App.Config`, the event bus in `backpack.App.Events` and a typed service registry. Nothing in it is process-global, so two applications in one test do not share state.
|
||||
|
||||
Services are keyed by their Go type. A plugin publishes a value with `backpack.App.Publish` and another plugin reads it with `backpack.App.Lookup` and the same type argument. Publish under an interface type when consumers should not depend on your implementation:
|
||||
|
||||
```go src=modules/backpack/example_test.go#ExampleApp_Publish
|
||||
app := backpack.New(&compass.Config{})
|
||||
app.SetPlugins([]string{"acme.greeter", "acme.blog"})
|
||||
|
||||
// acme.greeter, in its Register step: publish under the interface type.
|
||||
var greeter Greeter = englishGreeter{}
|
||||
if err := app.Publish(greeter); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// acme.blog, in its Boot step: look the service up by the same type.
|
||||
if app.HasPlugin("acme.greeter") {
|
||||
if found, ok := app.Lookup[Greeter](); ok {
|
||||
fmt.Println(found.Greet("blog"))
|
||||
}
|
||||
}
|
||||
|
||||
// A second Publish under the same type is refused.
|
||||
fmt.Println(app.Publish(greeter) != nil)
|
||||
// Output:
|
||||
// Hello, blog
|
||||
// true
|
||||
```
|
||||
|
||||
## Capability interfaces
|
||||
|
||||
Beyond the four `party.Plugin` methods, a plugin declares what it contributes by implementing interfaces from [pact](../../modules/pact/README.md). The framework package that owns a capability finds it with a type assertion: surf asks for `pact.HasRoutes` and `pact.HasMiddleware`, lagoon for `pact.HasMigrations`, cabana for `pact.HasAdminControllers`, conga for `pact.HasJobs` and `pact.HasSchedule`. A plugin that does not implement an interface simply does not take part in that capability.
|
||||
|
||||
## Database-dependent boot work
|
||||
|
||||
Boot runs before any command opens the database: `migrate` and `serve` open it after activation, and commands such as `key:generate` never open it. Code that needs the database handle during boot, such as registering GORM callbacks, therefore goes through `lagoon.OnDatabase`. It runs the function immediately when the database is already published, and otherwise queues it until `lagoon.Publish` makes the shared `*sql.DB` and `*gorm.DB` handles available. An error from a queued function is returned by `lagoon.Publish`, so the command that opened the database fails instead of running with a half-registered plugin. [Extending plugins](../plugins/extending.md) shows where GORM callbacks fit.
|
||||
69
docs/architecture/go-modules-and-workspaces.md
Normal file
69
docs/architecture/go-modules-and-workspaces.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Go modules and workspaces
|
||||
description: Require the framework module, develop plugins as local modules in a Go workspace, list them in summer.yaml and fork a plugin with a replace directive.
|
||||
section: architecture
|
||||
order: 20
|
||||
---
|
||||
# Go modules and workspaces
|
||||
|
||||
WinterCMS uses Composer to pull in the framework and plugins. SummerCMS uses Go modules: the framework is one module, each plugin is its own module, and the application module requires them all.
|
||||
|
||||
## The framework module
|
||||
|
||||
The framework is the module `git.golem15.com/golem15/summercms`. Every framework package is imported from `git.golem15.com/golem15/summercms/modules/<name>`, for example `git.golem15.com/golem15/summercms/modules/party`.
|
||||
|
||||
An application requires the framework in its `go.mod`. While you work against a local checkout of the framework, point the requirement at it with a `replace` directive. For an application module `acme` in a directory next to the framework checkout:
|
||||
|
||||
```text
|
||||
module git.golem15.com/acme/acme
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require git.golem15.com/golem15/summercms v0.0.0
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||
```
|
||||
|
||||
`summer make:plugin` copies this framework `replace` into the new plugin's `go.mod`, rewritten relative to the plugin directory, so the plugin builds against the same checkout.
|
||||
|
||||
## The summer.yaml manifest
|
||||
|
||||
The manifest in the application root names the application module, the binary `summer build` writes to `bin/`, and the plugins in activation order:
|
||||
|
||||
```yaml
|
||||
module: git.golem15.com/acme/acme
|
||||
binary: acme
|
||||
plugins:
|
||||
- id: acme.user
|
||||
module: git.golem15.com/acme/acme/plugins/user
|
||||
- id: acme.blog
|
||||
module: git.golem15.com/acme/acme/plugins/blog
|
||||
```
|
||||
|
||||
`summer build` turns this list into `plugins.gen.go`. The order is the manifest order, adjusted so that each plugin comes after the plugins it requires. Plugins with no dependency between them keep the order you wrote.
|
||||
|
||||
## Local plugins in a workspace
|
||||
|
||||
A plugin you develop inside the application lives in `plugins/<name>` as its own module. `summer make:plugin acme.blog` creates it there, and `summer plugin:add plugins/blog` registers it:
|
||||
|
||||
- it adds the plugin to `summer.yaml`;
|
||||
- it adds a `require` and a `replace` pointing at the local directory to the application's `go.mod`;
|
||||
- it adds the plugin directory to the nearest `go.work`, creating one in the application root when there is none.
|
||||
|
||||
With a `go.work` in place, `go build`, `go test` and your editor see every local plugin module together. `summer build` uses the nearest `go.work` it finds; without one it builds in module mode.
|
||||
|
||||
```sh
|
||||
summer make:plugin acme.blog
|
||||
summer plugin:add plugins/blog
|
||||
summer build
|
||||
```
|
||||
|
||||
## Replacing and forking a plugin
|
||||
|
||||
WinterCMS lets you replace a plugin by overriding its classes. In SummerCMS you fork the plugin's module and point the application at your fork with a `replace` directive. The plugin ID and the import path stay the same, so nothing else in the application changes:
|
||||
|
||||
```text
|
||||
replace git.golem15.com/acme/user => ../forks/user
|
||||
```
|
||||
|
||||
Keep the fork's plugin ID unchanged when it must stand in for the original, since other plugins require it by ID. If you want both to exist side by side, give the fork a new module path and a new ID and list it in `summer.yaml` instead. To extend a plugin without forking it, see [Extending plugins](../plugins/extending.md).
|
||||
41
docs/architecture/introduction.md
Normal file
41
docs/architecture/introduction.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Architecture introduction
|
||||
description: "How a SummerCMS application is built: one Go binary with compiled plugins, a headless JSON API, an embedded admin SPA and console commands."
|
||||
section: architecture
|
||||
order: 10
|
||||
---
|
||||
# Architecture introduction
|
||||
|
||||
A SummerCMS application is one Go binary. The framework modules, the application's plugins, the embedded admin SPA and every console command are compiled into it. You deploy that file and its `config/` directory; nothing is installed or loaded at runtime.
|
||||
|
||||
## One binary, compiled plugins
|
||||
|
||||
A plugin is a Go package that implements `party.Plugin` and registers itself from `init` with `party.Register`. The application lists the plugins it uses in its `summer.yaml` manifest. `summer build` reads that manifest, generates `plugins.gen.go` (a blank import for every plugin and the ordered `PluginIDs` list) and a `main.go`, then runs `go build`. Adding or removing a plugin is a rebuild, not a runtime switch.
|
||||
|
||||
This replaces the WinterCMS plugin directory scan. The compiler checks every plugin against the interfaces it claims to implement, and a missing dependency fails the build or the boot, never a later request.
|
||||
|
||||
## Headless by design
|
||||
|
||||
SummerCMS serves a JSON API and the admin SPA. It has no themes, CMS pages or frontend components: the public site is a separate application that calls the API and subscribes to realtime channels. The admin is a compiled Vue application that [boardwalk](../../modules/boardwalk/README.md) serves from the binary, and it reads the admin API that [cabana](../../modules/cabana/README.md) builds from each plugin's `fields.yaml` and `columns.yaml`.
|
||||
|
||||
## The framework modules
|
||||
|
||||
Each framework module is one Go package under `modules/`, documented by its README and its API reference page.
|
||||
|
||||
| Concern | Modules |
|
||||
|---------|---------|
|
||||
| Plugins and the container | [party](../../modules/party/README.md), [pact](../../modules/pact/README.md), [backpack](../../modules/backpack/README.md), [festival](../../modules/festival/README.md), [compass](../../modules/compass/README.md) |
|
||||
| HTTP | [surf](../../modules/surf/README.md), [towel](../../modules/towel/README.md), [wire](../../modules/wire/README.md), [bouncer](../../modules/bouncer/README.md), [wristband](../../modules/wristband/README.md), [fetchguard](../../modules/fetchguard/README.md) |
|
||||
| Data | [lagoon](../../modules/lagoon/README.md), [beachcomber](../../modules/beachcomber/README.md) |
|
||||
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md) |
|
||||
| Services | [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md) |
|
||||
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md) |
|
||||
|
||||
## What runs where
|
||||
|
||||
Two programs carry console commands:
|
||||
|
||||
- The `summer` tool is the developer CLI. You install it once with `go install ./cmd/summer`. It builds and watches applications (`summer build`, `summer dev`), scaffolds plugins and their parts (`summer make:plugin`, `summer make:model` and the other `make:` commands), records and replays API parity fixtures and builds these docs.
|
||||
- The application binary, for example `bin/hello`, carries the runtime commands: `serve`, `migrate`, `route:list`, `queue:work`, `admin:create` and the commands its plugins add. It is what you run in production.
|
||||
|
||||
Some `summer` commands, such as `summer migrate` and `summer serve`, run the matching command of the application binary in the current application directory, building it first when it is missing. The [Installation](../setup/installation.md) guide walks through both programs.
|
||||
82
docs/architecture/request-lifecycle.md
Normal file
82
docs/architecture/request-lifecycle.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Request lifecycle
|
||||
description: "How an HTTP request reaches a plugin handler: route collection, named middleware, constraints, body limits, CORS, recovery and request context values."
|
||||
section: architecture
|
||||
order: 40
|
||||
---
|
||||
# Request lifecycle
|
||||
|
||||
The `serve` command builds one `http.Handler` from every plugin's route declarations and serves it with the Go standard library. This page follows a request from the socket to your handler and back.
|
||||
|
||||
## Building the router
|
||||
|
||||
When `serve` starts, `surf.BuildRouter` collects the routes:
|
||||
|
||||
1. It registers the built-in middleware (`throttle`, `body.limit`, `locale.from-principal` and, when the admin is enabled, `backend`).
|
||||
2. It registers the named middleware of every plugin that implements `pact.HasMiddleware`, `pact.HasMiddlewareFactories` or `pact.HasHouseMiddleware`, and the rate-limit buckets of every `surf.BucketProvider`.
|
||||
3. It calls `pact.HasRoutes.Routes` on every plugin in activation order, passing a `pact.Router` that records groups, routes, middleware names and constraints.
|
||||
4. It mounts the cabana admin API and checks every route.
|
||||
|
||||
`surf.Assemble` then compiles the routes onto a standard library `http.ServeMux`. A duplicate route, an unknown middleware name or a malformed `throttle` parameter fails here, at boot, and `serve` exits with the error instead of serving a broken router. `./bin/acme route:list` builds the same router without opening the database and prints the route table.
|
||||
|
||||
## Declaring routes
|
||||
|
||||
A plugin declares routes the way a WinterCMS `routes.php` file does, with groups that share a prefix and middleware:
|
||||
|
||||
```php
|
||||
Route::group(['prefix' => 'api/blog', 'middleware' => ['auth']], function () {
|
||||
Route::get('posts/{id}', 'Acme\Blog\Http\Posts@show')->where('id', '[0-9]+');
|
||||
});
|
||||
```
|
||||
|
||||
In Go the same declaration is a `pact.HasRoutes` method. Paths use Go `http.ServeMux` patterns, `pact.Router.Where` and `pact.Router.WhereIn` constrain the last declared route, and middleware names are strings that must be registered by the time the router is built. The [surf](../../modules/surf/README.md) reference has the full route builder with rate-limit buckets and per-route body limits.
|
||||
|
||||
## The wrapping order
|
||||
|
||||
surf wraps every non-raw route in the same layers. From the outside in:
|
||||
|
||||
1. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
|
||||
2. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
|
||||
3. The request locale, taken from the `Accept-Language` header and stored in the context.
|
||||
4. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
|
||||
5. The route's middleware, in the order you listed them: group middleware first, then the route's own.
|
||||
6. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
|
||||
7. Your handler.
|
||||
|
||||
Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500.
|
||||
|
||||
## Request context values
|
||||
|
||||
WinterCMS reads the current locale and user through facades. SummerCMS carries them on the request's `context.Context`. surf stores the locale with `towel.WithLocale`, the authentication middleware stores the signed-in principal, and your own middleware can add the organization or collection with `towel.WithOrganization` and `towel.WithCollection`. Any code that receives the context reads them back without a global lookup:
|
||||
|
||||
```go src=modules/towel/example_test.go#ExampleWithLocale
|
||||
// A middleware stores the organization once for the whole request.
|
||||
withAcme := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := towel.WithOrganization(r.Context(), "acme")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// The handler reads the values back from its request context.
|
||||
listPosts := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
org, _ := towel.Organization(r.Context())
|
||||
locale, ok := towel.Locale(r.Context())
|
||||
if !ok {
|
||||
locale = "en"
|
||||
}
|
||||
fmt.Fprintf(w, "posts for %s in %s", org, locale)
|
||||
})
|
||||
|
||||
// surf sets the locale from Accept-Language; here the test sets it.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/blog/posts", nil)
|
||||
req = req.WithContext(towel.WithLocale(req.Context(), "pl"))
|
||||
rec := httptest.NewRecorder()
|
||||
withAcme(listPosts).ServeHTTP(rec, req)
|
||||
fmt.Println(rec.Body.String())
|
||||
// Output: posts for acme in pl
|
||||
```
|
||||
|
||||
## Writing responses
|
||||
|
||||
Handlers write JSON with `wire.WriteJSON`, which keeps bodies byte-compatible with a Laravel backend: HTML characters are not escaped and there is no trailing newline. `wire.Slice` turns a nil list into `[]`, `wire.Time` marshals timestamps in Carbon's `+00:00` form and `wire.TriBool` models a nullable boolean. Errors that should look like your API's error envelope go through the house middleware a plugin registers with `pact.HasHouseMiddleware`.
|
||||
Reference in New Issue
Block a user