toiljs 0.0.85 → 0.0.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +303 -293
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/docs.js +8 -24
- package/build/compiler/generate.js +4 -2
- package/build/compiler/index.d.ts +1 -1
- package/build/compiler/index.js +69 -6
- package/build/compiler/toil-docs.generated.js +64 -22
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +7 -3
- package/build/devserver/db/database.d.ts +4 -0
- package/build/devserver/db/database.js +43 -1
- package/build/devserver/db/index.d.ts +1 -1
- package/build/devserver/db/index.js +1 -1
- package/build/devserver/db/types.d.ts +3 -0
- package/build/devserver/db/types.js +2 -0
- package/build/devserver/runtime/module.js +4 -1
- package/docs/README.md +104 -65
- package/docs/auth/README.md +102 -0
- package/docs/auth/configuration.md +94 -0
- package/docs/auth/extending.md +202 -0
- package/docs/auth/how-it-works.md +138 -0
- package/docs/auth/usage.md +188 -0
- package/docs/backend/README.md +143 -0
- package/docs/backend/data.md +351 -0
- package/docs/backend/rest.md +402 -0
- package/docs/backend/rpc.md +226 -0
- package/docs/background/README.md +114 -0
- package/docs/background/daemons.md +230 -0
- package/docs/background/derive.md +179 -0
- package/docs/cli/README.md +377 -0
- package/docs/concepts/config.md +416 -0
- package/docs/concepts/decorators.md +127 -0
- package/docs/concepts/security.md +108 -0
- package/docs/concepts/tiers.md +166 -0
- package/docs/concepts/types.md +216 -0
- package/docs/database/README.md +143 -0
- package/docs/database/capacity.md +350 -0
- package/docs/database/counters.md +174 -0
- package/docs/database/documents.md +255 -0
- package/docs/database/events.md +307 -0
- package/docs/database/membership.md +246 -0
- package/docs/database/setup.md +155 -0
- package/docs/database/unique.md +216 -0
- package/docs/database/views.md +246 -0
- package/docs/frontend/README.md +101 -0
- package/docs/frontend/data-fetching.md +243 -0
- package/docs/frontend/images.md +148 -0
- package/docs/frontend/metadata.md +344 -0
- package/docs/frontend/rendering.md +236 -0
- package/docs/frontend/routing.md +344 -0
- package/docs/frontend/scripts.md +118 -0
- package/docs/frontend/search.md +191 -0
- package/docs/frontend/styling.md +147 -0
- package/docs/getting-started/README.md +81 -0
- package/docs/getting-started/create-project.md +131 -0
- package/docs/getting-started/deploy.md +101 -0
- package/docs/getting-started/first-app.md +215 -0
- package/docs/getting-started/installation.md +106 -0
- package/docs/getting-started/migrating.md +125 -0
- package/docs/getting-started/project-structure.md +163 -0
- package/docs/introduction/README.md +41 -0
- package/docs/introduction/design-principles.md +55 -0
- package/docs/introduction/distributed.md +74 -0
- package/docs/introduction/how-it-works.md +74 -0
- package/docs/introduction/hyperscale.md +51 -0
- package/docs/introduction/modern-stack.md +36 -0
- package/docs/introduction/vs-other-frameworks.md +48 -0
- package/docs/introduction/why-toil.md +93 -0
- package/docs/llms.txt +90 -0
- package/docs/realtime/README.md +102 -0
- package/docs/realtime/channels.md +211 -0
- package/docs/realtime/streams.md +369 -0
- package/docs/services/README.md +60 -0
- package/docs/services/analytics.md +268 -0
- package/docs/services/caching.md +175 -0
- package/docs/services/cookies.md +235 -0
- package/docs/services/crypto.md +209 -0
- package/docs/services/email.md +289 -0
- package/docs/services/environment.md +141 -0
- package/docs/services/ratelimit.md +174 -0
- package/docs/services/time.md +85 -0
- package/examples/basic/client/routes/analytics.tsx +13 -12
- package/examples/basic/server/models/SiteAnalytics.ts +29 -17
- package/examples/basic/server/routes/Analytics.ts +15 -17
- package/examples/basic/server/routes/UserId.ts +22 -0
- package/package.json +15 -11
- package/scripts/gen-toil-docs.mjs +24 -35
- package/server/auth/AuthController.ts +336 -0
- package/server/auth/AuthUser.ts +23 -0
- package/server/auth/index.ts +16 -0
- package/server/globals/auth.ts +31 -0
- package/server/globals/userid.ts +107 -0
- package/src/compiler/config.ts +13 -0
- package/src/compiler/docs.ts +16 -33
- package/src/compiler/generate.ts +6 -2
- package/src/compiler/index.ts +114 -6
- package/src/compiler/toil-docs.generated.ts +64 -22
- package/src/devserver/analytics/index.ts +10 -4
- package/src/devserver/db/database.ts +67 -1
- package/src/devserver/db/index.ts +1 -0
- package/src/devserver/db/types.ts +13 -0
- package/src/devserver/runtime/module.ts +7 -0
- package/test/analytics-dev.test.ts +2 -1
- package/test/devserver-database.test.ts +113 -0
- package/docs/auth-todo.md +0 -149
- package/docs/auth.md +0 -322
- package/docs/caching.md +0 -115
- package/docs/cli.md +0 -17
- package/docs/client.md +0 -39
- package/docs/cookies.md +0 -457
- package/docs/crypto.md +0 -130
- package/docs/daemon.md +0 -123
- package/docs/data.md +0 -131
- package/docs/derive.md +0 -159
- package/docs/email.md +0 -326
- package/docs/environment.md +0 -97
- package/docs/getting-started.md +0 -128
- package/docs/index.md +0 -30
- package/docs/ratelimit.md +0 -95
- package/docs/routing.md +0 -259
- package/docs/rpc.md +0 -149
- package/docs/server.md +0 -61
- package/docs/ssr.md +0 -632
- package/docs/streams.md +0 -178
- package/docs/styling.md +0 -22
- package/docs/tiers.md +0 -133
- package/docs/time.md +0 -43
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# HTTP routes (`@rest`)
|
|
2
|
+
|
|
3
|
+
Expose a real HTTP API by tagging a class `@rest` and its methods with an HTTP verb; toiljs generates the router for you.
|
|
4
|
+
|
|
5
|
+
## Why and when
|
|
6
|
+
|
|
7
|
+
Use `@rest` when something outside your own frontend needs to call your backend over plain HTTP: a webhook from another service, a public API, a mobile app, a `curl` script, or a browser hitting a URL directly. REST endpoints are ordinary URLs with ordinary HTTP methods and status codes, so any HTTP client understands them.
|
|
8
|
+
|
|
9
|
+
If instead you are calling your own backend from your own React frontend, [typed RPC](./rpc.md) is usually nicer (you get end-to-end types and skip URL wrangling). The two live happily side by side.
|
|
10
|
+
|
|
11
|
+
## The shape of a route
|
|
12
|
+
|
|
13
|
+
A REST route is a method on a class. The class decorator says where the class is mounted; the method decorator says which HTTP method and path it answers.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
17
|
+
|
|
18
|
+
@rest('players') // this controller is mounted at /players
|
|
19
|
+
class Players {
|
|
20
|
+
@get('/:id') // answers GET /players/:id
|
|
21
|
+
public get(ctx: RouteContext): Response {
|
|
22
|
+
const id = ctx.param('id');
|
|
23
|
+
return Response.json(`{"id":"${id}"}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Save that file, import it once (see [How routes are discovered](#how-routes-are-discovered)), and `GET /players/42` returns `{"id":"42"}`. That is a working endpoint.
|
|
29
|
+
|
|
30
|
+
## `@rest`: mounting a controller
|
|
31
|
+
|
|
32
|
+
`@rest` marks a class as a route **controller** (a group of related routes) and mounts it at a URL prefix.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
@rest('api') // mounted at /api
|
|
36
|
+
@rest('/') // or @rest('') : mounted at the root
|
|
37
|
+
@rest({ stream: DataStream.Binary }) // root mount, binary body codec by default
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- The string is the mount prefix. `"api"`, `"/api"`, and `"api/"` all normalize to `/api`. `""` and `"/"` mean the root.
|
|
41
|
+
- The object form sets class-wide defaults. `stream: DataStream.Binary` makes every route in the class use the binary body codec instead of JSON (more on that in [Request and response bodies](#request-and-response-bodies)).
|
|
42
|
+
|
|
43
|
+
The full URL of a route is the controller prefix joined with the method path. With `@rest('api')` and `@get('/todos/:id')`, the route is `GET /api/todos/:id`.
|
|
44
|
+
|
|
45
|
+
## Verb decorators
|
|
46
|
+
|
|
47
|
+
Each HTTP method has a decorator that takes the route path:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
@get('/path') @post('/path') @put('/path') @patch('/path')
|
|
51
|
+
@del('/path') @head('/path') @options('/path')
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Note the delete decorator is `@del`, not `@delete` (the word `delete` is reserved in TypeScript, so it cannot be a decorator name).
|
|
55
|
+
|
|
56
|
+
### `@route`: the explicit form
|
|
57
|
+
|
|
58
|
+
`@route` is the general form. Reach for it when you want to set the body codec per route, or you just prefer an object:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
@route({ method: Methods.POST, path: '/upload', stream: DataStream.Binary })
|
|
62
|
+
public upload(body: FileData): FileResult { /* ... */ }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`method` (from the `Methods` enum) and `path` are required; `stream` is optional and overrides the controller default. `Methods` and `DataStream` are global enums (like the decorators themselves, they need no import).
|
|
66
|
+
|
|
67
|
+
## Path parameters
|
|
68
|
+
|
|
69
|
+
A path segment written as `:name` is a **path parameter**: it captures whatever the request has in that position. Read it with `ctx.param('name')`.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
@get('/todos/:id/items/:itemId')
|
|
73
|
+
public getItem(ctx: RouteContext): Response {
|
|
74
|
+
const id = ctx.param('id'); // "42" for /todos/42/items/9
|
|
75
|
+
const itemId = ctx.param('itemId'); // "9"
|
|
76
|
+
return Response.json(`{"todo":"${id}","item":"${itemId}"}`);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Matching is **segment-exact**: the request path must have the same number of `/`-separated segments, the static segments must match literally, and each `:param` captures one segment. The query string is removed before matching. A captured param is always a string; convert it yourself if you need a number (for example `u64.parse(ctx.param('id'))`).
|
|
81
|
+
|
|
82
|
+
## Method parameters: reading the request
|
|
83
|
+
|
|
84
|
+
A route method may declare zero, one, or two parameters. toiljs looks at their types to decide what to pass:
|
|
85
|
+
|
|
86
|
+
- a parameter typed `RouteContext` receives the [request context](#the-routecontext-object) (path params, query, headers, raw body);
|
|
87
|
+
- any other type is treated as the **request body**, decoded into that [`@data`](./data.md) type.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
@get('/status')
|
|
91
|
+
public status(): StatusResponse { /* no body, no context */ }
|
|
92
|
+
|
|
93
|
+
@get('/user/:id')
|
|
94
|
+
public getUser(ctx: RouteContext): User { /* context only */ }
|
|
95
|
+
|
|
96
|
+
@post('/create')
|
|
97
|
+
public create(input: NewTodo): Todo { /* body only */ }
|
|
98
|
+
|
|
99
|
+
@post('/user/:id/score')
|
|
100
|
+
public addScore(input: ScoreDelta, ctx: RouteContext): Player {
|
|
101
|
+
const id = ctx.param('id'); // body AND context
|
|
102
|
+
/* ... */
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The order of the two parameters does not matter; toiljs classifies them by type.
|
|
107
|
+
|
|
108
|
+
## Return types
|
|
109
|
+
|
|
110
|
+
You return one of two things from a route, and toiljs encodes it for you:
|
|
111
|
+
|
|
112
|
+
| You return | toiljs sends |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| a [`@data`](./data.md) value | The value serialized (JSON by default, binary if the route is in binary mode). Status `200`. |
|
|
115
|
+
| a `Response` | Exactly that response: your status, headers, cookies, and body, untouched. |
|
|
116
|
+
| `void` (nothing) | `204 No Content`. |
|
|
117
|
+
|
|
118
|
+
Returning a `@data` value is the short path when you just want to send the data. Returning a `Response` gives you full control: a custom status, extra headers, cookies, caching. Use whichever fits.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// Short path: return the data, let toiljs serialize it as JSON.
|
|
122
|
+
@get('/me')
|
|
123
|
+
public me(): Player { return currentPlayer(); }
|
|
124
|
+
|
|
125
|
+
// Full control: custom 404, a header, and the body serialized by hand.
|
|
126
|
+
@get('/:id')
|
|
127
|
+
public get(ctx: RouteContext): Response {
|
|
128
|
+
const id = u64.parse(ctx.param('id'));
|
|
129
|
+
if (!store.has(id)) return Response.notFound();
|
|
130
|
+
return Response.json(store.get(id).toJSON().toString())
|
|
131
|
+
.setHeader('cache-control', 'no-store');
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Request and response bodies
|
|
136
|
+
|
|
137
|
+
Every route is either a **JSON route** (the default) or a **Binary route**. This decides how request bodies are decoded and response values are encoded:
|
|
138
|
+
|
|
139
|
+
- **JSON**: the request body is parsed as JSON and revived into your `@data` type; the response value is serialized to JSON. Best for endpoints a browser or a third party calls directly, because JSON is universally readable.
|
|
140
|
+
- **Binary**: the request body and response use toiljs's compact binary codec (`DataWriter`/`DataReader`). Smaller and faster, and exact for very large integers. Best for app-to-app traffic and anything performance sensitive.
|
|
141
|
+
|
|
142
|
+
Set the mode on the whole controller with `@rest({ stream: DataStream.Binary })`, or per route with `@route({ ..., stream: DataStream.Binary })`. Full detail on both codecs is in [Data types](./data.md).
|
|
143
|
+
|
|
144
|
+
> Large integers and JSON: numbers of 64 bits or more (`u64`, `i64`, `u128`, `u256`, and friends) are sent over JSON as decimal strings, so they stay exact at any size (plain JSON numbers would lose precision). The generated client turns them back into `bigint`. See [Types](../concepts/types.md).
|
|
145
|
+
|
|
146
|
+
## The `RouteContext` object
|
|
147
|
+
|
|
148
|
+
`RouteContext` is your window into the incoming request. toiljs builds one and passes it to any route method that declares a `RouteContext` parameter.
|
|
149
|
+
|
|
150
|
+
| Member | Signature | What it gives you |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `request` | `Request` | The raw request (method, path, headers, body). |
|
|
153
|
+
| `param` | `param(name: string): string` | A captured path param, or `""` if absent. |
|
|
154
|
+
| `query` | `query(name: string): string` | A query-string value (`?q=hi`), or `""` if absent. Not URL-decoded in v1. |
|
|
155
|
+
| `header` | `header(name: string): string \| null` | A request header, case-insensitive, or `null`. |
|
|
156
|
+
| `text` | `text(): string` | The raw request body decoded as UTF-8 text. |
|
|
157
|
+
| `clientIp` | `clientIp(): string` | The connecting client's IP, or `""` if unavailable. |
|
|
158
|
+
|
|
159
|
+
`clientIp()` is the real socket address the edge observed, not a forgeable header like `X-Forwarded-For`, so it is safe to key rate limits, geo lookups, or audit logs on it.
|
|
160
|
+
|
|
161
|
+
## The `Request` object
|
|
162
|
+
|
|
163
|
+
The raw request. You get it as `ctx.request`, or directly as the argument to a hand-written `handle(req)`.
|
|
164
|
+
|
|
165
|
+
**Fields:**
|
|
166
|
+
|
|
167
|
+
| Field | Type | Notes |
|
|
168
|
+
| --- | --- | --- |
|
|
169
|
+
| `method` | `Method` | `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, or `UNKNOWN`. |
|
|
170
|
+
| `path` | `string` | The path, including the query string. |
|
|
171
|
+
| `headers` | `Array<Header>` | Ordered list; a `Header` is `{ name, value }`. |
|
|
172
|
+
| `body` | `Uint8Array` | The raw request body bytes. |
|
|
173
|
+
|
|
174
|
+
**Methods:**
|
|
175
|
+
|
|
176
|
+
| Method | Signature | Notes |
|
|
177
|
+
| --- | --- | --- |
|
|
178
|
+
| `header` | `header(name: string): string \| null` | Case-insensitive lookup, `null` if absent. |
|
|
179
|
+
| `cookies` | `cookies(): CookieMap` | Parses the `Cookie` header (values percent-decoded); cached per request. |
|
|
180
|
+
| `cookie` | `cookie(name: string): string \| null` | A single cookie value, or `null`. |
|
|
181
|
+
|
|
182
|
+
The `Method` enum and the `Header` class are exported from `toiljs/server/runtime`.
|
|
183
|
+
|
|
184
|
+
## Building a response
|
|
185
|
+
|
|
186
|
+
`Response` is what you return. Create one with a static factory, then chain instance methods to add headers, cookies, and caching. Every instance method returns the same `Response`, so calls chain.
|
|
187
|
+
|
|
188
|
+
### Static factories
|
|
189
|
+
|
|
190
|
+
| Factory | Signature | Status | Content-Type |
|
|
191
|
+
| --- | --- | --- | --- |
|
|
192
|
+
| `Response.text` | `text(body: string, status = 200)` | 200 | `text/plain; charset=utf-8` |
|
|
193
|
+
| `Response.html` | `html(body: string, status = 200)` | 200 | `text/html; charset=utf-8` |
|
|
194
|
+
| `Response.json` | `json(body: string, status = 200)` | 200 | `application/json; charset=utf-8` |
|
|
195
|
+
| `Response.bytes` | `bytes(body: Uint8Array, status = 200)` | 200 | `application/octet-stream` |
|
|
196
|
+
| `Response.empty` | `empty(status)` | custom | (none) |
|
|
197
|
+
| `Response.notFound` | `notFound()` | 404 | text |
|
|
198
|
+
| `Response.badRequest` | `badRequest(msg = 'bad request')` | 400 | text |
|
|
199
|
+
| `Response.internalError` | `internalError(msg = 'internal error')` | 500 | text |
|
|
200
|
+
| `Response.unhandled` | `unhandled()` | 404 | text, plus a marker header (see below) |
|
|
201
|
+
|
|
202
|
+
`Response.json` takes an **already-serialized** string, not an object. Build it from a `@data` value with `value.toJSON().toString()`, or return the `@data` value directly and let toiljs serialize it (usually simpler).
|
|
203
|
+
|
|
204
|
+
### Instance methods
|
|
205
|
+
|
|
206
|
+
| Method | Signature | What it does |
|
|
207
|
+
| --- | --- | --- |
|
|
208
|
+
| `setHeader` | `setHeader(name, value): Response` | Appends a header (call again to add more). |
|
|
209
|
+
| `setCookie` | `setCookie(cookie: Cookie): Response` | Appends a `Set-Cookie`. |
|
|
210
|
+
| `setCookieKV` | `setCookieKV(name, value): Response` | Shorthand for a cookie with no attributes. |
|
|
211
|
+
| `clearCookie` | `clearCookie(name, path = '/', domain = ''): Response` | Emits a deletion cookie (empty value, `Max-Age=0`). |
|
|
212
|
+
| `cache` | `cache(edgeTtlMinutes, browserTtlSeconds = 0, privateScope = false, allowAuth = false): Response` | Marks the response cacheable. See [Caching](../services/caching.md). |
|
|
213
|
+
| `cacheFor` | `cacheFor(minutes): Response` | Shorthand for edge-caching for N minutes. |
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
return Response.json('{"id":42}')
|
|
217
|
+
.setHeader('x-trace', traceId)
|
|
218
|
+
.setCookieKV('seen', '1')
|
|
219
|
+
.cacheFor(5);
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
See [Cookies](../services/cookies.md) for the full cookie builder and [Caching](../services/caching.md) for the caching rules.
|
|
223
|
+
|
|
224
|
+
## How dispatch works
|
|
225
|
+
|
|
226
|
+
Every `@rest` controller registers itself into a global `Rest` registry when its module loads. At request time, `Rest.dispatch(req)` walks the controllers and tries their routes.
|
|
227
|
+
|
|
228
|
+
```mermaid
|
|
229
|
+
flowchart TD
|
|
230
|
+
A["Request arrives"] --> B["Rest.dispatch(req)"]
|
|
231
|
+
B --> C{"Any controller<br/>route match<br/>the method + path?"}
|
|
232
|
+
C -->|"Yes"| D["Run that method"]
|
|
233
|
+
D --> E["Return its Response"]
|
|
234
|
+
C -->|"No"| F["dispatch returns null"]
|
|
235
|
+
F --> G["Your fallback logic<br/>(or Response.unhandled)"]
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Matching order is deterministic: controllers are tried in the order their modules load, and routes within a controller in declaration order. The **first match wins**, so put more specific routes before catch-all ones.
|
|
239
|
+
|
|
240
|
+
## How routes are discovered
|
|
241
|
+
|
|
242
|
+
`toiljs build` scans every file under `server/` and finds your decorated classes on its own, so a route file is picked up as soon as it exists. To keep a plain `toilscript` run (and your editor) seeing the same set, projects also `import` each route file once in `server/main.ts`:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// server/main.ts (excerpt)
|
|
246
|
+
import './routes/Players';
|
|
247
|
+
import './routes/Todos';
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Adding a new controller is two steps: write the file, add the one-line import.
|
|
251
|
+
|
|
252
|
+
## Dispatch and the 404 fallback
|
|
253
|
+
|
|
254
|
+
Inside your handler, the usual pattern is: try REST first, then fall through to anything else.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const hit = Rest.dispatch(req); // Response, or null if nothing matched
|
|
258
|
+
if (hit != null) return hit;
|
|
259
|
+
return Response.unhandled(); // no route matched here
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
There are two different 404s, and the difference matters:
|
|
263
|
+
|
|
264
|
+
- **`Response.notFound()`** means "I looked, and that resource does not exist." It is sent to the client as a plain `404`.
|
|
265
|
+
- **`Response.unhandled()`** means "this server has no route for that path." It is a `404` carrying a marker header (`x-toil-unhandled`). The edge (and the dev server) reads that marker and tries to serve the path another way: a static file, or the client-side app. The marker is stripped before anything reaches the browser.
|
|
266
|
+
|
|
267
|
+
Rule of thumb: return `unhandled()` when a path is simply not yours to handle, and `notFound()` when the path is yours but the specific thing is missing.
|
|
268
|
+
|
|
269
|
+
If your project is REST-only, you do not need a custom handler at all. toiljs ships `RestHandler`, which does exactly the dispatch-then-`unhandled` dance:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import { Server, RestHandler } from 'toiljs/server/runtime';
|
|
273
|
+
Server.handler = () => new RestHandler();
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Guards: auth, rate limits, caching
|
|
277
|
+
|
|
278
|
+
You can stack extra decorators on a route (or a whole controller) to protect or cache it. They compose with the verb decorators:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
@rest('admin')
|
|
282
|
+
class Admin {
|
|
283
|
+
@get('/stats')
|
|
284
|
+
@auth // reject with 401 if there is no valid session
|
|
285
|
+
@ratelimit(RateLimit.SlidingWindow, 30, 60) // at most 30 requests per 60 seconds
|
|
286
|
+
public stats(): Stats { /* ... */ }
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- `@auth` requires a valid signed session, else the request is rejected with `401` before your method runs. See the [Auth guide](../auth/README.md).
|
|
291
|
+
- `@ratelimit` caps how often a caller may hit the route. See [Rate limiting](../services/ratelimit.md).
|
|
292
|
+
- Response caching is opt-in per response with `.cache(...)` / `.cacheFor(...)`. See [Caching](../services/caching.md).
|
|
293
|
+
|
|
294
|
+
## A complete CRUD example
|
|
295
|
+
|
|
296
|
+
A small in-memory players API showing create, read, update, and delete. In a real app you would store players in [ToilDB](../database/README.md) instead of a local map, because each request gets a fresh handler (see [statelessness](./README.md#stateless-by-default)); this example keeps it in memory to stay focused on routing.
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
// server/models/NewPlayer.ts
|
|
300
|
+
@data
|
|
301
|
+
export class NewPlayer {
|
|
302
|
+
name: string = '';
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
// server/models/Player.ts
|
|
308
|
+
@data
|
|
309
|
+
export class Player {
|
|
310
|
+
id: u64 = 0;
|
|
311
|
+
name: string = '';
|
|
312
|
+
score: i64 = 0;
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
// server/routes/Players.ts
|
|
318
|
+
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
319
|
+
import { NewPlayer } from '../models/NewPlayer';
|
|
320
|
+
import { Player } from '../models/Player';
|
|
321
|
+
|
|
322
|
+
// A stand-in store. Real apps use ToilDB (see the database section).
|
|
323
|
+
const store = new Map<u64, Player>();
|
|
324
|
+
let nextId: u64 = 1;
|
|
325
|
+
|
|
326
|
+
@rest('players')
|
|
327
|
+
class Players {
|
|
328
|
+
// CREATE: POST /players (JSON body -> Player)
|
|
329
|
+
@post('/')
|
|
330
|
+
public create(input: NewPlayer): Player {
|
|
331
|
+
const p = new Player();
|
|
332
|
+
p.id = nextId++;
|
|
333
|
+
p.name = input.name;
|
|
334
|
+
p.score = 0;
|
|
335
|
+
store.set(p.id, p);
|
|
336
|
+
return p; // 200 with the player as JSON
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// READ: GET /players/:id
|
|
340
|
+
@get('/:id')
|
|
341
|
+
public get(ctx: RouteContext): Response {
|
|
342
|
+
const id = u64.parse(ctx.param('id'));
|
|
343
|
+
if (!store.has(id)) return Response.notFound();
|
|
344
|
+
return Response.json(store.get(id).toJSON().toString());
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// UPDATE: PUT /players/:id (JSON body -> Player)
|
|
348
|
+
@put('/:id')
|
|
349
|
+
public update(input: NewPlayer, ctx: RouteContext): Response {
|
|
350
|
+
const id = u64.parse(ctx.param('id'));
|
|
351
|
+
if (!store.has(id)) return Response.notFound();
|
|
352
|
+
const p = store.get(id);
|
|
353
|
+
p.name = input.name;
|
|
354
|
+
return Response.json(p.toJSON().toString());
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// DELETE: DELETE /players/:id
|
|
358
|
+
@del('/:id')
|
|
359
|
+
public remove(ctx: RouteContext): Response {
|
|
360
|
+
const id = u64.parse(ctx.param('id'));
|
|
361
|
+
if (!store.has(id)) return Response.notFound();
|
|
362
|
+
store.delete(id);
|
|
363
|
+
return Response.empty(204); // 204 No Content
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Calling it from anywhere:
|
|
369
|
+
|
|
370
|
+
```sh
|
|
371
|
+
curl -X POST localhost:5173/players -d '{"name":"Ada"}'
|
|
372
|
+
curl localhost:5173/players/1
|
|
373
|
+
curl -X PUT localhost:5173/players/1 -d '{"name":"Ada Lovelace"}'
|
|
374
|
+
curl -X DELETE localhost:5173/players/1
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Because `@rest` also generates a typed fetch client, your React frontend can call the same routes without writing URLs:
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
await Server.REST.players.create({ body: new NewPlayer('Ada') });
|
|
381
|
+
await Server.REST.players.get({ params: { id: 1 } });
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
See [RPC and the generated client](./rpc.md#the-rest-fetch-client) for that client.
|
|
385
|
+
|
|
386
|
+
## Gotchas
|
|
387
|
+
|
|
388
|
+
- **Fields do not persist.** A fresh controller instance serves each request, so instance fields reset every time and are never shared between requests or edge nodes. Persist to [ToilDB](../database/README.md).
|
|
389
|
+
- **`Response.json` wants a string.** Pass an already-serialized JSON string (or return the `@data` value directly). Passing an object will not do what you expect.
|
|
390
|
+
- **`@del`, not `@delete`.** `delete` is a reserved word, so the decorator is `@del`.
|
|
391
|
+
- **Query values are not URL-decoded in v1.** `ctx.query('q')` returns the raw value; decode it yourself if it may contain percent-encoding.
|
|
392
|
+
- **Path matching is exact on segment count.** `/todos/:id` does not match `/todos/1/extra`. Add a route for the longer path.
|
|
393
|
+
- **`notFound()` vs `unhandled()`.** Returning `notFound()` from your top handler stops the edge from falling through to static files or the client app. Use `unhandled()` for "not my path."
|
|
394
|
+
|
|
395
|
+
## Related
|
|
396
|
+
|
|
397
|
+
- [Data types (`@data`)](./data.md): the request and response body structs, and the JSON vs binary codecs.
|
|
398
|
+
- [Typed RPC](./rpc.md): call your backend from your frontend with end-to-end types (and the generated REST fetch client).
|
|
399
|
+
- [Backend overview](./README.md): the request lifecycle and handler model.
|
|
400
|
+
- [Cookies](../services/cookies.md), [Caching](../services/caching.md), [Rate limiting](../services/ratelimit.md): response helpers and guards.
|
|
401
|
+
- [Auth](../auth/README.md): protecting routes with `@auth`.
|
|
402
|
+
- [The database](../database/README.md): persisting data between requests.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Typed RPC (`@service` / `@remote`)
|
|
2
|
+
|
|
3
|
+
Write a server function, tag it `@remote`, and call it from your React frontend like a local async function, with the argument and return types checked end to end.
|
|
4
|
+
|
|
5
|
+
## What RPC is
|
|
6
|
+
|
|
7
|
+
**RPC** stands for Remote Procedure Call. The idea is old and simple: call a function that actually runs somewhere else (here, on the edge) as if it were a normal function in your own code. You write a method on the server, and on the client you `await` it. No URLs, no `fetch`, no manual JSON. Just a function call.
|
|
8
|
+
|
|
9
|
+
toiljs makes that call **fully typed**. When you build the server, it generates a TypeScript file (`shared/server.ts`) describing every callable function, its arguments, and its return type. Your frontend imports nothing extra: a global object called `Server` is available with all of it typed. If you change a server function's signature and rebuild, your frontend code stops type-checking until you fix the call. The client can never drift from the server.
|
|
10
|
+
|
|
11
|
+
## Why and when
|
|
12
|
+
|
|
13
|
+
Use RPC when **your own frontend** talks to **your own backend**. It is the most ergonomic and safest way to do that:
|
|
14
|
+
|
|
15
|
+
- End-to-end types: rename a field on the server, and the client sees it immediately.
|
|
16
|
+
- No plumbing: no route paths to invent, no request or response shapes to hand-write.
|
|
17
|
+
- Exact numbers: 64-bit-and-larger integers arrive as `bigint`, never rounded.
|
|
18
|
+
|
|
19
|
+
Use [`@rest`](./rest.md) instead when the caller is **not** your frontend: a webhook, a third-party integration, a public API, a mobile client, or anything that speaks in plain URLs and HTTP methods. RPC uses one internal endpoint and a binary wire format, so it is not meant to be called by hand.
|
|
20
|
+
|
|
21
|
+
You can use both in the same project. A common split: RPC for your app's own screens, REST for everything public.
|
|
22
|
+
|
|
23
|
+
## Declaring callable functions
|
|
24
|
+
|
|
25
|
+
Two decorators expose server code to the client:
|
|
26
|
+
|
|
27
|
+
- **`@remote`** on a top-level function makes it directly callable.
|
|
28
|
+
- **`@service`** on a class groups related `@remote` methods under a namespace.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// server/services/Stats.ts
|
|
32
|
+
import { store } from '../core/store';
|
|
33
|
+
|
|
34
|
+
@service
|
|
35
|
+
class Stats {
|
|
36
|
+
@remote
|
|
37
|
+
public playerCount(): i32 {
|
|
38
|
+
return store.size;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// server/services/remotes.ts
|
|
45
|
+
@remote
|
|
46
|
+
function ping(n: i32): i32 {
|
|
47
|
+
return n + 1;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
That is the whole server side. Build the server and the client can call them.
|
|
52
|
+
|
|
53
|
+
## Calling from the frontend
|
|
54
|
+
|
|
55
|
+
The generated client surfaces everything on a global `Server`. A `@service` becomes a namespace keyed by the class name with a lowercase first letter (`Stats` becomes `stats`). A free `@remote` sits directly on `Server`. Every call returns a `Promise`.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// anywhere in your React app, no import needed
|
|
59
|
+
const count = await Server.stats.playerCount(); // number
|
|
60
|
+
const next = await Server.ping(41); // number -> 42
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Autocomplete, argument checking, and the return type all come from `shared/server.ts`, which the build regenerates every time (and `toiljs dev` regenerates on save), so it is always in sync.
|
|
64
|
+
|
|
65
|
+
## The round trip
|
|
66
|
+
|
|
67
|
+
Here is what actually happens under that innocent-looking `await`.
|
|
68
|
+
|
|
69
|
+
```mermaid
|
|
70
|
+
sequenceDiagram
|
|
71
|
+
participant C as Browser (Server.stats.playerCount)
|
|
72
|
+
participant E as Dacely edge
|
|
73
|
+
participant W as Your backend (WASM)
|
|
74
|
+
C->>C: encode args with DataWriter
|
|
75
|
+
C->>E: POST /__toil_rpc<br/>header dacely-rpc = method id<br/>body = encoded args
|
|
76
|
+
E->>W: dispatch to the matching @remote
|
|
77
|
+
W->>W: run playerCount(), encode the result
|
|
78
|
+
W->>E: encoded result bytes
|
|
79
|
+
E->>C: 200 application/octet-stream
|
|
80
|
+
C->>C: decode into the typed value
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A few facts worth knowing:
|
|
84
|
+
|
|
85
|
+
- Every callable has a stable numeric **method id** (a hash of `"Service.method"` or the function name). The client sends it in the `dacely-rpc` header; the server dispatches on it.
|
|
86
|
+
- Arguments and results travel in the compact **binary `@data` codec** (see [Data types](./data.md)), so large integers are exact and payloads are small.
|
|
87
|
+
- The endpoint is a single reserved path, `/__toil_rpc`. You never route it yourself; the framework handles it before your `handle` runs.
|
|
88
|
+
|
|
89
|
+
## RPC is stateless too
|
|
90
|
+
|
|
91
|
+
Just like a REST controller, a fresh service instance serves each call. Fields you set on a `@service` class do not survive between calls. If two calls need to share data, that data lives in [ToilDB](../database/README.md), not in an instance field. See [statelessness](./README.md#stateless-by-default).
|
|
92
|
+
|
|
93
|
+
## Argument and return types
|
|
94
|
+
|
|
95
|
+
Arguments and return values may be scalars, arrays, or [`@data`](./data.md) classes, in both directions. Here is how toilscript's types map to what you see on the TypeScript client:
|
|
96
|
+
|
|
97
|
+
| ToilScript type | TypeScript type |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `u8`, `u16`, `u32`, `i8`, `i16`, `i32`, `f32`, `f64` | `number` |
|
|
100
|
+
| `u64`, `i64`, `u128`, `i128`, `u256`, `i256` | `bigint` |
|
|
101
|
+
| `bool` | `boolean` |
|
|
102
|
+
| `string` | `string` |
|
|
103
|
+
| a `@data` class `T` | `T` (the generated class) |
|
|
104
|
+
| `T[]` | `T[]` |
|
|
105
|
+
|
|
106
|
+
Integers of 64 bits or more become `bigint` on the client, so they are exact at any magnitude. See [Types](../concepts/types.md) for the full number story.
|
|
107
|
+
|
|
108
|
+
Passing a `@data` value is just as easy as a scalar. Construct it on the client and pass it in:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { NewPlayer } from './shared/server';
|
|
112
|
+
|
|
113
|
+
const created = await Server.roster.add(new NewPlayer('Ada')); // returns a typed Player
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The `@data` classes in `shared/server.ts` share a byte-for-byte identical codec with the server, so values round-trip exactly.
|
|
117
|
+
|
|
118
|
+
## Reading and writing the database from a `@remote`
|
|
119
|
+
|
|
120
|
+
A `@remote` can use the database, but with a safety default: **a plain `@remote` is read-only.** If it tries to write to ToilDB, the compiler rejects it. To let a `@remote` write, add `@action`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
@service
|
|
124
|
+
class Roster {
|
|
125
|
+
@remote
|
|
126
|
+
public count(): i32 { // read-only: fine to just read
|
|
127
|
+
return db.players.count();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@remote
|
|
131
|
+
@action // opts into writes
|
|
132
|
+
public add(input: NewPlayer): Player {
|
|
133
|
+
return db.players.create(/* ... */);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`@query` is the explicit opposite of `@action`: it marks a function read-only on purpose (the default for a `@remote`, so you rarely need to write it). These are ToilDB **function kinds**; the full rules, including what each kind may and may not do, are in the [database docs](../database/README.md). The takeaway for RPC: reads work out of the box, and a write needs `@action`, so a read-only endpoint can never silently mutate your data.
|
|
139
|
+
|
|
140
|
+
## Guarding a `@remote`
|
|
141
|
+
|
|
142
|
+
Guards stack on a `@remote` exactly as they do on a REST route:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
@service
|
|
146
|
+
class Stats {
|
|
147
|
+
@remote
|
|
148
|
+
@auth // reject with 401 when there is no valid session
|
|
149
|
+
public secretCount(): i32 {
|
|
150
|
+
return store.size;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The RPC dispatcher enforces `@auth` (and `@ratelimit`) the same way the REST router does: the guard runs first, and an unauthenticated call gets a `401` before your method body executes. See the [Auth guide](../auth/README.md) and [Rate limiting](../services/ratelimit.md).
|
|
156
|
+
|
|
157
|
+
## The generated `Server` surface
|
|
158
|
+
|
|
159
|
+
`shared/server.ts` declares `Server` as a global with a shape like this (schematic):
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
declare global {
|
|
163
|
+
const Server: {
|
|
164
|
+
// free @remote functions
|
|
165
|
+
ping(n: number): Promise<number>;
|
|
166
|
+
|
|
167
|
+
// @service classes, keyed by lowercased name
|
|
168
|
+
readonly stats: {
|
|
169
|
+
playerCount(): Promise<number>;
|
|
170
|
+
secretCount(): Promise<number>;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// @rest controllers get a fetch client under REST (see below)
|
|
174
|
+
readonly REST: { /* ... */ };
|
|
175
|
+
|
|
176
|
+
// @stream classes get a client under Stream (see the realtime docs)
|
|
177
|
+
readonly Stream: { /* ... */ };
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
You never edit this file; the build regenerates it. If a `Server` method throws that it is "unavailable," the generated client has not loaded yet: run the server build (or `toiljs dev`, which does it on save).
|
|
183
|
+
|
|
184
|
+
## The REST fetch client
|
|
185
|
+
|
|
186
|
+
`@rest` controllers also get a typed client, under `Server.REST.<controller>.<route>`. It is real `fetch` code (because REST is just HTTP), and it is handy when you want your frontend to call a route you also expose publicly:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// controller @rest('players') with a create route taking a NewPlayer body
|
|
190
|
+
const player = await Server.REST.players.create({
|
|
191
|
+
body: new NewPlayer('Ada'), // present only if the route takes a body
|
|
192
|
+
// params: { id: 7 }, // present only if the path has :params
|
|
193
|
+
query: { ref: 'home' }, // optional
|
|
194
|
+
headers: { 'x-trace': id }, // optional
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The wrapper builds the URL, substitutes `:params`, appends `query`, sends the request, throws on a non-2xx status, and decodes the response into the route's return type. A route declared to return `Response` resolves to the raw `fetch` `Response` so you can inspect headers or stream it yourself. See [REST](./rest.md) for the routes themselves.
|
|
199
|
+
|
|
200
|
+
## RPC vs REST at a glance
|
|
201
|
+
|
|
202
|
+
| | RPC (`@service` / `@remote`) | REST (`@rest`) |
|
|
203
|
+
| --- | --- | --- |
|
|
204
|
+
| Caller | Your own frontend | Anyone (browser, webhook, third party, `curl`) |
|
|
205
|
+
| Client | `Server.svc.method(args)` | `Server.REST.ctrl.route(args)`, or plain `fetch` |
|
|
206
|
+
| URL shape | One internal endpoint | Real paths and HTTP methods you design |
|
|
207
|
+
| Wire format | Compact binary `@data` | JSON (or binary), your choice |
|
|
208
|
+
| Types | End to end, automatic | End to end for the generated client |
|
|
209
|
+
| Best for | App-internal calls | Public APIs, integrations, webhooks |
|
|
210
|
+
|
|
211
|
+
## Gotchas
|
|
212
|
+
|
|
213
|
+
- **RPC is not a public API.** It uses one reserved endpoint and a binary format meant for the generated client. If an outside system needs to call in, expose a [`@rest`](./rest.md) route.
|
|
214
|
+
- **Instance fields do not persist.** A fresh service instance serves every call. Shared state belongs in [ToilDB](../database/README.md).
|
|
215
|
+
- **Writes need `@action`.** A plain `@remote` is read-only; the compiler rejects a database write unless the method is `@action`.
|
|
216
|
+
- **Rebuild after signature changes.** `shared/server.ts` is generated. If autocomplete looks stale, rebuild the server (`toiljs dev` does this on save).
|
|
217
|
+
- **`bigint`, not `number`, for 64-bit values.** A `u64`/`i64`/`u256` argument or return is a `bigint` on the client. Pass `10n`, not `10`.
|
|
218
|
+
|
|
219
|
+
## Related
|
|
220
|
+
|
|
221
|
+
- [Data types (`@data`)](./data.md): the structs your RPC arguments and results are made of, and the binary codec they travel in.
|
|
222
|
+
- [HTTP routes (`@rest`)](./rest.md): the public-facing alternative, and the `Server.REST` fetch client.
|
|
223
|
+
- [Types](../concepts/types.md): `u64`, `u256`, and how they map to `number` / `bigint`.
|
|
224
|
+
- [The database](../database/README.md): `@action` vs `@query`, and persisting state.
|
|
225
|
+
- [Fetching data on the frontend](../frontend/data-fetching.md): using `Server.*` from your React components.
|
|
226
|
+
- [Auth](../auth/README.md): guarding a `@remote` with `@auth`.
|