innis / hubstr-core
Shared runtime kernel for Hubstr services: application lifecycle, configuration, logging, SQLite persistence, templating, and the shared amphp HTTP host kit
Requires
- php: ^8.4
- composer-runtime-api: ^2.2
- ext-pcntl: *
- ext-pdo_sqlite: *
- ext-zlib: *
- amphp/amp: ^3
- amphp/byte-stream: ^2
- amphp/http: ^2
- amphp/http-server: ^3
- amphp/http-server-router: ^2
- amphp/http-server-static-content: ^2
- amphp/log: ^2
- amphp/socket: ^2
- latte/latte: ^3.1
- monolog/monolog: ^3.10
- psr/log: ^3.0
Requires (Dev)
- ext-posix: *
- friendsofphp/php-cs-fixer: ^3.95
- innis/coding-standards: ^0.1
- league/uri: ^7.1
- phpstan/phpstan: ~2.2.6
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^13.1
- rector/rector: ~2.5.9
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-22 05:56:44 UTC
README
The shared runtime kernel beneath the Hubstr services.
Hubstr is a set of small, self-hosted Nostr services, each a single long-running PHP process on amphp with its data in SQLite: hubstr-relay (a personal relay), hubstr-blossom (a Blossom media server) and hubstr-signer (a NIP-46 remote signer). This package is what they have in common: how a service reads its config, opens and migrates its database, logs, renders a page, hosts HTTP, and shuts down cleanly.
It carries no Nostr or Blossom logic. Those live in the innis/nostr-* libraries and in the services. It is published because the services depend on it, and you can build your own amphp service on it, but it is deliberately opinionated — amphp, SQLite, Latte templates, a PHP config file, logs on standard output — and makes no attempt to be a general framework.
Status: pre-1.0. A minor version may change the public API.
Features
- Configuration — one PHP file, read through strict typed values; a mistyped value or a misspelt key stops the service at start-up and names the key.
- Database and migrations — a SQLite connection with the settings that matter under concurrency, a file only its owner can read, and numbered migrations applied atomically.
- HTTP host — the amphp server assembly the services share, with the middleware, error handling and page rendering that go with it.
- Lifecycle and shutdown — start, wait for a signal, drain in-flight work, stop; and the same sequence when something fails.
- Logging — PSR-3, to standard output, without blocking the event loop.
- Templating and version reporting — Latte with context-aware escaping, and the installed version for a page footer.
Requirements
Declared in composer.json:
- PHP 8.4 or higher, on a POSIX system
ext-pdo_sqlite(the database)ext-pcntl(trappingSIGINTandSIGTERMfor shutdown)ext-zlib(response compression)- the amphp v3 family (the event loop, the HTTP server, its router and static-file handler, and the non-blocking log handler)
latte/latte(templating),monolog/monolog(the PSR-3 logger)
Installation
composer require innis/hubstr-core
Quick Start
A whole service: config, logging, a migrated database, an HTTP host with a landing page, static files and templated error pages, and a clean shutdown on Ctrl-C. This is the body of examples/serve_site.php, less its imports; run that file to see it work.
$values = new ConfigLoader('HUBSTR_CORE_EXAMPLE_CONFIG')->load(__DIR__.'/config/site.php'); $values->rejectUnknownKeys('site_name', 'owner_npub', 'template_cache_path', ...ServiceRuntimeConfig::KEYS); $runtime = ServiceRuntimeConfig::fromValues($values); $binding = $runtime->getBinding(); $logger = new LoggerFactory(getStdout(), $runtime->getLogLevel())->create('example'); $database = SqliteDatabase::atPath($runtime->getDatabasePath())->connect(); new SchemaMigrator($database)->migrate(__DIR__.'/resources/migrations'); $database->exec("INSERT INTO starts (started_at) VALUES (strftime('%s', 'now'))"); $renderer = LatteTemplateRenderer::create(__DIR__.'/templates', $values->string('template_cache_path')); $site = new SiteInfo($values->string('site_name'), new ComposerVersionProvider()->getVersion(), $values->optionalString('owner_npub')); $landingPage = new LandingPageResponder('index.latte', $renderer, $site); $errorHandler = new TemplatedErrorHandler(new ErrorPageResponder('error.latte', $renderer, $site)); $definition = new RouterDefinition( [new Route(HttpMethod::Get, '/', static fn (Request $request): Response => $landingPage->respond())], $errorHandler, __DIR__.'/public', ); $factory = new HttpServerFactory($logger, new ResourceServerSocketFactory()); $socketServer = $factory->createSocketServer($binding, HttpServerOptions::create(concurrencyLimit: 64)); $server = $factory->createServer($socketServer, $definition); new Kernel($logger, new AmphpShutdownSignal())->run($server, 'Serving the example site', [ 'url' => sprintf('http://%s:%d', $binding->getHost(), $binding->getPort()), 'database' => $runtime->getDatabasePath(), 'start' => $database->lastInsertId(), ]);
Configuration
A service is configured by one PHP file that returns an array. ConfigLoader is built with the name of an environment variable that may point it at a different file, and load() returns a ConfigValues.
$values = new ConfigLoader('MY_SERVICE_CONFIG')->load(__DIR__.'/config/service.php');
ConfigValues reads strictly, with no coercion: string, optionalString, int, optionalInt, optionalBool, optionalStringList, and section for a nested array. An integer is an int, never '8080'. A default is written where it is used:
$workers = $values->optionalInt('worker_pool_limit') ?? 0; $limits = $values->section('limits')->optionalInt('max_filters') ?? 5;
ServiceRuntimeConfig::fromValues() builds the part every service shares, from these keys:
| Key | Required | Default | Meaning |
|---|---|---|---|
database_path |
yes | The SQLite database file | |
port |
yes* | Port to bind to, 1–65535 | |
host |
no | 127.0.0.1 |
IP address to bind to; a hostname is refused |
trusted_proxies |
no | ['127.0.0.1'] |
Addresses or CIDR blocks whose X-Forwarded-For to trust |
log_level |
no | info |
debug to emergency, in any case |
* A service may pass its own default port to fromValues().
A service reads its own keys from the same ConfigValues, and declares every key it knows, so a misspelt one is an error rather than a silently applied default (see ADR-0018):
$values->rejectUnknownKeys('relay_url', 'admin_pubkey', ...ServiceRuntimeConfig::KEYS);
A missing file throws ConfigFileNotFoundException. A bad value or an unknown key throws InvalidArgumentException naming the key, by its full path inside a section (limits.max_filters).
Database and migrations
SqliteDatabase names a database; connect() opens a PDO handle with write-ahead journalling, a busy timeout, foreign keys and the cache settings applied. It is a value, so it can be serialised and sent to a worker process, which connects there.
$database = SqliteDatabase::atPath($runtime->getDatabasePath()); // or SqliteDatabase::inMemory() $pdo = $database->connect();
A database it creates is readable only by its owner, and an existing one the service owns has access for other users removed each time it connects, because a service's database may hold secrets. Group access you have granted is left alone (see ADR-0012).
A schema is a directory of migrations, applied by SchemaMigrator (see ADR-0010):
resources/migrations/0001-initial-schema.sql
resources/migrations/0002-add-expiry-to-sessions.sql
- Files are named
NNNN-description.sqland numbered contiguously from0001. - Each is applied once, in order, in its own transaction. The version reached is kept in SQLite's
user_version, and a failed migration rolls back completely and names the file. - A migration must not contain
BEGIN,COMMITorROLLBACK. - Never edit a migration that has shipped: a database that ran it will not run it again. A schema change is a new file.
- A gap, a duplicate number, an empty file and a database newer than the code are all refused before anything is applied. There are no down-migrations.
HTTP host
HttpServerFactory builds a server in two calls, so that a host which needs the socket server while building its own handlers — the relay does, for its websocket — works between them instead of forking the assembly (see ADR-0016).
$socketServer = $factory->createSocketServer($binding, HttpServerOptions::create(concurrencyLimit: 64)); $server = $factory->createServer($socketServer, new RouterDefinition($routes, $errorHandler, $publicDirectory));
A Route is an HttpMethod, a pattern and a handler. Anything no route matches is served from the public directory, and anything not found there goes to the error handler.
Every response passes through the kit's middleware, which:
- limits concurrent requests to
HttpServerOptions' limit; - believes
X-Forwarded-Foronly from the configured trusted proxies; - compresses compressible responses, and never a
text/event-stream(see ADR-0006); - sends
X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer, unless the service set its own (see ADR-0013); - answers a client error a handler lets escape — a body over the size limit, above all — with the right status through the service's error handler, where amphp alone would leave the client waiting (see ADR-0009);
- answers an unhandled exception with the service's 500 page, logging the exception and sending none of its text to the client, and refuses a request method it does not route, both inside its own stack so that the headers above and a service's middleware apply to every error page (see ADR-0020).
ErrorPageResponder (through TemplatedErrorHandler) and LandingPageResponder render pages from a SiteInfo: the site's name, version and owner. An error message reaches the client only through the template, never the status line (see ADR-0014).
The kit leaves to each service what differs between them: its routes, cross-origin handling, framing and content security policy, and the content of its pages. Extra middleware goes in through HttpServerOptions.
Lifecycle and shutdown
Kernel::run() starts the server, starts the optional LifecycleInterface, logs a banner and waits for SIGINT or SIGTERM. It then stops the server — which drains in-flight work through the lifecycle's drain() as it stops — and calls the lifecycle's stop() (see ADR-0007).
new Kernel($logger, new AmphpShutdownSignal(), $lifecycle)->run($server, 'Relay started', ['port' => 8080]);
Once the server has started, that shutdown always runs: if the lifecycle fails to start, or the server's own stop throws, the server and the lifecycle are still stopped and the failure propagates. A LifecycleInterface implementation must therefore tolerate drain() and stop() after a start() that failed.
The kernel waits for in-flight requests for as long as they take, and leaves the deadline to the process supervisor. A second signal, the supervisor's kill, or a signal that arrives during start-up ends the process at once, so stop() must never be the only thing protecting durable state.
Logging
$logger = new LoggerFactory(getStdout(), $runtime->getLogLevel())->create('relay');
A service writes plain single-line records to its standard output, through a handler that does not block the event loop, and keeps no log file (see ADR-0011). Whatever runs the process — systemd's journal, a container runtime — keeps and rotates the log.
Templating and version reporting
TemplateRendererInterface, implemented by LatteTemplateRenderer, renders a named Latte template with context-aware escaping: a value is escaped for the position it occupies, whether HTML text, an attribute, a URL or a script (see ADR-0002). LatteTemplateRenderer::create() takes the template directory and a directory for the compile cache. Keep that cache somewhere only the service can write: Latte executes what it finds there.
VersionProviderInterface, implemented by ComposerVersionProvider, reports the version of the installed root package — the service — with a short commit reference appended to a development version.
Error handling
Faults are thrown. HubstrException is the abstract root, and its final leaves cover a missing config file, migrations that cannot be read or do not form a sequence, a migration that failed, a database newer than the code, and a directory that cannot be created. A service roots its own faults at HubstrException too, so one catch (HubstrException) scopes to the kernel and the services built on it, and never to a Nostr library's faults (see ADR-0001).
A malformed config value or an unknown key is an operator error met at start-up, and throws InvalidArgumentException naming the key.
Security
What the kernel guarantees, what it leaves to the service and its operator, and how to report a vulnerability are set out in SECURITY.md.
Examples
php examples/render_template.php # context-aware escaping through the template port php examples/serve_site.php # the Quick Start above, on http://127.0.0.1:8080
serve_site.php reads examples/config/site.php, and HUBSTR_CORE_EXAMPLE_CONFIG points it at another file. Both write only under the package's own var/example/.
Architecture
Clean architecture, with dependencies pointing inward.
- Domain — the value objects (
ConfigValues,ServiceRuntimeConfig,HttpBinding,SiteInfo,PackageVersion), theLogLevelandHttpMethodenums, and the fault hierarchy. - Application — the ports a host or the infrastructure implements (
ServerInterface,LifecycleInterface,ShutdownSignalInterface,TemplateRendererInterface,VersionProviderInterface) and theKernelthat orchestrates them. - Infrastructure — by concern:
Config/,Filesystem/,Http/(the server assembly),Logging/,Persistence/,Process/,Templating/,Version/. - Presentation —
Http/: the error and landing page rendering.
Architecture decisions
Design rationale lives in docs/adr/ as sequentially-numbered Architecture Decision Records — read these before "correcting" a choice that reads like a smell. Each record states the context, the decision, and what it forbids; the filenames are the index.
Testing
composer test # PHPUnit (Unit and Integration) and PHPStan at level 9 composer check-style # php-cs-fixer, dry run composer check-rector # Rector's PHP 8.4 modernisation check
Licence
MIT