edgeway 0.1.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +784 -125
- data/js/cloudflare-d1-sessions.js +68 -0
- data/js/cloudflare-kv.js +47 -0
- data/js/cloudflare-ratelimit.js +31 -0
- data/js/cloudflare-test-database.js +112 -0
- data/js/cloudflare-test-runner.js +76 -0
- data/js/cloudflare.js +144 -22
- data/js/cors.js +15 -16
- data/js/fetch.js +113 -0
- data/js/headers.js +16 -0
- data/js/jspi.js +32 -0
- data/js/jwt.js +6 -40
- data/js/runtime.js +42 -5
- data/lib/edgeway/application.rb +21 -9
- data/lib/edgeway/cache/limits.rb +8 -0
- data/lib/edgeway/cache/memory_store.rb +24 -0
- data/lib/edgeway/cache/null_store.rb +13 -0
- data/lib/edgeway/cache/store.rb +189 -0
- data/lib/edgeway/cache.rb +13 -0
- data/lib/edgeway/cli/build/app_config.rb +39 -0
- data/lib/edgeway/cli/build/app_files.rb +40 -0
- data/lib/edgeway/cli/build/cache_config.rb +108 -0
- data/lib/edgeway/cli/build/database_config.rb +96 -0
- data/lib/edgeway/cli/build/js_files.rb +30 -0
- data/lib/edgeway/cli/build/view_compiler.rb +191 -0
- data/lib/edgeway/cli/build.rb +50 -18
- data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
- data/lib/edgeway/cli/cloudflare_test.rb +120 -0
- data/lib/edgeway/cli/generate.rb +174 -26
- data/lib/edgeway/cli/new.rb +1 -1
- data/lib/edgeway/cli/test.rb +28 -9
- data/lib/edgeway/cli/toolchain.rb +5 -0
- data/lib/edgeway/cli.rb +7 -4
- data/lib/edgeway/controller/base.rb +32 -3
- data/lib/edgeway/controller/caching.rb +140 -0
- data/lib/edgeway/controller/callbacks.rb +5 -1
- data/lib/edgeway/controller/dispatcher.rb +11 -1
- data/lib/edgeway/controller/rate_limiting.rb +145 -0
- data/lib/edgeway/controller/rendering.rb +24 -4
- data/lib/edgeway/db/adapter.rb +38 -0
- data/lib/edgeway/db/memory.rb +9 -2
- data/lib/edgeway/db/query_stats.rb +42 -0
- data/lib/edgeway/db/sqlite.rb +73 -9
- data/lib/edgeway/errors.rb +43 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
- data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
- data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
- data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
- data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
- data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
- data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
- data/lib/edgeway/http/auth/stub.rb +1 -3
- data/lib/edgeway/http/client.rb +80 -0
- data/lib/edgeway/http/request.rb +7 -22
- data/lib/edgeway/http/stub_transport.rb +38 -0
- data/lib/edgeway/inflector.rb +19 -0
- data/lib/edgeway/logger.rb +33 -5
- data/lib/edgeway/model/attributes.rb +93 -10
- data/lib/edgeway/model/base.rb +50 -5
- data/lib/edgeway/model/connection_registry.rb +25 -0
- data/lib/edgeway/model/macros.rb +5 -1
- data/lib/edgeway/model/persistence.rb +18 -11
- data/lib/edgeway/model/relation.rb +106 -56
- data/lib/edgeway/model/validations.rb +29 -23
- data/lib/edgeway/rate_limit/limiter.rb +9 -0
- data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
- data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
- data/lib/edgeway/rate_limit.rb +2 -0
- data/lib/edgeway/routing/route.rb +1 -6
- data/lib/edgeway/server.rb +13 -0
- data/lib/edgeway/service/cloudflare/d1.rb +44 -30
- data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
- data/lib/edgeway/service/cloudflare/kv.rb +45 -0
- data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
- data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
- data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
- data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
- data/lib/edgeway/service/fetch.rb +26 -0
- data/lib/edgeway/service/import_result.rb +24 -0
- data/lib/edgeway/test/sqlite_databases.rb +18 -0
- data/lib/edgeway/test.rb +86 -26
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway/view.rb +24 -0
- data/lib/edgeway.rb +9 -0
- metadata +50 -4
- data/lib/edgeway/cli/app_config.rb +0 -35
- data/lib/edgeway/cli/app_files.rb +0 -37
data/README.md
CHANGED
|
@@ -38,27 +38,221 @@ end
|
|
|
38
38
|
|
|
39
39
|
## Contents
|
|
40
40
|
|
|
41
|
+
- [Usage](#usage)
|
|
42
|
+
- [Generators](#generators)
|
|
43
|
+
- [Building](#building)
|
|
44
|
+
- [Spinel version](#spinel-version)
|
|
45
|
+
- [Configuration](#configuration)
|
|
41
46
|
- [Models](#models)
|
|
47
|
+
- [Attribute types](#attribute-types)
|
|
42
48
|
- [Strong parameters](#strong-parameters)
|
|
43
49
|
- [Uniqueness](#uniqueness)
|
|
50
|
+
- [Database errors](#database-errors)
|
|
44
51
|
- [Ordering and deleting](#ordering-and-deleting)
|
|
52
|
+
- [Queries](#queries)
|
|
53
|
+
- [Query stats](#query-stats)
|
|
45
54
|
- [Batch](#batch)
|
|
46
55
|
- [Associations](#associations)
|
|
47
56
|
- [Eager loading](#eager-loading)
|
|
48
|
-
- [
|
|
49
|
-
- [
|
|
50
|
-
- [
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- [
|
|
57
|
+
- [Multiple databases](#multiple-databases)
|
|
58
|
+
- [Read replication (Sessions)](#read-replication-sessions)
|
|
59
|
+
- [D1 API coverage](#d1-api-coverage)
|
|
60
|
+
- [Views](#views)
|
|
61
|
+
- [Static assets](#static-assets)
|
|
62
|
+
- [Logging](#logging)
|
|
54
63
|
- [Authentication](#authentication)
|
|
55
64
|
- [CORS](#cors)
|
|
65
|
+
- [HTTP caching](#http-caching)
|
|
66
|
+
- [Caching](#caching)
|
|
67
|
+
- [Stores](#stores)
|
|
68
|
+
- [config/cache.yml](#configcacheyml)
|
|
69
|
+
- [KV](#kv)
|
|
70
|
+
- [In tests](#in-tests)
|
|
71
|
+
- [Outbound HTTP](#outbound-http)
|
|
72
|
+
- [Rate limiting](#rate-limiting)
|
|
73
|
+
- [In tests](#in-tests-1)
|
|
56
74
|
- [Testing an app](#testing-an-app)
|
|
75
|
+
- [Writing tests](#writing-tests)
|
|
76
|
+
- [Local D1 (`adapter: d1` in `test:`)](#local-d1-adapter-d1-in-test)
|
|
77
|
+
- [SQLite (`adapter: memory` in `test:`)](#sqlite-adapter-memory-in-test)
|
|
78
|
+
- [Differences from production](#differences-from-production)
|
|
79
|
+
- [Known limitations](#known-limitations)
|
|
57
80
|
- [Development](#development)
|
|
58
81
|
- [License](#license)
|
|
59
82
|
|
|
83
|
+
## Usage
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
gem install edgeway
|
|
87
|
+
|
|
88
|
+
edgeway new myapp
|
|
89
|
+
|
|
90
|
+
# mise, bundle, pnpm and the local D1 migrations
|
|
91
|
+
cd myapp && bin/setup
|
|
92
|
+
|
|
93
|
+
# the app's tests, as wasm on a local D1 (see "Testing an app")
|
|
94
|
+
bundle exec edgeway test # or bin/test [FILE...] in a generated app
|
|
95
|
+
|
|
96
|
+
# edgeway build: writes build/app.wasm and build/edgeway/ (the JS).
|
|
97
|
+
# A development build: Spinel's --incremental caches the generated C in units,
|
|
98
|
+
# so a rebuild recompiles only what changed (no inlining across units)
|
|
99
|
+
bin/build
|
|
100
|
+
|
|
101
|
+
pnpm exec wrangler dev
|
|
102
|
+
edgeway routes
|
|
103
|
+
|
|
104
|
+
# bin/build --release (one unit, without --incremental), remote D1 migrations,
|
|
105
|
+
# then pnpm exec wrangler deploy
|
|
106
|
+
bin/deploy
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`edgeway` commands: `new`, `build`, `test`, `generate` (`g`), `routes`, `version` (`edgeway help` lists them).
|
|
110
|
+
|
|
111
|
+
Before `bin/deploy`:
|
|
112
|
+
|
|
113
|
+
1. Log in (`pnpm exec wrangler login`).
|
|
114
|
+
2. Create the D1 database (`pnpm exec wrangler d1 create <name>-db`).
|
|
115
|
+
3. Put its `database_id` in `wrangler.toml`.
|
|
116
|
+
|
|
117
|
+
`bin/setup` prints these steps when they are missing.
|
|
118
|
+
|
|
119
|
+
- A generated app's Gemfile takes `edgeway` and `spinel_test-unit` from RubyGems.
|
|
120
|
+
- A generated app's `.github/workflows/ci.yml` installs Ruby (from `.ruby-version`, which `edgeway new` writes with the Ruby that ran it), mise's tools and wrangler, then runs `bin/test` and `bin/build --release`.
|
|
121
|
+
- An app uses [pnpm](https://pnpm.io/) for wrangler (`package.json`, `bin/setup`, `mise.toml`);
|
|
122
|
+
`edgeway new` names it in one place (`Edgeway::CLI::PackageManager`).
|
|
123
|
+
- `SPINEL=/path/to/spinel bin/build` uses a Spinel other than the one on `PATH`.
|
|
124
|
+
- `bin/deploy` applies pending remote D1 migrations (`wrangler d1 migrations apply <name>-db --remote`) before deploying.
|
|
125
|
+
|
|
126
|
+
### Generators
|
|
127
|
+
|
|
128
|
+
Inside an app made by `edgeway new`, `edgeway generate` (or `edgeway g`) writes the files and never overwrites one:
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
# app/models/todo.rb, db/migrations/0001_create_todos.sql, test/models/todo_test.rb
|
|
132
|
+
edgeway generate model Todo title:string done:boolean
|
|
133
|
+
|
|
134
|
+
# app/controllers/todos_controller.rb, test/controllers/todos_test.rb
|
|
135
|
+
edgeway generate controller Todos index show
|
|
136
|
+
|
|
137
|
+
# the model's files, a JSON API controller (index, show, create, update, destroy) and its test
|
|
138
|
+
edgeway generate scaffold Todo title:string done:boolean
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- Field types are `string` (the default), `text`, `integer`, `float`, `boolean` and `datetime`.
|
|
142
|
+
- A model gets `timestamps` and an `id`, `created_at` and `updated_at` column.
|
|
143
|
+
- The migration is numbered one above the highest in `db/migrations`.
|
|
144
|
+
- Each action of a controller is a stub that renders `{}`.
|
|
145
|
+
- `config/routes.rb` is not changed by `model` and `controller`: the command prints the route to add (`r.resources :todos`).
|
|
146
|
+
- `model` and `scaffold` stop if a `db/migrations/*_create_todos.sql` already exists (a rerun would add a second table).
|
|
147
|
+
- `scaffold` singularizes its name (`scaffold Todos` is `Todo`), checks that none of its files exist before it writes one, and adds `r.resources :todos` as the last line of the `routes.draw` block, so that it does not shadow an earlier `/todos/search`. It leaves a route that is already there alone, and prints the route and the reason instead if there is no `routes.draw do |r|` block with a closing `end`.
|
|
148
|
+
- The scaffold's controller permits the given fields (`params.require(:todo).permit(...)`), and with no fields it takes no params.
|
|
149
|
+
|
|
150
|
+
### Building
|
|
151
|
+
|
|
152
|
+
`edgeway build` needs Spinel and the WASI SDK.
|
|
153
|
+
|
|
154
|
+
- An app made by `edgeway new` has a `mise.toml` for both (`mise install`):
|
|
155
|
+
Spinel comes from the [mise-spinel](https://github.com/y-yagi/mise-spinel) plugin and is put on `PATH`,
|
|
156
|
+
and the WASI SDK is pointed at by `WASI_SDK`.
|
|
157
|
+
- Without mise, set `SPINEL` (or put `spinel` on `PATH`) and `WASI_SDK`.
|
|
158
|
+
- `SPINEL` wins over `spinel` on `PATH`, which is how to use a Spinel checkout instead of a release.
|
|
159
|
+
|
|
160
|
+
See `todo-api` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples) for an example (checked with its `check.sh`).
|
|
161
|
+
|
|
162
|
+
### Spinel version
|
|
163
|
+
|
|
164
|
+
Edgeway needs a Spinel newer than the 2026.09.12 release
|
|
165
|
+
(that release refuses `send` with a computed name, which `Callbacks#run_before_actions` uses).
|
|
166
|
+
|
|
167
|
+
- `mise.toml` (Edgeway's own and the one `edgeway new` writes) pins a commit of [matz/spinel](https://github.com/matz/spinel),
|
|
168
|
+
`ec0ad13e4`, which mise-spinel builds from source.
|
|
169
|
+
A commit rather than `master`, so that an install is the Spinel that was tested.
|
|
170
|
+
- The WASI SDK (`wasi-sdk-34`) is installed first,
|
|
171
|
+
because mise-spinel builds Spinel's wasm32-wasi runtime only when it finds one.
|
|
172
|
+
- Changing the pin means re-running `rake test` and `todo-api/check.sh` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples).
|
|
173
|
+
|
|
174
|
+
## Configuration
|
|
175
|
+
|
|
176
|
+
Two files configure the database, each for its own side:
|
|
177
|
+
|
|
178
|
+
- `config/database.yml` is Edgeway's: for each environment (`edgeway build --env ENV`),
|
|
179
|
+
the adapter (`d1`, or `memory`, which stores nothing) and the name of the D1 binding the app uses. It is compiled into the wasm.
|
|
180
|
+
- `wrangler.toml` is Cloudflare's: which D1 database the binding is (`database_name`, `database_id`),
|
|
181
|
+
the migrations, and the Worker. `binding` in it must match `binding` in `config/database.yml`.
|
|
182
|
+
|
|
183
|
+
Another database goes under `databases:` of an environment, with its own binding in `wrangler.toml`:
|
|
184
|
+
|
|
185
|
+
```yaml
|
|
186
|
+
production:
|
|
187
|
+
adapter: d1
|
|
188
|
+
binding: DB
|
|
189
|
+
databases:
|
|
190
|
+
analytics:
|
|
191
|
+
binding: ANALYTICS
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Its migrations are in `db/analytics/migrations` (`migrations_dir` of its `[[d1_databases]]`; apply them per database).
|
|
195
|
+
A name is lowercase letters, digits and `_`, and not `primary`; `binding:` is required.
|
|
196
|
+
The extra databases use the environment's adapter.
|
|
197
|
+
`sessions: true` next to `binding:` (in the environment for the primary, in the entry for another) turns on D1 Sessions for it (see [Read replication](#read-replication-sessions)).
|
|
198
|
+
|
|
199
|
+
`database_id` is not a secret: it only names the database, and using it takes an API token of the account,
|
|
200
|
+
so it is committed with `wrangler.toml` as Cloudflare's own examples do.
|
|
201
|
+
Secrets the app needs go in Worker secrets (`pnpm exec wrangler secret put NAME`),
|
|
202
|
+
which the app reads with `Edgeway.config("NAME")`.
|
|
203
|
+
|
|
204
|
+
Not every Worker variable reaches the app. Only these are passed into the wasm:
|
|
205
|
+
|
|
206
|
+
- the framework's own settings (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_LEEWAY`, `JWT_ALGORITHMS`, `JWT_JWKS_URL`, `LOG_LEVEL`)
|
|
207
|
+
- the variables whose name starts with `APP_`
|
|
208
|
+
|
|
209
|
+
Name your own settings `APP_...` (`APP_PAGE_SIZE = "20"` in `[vars]`, `Edgeway.config("APP_PAGE_SIZE", "20")` in Ruby),
|
|
210
|
+
and likewise your secrets (`wrangler secret put APP_NAME`).
|
|
211
|
+
Anything else, such as a key the Worker holds for another purpose, is never visible to the app.
|
|
212
|
+
`JWT_SECRET` is never passed.
|
|
213
|
+
|
|
60
214
|
## Models
|
|
61
215
|
|
|
216
|
+
### Attribute types
|
|
217
|
+
|
|
218
|
+
| Type | Ruby value | SQLite column | In JSON |
|
|
219
|
+
| --- | --- | --- | --- |
|
|
220
|
+
| `:string`, `:text` | String | TEXT | string |
|
|
221
|
+
| `:integer` | Integer (32 bits) | INTEGER | number |
|
|
222
|
+
| `:float` | Float | REAL | number |
|
|
223
|
+
| `:boolean` | `true`/`false` | INTEGER (1/0) | `true`/`false` |
|
|
224
|
+
| `:datetime` | String, ISO 8601 UTC | TEXT | string |
|
|
225
|
+
| `:binary` | binary String (`.b`) | BLOB | base64 string |
|
|
226
|
+
| `:bigint` | decimal String (`"3000000000"`) | INTEGER | string |
|
|
227
|
+
|
|
228
|
+
#### `:integer` and `:bigint`
|
|
229
|
+
|
|
230
|
+
- Integers are 32 bits in a wasm build. An `:integer` value outside -2147483648..2147483647 casts to `nil` and fails validation
|
|
231
|
+
("is out of range"); use `:bigint` for such columns. A row that already holds one loads as `nil`, and saving it fails the same way
|
|
232
|
+
instead of writing `NULL` over it.
|
|
233
|
+
- A `:bigint` is a String of an optional `-` and digits, without leading zeros (`"-0"` is `"0"`); anything else, or a value beyond 64 bits,
|
|
234
|
+
casts to `nil` ("is invalid"). It is stored as an INTEGER, so `where`, `order` and comparisons work on the number,
|
|
235
|
+
and it renders in JSON as a String.
|
|
236
|
+
- D1 hands rows over as JavaScript numbers, so a value beyond 2^53 (9007199254740991) loses precision before Ruby sees it.
|
|
237
|
+
Keep values below 2^53, or store them as `:string`.
|
|
238
|
+
- Pass bigint values as Strings: `Todo.find("3000000000")`, `where(id: "3000000000")`, `create!(owner_id: "5000000000")`,
|
|
239
|
+
`Model.execute("... WHERE id = ?", ["3000000000"])`. An Integer literal beyond 32 bits in a Hash raises `RangeError` in a wasm build (a Spinel limitation).
|
|
240
|
+
- `attribute :id, :bigint` makes the primary key a bigint, and `id` is a String. A foreign key to it is `attribute :owner_id, :bigint`,
|
|
241
|
+
declared before `belongs_to` so it keeps its type.
|
|
242
|
+
- Raw SQL rows (`Model.select_all`) hold what the database gave: an INTEGER beyond 32 bits is a Bignum in a wasm build, and `render json:` of it raises.
|
|
243
|
+
Select it as text (`SELECT CAST(id AS TEXT) AS id ...`) or convert it with `Edgeway::Model.cast(:bigint, value)`.
|
|
244
|
+
- `Model.execute` answers `"last_insert_id"` as an Integer, or as a decimal String when the rowid is beyond 32 bits.
|
|
245
|
+
|
|
246
|
+
#### `:binary`
|
|
247
|
+
|
|
248
|
+
- A `:binary` attribute holds a binary String. It is bound as a BLOB (`where(body: bytes)` too) and renders in JSON as a base64 String.
|
|
249
|
+
- Assigned from Ruby, a String keeps its bytes (`Document.new(body: bytes)`), and so does a `{"$blob" => base64}` Hash.
|
|
250
|
+
- In a request body the value is the base64 String, so a GET then PATCH round trip is stable. Invalid base64 fails validation ("is invalid"),
|
|
251
|
+
and `{"$blob": ...}` objects are not supported (nested objects are dropped).
|
|
252
|
+
- `presence` counts a binary String blank only when empty, and `length` counts bytes.
|
|
253
|
+
- In raw SQL (`Model.select_all`, `Model.execute`, `where("...", value)`), bind a BLOB with `Edgeway::Model.blob(bytes)`;
|
|
254
|
+
a BLOB column comes back as `{"$blob" => base64}`, the form in which BLOBs cross between wasm and JS.
|
|
255
|
+
|
|
62
256
|
### Strong parameters
|
|
63
257
|
|
|
64
258
|
Params must go through `permit` before a model takes them.
|
|
@@ -92,6 +286,22 @@ Project.where(owner_id: uid).find_or_create_by(name: "default")
|
|
|
92
286
|
the database's message (table and columns) goes only to the log.
|
|
93
287
|
- A controller can `rescue_from Edgeway::RecordNotUnique` to answer otherwise.
|
|
94
288
|
|
|
289
|
+
### Database errors
|
|
290
|
+
|
|
291
|
+
All are `Edgeway::DatabaseError`; the database's message goes only to the log.
|
|
292
|
+
|
|
293
|
+
- `NotNullViolation`, `InvalidForeignKey` and `CheckViolation` (a constraint the app broke):
|
|
294
|
+
answered 500 `{"error":"internal error"}`, like any other `DatabaseError`.
|
|
295
|
+
- `DatabaseUnavailable` (D1 lost its connection, was reset or is overloaded):
|
|
296
|
+
answered 503 `{"error":"database unavailable"}` with `Retry-After: 1`.
|
|
297
|
+
- A controller can `rescue_from` any of them to answer otherwise.
|
|
298
|
+
`rescue_from Edgeway::DatabaseError` also catches `DatabaseUnavailable` and `RecordNotUnique`,
|
|
299
|
+
so it replaces their default 503 and 409.
|
|
300
|
+
- D1 retries read-only queries itself. Edgeway never retries a query, a write or a batch.
|
|
301
|
+
- A write that got a 503 may still have been applied:
|
|
302
|
+
only an idempotent request (a GET, or a write the app made idempotent) is safe to retry.
|
|
303
|
+
- Timeouts and CPU or memory limits stay a plain `DatabaseError` (500).
|
|
304
|
+
|
|
95
305
|
### Ordering and deleting
|
|
96
306
|
|
|
97
307
|
```ruby
|
|
@@ -115,6 +325,61 @@ class Todo < ApplicationModel
|
|
|
115
325
|
end
|
|
116
326
|
```
|
|
117
327
|
|
|
328
|
+
### Queries
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
Todo.where(id: [1, 2]) # id IN (?, ?)
|
|
332
|
+
Todo.where(position: 1..5) # position >= ? AND position <= ?
|
|
333
|
+
Todo.where(position: (3..)) # position >= ?
|
|
334
|
+
Todo.where("title LIKE ?", "%x%") # (title LIKE ?)
|
|
335
|
+
Todo.where("done = 1")
|
|
336
|
+
|
|
337
|
+
Todo.find_by_sql("SELECT * FROM todos WHERE done = ?", [true]) # records
|
|
338
|
+
Edgeway::Model.select_all("SELECT COUNT(*) AS n FROM todos", []) # row Hashes
|
|
339
|
+
Edgeway::Model.execute("DELETE FROM todos WHERE done = ?", [true]) # {"changes" => n, "last_insert_id" => id}
|
|
340
|
+
Edgeway::Model.exec("DELETE FROM tags\nDELETE FROM todos") # nil
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
- `where(column: value)` is `=`, `IS NULL` for `nil`. Every form chains, and works with `order`, `limit`, `count`, `delete_all`, `eager_load` and `todo.comments.where(...)`.
|
|
344
|
+
- An Array is `IN`; each value is cast by the column type. An empty Array matches nothing (`1 = 0`).
|
|
345
|
+
A `nil` in an Array also matches `NULL`: `(position IN (?) OR position IS NULL)`.
|
|
346
|
+
- A Range is `>=` and `<=` (`<` for `1...5`); an endless or beginless Range has one side.
|
|
347
|
+
Spinel can't build a Range of Times: use ISO 8601 Strings (`"2026-10-28"..."2026-10-29"`) or a fragment.
|
|
348
|
+
- A String is a raw SQL fragment, in parentheses and ANDed with the rest; its binds follow it.
|
|
349
|
+
It is raw SQL: put user input in the binds, never interpolate it.
|
|
350
|
+
Its binds are not cast by column type: only booleans (1/0) and Times (ISO 8601) are converted.
|
|
351
|
+
An Array bind raises `ArgumentError` (not expanded): use `where(column: [...])`.
|
|
352
|
+
- `build`/`create`/`find_or_create_by` on a relation copy only the plain equality conditions.
|
|
353
|
+
- `find_by_sql` makes records of the rows, so select the model's columns (`SELECT *` or each of them): a missing one is `nil`.
|
|
354
|
+
- `Model.select_all` and `Model.execute` take `?` placeholders. Booleans are bound as 1/0, and a Time as ISO 8601 UTC (`2026-10-28T05:00:00.000Z`, as timestamps are stored), also in `where` values.
|
|
355
|
+
`execute` is a write, so inside `Model.batch` it is queued (and answers 0 counts).
|
|
356
|
+
- `Model.exec` is D1's `exec()`: one or more statements with no binds, and it answers `nil`. Meant for one-off maintenance, as D1 says.
|
|
357
|
+
A statement must fit on one line (D1 splits the text at newlines); `a; b` on one line also works. Empty SQL raises `ArgumentError`.
|
|
358
|
+
When a statement fails, none of them stay applied (seen on local D1; the SQLite adapter in tests does the same).
|
|
359
|
+
D1's error message names the first line, not the one that failed.
|
|
360
|
+
It can't run inside `Model.batch` (`ArgumentError`), and fails on a database with `sessions: true` (`DatabaseError`): a D1 session has no `exec()`.
|
|
361
|
+
- D1 allows 100 bound parameters per statement: more raises `ArgumentError`, in every adapter (`limit` and `offset` are literals, not binds).
|
|
362
|
+
`where(id: ids)` with over 100 ids needs chunking.
|
|
363
|
+
|
|
364
|
+
### Query stats
|
|
365
|
+
|
|
366
|
+
`Edgeway::Model.query_stats` (it raises `ConfigurationError` without a connection) is what D1 reported for the queries of the current request
|
|
367
|
+
(a wasm instance serves one request, so it starts at zero):
|
|
368
|
+
|
|
369
|
+
- `queries`: the number of successful D1 calls (a `Model.batch` is one). A failed call and its time are not counted.
|
|
370
|
+
- `rows_read`, `rows_written`: the sums of D1's `meta`, capped at 2147483647 (32-bit).
|
|
371
|
+
- `duration_ms` (Float): the sum of D1's `meta.duration`.
|
|
372
|
+
- `served_by_region` and `served_by_primary`: from the last call (`""` and `true` when D1 doesn't say, as in `wrangler dev`).
|
|
373
|
+
|
|
374
|
+
When a request ran any query, `Edgeway::Server` logs one `query stats` line after it ([Logging](#logging)), also for a request that raised and was rescued.
|
|
375
|
+
Its fields are `queries`, `rows_read`, `rows_written`, `db_ms` and, for a database other than the primary, `database`:
|
|
376
|
+
|
|
377
|
+
```json
|
|
378
|
+
{"level":"info","message":"query stats","queries":3,"rows_read":12,"rows_written":1,"db_ms":2.46}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The Memory and SQLite adapters report zeros and log nothing.
|
|
382
|
+
|
|
118
383
|
### Batch
|
|
119
384
|
|
|
120
385
|
`Edgeway::Model.batch` makes several writes atomic.
|
|
@@ -179,129 +444,171 @@ Todo.eager_load(:comments, :tags) # several at once, still one query
|
|
|
179
444
|
- Only `to_a` and what is built on it (`each`, `map`, `size`) read the loaded records;
|
|
180
445
|
`comments.where(...)`, `count` and `first` query as usual.
|
|
181
446
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
```sh
|
|
185
|
-
gem install edgeway
|
|
447
|
+
### Multiple databases
|
|
186
448
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
# mise, bundle, pnpm and the local D1 migrations
|
|
190
|
-
cd myapp && bin/setup
|
|
449
|
+
A model uses the primary database unless it names another, declared in `config/database.yml` (see [Configuration](#configuration)):
|
|
191
450
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
pnpm exec wrangler dev
|
|
199
|
-
edgeway routes
|
|
451
|
+
```ruby
|
|
452
|
+
class Event < ApplicationModel
|
|
453
|
+
self.database = :analytics # inherited by subclasses
|
|
454
|
+
attribute :name, :string
|
|
455
|
+
end
|
|
200
456
|
|
|
201
|
-
|
|
202
|
-
|
|
457
|
+
Event.create!(name: "signup")
|
|
458
|
+
Edgeway::Model.select_all("SELECT COUNT(*) AS n FROM events", [], database: :analytics)
|
|
459
|
+
Edgeway::Model.execute("DELETE FROM events", [], database: :analytics)
|
|
203
460
|
```
|
|
204
461
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
`
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
# app/controllers/todos_controller.rb, test/controllers/todos_test.rb
|
|
230
|
-
edgeway generate controller Todos index show
|
|
462
|
+
- Every query and write of a model goes to its database, including `find_by_sql`.
|
|
463
|
+
`Model.select_all` and `Model.execute` use the primary unless given `database:`.
|
|
464
|
+
- Associations between models on different databases work as separate queries.
|
|
465
|
+
A `has_many` with `dependent:` to a model on another database raises `ArgumentError` on `destroy`, before any query: it can't be atomic.
|
|
466
|
+
`eager_load` across databases raises `ArgumentError`: a `JOIN` can't cross them.
|
|
467
|
+
- A `Model.batch` belongs to the database of its first write; a write for another one raises `ArgumentError`
|
|
468
|
+
(D1 batches are per database). Use one `batch` per database.
|
|
469
|
+
- `Edgeway::Model.query_stats(:analytics)` is that database's stats. The request logs one `query stats` line per database that ran queries,
|
|
470
|
+
with a `database` field for the ones other than the primary.
|
|
471
|
+
|
|
472
|
+
### Read replication (Sessions)
|
|
473
|
+
|
|
474
|
+
With D1 read replication enabled for a database (dashboard, or `PUT` its settings), reads can be served by a replica near the user.
|
|
475
|
+
A replica may lag, so D1's Sessions API keeps a request sequentially consistent through a bookmark. Opt in per database in `config/database.yml`:
|
|
476
|
+
|
|
477
|
+
```yaml
|
|
478
|
+
production:
|
|
479
|
+
adapter: d1
|
|
480
|
+
binding: DB
|
|
481
|
+
sessions: true
|
|
482
|
+
databases:
|
|
483
|
+
analytics:
|
|
484
|
+
binding: ANALYTICS
|
|
485
|
+
sessions: true
|
|
231
486
|
```
|
|
232
487
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
- The migration is numbered one above the highest in `db/migrations`.
|
|
236
|
-
- Each action of a controller is a stub that renders `{}`.
|
|
237
|
-
- `config/routes.rb` is not changed: the command prints the route to add (`r.resources :todos`).
|
|
238
|
-
|
|
239
|
-
### Building
|
|
240
|
-
|
|
241
|
-
`edgeway build` needs Spinel and the WASI SDK.
|
|
242
|
-
|
|
243
|
-
- An app made by `edgeway new` has a `mise.toml` for both (`mise install`):
|
|
244
|
-
Spinel comes from the [mise-spinel](https://github.com/y-yagi/mise-spinel) plugin and is put on `PATH`,
|
|
245
|
-
and the WASI SDK is pointed at by `WASI_SDK`.
|
|
246
|
-
- Without mise, set `SPINEL` (or put `spinel` on `PATH`) and `WASI_SDK`.
|
|
247
|
-
- `SPINEL` wins over `spinel` on `PATH`, which is how to use a Spinel checkout instead of a release.
|
|
248
|
-
|
|
249
|
-
See `todo-api` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples) for an example (checked with its `check.sh`).
|
|
488
|
+
`sessions:` must be `true` or `false` (anything else fails the build), and does nothing with the `memory` adapter.
|
|
489
|
+
Every query and `Model.batch` of such a database then goes through `env.DB.withSession(...)`, created on first use and kept for the request.
|
|
250
490
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
491
|
+
- A `GET` or `HEAD` starts from the bookmark the client sent, else `first-unconstrained`, so it may read a replica. Other methods always start `first-primary` and ignore the sent bookmark.
|
|
492
|
+
- The response has `X-D1-Bookmark: DB=<bookmark>, ANALYTICS=<bookmark>`: a `BINDING=bookmark` pair for each session the request used (also with one database).
|
|
493
|
+
- A client that sends that header back as it is on its next request starts that session from the bookmark and so reads its own writes (`GET` and `HEAD` only). On a `GET` or `HEAD`, a binding that is not in the header starts `first-unconstrained`. A bookmark is D1's and opaque; one that does not look like it (not lowercase hex and `-`, or over 128 characters) is ignored, and not echoed.
|
|
494
|
+
- A database the request did not use keeps the bookmark the client sent for it in the response (on any method), so the client just stores the latest header value whole and sends it back.
|
|
495
|
+
- Responses of an app with sessions get `Vary: X-D1-Bookmark`.
|
|
496
|
+
- With CORS on, `X-D1-Bookmark` is exposed to the page and allowed in preflights (no need to add it to `CORS_HEADERS` or `CORS_EXPOSE_HEADERS`).
|
|
497
|
+
- `Edgeway::Model.query_stats(:analytics)` has `served_by_primary` and `served_by_region` of the last query, to see which one answered (`true` and `""` in `wrangler dev`, which has no replicas).
|
|
255
498
|
|
|
256
|
-
|
|
257
|
-
`530ba2b17`, which mise-spinel builds from source.
|
|
258
|
-
A commit rather than `master`, so that an install is the Spinel that was tested.
|
|
259
|
-
- The WASI SDK (`wasi-sdk-34`) is installed first,
|
|
260
|
-
because mise-spinel builds Spinel's wasm32-wasi runtime only when it finds one.
|
|
261
|
-
- Changing the pin means re-running `rake test` and `todo-api/check.sh` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples).
|
|
499
|
+
### D1 API coverage
|
|
262
500
|
|
|
263
|
-
|
|
501
|
+
| D1 Worker API | In Edgeway |
|
|
502
|
+
| --- | --- |
|
|
503
|
+
| `prepare().bind().all()` | every query, `find_by_sql`, `Model.select_all`, and inserts (`INSERT ... RETURNING id`) |
|
|
504
|
+
| `prepare().bind().run()` | updates and deletes, `Model.execute` |
|
|
505
|
+
| `batch()` | `Model.batch` (and `destroy` with dependents) |
|
|
506
|
+
| `exec()` | `Model.exec` |
|
|
507
|
+
| `withSession()`, `getBookmark()` | `sessions: true` ([Read replication](#read-replication-sessions)) |
|
|
508
|
+
| `result.meta` | `Model.query_stats` and the `query stats` log line |
|
|
509
|
+
| `first()`, `raw()` | not used (`find_by`/`first` send `LIMIT 1` through `all()`) |
|
|
510
|
+
| `dump()` | not supported (alpha databases only) |
|
|
264
511
|
|
|
265
|
-
|
|
266
|
-
- `has_many` does not work with namespaced models (`Admin::Post`);
|
|
267
|
-
`belongs_to` and `has_many` look models up by their full name, and that case is not handled.
|
|
268
|
-
- Cloudflare Access is not supported in this release: its token comes in `Cf-Access-Jwt-Assertion`,
|
|
269
|
-
and only `Authorization: Bearer` is read (see [Authentication](#authentication)).
|
|
270
|
-
- Tests run natively, not in wasm: D1 and JSPI (`Edgeway::Cloudflare::JWTVerifier`) are not covered by them,
|
|
271
|
-
and the production wasm never loads the SQLite adapter (see [Testing an app](#testing-an-app)).
|
|
272
|
-
- Not verified on a production Cloudflare deploy yet: CPU and wall time on a cold start and on a JWKS cache miss.
|
|
273
|
-
Everything was checked under `wrangler dev`.
|
|
512
|
+
Time Travel, export/import and migrations are `wrangler d1` commands, outside the Worker API.
|
|
274
513
|
|
|
275
|
-
##
|
|
514
|
+
## Views
|
|
276
515
|
|
|
277
|
-
|
|
516
|
+
```ruby
|
|
517
|
+
# app/controllers/todos_controller.rb
|
|
518
|
+
class TodosController < ApplicationController
|
|
519
|
+
def index
|
|
520
|
+
@todos = Todo.order(:id).limit(50)
|
|
521
|
+
# renders app/views/todos/index.html.erb
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
```
|
|
278
525
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
526
|
+
```erb
|
|
527
|
+
<%# app/views/todos/index.html.erb %>
|
|
528
|
+
<ul>
|
|
529
|
+
<% @todos.each do |t| -%>
|
|
530
|
+
<li><%= t.title %></li>
|
|
531
|
+
<% end -%>
|
|
532
|
+
</ul>
|
|
533
|
+
```
|
|
283
534
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
535
|
+
Spinel has no `eval`, so `edgeway build` (and `edgeway test`) compile `app/views/**/*.html.erb` into Ruby
|
|
536
|
+
(`build/views.rb`, `tmp/views.rb`): each template becomes a method of its controller,
|
|
537
|
+
so it sees the action's `@variables`, `params` and the controller's methods.
|
|
538
|
+
|
|
539
|
+
- `<%= expr %>` is HTML-escaped (`Edgeway::View.escape`) unless the value is `raw(text)` or `text.html_safe`
|
|
540
|
+
(an `Edgeway::View::SafeHTML`); `<%== expr %>` is never escaped.
|
|
541
|
+
Safety is not kept across string operations (`"<b>".html_safe + x` is not defined): build the HTML in a template,
|
|
542
|
+
or escape the pieces with `Edgeway::View.escape` and mark the result.
|
|
543
|
+
`<% code %>`, `<%# comment %>`, `<%%` (a literal `<%`) and `-%>` (drops the newline after the tag) work as in ERB.
|
|
544
|
+
- The public methods of `Edgeway::Controller::Base` (`render`, `head`, `params`, `raw`, ...) are not actions; a route to one is a 404.
|
|
545
|
+
- An action that renders nothing renders its template, or answers 204 when it has none.
|
|
546
|
+
- `render :name` / `render "name"` renders another template of the controller, `render html: value` a string (HTML-escaped; a `SafeHTML` is output as is);
|
|
547
|
+
both take `status:` and `layout: false`.
|
|
548
|
+
A name with no template raises `Edgeway::MissingTemplate` (a 500).
|
|
549
|
+
- `app/views/layouts/application.html.erb` wraps every HTML response, at `<%= yield %>`. `edgeway new` writes one.
|
|
550
|
+
- HTML responses are `Content-Type: text/html; charset=utf-8` with `X-Content-Type-Options: nosniff`.
|
|
551
|
+
- A template's directory names its controller (`app/views/admin/posts/` is `Admin::PostsController`);
|
|
552
|
+
one without a controller is a build error, and so is a syntax error in a template,
|
|
553
|
+
which Spinel reports in the generated file (its `# app/views/...:LINE` comments point back).
|
|
554
|
+
|
|
555
|
+
Not supported yet: partials (`render partial:`), layouts other than `application`, and templates other than `.html.erb`.
|
|
556
|
+
|
|
557
|
+
## Static assets
|
|
558
|
+
|
|
559
|
+
`public/` is served by Cloudflare as Workers Static Assets (`[assets]` in `wrangler.toml`).
|
|
560
|
+
A request that matches a file is answered without starting the wasm (no CPU time for the app); the others go to the app.
|
|
561
|
+
|
|
562
|
+
- The files are public: authentication in the app does not apply to them.
|
|
563
|
+
- A file takes its path from the app, for every method. With the default `html_handling` (`auto-trailing-slash`),
|
|
564
|
+
`public/todos.html` answers `GET /todos`, and `POST /todos` is an empty 405; `public/index.html` answers `/`.
|
|
565
|
+
- The Worker does not run for them, so CORS and the app's headers do not apply (Cloudflare reads headers for them from `public/_headers`).
|
|
566
|
+
- `public/.assetsignore` (`.gitignore` syntax) lists files not to upload. `edgeway new` writes it, which also keeps `public/` in git
|
|
567
|
+
(wrangler fails without the directory).
|
|
568
|
+
- `edgeway test` calls the app directly, so tests do not see `public/`.
|
|
569
|
+
|
|
570
|
+
For a single-page app, `not_found_handling = "single-page-application"` serves `index.html` for unknown paths.
|
|
571
|
+
With a `compatibility_date` of 2025-04-01 or later it also answers browser navigations (`Sec-Fetch-Mode: navigate`)
|
|
572
|
+
with assets before the Worker runs, so the app's HTML views are hidden and a browser that opens an API URL gets `index.html`.
|
|
573
|
+
List the app's paths in `run_worker_first` (patterns support `*` and `!` negation; needs wrangler 4.20.0 or later).
|
|
574
|
+
It replaces that navigation detection, so every app path must be listed: one that is not gets `index.html`, even from `fetch`.
|
|
575
|
+
|
|
576
|
+
```toml
|
|
577
|
+
[assets]
|
|
578
|
+
directory = "./public"
|
|
579
|
+
not_found_handling = "single-page-application"
|
|
580
|
+
run_worker_first = ["/health", "/todos", "/todos/*"]
|
|
581
|
+
```
|
|
288
582
|
|
|
289
|
-
|
|
583
|
+
## Logging
|
|
290
584
|
|
|
291
|
-
|
|
292
|
-
|
|
585
|
+
```ruby
|
|
586
|
+
Edgeway.logger.info("order placed", order_id: 42, total: 12.5)
|
|
587
|
+
Edgeway.logger.error("payment failed", order_id: 42)
|
|
588
|
+
```
|
|
293
589
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
`
|
|
590
|
+
- `debug`, `info`, `warn` and `error` take a message and optional fields (a Hash, or `key: value`).
|
|
591
|
+
- Each call writes one line to stderr, a JSON object: `{"level":"info","message":"order placed","order_id":42,"total":12.5}`.
|
|
592
|
+
Field names are Strings or Symbols; values are Strings, numbers, `true`/`false`, `nil`, or Arrays/Hashes of them.
|
|
593
|
+
`level` and `message` can't be fields.
|
|
594
|
+
- `LOG_LEVEL` (`debug`, `info`, `warn`, `error`; default `info`) is the lowest level written. An unknown value counts as `info`.
|
|
595
|
+
Set it in `[vars]` of `wrangler.toml`.
|
|
596
|
+
- The runtime sends each line to `console.<level>` as an object, so every line is one Workers Logs entry at its level, with its fields indexed.
|
|
597
|
+
It adds `ray` (the `cf-ray` header, when there is one). A field of the same name that the app logs wins over it.
|
|
598
|
+
- Other stderr text (Spinel's runtime messages, a trap) goes out as text at error level.
|
|
599
|
+
- Turn Workers Logs on with `[observability]` `enabled = true` in `wrangler.toml` (`edgeway new` writes it).
|
|
600
|
+
It records at most 256 KB of log data per request; nothing beyond that is kept.
|
|
601
|
+
- `Edgeway::Application` logs the rescued errors (401, 404, 409, 429 at `info`, 503 and 500 at `error`) with a `status` field.
|
|
602
|
+
- `edgeway test` with a local D1 prints a structured line as text, `[edgeway] INFO order placed order_id=42 total=12.5`.
|
|
603
|
+
A native build (`adapter: memory`) writes the JSON lines as they are.
|
|
604
|
+
- `Edgeway.logger =` replaces the logger (an object with the four methods, each `(message, fields = {})`), as the framework's tests do.
|
|
298
605
|
|
|
299
606
|
## Authentication
|
|
300
607
|
|
|
301
608
|
Edgeway verifies JWTs that an external IdP issued (Auth0, Firebase, Cognito, ...) and that come in `Authorization: Bearer`;
|
|
302
609
|
it does not issue them.
|
|
303
610
|
|
|
304
|
-
- Cloudflare Access, which sends its token in `Cf-Access-Jwt-Assertion`, is not supported
|
|
611
|
+
- Cloudflare Access, which sends its token in `Cf-Access-Jwt-Assertion`, is not supported,
|
|
305
612
|
nor are Cognito access tokens, which carry `client_id` instead of `aud` (Cognito ID tokens work).
|
|
306
613
|
- `sub` is required and must be a non-empty string.
|
|
307
614
|
- `auth_time`, when present, must not be in the future (leeway applies).
|
|
@@ -324,19 +631,29 @@ Without a valid `Authorization: Bearer <token>`, the request is answered 401 wit
|
|
|
324
631
|
- a bad token: `{"error":"invalid token"}` with `WWW-Authenticate: Bearer error="invalid_token"` (`Edgeway::InvalidToken`)
|
|
325
632
|
|
|
326
633
|
`rescue_from` them to answer differently.
|
|
327
|
-
The reason ("token expired", "unknown kid", ...) is only in the log
|
|
634
|
+
The reason ("token expired", "unknown kid", ...) is only in the log, never the token,
|
|
328
635
|
so a client learns nothing from the wording.
|
|
329
636
|
|
|
330
637
|
Settings are Worker variables in `wrangler.toml`, and the secret is a Worker secret (`.dev.vars` locally):
|
|
331
638
|
|
|
332
639
|
| Variable | |
|
|
333
640
|
|---|---|
|
|
641
|
+
| `JWT_ISSUER`, `JWT_AUDIENCE` | **required**: the token's `iss` and `aud` must match |
|
|
334
642
|
| `JWT_ALGORITHMS` | allowed algorithms, comma-separated (default `RS256`; `HS256/384/512`, `RS*`, `PS*`, `ES256/384`, `EdDSA`). `none` is never accepted |
|
|
335
|
-
| `JWT_JWKS_URL` | the IdP's JWKS, for the asymmetric algorithms
|
|
336
|
-
| `JWT_SECRET` | the shared secret, for `HS*` (a secret; it never enters the wasm)
|
|
337
|
-
| `
|
|
338
|
-
|
|
339
|
-
|
|
643
|
+
| `JWT_JWKS_URL` | the IdP's JWKS, for the asymmetric algorithms |
|
|
644
|
+
| `JWT_SECRET` | the shared secret, for `HS*` (a Worker secret; it never enters the wasm) |
|
|
645
|
+
| `JWT_LEEWAY` | seconds of clock skew allowed for `exp`, `nbf`, `iat` and `auth_time` (default 5, up to 300) |
|
|
646
|
+
|
|
647
|
+
A misconfigured setting answers every request 500, with the details only in the log; a request is never let through.
|
|
648
|
+
|
|
649
|
+
- `JWT_ISSUER` and `JWT_AUDIENCE` apply to every algorithm, `HS*` too. Without them a token the IdP issued for another API would pass:
|
|
650
|
+
Firebase's signing keys are shared by all Firebase projects, and Auth0 signs its Management API tokens with the tenant's keys.
|
|
651
|
+
A token without `iss` or `aud` is 401.
|
|
652
|
+
- `JWT_JWKS_URL` must be `https` (`http` only for `localhost`, `127.0.0.1`, `[::1]`).
|
|
653
|
+
The JWKS is cached for 10 minutes and fetched again when a `kid` is unknown (at most every 30 seconds); an expired one is never used.
|
|
654
|
+
The fetch has a 5 second timeout, follows no redirect, and needs status 200 and a body of at most 64 KB.
|
|
655
|
+
A token needs a `kid`, and a JWK with `key_ops` must list `verify`.
|
|
656
|
+
- `JWT_SECRET` must be at least 32 bytes for `HS256`, 48 for `HS384` and 64 for `HS512` (RFC 7518 3.2).
|
|
340
657
|
- `exp` is required, and a token with a `crit` header is refused (no extension is understood, RFC 7515 4.1.11).
|
|
341
658
|
- In tests, use `Edgeway::Auth::StubVerifier` (`edgeway/http/auth/stub`; see `test/auth.rb`).
|
|
342
659
|
- Like D1, this needs JSPI.
|
|
@@ -362,27 +679,303 @@ Settings are Worker variables in `wrangler.toml`:
|
|
|
362
679
|
|
|
363
680
|
| Variable | |
|
|
364
681
|
|---|---|
|
|
365
|
-
| `CORS_ORIGINS` | allowed origins, comma-separated
|
|
366
|
-
| `CORS_HEADERS` | request headers a preflight allows (default `Authorization, Content-Type`)
|
|
367
|
-
| `CORS_MAX_AGE` | seconds a browser may cache a preflight (default `600`)
|
|
368
|
-
| `CORS_EXPOSE_HEADERS` | response headers the page may read (
|
|
369
|
-
|
|
682
|
+
| `CORS_ORIGINS` | allowed origins, comma-separated (`https://app.example, http://localhost:3000`), or `*` for any. Unset or empty turns CORS off |
|
|
683
|
+
| `CORS_HEADERS` | request headers a preflight allows (default `Authorization, Content-Type`) |
|
|
684
|
+
| `CORS_MAX_AGE` | seconds a browser may cache a preflight (default `600`) |
|
|
685
|
+
| `CORS_EXPOSE_HEADERS` | response headers the page may read (e.g. `X-Total-Count`) |
|
|
686
|
+
|
|
687
|
+
- Origins are compared as exact strings with the request's `Origin` (no trailing slash). The origin `null` (sandboxed iframes, `file:` pages)
|
|
688
|
+
is never allowed, even with `*`. With CORS off, nothing is added and `OPTIONS` goes to the app.
|
|
689
|
+
- List `CORS_HEADERS` one by one: `*` does not cover `Authorization`.
|
|
690
|
+
- `Retry-After` is always exposed. With `sessions: true`, `X-D1-Bookmark` is added to the allowed and exposed headers.
|
|
691
|
+
- A `CORS_MAX_AGE` that is not a non-negative integer answers every request 500, as a bad JWT setting does.
|
|
370
692
|
- A preflight from an allowed origin is answered 204 with `Access-Control-Allow-Origin` (the request's origin),
|
|
371
693
|
`Access-Control-Allow-Methods` (`GET, HEAD, POST, PUT, PATCH, DELETE`), `Access-Control-Allow-Headers` and `Access-Control-Max-Age`.
|
|
372
694
|
- A preflight from another origin is 403 `{"error":"origin not allowed"}`.
|
|
373
695
|
- Other responses get `Access-Control-Allow-Origin` when the `Origin` is allowed, and `Origin` is added to `Vary`.
|
|
696
|
+
- Files in `public/` are answered before the Worker, so they get no CORS headers and a preflight to one is 405 (see [Static assets](#static-assets)).
|
|
374
697
|
- Cookies (credentials) are not supported: `Access-Control-Allow-Credentials` is never sent,
|
|
375
698
|
and a token that `fetch` puts in `Authorization` does not need it.
|
|
376
699
|
|
|
377
|
-
Serving the frontend from the same origin as the API avoids CORS altogether.
|
|
700
|
+
Serving the frontend from the same origin as the API (see [Static assets](#static-assets)) avoids CORS altogether.
|
|
701
|
+
|
|
702
|
+
## HTTP caching
|
|
703
|
+
|
|
704
|
+
```ruby
|
|
705
|
+
class TodosController < ApplicationController
|
|
706
|
+
def show
|
|
707
|
+
todo = Todo.find(params["id"])
|
|
708
|
+
render json: todo if stale?(etag: todo, last_modified: todo.updated_at)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def index
|
|
712
|
+
todos = Todo.order(:id)
|
|
713
|
+
render json: todos if stale?(etag: todos)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def settings
|
|
717
|
+
expires_in 300, public: true
|
|
718
|
+
render json: { "theme" => "dark" }
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
- `stale?(etag:, last_modified:, public: false)` sets the validators and answers `304 Not Modified` (empty body; `Content-Type` is dropped)
|
|
724
|
+
when the request's `If-None-Match` / `If-Modified-Since` match; it returns `false` then, so the action stops. `fresh_when` is the same
|
|
725
|
+
and returns `true` when it answered 304.
|
|
726
|
+
- `etag:` takes a model (its class, `id` and `updated_at`; a model without `updated_at` raises `ArgumentError`), a `Relation` or an Array of them (each element counts, in order),
|
|
727
|
+
or anything else (its `to_s`). The header is a weak `ETag: W/"<32 hex digits>"` (a proxy may recompress the body and weaken a strong one).
|
|
728
|
+
- The ETag depends only on what you pass, not on the JSON an action renders. When a deploy changes what an action renders for the same record,
|
|
729
|
+
add a version to the source (`stale?(etag: [todo, "v2"], ...)`), or clients keep the old shape through 304 until the record changes.
|
|
730
|
+
- Cross-origin JavaScript that handles ETags itself needs `If-None-Match` in `CORS_HEADERS` and `ETag` in `CORS_EXPOSE_HEADERS`
|
|
731
|
+
(see [CORS](#cors)); the browser's own cache revalidation needs neither.
|
|
732
|
+
- `last_modified:` takes a `Time` or an ISO 8601 String (a model's `updated_at`); anything else is an `ArgumentError`.
|
|
733
|
+
- Freshness: when `If-None-Match` is present it alone decides (weak comparison; a list, or `*` when the action has an ETag) and
|
|
734
|
+
`If-Modified-Since` is ignored; otherwise the response is fresh when `Last-Modified` is not later than `If-Modified-Since`
|
|
735
|
+
(HTTP-date only; one that does not parse is stale). Only GET and HEAD are answered 304; other methods get the headers only.
|
|
736
|
+
- Only the validators you give are used; Edgeway does not derive an ETag from the body.
|
|
737
|
+
- Unless the action set `Cache-Control` already (`expires_in`, `no_store` or `headers["Cache-Control"]`), `fresh_when` sets
|
|
738
|
+
`max-age=0, private, must-revalidate` (`public` with `public: true`). Without a `Cache-Control`, a shared cache may keep the response by heuristic.
|
|
739
|
+
- `expires_in(seconds, public: false, must_revalidate: false)` sets `Cache-Control: max-age=N, private` (or `public`, and `, must-revalidate`);
|
|
740
|
+
`seconds` must be a non-negative Integer. `no_store` sets `Cache-Control: no-store`.
|
|
741
|
+
|
|
742
|
+
Cloudflare's Workers Cache (opt-in: `[cache] enabled = true` in `wrangler.toml`, wrangler 4.69.0 or later) sits in front of the Worker.
|
|
743
|
+
Edgeway does not enable it. If you do:
|
|
744
|
+
|
|
745
|
+
- A `public` response with `max-age` is served from the cache without running the app, so `before_action :authenticate!` does not run for it.
|
|
746
|
+
A request with an `Authorization` header bypasses the cache unless the response is `public`, `must-revalidate` or has `s-maxage`.
|
|
747
|
+
- `private` and `no-store` responses bypass it.
|
|
748
|
+
- A response with no `Cache-Control` is cached by heuristic (a 200 for 2 hours).
|
|
749
|
+
|
|
750
|
+
## Caching
|
|
751
|
+
|
|
752
|
+
`Edgeway.cache` is a cache store like Rails' `Rails.cache`:
|
|
753
|
+
|
|
754
|
+
```ruby
|
|
755
|
+
class TodosController < ApplicationController
|
|
756
|
+
def stats
|
|
757
|
+
stats = Edgeway.cache.fetch("todo-stats", expires_in: 300) do
|
|
758
|
+
{ "open" => Todo.where(done: false).count, "done" => Todo.where(done: true).count }
|
|
759
|
+
end
|
|
760
|
+
render json: stats
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def show
|
|
764
|
+
todo = Todo.find(params["id"])
|
|
765
|
+
render json: Edgeway.cache.fetch(todo) { todo.as_json }
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
- `read(key)` answers the value, or `nil` for a miss. `write(key, value, expires_in: nil)` and `delete(key)` answer `true`, or `false` when the store failed.
|
|
771
|
+
`exist?(key)` is true for an entry, also one that holds `nil`.
|
|
772
|
+
- `fetch(key, expires_in: nil, force: false, skip_nil: false) { ... }` answers the cached value, or runs the block, stores its value and answers it.
|
|
773
|
+
`force: true` always runs the block. A `nil` is cached like any value; with `skip_nil: true` a `nil` from the block is not stored.
|
|
774
|
+
Without a block, `fetch` is `read` (with `force: true` it raises `ArgumentError`). A `delete` answers `true` also when there was nothing to delete.
|
|
775
|
+
- Values are JSON values: a String (valid UTF-8), Integer, Float (finite), `true`, `false`, `nil`, and Arrays and Hashes of them.
|
|
776
|
+
Anything else (a Time, a model, a String that is not valid UTF-8, which includes a binary String such as `"\xFF".b`) raises `ArgumentError`; put `todo.as_json` in the cache, not the model.
|
|
777
|
+
A Hash comes back with String keys.
|
|
778
|
+
- Keys: a String, Symbol or Integer is used as it is (any other type raises `ArgumentError`, and so does a key that is not valid UTF-8 or has a NUL byte); an Array is joined with `/`; a model is `"<table>/<id>-<updated_at>"`, so updating
|
|
779
|
+
the record makes a new key instead of needing a delete (a model without `updated_at` raises `ArgumentError`).
|
|
780
|
+
A key must not be blank. A key over 512 bytes is cut and ends with the SHA256 of the whole key, so two long keys that share a start stay apart.
|
|
781
|
+
- `expires_in:` is a whole number of seconds, 1 to 31,536,000 (a year); anything else raises `ArgumentError`. The store checks it on every read, to the second.
|
|
782
|
+
Without `expires_in:` (and without a default from `config/cache.yml`) an entry stays until it is deleted, or the backend drops it.
|
|
783
|
+
- A store failure never fails the request: it is logged at `warn` (with the binding and the operation, not the key), a read is a miss,
|
|
784
|
+
and `write` and `delete` answer `false`. An explicit `expires_in: nil` is the same as leaving it out: the store's default from `config/cache.yml` applies (unlike Rails, where it means no expiry).
|
|
785
|
+
- `Edgeway.cache = store` replaces the store.
|
|
786
|
+
|
|
787
|
+
### Stores
|
|
788
|
+
|
|
789
|
+
| `store:` | Class | Entries |
|
|
790
|
+
| --- | --- | --- |
|
|
791
|
+
| `null` (the default) | `Edgeway::Cache::NullStore` | not kept: `fetch` always runs its block |
|
|
792
|
+
| `memory` | `Edgeway::Cache::MemoryStore` | in the process: in a Worker, for one request |
|
|
793
|
+
| `kv` | `Edgeway::Cloudflare::KVStore` | in a Cloudflare KV namespace |
|
|
794
|
+
|
|
795
|
+
### config/cache.yml
|
|
796
|
+
|
|
797
|
+
For each environment (`edgeway build --env ENV`):
|
|
798
|
+
|
|
799
|
+
```yaml
|
|
800
|
+
production:
|
|
801
|
+
store: kv
|
|
802
|
+
binding: CACHE # a [[kv_namespaces]] binding in wrangler.toml; required for kv
|
|
803
|
+
namespace: myapp # optional: every key starts with "myapp:"
|
|
804
|
+
expires_in: 3600 # optional: the seconds for a call that gives no expires_in:
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
Without the file, or a section for the environment, the store is `null`. `store: null` (also written without quotes) is the same.
|
|
808
|
+
`edgeway build` writes the matching `Edgeway.cache = ...` line into `build/main.rb`, and links the KV backend only for `store: kv`.
|
|
809
|
+
|
|
810
|
+
The binding is declared in `wrangler.toml` (`edgeway new` writes this commented out):
|
|
811
|
+
|
|
812
|
+
```toml
|
|
813
|
+
[[kv_namespaces]]
|
|
814
|
+
binding = "CACHE"
|
|
815
|
+
id = "<namespace id>"
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
Create the namespace with `pnpm exec wrangler kv namespace create CACHE`. `wrangler dev` simulates it locally.
|
|
819
|
+
A `kv` store that names a binding the Worker does not have fails every operation (logged, read as a miss).
|
|
820
|
+
|
|
821
|
+
### KV
|
|
822
|
+
|
|
823
|
+
- KV drops an entry when its `expirationTtl` passes, and its minimum is 60 seconds. For an `expires_in:` under 60 the store asks KV for 60 and
|
|
824
|
+
checks the exact time itself on read, so `expires_in: 10` still means 10 seconds.
|
|
825
|
+
- KV is eventually consistent: a write or delete is seen at once in the same location, and in other locations up to 60 seconds later
|
|
826
|
+
(last write wins). Prefer keys that change with the data (a model as the key, or a version in an Array key) over deleting a key and expecting every location to miss.
|
|
827
|
+
- Writes to the same key are limited to 1 per second (more is a 429, which is logged and answered `false`).
|
|
828
|
+
- The Free plan allows 1,000 writes, 1,000 deletes and 100,000 reads a day, and a Worker invocation can make 1,000 KV operations.
|
|
829
|
+
Each `fetch` is one read, and one write on a miss; a `delete` counts against the deletes.
|
|
830
|
+
- A key is at most 512 bytes (handled by the store) and a value at most 25 MiB. KV refuses the keys `.` and `..`, so the `kv` store raises `ArgumentError` for them.
|
|
831
|
+
- Like D1, this needs JSPI.
|
|
832
|
+
|
|
833
|
+
### In tests
|
|
834
|
+
|
|
835
|
+
`Edgeway.cache` is a `NullStore` in every test (`edgeway test`), whatever `config/cache.yml` says, so tests do not depend on each other.
|
|
836
|
+
A test of caching sets a store itself:
|
|
837
|
+
|
|
838
|
+
```ruby
|
|
839
|
+
class StatsTest < Edgeway::IntegrationTest
|
|
840
|
+
def test_second_request_is_cached
|
|
841
|
+
Edgeway.cache = Edgeway::Cache::MemoryStore.new
|
|
842
|
+
get "/stats"
|
|
843
|
+
Todo.create!(title: "new")
|
|
844
|
+
get "/stats"
|
|
845
|
+
assert_equal 0, json["open"]
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
## Outbound HTTP
|
|
851
|
+
|
|
852
|
+
```ruby
|
|
853
|
+
response = Edgeway::HTTP.get("https://api.example/items", headers: { "Authorization" => "Bearer #{token}" })
|
|
854
|
+
response = Edgeway::HTTP.post("https://api.example/items", body: JSON.generate({ "name" => "a" }),
|
|
855
|
+
headers: { "Content-Type" => "application/json" }, timeout: 5)
|
|
856
|
+
response.status # Integer
|
|
857
|
+
response.success? # 2xx
|
|
858
|
+
response.headers # Hash, names in lower case
|
|
859
|
+
response.body # String
|
|
860
|
+
response.json # the body parsed
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
- `get` and `delete` take `headers:`; `post`, `put` and `patch` also take `body:` (a String, empty by default).
|
|
864
|
+
All take `timeout:` in seconds (default 10; from 0.001 to 300, otherwise `ArgumentError`).
|
|
865
|
+
A `body:` must be UTF-8 text; binary uploads are not supported.
|
|
866
|
+
- The answer is an `Edgeway::HTTP::ClientResponse`. A non-2xx status does not raise.
|
|
867
|
+
- Redirects are not followed: a 3xx comes back as it is, with its `location` header.
|
|
868
|
+
- Of repeated response headers only the last `Set-Cookie` is kept; other repeated headers are joined with ", ".
|
|
869
|
+
- The URL must be `https` (`http` only for `localhost`, `127.0.0.1`, `[::1]`); any other is an error.
|
|
870
|
+
- The response body is at most 1 MB; a larger one is an error. A binary body keeps its bytes but is labelled UTF-8 (`body.b` has the bytes).
|
|
871
|
+
- `Edgeway::HTTP::Error` is raised for a network failure, a refused URL and a response that is too large;
|
|
872
|
+
`Edgeway::HTTP::Timeout` (a subclass) when the timeout passes. Unrescued they are a 500, with the message only in the log.
|
|
873
|
+
- A Worker can make 50 subrequests per invocation on the Free plan and 10,000 by default on Paid,
|
|
874
|
+
and has at most 6 connections open at once.
|
|
875
|
+
- Like D1 and the JWT check, this needs JSPI.
|
|
876
|
+
- In tests, `stub_http` (see [Writing tests](#writing-tests)) stands in for the network, so no test makes a request.
|
|
877
|
+
|
|
878
|
+
## Rate limiting
|
|
879
|
+
|
|
880
|
+
```ruby
|
|
881
|
+
class PostsController < ApplicationController
|
|
882
|
+
before_action :authenticate!
|
|
883
|
+
rate_limit "WRITE_LIMITER", only: [:create, :update, :destroy], retry_after: 10
|
|
884
|
+
end
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
The limit and the period are set on the binding in `wrangler.toml`, where Cloudflare reads them (the period is 10 or 60 seconds only; `namespace_id`
|
|
888
|
+
is a positive integer, as a string, unique in your account):
|
|
889
|
+
|
|
890
|
+
```toml
|
|
891
|
+
[[ratelimits]]
|
|
892
|
+
name = "WRITE_LIMITER"
|
|
893
|
+
namespace_id = "1001"
|
|
894
|
+
[ratelimits.simple]
|
|
895
|
+
limit = 100
|
|
896
|
+
period = 60
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
`wrangler dev` enforces the limit locally. `edgeway new` writes this commented out. It needs wrangler 4.36.0 or later (the generated app pins a newer one).
|
|
900
|
+
|
|
901
|
+
`rate_limit` is a `before_action` (run in declaration order), like Rails' `rate_limit`:
|
|
902
|
+
|
|
903
|
+
```ruby
|
|
904
|
+
rate_limit "API_LIMITER", by: :rate_limit_key, name: nil, retry_after: nil, only: [...], except: [...]
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
- The first argument is the binding name. A controller can have several, such as a 10 second and a 60 second binding.
|
|
908
|
+
- A request over the limit raises `Edgeway::TooManyRequests`: the answer is 429 with `{"error":"rate limit exceeded"}`.
|
|
909
|
+
`rescue_from Edgeway::TooManyRequests` answers otherwise; the error has `retry_after` (0 without `retry_after:`).
|
|
910
|
+
- `Retry-After` is sent only with `retry_after:` (a positive Integer, in seconds): the binding does not tell when the count resets.
|
|
911
|
+
- The key is `"<name>:<by>"`. `name:` defaults to the controller's class name, so every controller has its own count; give several controllers the same `name:` to share one.
|
|
912
|
+
- `by:` is a Symbol naming a controller method (not a lambda) that answers the key's second part, such as `:current_account_id`.
|
|
913
|
+
That method is never a route's action, even if it is public.
|
|
914
|
+
Don't key on a value the client sends (a header, a parameter): the client could pick a fresh count with every request.
|
|
915
|
+
- Without `by:`, the key is `"user:<sub>"` for a request that passed `authenticate!`, and otherwise `"ip:<client address>"`
|
|
916
|
+
(`request.remote_addr`, from `CF-Connecting-IP`).
|
|
917
|
+
An IPv6 address is keyed by its /64 prefix (`2001:db8:1:2::/64`), and an IPv4-mapped one (`::ffff:192.0.2.1`) by its IPv4 address.
|
|
918
|
+
- Order matters: a `rate_limit` declared after `before_action :authenticate!` never sees a request that `authenticate!` rejects (401), so token probing is not counted.
|
|
919
|
+
Declare an IP-keyed limit before `authenticate!` (it sees every request) and, if wanted, a per-user limit after it:
|
|
920
|
+
|
|
921
|
+
```ruby
|
|
922
|
+
rate_limit "IP_LIMITER" # before authenticate!: no user is known yet, so the key is the IP; 401s are counted
|
|
923
|
+
before_action :authenticate!
|
|
924
|
+
rate_limit "USER_LIMITER" # after: the key is the user's sub
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
- An empty key part (no client address, or a `by:` method answering `nil` or `""`) puts every such request in one shared count.
|
|
928
|
+
- The binding gets a SHA256 digest of the key, so the key is never sent as it is, and any bytes are safe in it.
|
|
929
|
+
- `wrangler dev` passes a `CF-Connecting-IP` sent by the client through, so a local test of the IP key can be spoofed; in production Cloudflare sets it.
|
|
930
|
+
- Cloudflare warns that an IP address can be shared by many users (a company, a mobile carrier), and that a limit on it can block them all.
|
|
931
|
+
Prefer a stable identifier such as a user or an API key.
|
|
932
|
+
- The key is never logged.
|
|
933
|
+
|
|
934
|
+
Counts are kept per Cloudflare location and are eventually consistent, so they are approximate: a burst can get past the limit,
|
|
935
|
+
and a client in another location has its own count. For a strict limit use a Durable Object. The limit is not shown in the Cloudflare dashboard;
|
|
936
|
+
look for the 429s in the logs.
|
|
937
|
+
|
|
938
|
+
A binding the Worker does not have (a typo in the name), or a value that has no `limit` function, is a misconfiguration:
|
|
939
|
+
the request raises `Edgeway::ConfigurationError` (500, the details only in the log), like a missing `JWT_ISSUER`, so a typo cannot switch limiting off silently.
|
|
940
|
+
Any error thrown by `limit()` itself lets the request through (fail open), including a binding of another kind that happens to have the name: it logs `rate limit check failed` with the `binding` and the `error`, and the request goes on.
|
|
941
|
+
|
|
942
|
+
Like D1, this needs JSPI.
|
|
943
|
+
|
|
944
|
+
### In tests
|
|
945
|
+
|
|
946
|
+
No test is limited by default. A test turns a limit on for a binding, and reads the checks the app made:
|
|
947
|
+
|
|
948
|
+
```ruby
|
|
949
|
+
class PostsControllerTest < Edgeway::IntegrationTest
|
|
950
|
+
def test_over_the_limit
|
|
951
|
+
stub_rate_limit("WRITE_LIMITER", exceeded: true)
|
|
952
|
+
post "/posts", json: { post: { title: "a" } }
|
|
953
|
+
assert_response 429
|
|
954
|
+
assert_equal "WRITE_LIMITER", rate_limit_checks.first[0] # [[binding, key], ...]
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
The stub and the recorded checks start empty in every test.
|
|
960
|
+
|
|
961
|
+
A request has no client address unless the test gives one, so the default key is `ip:`; `remote_addr:` sets it for that request only:
|
|
962
|
+
|
|
963
|
+
```ruby
|
|
964
|
+
get "/posts", remote_addr: "203.0.113.7"
|
|
965
|
+
assert_equal "PostsController:ip:203.0.113.7", rate_limit_checks.first[1]
|
|
966
|
+
```
|
|
378
967
|
|
|
379
968
|
## Testing an app
|
|
380
969
|
|
|
381
970
|
```sh
|
|
382
971
|
bundle exec edgeway test [ARGS] # ARGS go to spinel_test-unit
|
|
972
|
+
bin/test [FILE...] # in a generated app: the same, all of test/ without FILE
|
|
383
973
|
```
|
|
384
974
|
|
|
385
975
|
`edgeway test` is the only supported way to run an app's tests.
|
|
976
|
+
`config/database.yml` decides how: with `adapter: d1` in its `test:` section (what `edgeway new` writes)
|
|
977
|
+
the tests are compiled to wasm32-wasi and run on a local D1, as in production; with any other adapter
|
|
978
|
+
(`memory`) they are a native build with an in-memory SQLite database (see [SQLite](#sqlite-adapter-memory-in-test)).
|
|
386
979
|
|
|
387
980
|
- `spinel_test-unit` is the test framework (on RubyGems;
|
|
388
981
|
`edgeway new` puts `gem "spinel_test-unit", "~> 0.1", group: :test` in the Gemfile).
|
|
@@ -394,15 +987,53 @@ bundle exec edgeway test [ARGS] # ARGS go to spinel_test-unit
|
|
|
394
987
|
|
|
395
988
|
- Tests `require "edgeway/test"` in `test/test_helper.rb`.
|
|
396
989
|
- Model tests inherit `Edgeway::UnitTest` (the database, no requests).
|
|
397
|
-
- `Edgeway::IntegrationTest` inherits it and adds `get`/`post`/..., `json` and `assert_response`.
|
|
990
|
+
- `Edgeway::IntegrationTest` inherits it and adds `get`/`post`/..., `json` and `assert_response`. `get`/`post`/... take `json:`, `headers:` and `remote_addr:` (the client address, for that request only).
|
|
398
991
|
- Both inherit `SpinelTest::Unit::TestCase`.
|
|
399
992
|
|
|
400
|
-
|
|
401
|
-
with `db/migrations/*.sql` applied in file name order, so requests really write and read rows.
|
|
993
|
+
Outgoing HTTP (see [Outbound HTTP](#outbound-http)) never reaches the network in a test:
|
|
402
994
|
|
|
403
|
-
|
|
995
|
+
```ruby
|
|
996
|
+
stub_http(:get, "https://api.example/items", status: 200, body: '{"items":[]}', headers: { "Content-Type" => "application/json" })
|
|
997
|
+
stub_http_error(:post, "https://api.example/items", "connection refused") # raises Edgeway::HTTP::Error
|
|
998
|
+
stub_http_error(:post, "https://api.example/items", "timed out", timeout: true) # raises Edgeway::HTTP::Timeout
|
|
999
|
+
http_requests # the requests sent, in order: Hashes with "method", "url", "headers", "body" and "timeout_ms"
|
|
1000
|
+
```
|
|
404
1001
|
|
|
405
|
-
|
|
1002
|
+
- A request is matched on its method and exact URL; one without a stub raises `Edgeway::HTTP::UnstubbedRequest`.
|
|
1003
|
+
- The stubs and the recorded requests start empty in every test.
|
|
1004
|
+
- This is plain Ruby, so it works in the native and the wasm builds alike.
|
|
1005
|
+
- `Edgeway.cache` is a `NullStore` in every test (see [Caching](#caching)); a test of caching sets a `MemoryStore` itself.
|
|
1006
|
+
- `stub_rate_limit` and `rate_limit_checks` stand in for the limiter (see [Rate limiting](#rate-limiting)); no request is limited unless a test says so.
|
|
1007
|
+
|
|
1008
|
+
Each test starts from an empty database with `db/migrations/*.sql` applied in file name order
|
|
1009
|
+
(so the migrations' seed rows are there), and requests really write and read rows.
|
|
1010
|
+
Each database under `databases:` gets its own, with `db/NAME/migrations`; `db(:analytics)` answers it, and `db` the primary.
|
|
1011
|
+
`db.log` has the SQL the test ran.
|
|
1012
|
+
|
|
1013
|
+
### Local D1 (`adapter: d1` in `test:`)
|
|
1014
|
+
|
|
1015
|
+
The tests are built like the app (`--target=wasm32-wasi`, with `--incremental`; the wasm is `tmp/test_app.wasm`) and run by Node.js
|
|
1016
|
+
(`js/cloudflare-test-runner.js`, copied to `tmp/edgeway/` with the other `js/` files) on the D1 that wrangler's `getPlatformProxy` starts,
|
|
1017
|
+
the one `wrangler dev` uses, but in memory (`persist: false`): `.wrangler/state` is never touched.
|
|
1018
|
+
|
|
1019
|
+
- Before each test every table and view is dropped (except `sqlite_*`, `_cf_*` and `d1_migrations`) and the migrations are applied again,
|
|
1020
|
+
so a test cannot see another's rows and the ids start from 1.
|
|
1021
|
+
Foreign keys are enforced as in D1.
|
|
1022
|
+
- The bindings are the `binding:` of `test:` and of its `databases:`, which must exist in `wrangler.toml`.
|
|
1023
|
+
Give each its own `database_id`: bindings with the same id are the same local database.
|
|
1024
|
+
Databases that share a binding are one database: it is emptied once and the migrations of each are applied, the primary's first.
|
|
1025
|
+
Only the databases in `test:` exist in the tests (not those of other environments).
|
|
1026
|
+
- The Worker variables of `wrangler.toml` (`JWT_*`, `APP_*`) are the app's settings, as in production.
|
|
1027
|
+
- Needs Node.js 24 or later (`edgeway test` passes `--experimental-wasm-jspi --experimental-wasm-exnref` to Node.js 24, which needs them),
|
|
1028
|
+
wrangler in `node_modules` (`pnpm install`, or `bin/setup`), `wrangler.toml`, the WASI SDK (`WASI_SDK`) and Spinel.
|
|
1029
|
+
It stops with the fix when one is missing.
|
|
1030
|
+
- The first build of an app takes a while; later ones reuse Spinel's cached C units.
|
|
1031
|
+
|
|
1032
|
+
### SQLite (`adapter: memory` in `test:`)
|
|
1033
|
+
|
|
1034
|
+
The tests are a native build with a real in-memory SQLite database per test (`Edgeway::DB::SQLiteAdapter`, through Spinel's FFI).
|
|
1035
|
+
Every database under `databases:` (in any environment) gets one.
|
|
1036
|
+
This needs the SQLite library at link time:
|
|
406
1037
|
`libsqlite3-dev` on Debian/Ubuntu, `sqlite-devel` on Fedora (preinstalled on macOS).
|
|
407
1038
|
|
|
408
1039
|
- `edgeway test` first tries to link `-lsqlite3` with `$CC` (or `cc`/`gcc`/`clang`) and stops with the fix if that fails.
|
|
@@ -413,22 +1044,50 @@ Edgeway's own tests use `MemoryAdapter`, which stores nothing; `SQLiteAdapter` i
|
|
|
413
1044
|
|
|
414
1045
|
### Differences from production
|
|
415
1046
|
|
|
416
|
-
|
|
1047
|
+
With a local D1 the tests are wasm on a D1 as in production, and JSPI and `Edgeway::Cloudflare::D1Adapter` are exercised.
|
|
1048
|
+
What differs:
|
|
1049
|
+
|
|
1050
|
+
- It is wrangler's local D1 (miniflare), not Cloudflare's: its limits and timing are not reproduced, and neither is read replication
|
|
1051
|
+
(`sessions: true` is passed on, but there is one local replica).
|
|
1052
|
+
- The app is called directly (`get`, `post`), not through `js/cloudflare.js` and the Worker: no CORS, no `X-D1-Bookmark`.
|
|
1053
|
+
- A JWT's signature is checked only when the test sets `Edgeway::Auth.verifier = Edgeway::Cloudflare::JWTVerifier.new`
|
|
1054
|
+
(`JWT_SECRET` and the like come from the Worker variables, `.dev.vars` included); otherwise use `Edgeway::Auth::StubVerifier`.
|
|
1055
|
+
- `db.log` has the SQL the test ran, but not the migrations'.
|
|
1056
|
+
|
|
1057
|
+
With SQLite the tests are native builds, while production is wasm32-wasi on Workers.
|
|
417
1058
|
|
|
418
1059
|
- D1 (`Edgeway::Cloudflare::D1Adapter`) and JSPI are not exercised,
|
|
419
1060
|
and neither is a JWT's signature check (use `Edgeway::Auth::StubVerifier`).
|
|
420
|
-
- `SQLiteAdapter` is loaded only by `edgeway
|
|
1061
|
+
- `SQLiteAdapter` is loaded only by `edgeway test`: `edgeway build` never requires `edgeway/db/sqlite`,
|
|
421
1062
|
because libsqlite3 can't be linked into the wasm.
|
|
422
1063
|
- SQLite and D1 (itself SQLite) take the same SQL, but D1's own limits
|
|
423
1064
|
(such as the number of bound parameters) are not reproduced.
|
|
424
1065
|
|
|
1066
|
+
## Known limitations
|
|
1067
|
+
|
|
1068
|
+
- Only the subset of Ruby that Spinel supports can be used.
|
|
1069
|
+
- `has_many` does not work with namespaced models (`Admin::Post`);
|
|
1070
|
+
`belongs_to` and `has_many` look models up by their full name, and that case is not handled.
|
|
1071
|
+
- Cloudflare Access is not supported: its token comes in `Cf-Access-Jwt-Assertion`,
|
|
1072
|
+
and only `Authorization: Bearer` is read (see [Authentication](#authentication)).
|
|
1073
|
+
- Tests call the app directly, not through the Worker, so CORS and `X-D1-Bookmark` are not covered.
|
|
1074
|
+
With `adapter: memory` in `test:` they are a native build, and D1 and JSPI are not covered either (see [Testing an app](#testing-an-app)).
|
|
1075
|
+
- Not verified on a production Cloudflare deploy yet: CPU and wall time on a cold start and on a JWKS cache miss,
|
|
1076
|
+
and read replication with real replicas (local D1 has none, and D1 itself accepts any bookmark there). Everything was checked under `wrangler dev`.
|
|
1077
|
+
- Integers are 32 bits in a wasm build: use `:bigint` for larger ones and pass them as Strings,
|
|
1078
|
+
and keep them below 2^53, as D1 hands rows over as JavaScript numbers (see [Attribute types](#attribute-types)).
|
|
1079
|
+
- A `Model.batch` runs on one database: there is no transaction across databases ([Multiple databases](#multiple-databases)).
|
|
1080
|
+
- `Model.exec` does not work on a database with `sessions: true`: a D1 session has no `exec()` ([Queries](#queries)).
|
|
1081
|
+
- Spinel can't build a Range of Times: use ISO 8601 Strings or a SQL fragment in `where` ([Queries](#queries)).
|
|
1082
|
+
- `first()` and `raw()` are not used and `dump()` is not supported; Time Travel and export/import are `wrangler d1` commands ([D1 API coverage](#d1-api-coverage)).
|
|
1083
|
+
|
|
425
1084
|
## Development
|
|
426
1085
|
|
|
427
1086
|
```sh
|
|
428
1087
|
# mise install (Spinel, the WASI SDK) and bundle install
|
|
429
1088
|
bin/setup
|
|
430
1089
|
|
|
431
|
-
# needs libsqlite3-dev (test/spinel/sqlite.rb);
|
|
1090
|
+
# needs libsqlite3-dev (test/spinel/sqlite.rb) and, for the Node.js tests, node;
|
|
432
1091
|
# runs test/*.rb under CRuby and as Spinel native builds, and compares with .expected
|
|
433
1092
|
bundle exec rake test
|
|
434
1093
|
|