Application#
src/app.zig is the application entry point. Every app exports default-initializable Locals and Session structs, a Flash struct with kind and message fields, a compile-time middleware constant typed as zyn.MiddlewareStack, and a routes module whose compile-time table constant is typed as zyn.RouteTable:
const zyn = @import("zyn");
/// Request-scoped state shared by actions, middleware, hooks, and guards.
pub const Locals = struct {
user_id: ?[]const u8 = null,
};
/// Encrypted browser state persisted across requests.
pub const Session = struct {
cart_id: ?[]const u8 = null,
};
/// Transient messages queued by actions and exposed to rendered views.
pub const Flash = struct {
kind: enum {
info,
success,
warning,
err,
},
message: []const u8,
};
pub const middleware: zyn.MiddlewareStack = &.{
.use(zyn.MethodOverride, .{ .param = "_method" }),
.use(zyn.RequestLogger, .{}),
};
pub const routes = @import("routes.zig");
pub const jobs = @import("jobs.zig");
pub const notifications = @import("notifications.zig");
Keep application wiring and registries at the root of src. Declare routes in src/routes.zig; put handlers and action-specific helpers under src/actions, grouped by feature. Logic shared with jobs or other application components can live in a module owned by that shared feature.
A generated application also exports optional jobs and notifications modules. The jobs module's public registry is a zyn.JobRegistry; applications without background jobs may omit it. A jobs module may additionally declare recurring as a zyn.RecurringJobRegistry; omitting it defines no recurring schedules. The notifications module's public registry is a zyn.NotificationRegistry; applications without notifications may omit it.
Applications using storage variants may also export a storage module with a pub const variants record of named zyn.ImageVariant definitions; see framework/storage. Applications without variants may omit it.
Application export validation reports diagnostic codes application-export-missing and application-export-shape, the affected export, the violated rule, repair guidance, and zyn docs framework/application. These messages retain Zig's compiler locations and reference traces; the export subject is not an inferred source location. Compiler failures outside export-shape validation, including non-constant initializers, retain their native diagnostics.
Request roles expose these capabilities:
- Actions receive
*zyn.Action; hooks receive*zyn.Hook; middleware receives*zyn.Middleware; guards receive*const zyn.Guard. - Actions, hooks, and middleware can use
db,jobs,notifications, immutable blobstorage, cookies, and mutable request state. Jobs receive the same database, queue, notification, storage, logger, I/O, and arena capabilities. Guards receive read-only request state and cookies. - Every role provides the same
request_idandlogger: *const zyn.Logger. - Actions also provide typed flash messages through
action.flash.
Use action.logger.info, debug, warn, err, or fatal for application messages.
Every parsed request receives a fresh 128-bit identifier from Zig's secure random source, including unmatched requests and requests rejected by zyn's request boundary. request_id is an immutable string containing its 32-character lowercase hexadecimal encoding; zyn does not adopt an inbound X-Request-ID. Actions, hooks, middleware, guards, and zyn.http.Request expose the same value when they run. Every response receives it as X-Request-ID unless application code explicitly sets that response field.
Application state#
Application state has three scopes: request-local Locals, encrypted browser Session state, and transient cross-request Flash messages.
Request locals#
Every request role exposes a pointer to the request's Locals instance as action.locals, middleware.request.locals, hook.locals, or guard.locals. The instance is initialized from the struct's field defaults and may contain arbitrary fields and callable methods:
pub const Locals = struct {
request_label: []const u8 = "unlabeled",
pub fn label(self: *@This()) []const u8 {
return self.request_label;
}
};
Handlers may read and mutate those fields or call methods directly:
fn account(action: *zyn.Action) !zyn.Response {
action.locals.request_label = "web";
return action.json(.{ .label = action.locals.label() }, .{});
}
The locals instance has one stable address until dispatch finishes. Every role and the live HTTP request reference that same instance; continuation does not copy or replace it. Assign the whole value through locals.*. Pointers into locals remain valid for the request, but cannot outlive it. Different requests have separate instances.
Sessions#
Every application defines its browser-owned Session in src/app.zig:
pub const Session = struct {
cart_id: ?[]const u8 = null,
return_to: ?[]const u8 = null,
};
Actions and hooks mutate it directly, middleware uses its live request, and guards receive a read-only view:
action.session.cart_id = cart.id;
action.session.return_to = "/checkout";
hook.session.return_to = null;
middleware.request.session.cart_id = cart_id;
action.session.* = .{};
Zyn encrypts the value in the browser-session cookie _zyn_session. Keep only identifiers and transient choices there; keep authoritative state in PostgreSQL. Managed authentication keeps its own revocable sessions in the zyn schema and exposes them through action.auth; do not put its credentials or session identifiers in the application's Session. Concurrent responses can overwrite browser-held values, which cannot be individually revoked.
Session must be default-initializable and JSON-compatible. New fields need defaults, removed fields are ignored, and assigned slices must live until response headers commit. Invalid cookies reset and expire, unchanged sessions are not written, .{} expires the cookie, and a complete cookie over 4096 bytes returns error.SessionTooLarge. Cookies are HttpOnly, SameSite=Lax, scoped to /, and Secure over HTTPS.
Flash messages#
The application-owned flash entry's kind and message fields may have any JSON-serializable types which can also be represented in a view:
pub const Flash = struct {
kind: enum {
info,
success,
warning,
err,
},
message: []const u8,
};
Queue a typed entry through action.flash.add:
try action.flash.add(.success, "Project created.");
return .redirect(.see_other, "/projects");
add copies each entry into request memory. Messages remain ordered, repeated kinds are allowed, and action.flash.messages() reads those available to the current response. A successfully delivered view exposes them as @flash and consumes them after hooks and middleware complete. Redirects preserve them, HEAD does not consume them, and messages added after a render wait for the next delivered view.
Zyn stores pending messages in the encrypted _zyn_flash cookie. Invalid cookies are discarded and expired. The queue may hold at most sixteen entries and 2,800 JSON bytes; add returns error.TooManyFlashMessages or error.FlashTooLarge when those limits are crossed. Mutate flash only before response headers commit.
Flash is for small presentation messages, not authentication, durable notifications, or authoritative state.
Runtime configuration#
Runtime configuration lives in zyn.zon. A fresh project includes:
.{
.name = "zyn app",
.db = .{
.defaults = .{
.postgresql_version = "18.1.0",
.host = "127.0.0.1",
.port = 7542,
.username = "zyn",
.max_result_count = 10_000,
.max_result_bytes = 16_777_216,
},
.profiles = .{
.development = .{
.database = "development",
.log_queries = true,
},
.testing = .{
.database = "test",
.log_queries = false,
},
.production = .{
.database = "production",
.log_queries = false,
},
},
},
.jobs = .{
.concurrency = 4,
.poll_interval_ms = 500,
.lease_seconds = 60,
.max_payload_bytes = 65_536,
.successful_retention_days = 7,
.failed_retention_days = 30,
.discarded_retention_days = 30,
},
.httpz = .{
.thread_pool = .{
.backlog = 64,
},
.timeout = .{
.request = 30,
.keepalive = 5,
},
.request = .{
.max_body_size = 1_048_576,
.max_form_count = 128,
.max_header_count = 64,
.max_param_count = 10,
.max_query_count = 32,
},
.response = .{
.max_body_size = 16_777_216,
.max_header_count = 64,
},
},
}
HTTP defaults are:
- 1 MiB request bodies, 128 form fields, 64 request headers, 10 route parameters, and 32 query fields;
- a backlog of 64 per execution thread;
- 30-second request and 5-second keepalive timeouts;
- 16 MiB buffered response bodies and 64 response headers.
Omit a section or individual value to use its default. Malformed ZON, wrong field types, and zero backlog or timeout values fail startup with error.InvalidZon; unknown fields are ignored.
db.defaults contains environment-neutral connection defaults shared by every named entry under db.profiles. Profile keys use canonical lowercase snake case. dev, test, and prod are reserved selector keys: the test selector resolves database profile testing while retaining credential profile test. Local development and database commands select development; deployment selects production; and other direct servers use ZYN_ENV. zyn test loads the test credential profile but does not start PostgreSQL unless a test calls zyn.testing.start. A missing selected profile is an error, and legacy singleton database configuration is accepted for development only.
Profile fields override db.defaults by field in one shallow merge. database is profile-only and is rejected under db.defaults. Every named profile must declare its own non-empty database, preventing test, production, or an arbitrary profile from silently inheriting the development database. All profiles are validated when configuration is loaded. The effective postgresql_version is required, semantic, and version 18 or newer. host and username default to 127.0.0.1 and zyn; they must be non-empty. They define configured network endpoints and the restricted deployment login. The bundled development and testing clusters use managed local authentication: a peer-authenticated private socket on Unix and loopback SCRAM on Windows. port defaults to 7542 and must be nonzero. password_credential optionally names a private credential path; a named profile may set it to null to clear an inherited path. Plaintext password is rejected. log_queries defaults to false, max_result_count defaults to 10,000 rows, and max_result_bytes defaults to 16 MiB of mapped result memory; both limits must be nonzero.
Encrypted profiles and exact process-environment overrides supply application credentials. Startup validates them and derives framework keys once; requests do not reread the environment. The development listener reads PORT from the invoking zyn process and defaults to 7532.
The supported public symbol list is available through zyn docs api, with exact generated detail through zyn docs api SYMBOL.