Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions caddy/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ func (f *FrankenPHPApp) Start() error {
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
frankenphp.WithWorkerSidekickRegistry(w.sidekickRegistry),
)

f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.options...))
Expand Down
17 changes: 16 additions & 1 deletion caddy/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@ type FrankenPHPModule struct {
Env map[string]string `json:"env,omitempty"`
// Workers configures the worker scripts to start.
Workers []workerConfig `json:"workers,omitempty"`
// SidekickEntrypoint is the script used to start sidekicks (e.g., bin/console)
SidekickEntrypoint string `json:"sidekick_entrypoint,omitempty"`

resolvedDocumentRoot string
preparedEnv frankenphp.PreparedEnv
preparedEnvNeedsReplacement bool
sidekickRegistry *frankenphp.SidekickRegistry
logger *slog.Logger
requestOptions []frankenphp.RequestOption
}
Expand Down Expand Up @@ -78,6 +81,10 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {

f.assignMercureHub(ctx)

if f.SidekickEntrypoint != "" {
f.sidekickRegistry = frankenphp.NewSidekickRegistry(f.SidekickEntrypoint)
}

loggerOpt := frankenphp.WithRequestLogger(f.logger)
for i, wc := range f.Workers {
// make the file path absolute from the public directory
Expand All @@ -91,6 +98,7 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
wc.inheritEnv(f.Env)
}

wc.sidekickRegistry = f.sidekickRegistry
wc.requestOptions = append(wc.requestOptions, loggerOpt)
f.Workers[i] = wc
}
Expand Down Expand Up @@ -241,6 +249,7 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c
opts,
frankenphp.WithOriginalRequest(new(ctx.Value(caddyhttp.OriginalRequestCtxKey).(http.Request))),
frankenphp.WithWorkerName(workerName),
frankenphp.WithRequestSidekickRegistry(f.sidekickRegistry),
)...,
)

Expand Down Expand Up @@ -297,6 +306,12 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
f.ResolveRootSymlink = &v

case "sidekick_entrypoint":
if !d.NextArg() {
return d.ArgErr()
}
f.SidekickEntrypoint = d.Val()

case "worker":
wc, err := unmarshalWorker(d)
if err != nil {
Expand All @@ -311,7 +326,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}

default:
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, worker", d.Val())
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, sidekick_entrypoint, worker", d.Val())
}
}
}
Expand Down
10 changes: 5 additions & 5 deletions caddy/workerconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ type workerConfig struct {
MatchPath []string `json:"match_path,omitempty"`
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`

options []frankenphp.WorkerOption
requestOptions []frankenphp.RequestOption
absFileName string
matchRelPath string // pre-computed relative URL path for fast matching
sidekickRegistry *frankenphp.SidekickRegistry
options []frankenphp.WorkerOption
requestOptions []frankenphp.RequestOption
absFileName string
matchRelPath string // pre-computed relative URL path for fast matching
}

func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
Expand Down
15 changes: 8 additions & 7 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import (
type frankenPHPContext struct {
mercureContext

documentRoot string
splitPath []string
env PreparedEnv
logger *slog.Logger
request *http.Request
originalRequest *http.Request
worker *worker
documentRoot string
splitPath []string
env PreparedEnv
logger *slog.Logger
request *http.Request
originalRequest *http.Request
worker *worker
sidekickRegistry *SidekickRegistry

docURI string
pathInfo string
Expand Down
136 changes: 136 additions & 0 deletions docs/sidekicks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Application Sidekicks

Sidekicks are long-running PHP workers that run **outside the HTTP request cycle**.
They observe their environment (Redis Sentinel, secret vaults, feature flag services, etc.)
and publish configuration to HTTP workers in real time — without polling, TTLs, or redeployment.

## How It Works

1. A sidekick runs its own event loop (subscribe to Redis, watch files, poll an API, etc.)
2. It calls `frankenphp_sidekick_set_vars()` to publish key-value pairs
3. HTTP workers call `frankenphp_sidekick_get_vars()` to read the latest snapshot
4. The first `get_vars` call **blocks until the sidekick has published** — no startup race condition

## Configuration

```caddyfile
example.com {
php_server {
sidekick_entrypoint /app/bin/console
}
}
```

Each `php_server` block has its own isolated sidekick scope.

## PHP API

### `frankenphp_sidekick_get_vars(string|array $name, float $timeout = 30.0): array`

Starts a sidekick (at-most-once) and returns its published variables.

- First call blocks until the sidekick calls `set_vars()` or the timeout expires
- Subsequent calls return the latest snapshot immediately
- When `$name` is an array, all sidekicks start in parallel and vars are returned keyed by name:

```php
$redis = frankenphp_sidekick_get_vars('redis-watcher');
// ['MASTER_HOST' => '10.0.0.1', 'MASTER_PORT' => '6379']

$all = frankenphp_sidekick_get_vars(['redis-watcher', 'feature-flags']);
// ['redis-watcher' => [...], 'feature-flags' => [...]]
```

- `$name` is available as `$_SERVER['FRANKENPHP_SIDEKICK_NAME']` and `$_SERVER['argv'][1]` in the entrypoint script
- Throws `RuntimeException` on timeout, missing entrypoint, or sidekick crash
- Works in both worker and non-worker mode

### `frankenphp_sidekick_set_vars(array $vars): void`

Publishes a snapshot of string key-value pairs from inside a sidekick.
Each call **replaces** the entire snapshot atomically.

- Throws `RuntimeException` if not called from a sidekick context
- Throws `ValueError` if keys or values are not strings

### `frankenphp_sidekick_should_stop(): bool`

Returns `true` when FrankenPHP is shutting down.

- Throws `RuntimeException` if not called from a sidekick context

## Example

### Sidekick Entrypoint

```php
<?php
// bin/console

require __DIR__.'/../vendor/autoload.php';

$command = $_SERVER['FRANKENPHP_SIDEKICK_NAME'] ?? '';

match ($command) {
'redis-watcher' => runRedisWatcher(),
default => throw new \RuntimeException("Unknown sidekick: $command"),
};

function runRedisWatcher(): void
{
frankenphp_sidekick_set_vars([
'MASTER_HOST' => '10.0.0.1',
'MASTER_PORT' => '6379',
]);

while (!frankenphp_sidekick_should_stop()) {
$master = discoverRedisMaster();
frankenphp_sidekick_set_vars([
'MASTER_HOST' => $master['host'],
'MASTER_PORT' => (string) $master['port'],
]);
usleep(100_000);
}
}
```

### HTTP Worker

```php
<?php
// public/index.php

require __DIR__.'/../vendor/autoload.php';

$app = new App();
$app->boot();

while (frankenphp_handle_request(function () use ($app) {
$redis = frankenphp_sidekick_get_vars('redis-watcher');

$app->handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER + $redis);
})) {
gc_collect_cycles();
}
```

### Graceful Degradation

```php
if (function_exists('frankenphp_sidekick_get_vars')) {
$config = frankenphp_sidekick_get_vars('config-watcher');
} else {
$config = ['MASTER_HOST' => getenv('REDIS_HOST') ?: '127.0.0.1'];
}
```

## Runtime Behavior

- Sidekick threads skip HTTP request startup/shutdown
- Execution timeout is automatically disabled
- Shebangs (`#!/usr/bin/env php`) are silently skipped
- `SCRIPT_FILENAME` is set to the entrypoint's full path
- `$_SERVER['FRANKENPHP_SIDEKICK_NAME']` and `$_SERVER['argv'][1]` contain the sidekick name
- Crash recovery: automatic restart with exponential backoff
- Graceful shutdown via `frankenphp_sidekick_should_stop()`
- Use `error_log()` or `frankenphp_log()` for logging — avoid `echo`
Loading
Loading