diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md deleted file mode 100644 index ed802d04..00000000 --- a/docs/06-concepts/18-webserver.md +++ /dev/null @@ -1,74 +0,0 @@ -# Web server - -In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it very easy to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or generate custom REST APIs to communicate with 3rd party services. - -:::caution - -Serverpod's web server is still experimental, and the APIs may change in the future. This documentation should give you some hints on getting started, but we plan to add more extensive documentation as the web server matures. - -::: - -When you create a new Serverpod project, it sets up a web server by default. When working with the web server, there are two main classes to understand; `ComponentRoute` and `Component`. The `ComponentRoute` provides an entry point for a call to the server and returns a `Component`. The `Component` renders a web page or response using templates, JSON, or other custom means. - -## Creating new routes and components - -To add new pages to your web server, you add new routes. Typically, you do this in your server.dart file before you start the Serverpod. By default, Serverpod comes with a `RootRoute` and a static directory. - -When receiving a web request, Serverpod will search and match the routes in the order they were added. You can end a route's path with an asterisk (`*`) to match all paths with the same beginning. - -```dart -// Add a single page. -pod.webServer.addRoute(MyRoute(), '/my/page/address'); - -// Match all paths that start with /item/ -pod.webServer.addRoute(AnotherRoute(), '/item/*'); -``` - -Typically, you want to create custom routes for your pages. Do this by overriding the ComponentRoute class and implementing the build method. - -```dart -class MyRoute extends ComponentRoute { - @override - Future build(Session session, HttpRequest request) async { - return MyPageComponent(title: 'Home page'); - } -} -``` - -Your route's build method returns a Component. The Component consists of an HTML template file and a corresponding Dart class. Create a new custom Component by overriding the Component class. Then add a corresponding HTML template and place it in the `web/templates` directory. The HTML file uses the [Mustache](https://mustache.github.io/) template language. You set your template parameters by updating the `values` field of your `Component` class. The values are converted to `String` objects before being passed to the template. This makes it possible to nest components, similarly to how widgets work in Flutter. - -```dart -class MyPageComponent extends Component { - MyPageComponent({String title}) : super(name: 'my_page') { - values = { - 'title': title, - }; - } -} -``` - -:::info - -In the future, we plan to add a component library to Serverpod with components corresponding to the standard widgets used by Flutter, such as Column, Row, Padding, Container, etc. This would make it possible to render server-side components with similar code used within Flutter. - -::: - -## Special components and routes - -Serverpod comes with a few useful special components and routes you can use out of the box. When returning these special component types, Serverpod's web server will automatically set the correct HTTP status codes and content types. - -- `ListComponent` concatenates a list of other components into a single component. -- `JsonComponent` renders a JSON document from a serializable structure of maps, lists, and basic values. -- `RedirectComponent` creates a redirect to another URL. - -To serve a static directory, use the `RouteStaticDirectory` class. Serverpod will set the correct content types for most file types automatically. - -:::caution - -Static files are configured to be cached hard by the web browser and through Cloudfront's content delivery network (if you use the AWS deployment). If you change static files, they will need to be renamed, or users will most likely access old files. To make this easier, you can add a version number when referencing the static files. The version number will be ignored when looking up the actual file. E.g., `/static/my_image@v42.png` will serve to the `/static/my_image.png` file. More advanced cache management will be coming to a future version of Serverpod. - -::: - -## Database access and logging - -The web server passes a `Session` object to the `ComponentRoute` class' `build` method. This gives you access to all the features you typically get from a standard method call to an endpoint. Use the database, logging, or caching the same way you would in a method call. diff --git a/docs/06-concepts/18-webserver/01-overview.md b/docs/06-concepts/18-webserver/01-overview.md new file mode 100644 index 00000000..13763796 --- /dev/null +++ b/docs/06-concepts/18-webserver/01-overview.md @@ -0,0 +1,153 @@ +# Overview + +In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it simple to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or define custom REST APIs to communicate with third-party services. + +Serverpod's web server is built on the [Relic](https://github.com/serverpod/relic) framework, giving you access to its routing engine, middleware system, and typed headers. This means you get the benefits of Serverpod's database integration and business logic alongside Relic's web server capabilities. + +## Your first route + +When you create a new Serverpod project, it sets up a web server by default. Here's how to add a simple API endpoint: + +```dart +import 'package:serverpod/serverpod.dart'; + +class HelloRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + return Response.ok( + body: Body.fromString( + jsonEncode({'message': 'Hello from Serverpod!'}), + mimeType: MimeType.json, + ), + ); + } +} +``` + +Register the route in your `server.dart` file before starting the server: + +```dart +pod.webServer.addRoute(HelloRoute(), '/api/hello'); +await pod.start(); +``` + +Visit `http://localhost:8080/api/hello` to see your API response. + +## Core concepts + +### Routes and handlers + +A **Route** is a destination in your web server that handles requests and generates responses. Routes extend the `Route` base class and implement the `handleCall()` method: + +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Your logic here + return Response.ok(); + } +} +``` + +The `handleCall()` method receives: + +- **Session** - Access to your database, logging, and authenticated user +- **Request** - The HTTP request with headers, body, and URL information + +### Response types + +Return different response types based on your needs: + +```dart +// Success responses +return Response.ok(body: Body.fromString('Success')); +return Response.created(body: Body.fromString('Created')); +return Response.noContent(); + +// Error responses +return Response.badRequest(body: Body.fromString('Invalid input')); +return Response.unauthorized(body: Body.fromString('Not authenticated')); +return Response.notFound(body: Body.fromString('Not found')); +return Response.internalServerError(body: Body.fromString('Server error')); +``` + +### Adding routes + +Routes are added with a path pattern: + +```dart +// Exact path +pod.webServer.addRoute(UserRoute(), '/api/users'); + +// Path with wildcard +pod.webServer.addRoute(StaticRoute.directory(Directory('web')), '/static/**'); +``` + +Routes are matched in the order they were added. + +## When to use what + +### Rest apis → custom routes + +For REST APIs, webhooks, or custom HTTP handlers, use custom `Route` classes: + +```dart +class UsersApiRoute extends Route { + UsersApiRoute() : super(methods: {Method.get, Method.post}); + + @override + Future handleCall(Session session, Request request) async { + if (request.method == Method.get) { + // List users + } else { + // Create user + } + } +} +``` + +See [Routing](routing) for details. + +### Static files → `StaticRoute` + +For serving CSS, JavaScript, images, or other static assets: + +```dart +pod.webServer.addRoute( + StaticRoute.directory(Directory('web/static')), + '/static/**', +); +``` + +See [Static Files](static-files) for cache-busting and optimization. + +## Database access + +The `Session` parameter gives you full access to your Serverpod database: + +```dart +class UserRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Query database + final users = await User.db.find(session); + + // Use logging + session.log('Retrieved ${users.length} users'); + + return Response.ok( + body: Body.fromString( + jsonEncode(users.map((u) => u.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } +} +``` + +## Next steps + +- **[Routing](routing)** - Learn about HTTP methods, path parameters, and wildcards +- **[Middleware](middleware)** - Add cross-cutting functionality like error handling and logging +- **[Static Files](static-files)** - Serve static assets with cache-busting +- **[Typed Headers](typed-headers)** - Work with HTTP headers in a type-safe way diff --git a/docs/06-concepts/18-webserver/02-routing.md b/docs/06-concepts/18-webserver/02-routing.md new file mode 100644 index 00000000..61236f65 --- /dev/null +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -0,0 +1,338 @@ +# Routing + +Routes are the foundation of your web server, directing incoming HTTP requests +to the right handlers. While simple routes work well for basic APIs, Serverpod +provides powerful routing features for complex applications: HTTP method +filtering, path parameters, wildcards, and fallback handling. Understanding +these patterns helps you build clean, maintainable APIs. + +## Route classes + +The `Route` base class gives you complete control over request handling. By +extending `Route` and implementing `handleCall()`, you can build REST APIs, +serve files, or handle any custom HTTP interaction. This is ideal when you need +to work directly with request bodies, headers, and response formats. + +```dart +class ApiRoute extends Route { + ApiRoute() : super(methods: {Method.get, Method.post}); + + @override + Future handleCall(Session session, Request request) async { + // Access request method + if (request.method == Method.post) { + // Read request body + final body = await request.readAsString(); + final data = jsonDecode(body); + + // Process and return JSON response + return Response.ok( + body: Body.fromString( + jsonEncode({'status': 'success', 'data': data}), + mimeType: MimeType.json, + ), + ); + } + + // Return data for GET requests + return Response.ok( + body: Body.fromString( + jsonEncode({'message': 'Hello from API'}), + mimeType: MimeType.json, + ), + ); + } +} +``` + +You need to register your custom routes with the build in router in under a given path: + +```dart +// Register the route +pod.webServer.addRoute(ApiRoute(), '/api/data'); +``` + +:::info + +The examples in this documentation omit error handling for brevity. See the +Error Handling section in Middleware below for the recommended approach using +global error-handling middleware. + +::: + +## Http methods + +Routes can specify which HTTP methods they respond to using the `methods` +parameter. The available methods are: + +- `Method.get` - Retrieve data +- `Method.post` - Create new resources +- `Method.put` - Update resources (full replacement) +- `Method.patch` - Update resources (partial) +- `Method.delete` - Delete resources +- `Method.head` - Same as GET but without response body +- `Method.options` - Query supported methods (used for CORS) + +```dart +class UserRoute extends Route { + UserRoute() : super( + methods: {Method.get, Method.post, Method.delete}, + ); + + @override + Future handleCall(Session session, Request request) async { + switch (request.method) { + case Method.get: + return await _getUser(request); + case Method.post: + return await _createUser(request); + case Method.delete: + return await _deleteUser(request); + default: + return Response.methodNotAllowed(); + } + } +} +``` + +## Path parameters + +Routes support named path parameters using the `:paramName` syntax. These are +automatically extracted and made available through the `Request` object: + +```dart +class UserRoute extends Route { + UserRoute() : super(methods: {Method.get}); + + @override + void injectIn(RelicRouter router) { + // Define route with path parameter + router.get('/:id', asHandler); + } + + @override + Future handleCall(Session session, Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final user = await User.db.findById(session, userId); + + if (user == null) { + return Response.notFound(); + } + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } +} + +// Register at /api/users - will match /api/users/123 +pod.webServer.addRoute(UserRoute(), '/api/users'); +``` + +You can use multiple path parameters in a single route: + +```dart +router.get('/:userId/posts/:postId', handler); +// Matches: /123/posts/456 +// request.pathParameters[#userId] => '123' +// request.pathParameters[#postId] => '456' +``` + +:::tip + +Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. +Always validate and parse these values since they come from user input as +strings. + +::: + +## Wildcards + +Routes also support wildcard matching for catching all paths: + +```dart +// Single-level wildcard - matches /item/foo but not /item/foo/bar +pod.webServer.addRoute(ItemRoute(), '/item/*'); + +// Tail-match wildcard - matches /item/foo and /item/foo/bar/baz +pod.webServer.addRoute(ItemRoute(), '/item/**'); +``` + +:::info Performance Guarantee + +The `/**` wildcard is a **tail-match** pattern and can only appear at the end of +a route path (e.g., `/static/**`). Patterns like `/a/**/b` are not supported. +This design ensures O(h) route lookup performance, where h is the path length, +without backtracking. This keeps routing fast and predictable, even with many +routes. + +::: + +Access the matched path information through the `Request` object: + +```dart +@override +Future handleCall(Session session, Request request) async { + // Get the remaining path after the route prefix + final remainingPath = request.url.path; + + // Access query parameters + final query = request.url.queryParameters['query']; + + return Response.ok( + body: Body.fromString('Path: $remainingPath, Query: $query'), + ); +} +``` + +::: A note on literal vs dynamic segments + +Routing never does back-tracking, and adding a route with a literal segment always wins over dynamic segments such as wildcards. Say you have a route registered at `/**`, and another at `/a/b`, then `/a/c` will not be matched, because `a` wins over `**` on the first segment, and `c` doesn't match on the second (only `b` does). Some find this behavior surprising. What you probably meant was for the `/**` to act as a fallback route. You should use an explicit `fallback` route for that instead. + +::: + +## Fallback routes + +You can set a fallback route that handles requests when no other route matches: + +```dart +class NotFoundRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + return Response.notFound( + body: Body.fromString('Page not found: ${request.url.path}'), + ); + } +} + +// Set as fallback +pod.webServer.fallbackRoute = NotFoundRoute(); +``` + +## Modular routes + +As your web server grows, managing dozens of individual route registrations can +become unwieldy. Modular routes solve this by letting you group related +endpoints into reusable modules. For example, you might create a +`UserCrudModule` that handles all user-related endpoints (`GET /users`, +`POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. + +The key to modular routes is the `injectIn()` method. When you call +`pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` +on a router group for that path. By overriding `injectIn()`, you can register +multiple handler functions instead of implementing a single `handleCall()` +method. This pattern is perfect for REST resources, API modules, or any group of +related endpoints. + +### Session access in modular routes + +When using `injectIn()` with handler functions (`router.get('/', _handler)`), +your handlers receive only a `Request` parameter. To access the `Session`, use +`request.session`: + +```dart +Future _handler(Request request) async { + final session = request.session; // Extract Session from Request + // ... use session +} +``` + +This differs from `Route.handleCall()` which receives both Session and Request +as explicit parameters. The modular route pattern uses Relic's router directly, +which only provides Request to handlers. + +### Creating a module + +Here's an example of a modular route that registers multiple endpoints with +path parameters: + +```dart +class UserCrudModule extends Route { + @override + void injectIn(RelicRouter router) { + // Register multiple routes with path parameters + router + ..get('/', _list) + ..get('/:id', _get) + } + + // Handler methods + Future _list(Request request) async { + final session = request.session; + final users = await User.db.find(session); + + return Response.ok( + body: Body.fromString( + jsonEncode(users.map((u) => u.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } + + Future _get(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final session = request.session; + final user = await User.db.findById(session, userId); + + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } +} + +// Register the entire CRUD module under /api/users +pod.webServer.addRoute(UserCrudModule(), '/api/users'); +``` + +This creates the following RESTful endpoints: + +- `GET /api/users` - List all users +- `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) + +## Next steps + +- Add [middleware](middleware) for cross-cutting concerns like logging and + error handling +- Learn about [typed headers](typed-headers) for type-safe header access +- Explore [static file serving](static-files) for assets and downloads diff --git a/docs/06-concepts/18-webserver/04-middleware.md b/docs/06-concepts/18-webserver/04-middleware.md new file mode 100644 index 00000000..18cdd627 --- /dev/null +++ b/docs/06-concepts/18-webserver/04-middleware.md @@ -0,0 +1,496 @@ +# Middleware + +Routes handle the core logic of your application, but many concerns cut across +multiple routes: logging every request, validating API keys, handling CORS +headers, or catching errors. Rather than duplicating this code in each route, +middleware lets you apply it globally or to specific path prefixes. + +Middleware functions are wrappers that sit between the incoming request and your +route handler. They can inspect or modify requests before they reach your +routes, and transform responses before they're sent back to the client. This +makes middleware perfect for authentication, logging, error handling, and any +other cross-cutting concern in your web server. + +## Adding middleware + +Use the `addMiddleware` method to apply middleware to specific path prefixes: + +```dart +// Apply to all routes +pod.webServer.addMiddleware(loggingMiddleware, '/'); + +// Apply only to API routes +pod.webServer.addMiddleware(authMiddleware, '/api'); +``` + +## Creating custom middleware + +Middleware is a function that takes a `Handler` and returns a new `Handler`. +Here's a simple logging middleware example: + +```dart +Handler loggingMiddleware(Handler next) { + return (Request request) async { + final start = DateTime.now(); + print('→ ${request.method.name.toUpperCase()} ${request.url.path}'); + + // Call the next handler in the chain + final response = await next(request); + + final duration = DateTime.now().difference(start); + print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); + + return response; + }; +} +``` + +## Api key validation middleware + +A common use case is validating API keys for protected routes: + +```dart +Handler apiKeyMiddleware(Handler next) { + return (Request request) async { + // Check for API key in header + final apiKey = request.headers['X-API-Key']?.firstOrNull; + + if (apiKey == null) { + return Response.unauthorized( + body: Body.fromString('API key required'), + ); + } + + // Verify API key + if (!await isValidApiKey(apiKey)) { + return Response.forbidden( + body: Body.fromString('Invalid API key'), + ); + } + + // Continue to the next handler + return await next(request); + }; +} + +// Apply to protected routes +pod.webServer.addMiddleware(apiKeyMiddleware, '/api'); +``` + +:::info + +For user authentication, use Serverpod's built-in authentication system which +integrates with the `Session` object. The middleware examples here are for +additional web-specific validations like API keys, rate limiting, or request +validation. + +::: + +## Cors middleware + +Enable Cross-Origin Resource Sharing for your API: + +```dart +Handler corsMiddleware(Handler next) { + return (Request request) async { + // Handle preflight requests + if (request.method == Method.options) { + return Response.ok( + headers: Headers.build((h) { + h.accessControlAllowOrigin = const AccessControlAllowOriginHeader.wildcard(); + h.accessControlAllowMethods = AccessControlAllowMethodsHeader.methods( + methods: [Method.get, Method.post, Method.put, Method.delete], + ); + h.accessControlAllowHeaders = AccessControlAllowHeadersHeader.headers( + headers: ['Content-Type', 'Authorization'], + ); + }), + ); + } + + // Process the request + final response = await next(request); + + // Add CORS headers to response + return response.copyWith( + headers: response.headers.transform((h) { + h.accessControlAllowOrigin = const AccessControlAllowOriginHeader.wildcard(); + }), + ); + }; +} + +pod.webServer.addMiddleware(corsMiddleware, '/api'); +``` + +## Error handling + +Production applications need robust error handling. Rather than adding try-catch +blocks to every route, use error-handling middleware to catch exceptions +globally and return consistent error responses. + +### Error-handling middleware + +Error-handling middleware wraps all your routes and catches any exceptions they +throw: + +```dart +Handler errorHandlingMiddleware(Handler next) { + return (Request request) async { + try { + return await next(request); + } on FormatException catch (e) { + // Handle JSON parsing errors + return Response.badRequest( + body: Body.fromString( + jsonEncode({'error': 'Invalid request format', 'message': e.message}), + mimeType: MimeType.json, + ), + ); + } catch (e, stackTrace) { + // Log the error + print('Error handling ${request.method} ${request.url.path}: $e'); + print(stackTrace); + + // Return a generic error response + return Response.internalServerError( + body: Body.fromString( + jsonEncode({'error': 'Internal server error'}), + mimeType: MimeType.json, + ), + ); + } + }; +} + +// Apply to all routes +pod.webServer.addMiddleware(errorHandlingMiddleware, '/'); +``` + +With error-handling middleware in place, your route handlers can focus on +business logic without extensive try-catch blocks. The middleware catches common +exceptions like: + +- `FormatException` from `jsonDecode()` - Returns 400 Bad Request +- Database errors - Returns 500 Internal Server Error with logging +- Any other uncaught exceptions - Returns 500 with error details logged + +If an exception escapes all middleware, Serverpod will automatically return a +500 Internal Server Error response. However, using error-handling middleware +gives you control over error responses and logging. + +## Middleware execution order + +Middleware is applied based on path hierarchy, with more specific paths taking +precedence. Within the same path, middleware executes in the order it was +registered: + +```dart +pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first (outer) +pod.webServer.addMiddleware(authMiddleware, '/api'); // Executes second (inner) for /api routes +pod.webServer.addMiddleware(rateLimitMiddleware, '/api'); // Executes third (innermost) for /api routes +``` + +For a request to `/api/users`, the execution order is: + +```mermaid +sequenceDiagram + participant Client + participant Logging as loggingMiddleware + participant Auth as authMiddleware + participant RateLimit as rateLimitMiddleware + participant Handler as Route Handler + + Client->>Logging: Request /api/users + activate Logging + Note over Logging: Before logic + Logging->>Auth: + activate Auth + Note over Auth: Before logic + Auth->>RateLimit: + activate RateLimit + Note over RateLimit: Before logic + RateLimit->>Handler: + activate Handler + Note over Handler: Execute route logic + Handler-->>RateLimit: Response + deactivate Handler + Note over RateLimit: After logic + RateLimit-->>Auth: + deactivate RateLimit + Note over Auth: After logic + Auth-->>Logging: + deactivate Auth + Note over Logging: After logic + Logging-->>Client: Response + deactivate Logging +``` + +## Request-scoped data with `ContextProperty` + +Middleware often needs to pass computed data to downstream handlers. For +example, a tenant identification middleware might extract the tenant ID from a +subdomain, or a logging middleware might generate a request ID for tracing. +Since `Request` objects are immutable, you can't just add properties to them. +This is where `ContextProperty` comes in. + +`ContextProperty` provides a type-safe way to attach data to a `Request` +object without modifying it. Think of it as a side channel for request-scoped +data that middleware can write to and routes can read from. The data is +automatically cleaned up when the request completes, making it perfect for +per-request state. + +:::info + +Note that Serverpod's `Route.handleCall()` already receives a `Session` parameter +which includes authenticated user information if available. Use `ContextProperty` +for web-specific request data that isn't part of the standard Session, such as +request IDs, feature flags, or API version information extracted from headers. + +::: + +### Why use `ContextProperty`? + +Since `Request` objects are immutable, you can't modify them directly. +`ContextProperty` allows you to associate additional data with a request that +can be accessed by all downstream middleware and handlers. Common use cases +include: + +- **Request ID tracking** - Generated correlation IDs for distributed tracing + (purely request-scoped, not session data) +- **API versioning** - Extract and store API version from headers +- **Feature flags** - Request-specific toggles based on headers or A/B testing +- **Rate limiting** - Per-request rate limit state +- **Tenant identification** - Multi-tenant context from subdomains (when not + part of user session) + +### Creating a `ContextProperty` + +Define a `ContextProperty` as a top-level constant or static field: + +```dart +// Define a property for request ID tracking +final requestIdProperty = ContextProperty('requestId'); + +// Define a property for tenant identification +final tenantProperty = ContextProperty('tenant'); + +// Optional: with a default value +final featureFlagsProperty = ContextProperty( + 'featureFlags', + defaultValue: () => FeatureFlags.defaults(), +); +``` + +### Setting values in middleware + +Middleware can set values on the context property, making them available to all +downstream handlers: + +```dart +final requestIdProperty = ContextProperty('requestId'); + +Handler requestIdMiddleware(Handler next) { + return (Request request) async { + // Generate a unique request ID for tracing + final requestId = Uuid().v4(); + + // Attach to request context + requestIdProperty[request] = requestId; + + // Log the incoming request + print('[$requestId] ${request.method} ${request.url.path}'); + + // Continue to next handler + final response = await next(request); + + // Log the response + print('[$requestId] Response: ${response.statusCode}'); + + // Optionally add request ID to response headers + return response.copyWith( + headers: response.headers.transform((h) { + h['X-Request-ID'] = [requestId]; + }), + ); + }; +} +``` + +### Accessing values in routes + +Route handlers can retrieve the value from the context property: + +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Get the request ID from context + final requestId = requestIdProperty[request]; + + // Use it for logging or tracing + session.log('Processing request $requestId'); + + // Your route logic here + final data = await processRequest(session); + + return Response.ok( + body: Body.fromString( + jsonEncode(data), + mimeType: MimeType.json, + ), + ); + } +} +``` + +### Safe access with `getOrNull` + +If a value might not be set, use `getOrNull()` to avoid exceptions: + +```dart +class TenantRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Safely get tenant, returns null if not set + final tenant = tenantProperty.getOrNull(request); + + if (tenant != null) { + // Fetch tenant-specific data + final data = await session.db.find(where: (t) => t.tenantId.equals(tenant)); + return Response.ok( + body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), + ); + } else { + return Response.badRequest( + body: Body.fromString('Missing tenant identifier'), + ); + } + } +} +``` + +### Complete multi-tenant example + +Here's a complete example showing tenant identification from subdomains: + +```dart +// Define the context property for tenant ID +final tenantProperty = ContextProperty('tenant'); + +// Tenant identification middleware (extracts from subdomain) +Handler tenantMiddleware(Handler next) { + return (Request request) async { + final host = request.headers.host; + + if (host == null) { + return Response.badRequest( + body: Body.fromString('Missing host header'), + ); + } + + // Extract tenant from subdomain (e.g., acme.example.com -> "acme") + final parts = host.host.split('.'); + if (parts.length < 2) { + return Response.badRequest( + body: Body.fromString('Invalid hostname format'), + ); + } + + final tenant = parts.first; + + // Validate tenant exists (implement your own logic) + final session = request.session; + final tenantExists = await validateTenant(session, tenant); + + if (!tenantExists) { + return Response.notFound( + body: Body.fromString('Tenant not found'), + ); + } + + // Attach tenant to context + tenantProperty[request] = tenant; + + return await next(request); + }; +} + +// Usage in your server +pod.webServer.addMiddleware(tenantMiddleware, '/'); + +// Routes automatically have access to the tenant +class TenantDataRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final tenant = tenantProperty[request]; + + // Fetch tenant-specific data + final data = await session.db.find( + where: (p) => p.tenantId.equals(tenant), + ); + + return Response.ok( + body: Body.fromString( + jsonEncode(data.map((p) => p.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } +} +``` + +### Multiple context properties + +You can use multiple context properties for different types of data: + +```dart +final requestIdProperty = ContextProperty('requestId'); +final tenantProperty = ContextProperty('tenant'); +final apiVersionProperty = ContextProperty('apiVersion'); + +Handler requestContextMiddleware(Handler next) { + return (Request request) async { + // Generate and attach request ID + final requestId = Uuid().v4(); + requestIdProperty[request] = requestId; + + // Extract tenant from subdomain or header + final host = request.headers.host; + if (host != null) { + final tenant = host.host.split('.').first; + tenantProperty[request] = tenant; + } + + // Extract API version from header + final apiVersion = request.headers['X-API-Version']?.firstOrNull ?? '1.0'; + apiVersionProperty[request] = apiVersion; + + return await next(request); + }; +} + +// Later in your route +class DataRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final requestId = requestIdProperty[request]; + final tenant = tenantProperty[request]; + final apiVersion = apiVersionProperty[request]; + + session.log('Request $requestId for tenant $tenant (API v$apiVersion)'); + + // Fetch tenant-specific data + final data = await fetchTenantData(session, tenant); + + return Response.ok( + body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), + ); + } +} +``` + +## Next steps + +- Serve [static files](static-files) with caching and compression +- Use [typed headers](typed-headers) for type-safe header access diff --git a/docs/06-concepts/18-webserver/05-static-files.md b/docs/06-concepts/18-webserver/05-static-files.md new file mode 100644 index 00000000..40e0598c --- /dev/null +++ b/docs/06-concepts/18-webserver/05-static-files.md @@ -0,0 +1,173 @@ +# Static files + +Static assets like images, CSS, and JavaScript files are essential for web +applications. The `StaticRoute.directory()` method makes it easy to serve entire +directories with automatic content-type detection for common file formats. + +## Serving static files + +The simplest way to serve static files is to use `StaticRoute.directory()`: + +```dart +final staticDir = Directory('web/static'); + +pod.webServer.addRoute( + StaticRoute.directory(staticDir), + '/static/**', +); +``` + +This serves all files from the `web/static` directory at the `/static` path. +For example, `web/static/logo.png` becomes accessible at `/static/logo.png`. + +:::info + +The `/**` tail-match wildcard is required for serving directories. It matches all +paths under the prefix, allowing `StaticRoute` to map URLs to file system paths. +See [Routing](routing#wildcards) for more on wildcards. + +::: + +## Cache control + +Control how browsers and CDNs cache your static files using the +`cacheControlFactory` parameter: + +```dart +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: const Duration(years: 1)), + ), + '/static/**', +); +``` + +Available cache control factories: + +- **`StaticRoute.publicImmutable()`** - For versioned assets that never change + + ```dart + StaticRoute.publicImmutable(maxAge: const Duration(years: 1)) // 1 year, perfect for cache-busted files + ``` + +- **`StaticRoute.public()`** - For public assets with revalidation + + ```dart + StaticRoute.public(maxAge: const Duration(hours: 1)) + ``` + +- **`StaticRoute.privateNoCache()`** - For user-specific files + + ```dart + StaticRoute.privateNoCache() // Must revalidate every time + ``` + +- **`StaticRoute.noStore()`** - For sensitive content that shouldn't be cached + + ```dart + StaticRoute.noStore() // Never cache + ``` + +## Static file cache-busting + +When deploying static assets, browsers and CDNs (like CloudFront) cache files +aggressively for performance. This means updated files may not be served to +users unless you implement a cache-busting strategy. + +Serverpod provides `CacheBustingConfig` to automatically version your static +files: + +```dart +final staticDir = Directory('web/static'); + +final cacheBustingConfig = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticDir, + separator: '@', // or use custom separator like '___' +); + +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheBustingConfig: cacheBustingConfig, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: const Duration(years: 1)), + ), + '/static/**', +); +``` + +### Generating versioned urls + +Use the `assetPath()` method to generate cache-busted URLs for your assets: + +```dart +// In your route handler +final imageUrl = await cacheBustingConfig.assetPath('/static/logo.png'); +// Returns: /static/logo@.png + +// Pass to your template +return MyPageWidget(logoUrl: imageUrl); +``` + +The cache-busting system: + +- Automatically generates content-based hashes for asset versioning +- Allows custom separators (default `@`, but you can use `___` or any other) +- Preserves file extensions +- Works transparently - requesting `/static/logo@abc123.png` serves + `/static/logo.png` + +## Conditional requests (`Etags` and `Last-Modified`) + +`StaticRoute` automatically supports HTTP conditional requests through Relic's +`StaticHandler`. This provides efficient caching without transferring file +content when unchanged: + +**Supported features:** + +- **ETag headers** - Content-based fingerprinting for cache validation +- **Last-Modified headers** - Timestamp-based cache validation +- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if + unchanged +- **If-Modified-Since** - Client sends timestamp, server returns 304 if not + modified + +These work automatically without configuration: + +**Initial request:** + +```http +GET /static/logo.png HTTP/1.1 +Host: example.com + +HTTP/1.1 200 OK +ETag: "abc123" +Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT +Content-Length: 12345 + +[file content] +``` + +**Subsequent request with ETag:** + +```http +GET /static/logo.png HTTP/1.1 +Host: example.com +If-None-Match: "abc123" + +HTTP/1.1 304 Not Modified +ETag: "abc123" + +[no body - saves bandwidth] +``` + +When combined with cache-busting, conditional requests provide a fallback +validation mechanism even for cached assets, ensuring efficient delivery while +maintaining correctness. + +## Next steps + +- Learn about [typed headers](typed-headers) for type-safe header access +- Explore [middleware](middleware) for cross-cutting concerns +- Understand [routing](routing) for custom request handling diff --git a/docs/06-concepts/18-webserver/06-typed-headers.md b/docs/06-concepts/18-webserver/06-typed-headers.md new file mode 100644 index 00000000..2b3db1cf --- /dev/null +++ b/docs/06-concepts/18-webserver/06-typed-headers.md @@ -0,0 +1,234 @@ +# Typed headers + +HTTP headers are traditionally accessed as strings, which means you need to +manually parse values, handle edge cases, and validate formats. Serverpod's web +server (via Relic) provides a better approach: typed headers that automatically +parse header values into strongly-typed Dart objects. + +For example, instead of parsing `Authorization: Bearer abc123` as a string and +extracting the token yourself, you can access `request.headers.authorization` to +get a `BearerAuthorizationHeader` object with the token already parsed and +validated. This eliminates boilerplate code, catches errors early, and makes +your code more maintainable. + +## Reading typed headers + +Access typed headers through extension methods on `request.headers`: + +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Type-safe accessors return parsed objects or null + final auth = request.headers.authorization; // AuthorizationHeader? + final contentType = request.headers.contentType; // ContentTypeHeader? + final cookies = request.headers.cookie; // CookieHeader? + final userAgent = request.headers.userAgent; // String? + final host = request.headers.host; // HostHeader? + + // Raw string access is also available for any header + final authRaw = request.headers['Authorization']; // Iterable? + final custom = request.headers['X-Custom-Header']; // Iterable? + + return Response.ok(); + } +} +``` + +Common request headers include: + +- `authorization` - AuthorizationHeader (Bearer/Basic/Digest) +- `contentType` - ContentTypeHeader +- `contentLength` - int +- `cookie` - CookieHeader +- `accept` - AcceptHeader (media types) +- `acceptEncoding` - AcceptEncodingHeader +- `acceptLanguage` - AcceptLanguageHeader +- `userAgent` - String +- `host` - HostHeader +- `referer` - Uri +- `origin` - Uri + +## Setting typed headers + +Set typed headers in responses using the `Headers.build()` builder pattern: + +```dart +return Response.ok( + headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 3600, + publicCache: true, + ); + h.contentType = ContentTypeHeader( + mimeType: MimeType.json, + charset: 'utf-8', + ); + + // Set custom headers (raw) + h['X-API-Version'] = ['2.0']; + }), + body: Body.fromString(jsonEncode(data)), +); +``` + +Common response headers include: + +- `cacheControl` - CacheControlHeader +- `setCookie` - SetCookieHeader +- `location` - Uri +- `contentDisposition` - ContentDispositionHeader +- `etag` - ETagHeader +- `vary` - VaryHeader + +:::tip Best Practices + +- Use typed headers for automatic parsing and validation +- Set appropriate cache headers for better performance +- Use `secure: true` and `httpOnly: true` for sensitive cookies +- Set proper `ContentDisposition` headers for file downloads +- Use `SameSite` cookie attribute for CSRF protection + +::: + +## Creating custom typed headers + +While Relic provides typed headers for all standard HTTP headers, your +application may use custom headers for API versioning, feature flags, or +application-specific metadata. Rather than falling back to string-based access +for these custom headers, you can create your own typed headers using the same +pattern Relic uses internally. + +Creating a custom typed header involves three steps: defining the header class +with parsing logic, creating a codec for serialization, and setting up a +`HeaderAccessor` for type-safe access. Once configured, your custom headers work +just like the built-in ones, with automatic parsing, validation, and convenient +property-style access. + +Here's a complete example for a custom `X-API-Version` header: + +```dart +// Define your typed header class +final class ApiVersionHeader { + final int major; + final int minor; + + ApiVersionHeader({required this.major, required this.minor}); + + // Parse from string format "1.2" + factory ApiVersionHeader.parse(String value) { + final parts = value.split('.'); + if (parts.length != 2) { + throw const FormatException('Invalid API version format'); + } + + final major = int.tryParse(parts[0]); + final minor = int.tryParse(parts[1]); + + if (major == null || minor == null) { + throw const FormatException('Invalid API version numbers'); + } + + return ApiVersionHeader(major: major, minor: minor); + } + + // Encode to string format + String encode() => '$major.$minor'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ApiVersionHeader && + major == other.major && + minor == other.minor; + + @override + int get hashCode => Object.hash(major, minor); + + @override + String toString() => 'ApiVersionHeader($major.$minor)'; +} + +// Create a HeaderCodec for encoding/decoding +const _apiVersionCodec = HeaderCodec.single( + ApiVersionHeader.parse, + (ApiVersionHeader value) => [value.encode()], +); + +// Create a global HeaderAccessor +const apiVersionHeader = HeaderAccessor( + 'x-api-version', + _apiVersionCodec, +); +``` + +### Using your custom header + +```dart +// Reading the header +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final version = apiVersionHeader[request.headers](); + + if (version != null && version.major < 2) { + return Response.badRequest( + body: Body.fromString('API version 2.0 or higher required'), + ); + } + + return Response.ok(); + } +} + +// Setting the header +return Response.ok( + headers: Headers.build((h) { + apiVersionHeader[h].set(ApiVersionHeader(major: 2, minor: 1)); + }), +); +``` + +### Optional: Add extension methods for convenient access + +For better ergonomics, you can add extension methods to access your custom +headers with property syntax: + +```dart +extension CustomHeadersEx on Headers { + ApiVersionHeader? get apiVersion => apiVersionHeader[this](); +} + +extension CustomMutableHeadersEx on MutableHeaders { + set apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); +} +``` + +Now you can use property syntax instead of the bracket notation: + +```dart +// Reading with property syntax +final version = request.headers.apiVersion; + +// Setting with property syntax +return Response.ok( + headers: Headers.build((h) { + h.apiVersion = ApiVersionHeader(major: 2, minor: 1); + }), +); +``` + +**Key points:** + +- Use `HeaderCodec.single()` when your header has only one value +- Use `HeaderCodec()` when your header can have multiple comma-separated values +- Define the `HeaderAccessor` as a global `const` +- Throw `FormatException` for invalid header values +- Implement `==` and `hashCode` for value equality +- The `HeaderAccessor` automatically caches parsed values for performance +- Optionally add extension methods for convenient property-style access + +## Next steps + +- Serve [static files](static-files) with caching and compression +- Add [middleware](middleware) for cross-cutting concerns diff --git a/docs/06-concepts/18-webserver/07-server-side-html.md b/docs/06-concepts/18-webserver/07-server-side-html.md new file mode 100644 index 00000000..8b14b846 --- /dev/null +++ b/docs/06-concepts/18-webserver/07-server-side-html.md @@ -0,0 +1,131 @@ +# Server-Side HTML + +:::info Recommended Alternative + +For modern server-side HTML rendering with Serverpod, we recommend using +[Jaspr](https://docs.page/schultek/jaspr), which provides a Flutter-like API for +building web applications. You can integrate Jaspr with Serverpod's web server +using custom `Route` classes, giving you full control over request handling +while leveraging Jaspr's component model. + +The `WidgetRoute` and `TemplateWidget` classes described below are available for +simple use cases, but Jaspr provides better tooling and ongoing development for +production applications. + +::: + +## WidgetRoute and TemplateWidget + +When you create a new Serverpod project, it sets up a web server by default. +For simple HTML pages, you can use `WidgetRoute` and `TemplateWidget`. The +`WidgetRoute` provides an entry point for handling requests and returns a +`WebWidget`. The `TemplateWidget` renders web pages using Mustache templates. + +### Creating a WidgetRoute + +Create custom routes by extending the `WidgetRoute` class and implementing the +`build` method: + +```dart +class MyRoute extends WidgetRoute { + @override + Future build(Session session, Request request) async { + return MyPageWidget(title: 'Home page'); + } +} + +// Register the route +pod.webServer.addRoute(MyRoute(), '/my/page/address'); +``` + +### Creating a TemplateWidget + +A `TemplateWidget` consists of a Dart class and a corresponding HTML template +file. Create a custom widget by extending `TemplateWidget`: + +```dart +class MyPageWidget extends TemplateWidget { + MyPageWidget({required String title}) : super(name: 'my_page') { + values = { + 'title': title, + }; + } +} +``` + +Place the corresponding HTML template in the `web/templates` directory. The HTML +file uses the [Mustache](https://mustache.github.io/) template language: + +```html + + + + {{title}} + + +

{{title}}

+

Welcome to my page!

+ + +``` + +Template values are converted to `String` objects before being passed to the +template. This makes it possible to nest widgets, similarly to how widgets work +in Flutter. + +## Built-in widgets + +Serverpod provides several built-in widgets for common use cases: + +- **`ListWidget`** - Concatenates multiple widgets into a single response + + ```dart + return ListWidget(children: [ + HeaderWidget(), + ContentWidget(), + FooterWidget(), + ]); + ``` + +- **`JsonWidget`** - Renders JSON documents from serializable data structures + + ```dart + return JsonWidget({'status': 'success', 'data': myData}); + ``` + +- **`RedirectWidget`** - Creates HTTP redirects to other URLs + + ```dart + return RedirectWidget('/new/location'); + ``` + +## Database access and logging + +The web server passes a `Session` object to the `WidgetRoute` class' `build` +method. This gives you access to all the features you typically get from a +standard method call to an endpoint. Use the database, logging, or caching the +same way you would in a method call: + +```dart +class DataRoute extends WidgetRoute { + @override + Future build(Session session, Request request) async { + // Access the database + final users = await User.db.find(session); + + // Logging + session.log('Rendering user list page'); + + return UserListWidget(users: users); + } +} +``` + +## Next steps + +- For modern server-side rendering, explore + [Jaspr](https://docs.page/schultek/jaspr) integration +- Use [custom routes](routing) for REST APIs and custom request handling +- Serve [static files](static-files) for CSS, JavaScript, and images +- Add [middleware](middleware) for cross-cutting concerns like logging and + error handling diff --git a/docs/06-concepts/18-webserver/_category_.json b/docs/06-concepts/18-webserver/_category_.json new file mode 100644 index 00000000..7999273f --- /dev/null +++ b/docs/06-concepts/18-webserver/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Web server", + "collapsed": true +} diff --git a/docs/08-upgrading/06-upgrade-to-three.md b/docs/08-upgrading/06-upgrade-to-three.md index 757b91e0..d3f0f6c8 100644 --- a/docs/08-upgrading/06-upgrade-to-three.md +++ b/docs/08-upgrading/06-upgrade-to-three.md @@ -1,216 +1,94 @@ # Upgrade to 3.0 -## Web Server: Widget to Component Rename +## Web Server Changes -In Serverpod 3.0, all web server related "Widget" classes have been renamed to "Component" to better reflect their purpose and avoid confusion with Flutter widgets. +### Widget Class Naming Updates -The following classes have been renamed: +In Serverpod 3.0, the web server widget classes have been reorganized for better clarity: -| Old Name | New Name | -| ---------------- | ------------------- | -| `Widget` | `Component` | -| `AbstractWidget` | `AbstractComponent` | -| `WidgetRoute` | `ComponentRoute` | -| `WidgetJson` | `JsonComponent` | -| `WidgetRedirect` | `RedirectComponent` | -| `WidgetList` | `ListComponent` | +- The old `Widget` class (for template-based widgets) has been renamed to `TemplateWidget` +- The old `AbstractWidget` class has been renamed to `WebWidget` +- Legacy class names (`Widget`, `AbstractWidget`, `WidgetList`, `WidgetJson`, `WidgetRedirect`) are deprecated but still available for backward compatibility -### 1. Update Route Classes +The `WidgetRoute` class remains unchanged and continues to be the base class for web routes. -Update all route classes that extend `WidgetRoute` to extend `ComponentRoute`, and rename them to follow the new naming convention: +**Recommended migration:** -**Before:** - -```dart -class RouteRoot extends WidgetRoute { - @override - Future build(Session session, HttpRequest request) async { - return MyPageWidget(); - } -} -``` - -**After:** - -```dart -class RootRoute extends ComponentRoute { - @override - Future build(Session session, HttpRequest request) async { - return MyPageComponent(); - } -} -``` - -### 2. Update Component Classes - -Update all classes that extend `Widget` to extend `Component`, and rename them from "Widget" to "Component": - -**Before:** +If you're using the old `Widget` class, update to `TemplateWidget`: ```dart +// Old (deprecated but still works) class MyPageWidget extends Widget { MyPageWidget({required String title}) : super(name: 'my_page') { - values = { - 'title': title, - }; + values = {'title': title}; } } -``` -**After:** - -```dart -class MyPageComponent extends Component { - MyPageComponent({required String title}) : super(name: 'my_page') { - values = { - 'title': title, - }; - } -} -``` - -### 3. Update Abstract Components - -If you have custom abstract components, update them from `AbstractWidget` to `AbstractComponent` and rename accordingly: - -**Before:** - -```dart -class CustomWidget extends AbstractWidget { - @override - String toString() { - return '...'; - } -} -``` - -**After:** - -```dart -class CustomComponent extends AbstractComponent { - @override - String toString() { - return '...'; +// New (recommended) +class MyPageWidget extends TemplateWidget { + MyPageWidget({required String title}) : super(name: 'my_page') { + values = {'title': title}; } } ``` -### 4. Update Special Component Types +### Static Route Updates -Update references to special component types: +The `RouteStaticDirectory` class has been deprecated in favor of `StaticRoute.directory()`: **Before:** ```dart -// JSON responses -return WidgetJson(object: {'status': 'success'}); - -// Redirects -return WidgetRedirect(url: '/login'); - -// Component lists -return WidgetList(widgets: [widget1, widget2]); +pod.webServer.addRoute( + RouteStaticDirectory( + serverDirectory: 'static', + basePath: '/', + ), + '/static/**', +); ``` **After:** ```dart -// JSON responses -return JsonComponent(object: {'status': 'success'}); - -// Redirects -return RedirectComponent(url: '/login'); - -// Component lists -return ListComponent(widgets: [widget1, widget2]); -``` - -### 5. Update Route Registration - -Update your route registration to use the renamed route classes: - -**Before:** - -```dart -pod.webServer.addRoute(RouteRoot(), '/'); -pod.webServer.addRoute(RouteRoot(), '/index.html'); +pod.webServer.addRoute( + StaticRoute.directory(Directory('static')), + '/static/**', +); ``` -**After:** +The new `StaticRoute` provides better cache control options. You can use the built-in static helper methods for common caching scenarios: ```dart -pod.webServer.addRoute(RootRoute(), '/'); -pod.webServer.addRoute(RootRoute(), '/index.html'); +// Example with immutable public caching +pod.webServer.addRoute( + StaticRoute.directory( + Directory('static'), + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 3600), + ), + '/static/**', +); ``` -### Directory Structure - -For consistency with the new naming convention, we recommend renaming your `widgets/` directories to `components/`. However, this is not strictly required - the directory structure can remain unchanged if needed. - -### Class Names - -For consistency and clarity, we recommend updating all class names from "Widget" to "Component" (e.g., `MyPageWidget` → `MyPageComponent`). While you can keep your existing class names and only update the inheritance, following the new naming convention will make your code more maintainable and consistent with Serverpod's conventions. - -### Complete Example - -Here's a complete example of migrating a simple web page: +Other available cache control factory methods: -**Before:** - -```dart -// lib/src/web/widgets/default_page_widget.dart -import 'package:serverpod/serverpod.dart'; - -class DefaultPageWidget extends Widget { - DefaultPageWidget() : super(name: 'default') { - values = { - 'served': DateTime.now(), - 'runmode': Serverpod.instance.runMode, - }; - } -} - -// lib/src/web/routes/root.dart -import 'dart:io'; -import 'package:my_server/src/web/widgets/default_page_widget.dart'; -import 'package:serverpod/serverpod.dart'; - -class RouteRoot extends WidgetRoute { - @override - Future build(Session session, HttpRequest request) async { - return DefaultPageWidget(); - } -} -``` +- `StaticRoute.public(maxAge: seconds)` - Public cache with optional max-age +- `StaticRoute.publicImmutable(maxAge: seconds)` - Public immutable cache with optional max-age +- `StaticRoute.privateNoCache()` - Private cache with no-cache directive +- `StaticRoute.noStore()` - No storage allowed -**After:** +You can also provide a custom factory function: ```dart -// lib/src/web/components/default_page_component.dart (renamed file and directory) -import 'package:serverpod/serverpod.dart'; - -class DefaultPageComponent extends Component { - DefaultPageComponent() : super(name: 'default') { - values = { - 'served': DateTime.now(), - 'runmode': Serverpod.instance.runMode, - }; - } -} - -// lib/src/web/routes/root.dart -import 'dart:io'; -import 'package:my_server/src/web/components/default_page_component.dart'; -import 'package:serverpod/serverpod.dart'; - -class RootRoute extends ComponentRoute { - @override - Future build(Session session, HttpRequest request) async { - return DefaultPageComponent(); - } -} - -// server.dart -pod.webServer.addRoute(RootRoute(), '/'); -pod.webServer.addRoute(RootRoute(), '/index.html'); +pod.webServer.addRoute( + StaticRoute.directory( + Directory('static'), + cacheControlFactory: (ctx, fileInfo) => CacheControlHeader( + publicCache: true, + maxAge: 3600, + immutable: true, + ), + ), + '/static/**', +); ``` diff --git a/package-lock.json b/package-lock.json index 53258002..d7830bc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -235,6 +235,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.40.1.tgz", "integrity": "sha512-Mw6pAUF121MfngQtcUb5quZVqMC68pSYYjCRZkSITC085S3zdk+h/g7i6FxnVdbSU6OztxikSDMh1r7Z+4iPlA==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.40.1", "@algolia/requester-browser-xhr": "5.40.1", @@ -391,6 +392,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -2153,6 +2155,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2175,6 +2178,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2284,6 +2288,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2705,6 +2710,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3592,6 +3598,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -4338,6 +4345,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -4473,6 +4481,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", "dev": true, + "peer": true, "dependencies": { "@octokit/auth-token": "^2.4.4", "@octokit/graphql": "^4.5.8", @@ -4859,6 +4868,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5443,6 +5453,7 @@ "version": "18.2.38", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.38.tgz", "integrity": "sha512-cBBXHzuPtQK6wNthuVMV6IjHAFkdl/FOPFIlkd81/Cd1+IqkHu/A+w4g43kaQQoYHik/ruaQBDL72HyCy1vuMw==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5787,6 +5798,7 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5867,6 +5879,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5912,6 +5925,7 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.40.1.tgz", "integrity": "sha512-iUNxcXUNg9085TJx0HJLjqtDE0r1RZ0GOGrt8KNQqQT5ugu8lZsHuMUYW/e0lHhq6xBvmktU9Bw4CXP9VQeKrg==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.6.1", "@algolia/client-abtesting": "5.40.1", @@ -6427,6 +6441,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -6595,9 +6610,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001745", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", - "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", "funding": [ { "type": "opencollective", @@ -6744,6 +6759,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7477,6 +7493,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -7794,6 +7811,7 @@ "version": "3.32.0", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.0.tgz", "integrity": "sha512-5JHBC9n75kz5851jeklCPmZWcg3hUe6sjqJvyk3+hVqFaKcHwHgxsjeN1yLmggoUc6STbtm9/NQyabQehfjvWQ==", + "peer": true, "engines": { "node": ">=0.10" } @@ -8179,6 +8197,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -9334,6 +9353,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14047,6 +14067,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14658,6 +14679,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15561,6 +15583,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -16359,6 +16382,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16368,6 +16392,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16420,6 +16445,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -16470,6 +16496,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -18051,6 +18078,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -18268,7 +18296,8 @@ "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "peer": true }, "node_modules/twitch-video-element": { "version": "0.1.5", @@ -18331,20 +18360,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/ua-parser-js": { "version": "1.0.41", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", @@ -18720,6 +18735,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -19005,6 +19021,7 @@ "version": "5.95.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", + "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -19274,6 +19291,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -19670,6 +19688,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }