edgeway 0.2.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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +602 -204
  4. data/js/cloudflare-kv.js +47 -0
  5. data/js/cloudflare-ratelimit.js +31 -0
  6. data/js/cloudflare-test-database.js +112 -0
  7. data/js/cloudflare-test-runner.js +76 -0
  8. data/js/cloudflare.js +25 -4
  9. data/js/fetch.js +113 -0
  10. data/js/jspi.js +32 -0
  11. data/js/jwt.js +5 -39
  12. data/js/runtime.js +42 -5
  13. data/lib/edgeway/application.rb +16 -7
  14. data/lib/edgeway/cache/limits.rb +8 -0
  15. data/lib/edgeway/cache/memory_store.rb +24 -0
  16. data/lib/edgeway/cache/null_store.rb +13 -0
  17. data/lib/edgeway/cache/store.rb +189 -0
  18. data/lib/edgeway/cache.rb +13 -0
  19. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  20. data/lib/edgeway/cli/build/database_config.rb +4 -0
  21. data/lib/edgeway/cli/build/js_files.rb +30 -0
  22. data/lib/edgeway/cli/build.rb +17 -10
  23. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  24. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  25. data/lib/edgeway/cli/test.rb +25 -9
  26. data/lib/edgeway/cli/toolchain.rb +5 -0
  27. data/lib/edgeway/cli.rb +2 -0
  28. data/lib/edgeway/controller/base.rb +22 -3
  29. data/lib/edgeway/controller/caching.rb +140 -0
  30. data/lib/edgeway/controller/callbacks.rb +5 -1
  31. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  32. data/lib/edgeway/db/query_stats.rb +2 -6
  33. data/lib/edgeway/errors.rb +21 -0
  34. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +3 -3
  35. data/lib/edgeway/generators/app/templates/README.md.tt +3 -1
  36. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  37. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  38. data/lib/edgeway/generators/app/templates/config/database.yml +8 -2
  39. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  40. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +34 -2
  41. data/lib/edgeway/http/client.rb +80 -0
  42. data/lib/edgeway/http/request.rb +5 -0
  43. data/lib/edgeway/http/stub_transport.rb +38 -0
  44. data/lib/edgeway/logger.rb +33 -5
  45. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  46. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  47. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  48. data/lib/edgeway/rate_limit.rb +2 -0
  49. data/lib/edgeway/server.rb +3 -2
  50. data/lib/edgeway/service/cloudflare/d1.rb +6 -21
  51. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  52. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  53. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  54. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  55. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  56. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  57. data/lib/edgeway/service/fetch.rb +26 -0
  58. data/lib/edgeway/service/import_result.rb +24 -0
  59. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  60. data/lib/edgeway/test.rb +72 -33
  61. data/lib/edgeway/version.rb +1 -1
  62. data/lib/edgeway.rb +6 -0
  63. metadata +34 -1
data/README.md CHANGED
@@ -38,6 +38,11 @@ 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)
42
47
  - [Attribute types](#attribute-types)
43
48
  - [Strong parameters](#strong-parameters)
@@ -53,18 +58,159 @@ end
53
58
  - [Read replication (Sessions)](#read-replication-sessions)
54
59
  - [D1 API coverage](#d1-api-coverage)
55
60
  - [Views](#views)
56
- - [Usage](#usage)
57
- - [Generators](#generators)
58
- - [Building](#building)
59
- - [Spinel version](#spinel-version)
60
- - [Known limitations](#known-limitations)
61
- - [Configuration](#configuration)
61
+ - [Static assets](#static-assets)
62
+ - [Logging](#logging)
62
63
  - [Authentication](#authentication)
63
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)
64
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)
65
80
  - [Development](#development)
66
81
  - [License](#license)
67
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
+
68
214
  ## Models
69
215
 
70
216
  ### Attribute types
@@ -79,33 +225,34 @@ end
79
225
  | `:binary` | binary String (`.b`) | BLOB | base64 string |
80
226
  | `:bigint` | decimal String (`"3000000000"`) | INTEGER | string |
81
227
 
82
- - Integers are 32 bits in a wasm build. A `:integer` value beyond -2147483648..2147483647 casts to `nil`; use `:bigint` for those columns.
83
- Assigning one is refused by validation ("is out of range"). A row that already holds one (a foreign key to a bigint parent
84
- declared as `:integer`) loads as `nil`, and saving it fails the same way instead of writing `NULL` over it.
85
- - A `:bigint` holds its value as a String of an optional `-` and digits, without leading zeros (`"-0"` is `"0"`);
86
- anything else, or a value beyond 64 bits, casts to `nil` ("is invalid"). It is bound as text and SQLite's INTEGER affinity
87
- stores it as an integer, so `where`, `order` and comparisons work on the number.
88
- It renders in JSON as a String, which JavaScript clients read exactly.
89
- D1 hands rows over as JavaScript numbers, so a value beyond 2^53 (9007199254740991) has already lost precision before Ruby sees it.
90
- Keep such values below 2^53, or store them as `:string`.
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`.
91
238
  - Pass bigint values as Strings: `Todo.find("3000000000")`, `where(id: "3000000000")`, `create!(owner_id: "5000000000")`,
92
- `Model.execute("... WHERE id = ?", ["3000000000"])`. A Hash holding an Integer literal beyond 32 bits raises `RangeError` in a wasm build
93
- (a Spinel limitation), so do not write them as Integers.
94
- - `attribute :id, :bigint` makes the primary key a bigint: `find`, `where(id: ...)`, `update`, `destroy` and an insert
95
- (`RETURNING id`) work past 2^31, and `id` is a String. A foreign key to it is `attribute :owner_id, :bigint`
96
- (declare it before `belongs_to` and it keeps its type).
97
- - Rows from raw SQL (`Model.select_all`) hold what the database gave: in a wasm build an INTEGER beyond 32 bits is a Bignum,
98
- and `render json:` of it raises. Select it as text (`SELECT CAST(id AS TEXT) AS id ...`) or pass it through `Edgeway::Model.cast(:bigint, value)`.
99
- - A `:binary` attribute holds a binary String. Assigned from Ruby, a String keeps its bytes (`Document.new(body: bytes)`), and so does a `{"$blob" => base64}` Hash.
100
- It is bound as a BLOB (`where(body: bytes)` too), and renders in JSON as a base64 String.
101
- In a request body the value is that base64 String, so a GET then PATCH round trip is stable: params assigned through `permit` are decoded,
102
- and invalid base64 fails validation ("is invalid"). `{"$blob": ...}` objects are not supported in a request body (nested objects are dropped).
103
- `presence` counts a binary String blank only when empty, and `length` counts bytes.
104
- A BLOB crosses between wasm and JS as `{"$blob": "<base64>"}`: binds and rows cross as JSON text, which can't hold arbitrary bytes.
105
- In raw SQL (`Model.select_all`, `Model.execute`, `where("...", value)`) bind a BLOB with `Edgeway::Model.blob(bytes)`,
106
- and a BLOB column comes back as `{"$blob" => base64}`.
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)`.
107
244
  - `Model.execute` answers `"last_insert_id"` as an Integer, or as a decimal String when the rowid is beyond 32 bits.
108
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
+
109
256
  ### Strong parameters
110
257
 
111
258
  Params must go through `permit` before a model takes them.
@@ -220,14 +367,15 @@ Edgeway::Model.exec("DELETE FROM tags\nDELETE FROM todos") # nil
220
367
  (a wasm instance serves one request, so it starts at zero):
221
368
 
222
369
  - `queries`: the number of successful D1 calls (a `Model.batch` is one). A failed call and its time are not counted.
223
- - `rows_read`, `rows_written`: the sums of D1's `meta`, capped at 2147483647 (32-bit; the log line shows a capped count with a trailing `+`).
370
+ - `rows_read`, `rows_written`: the sums of D1's `meta`, capped at 2147483647 (32-bit).
224
371
  - `duration_ms` (Float): the sum of D1's `meta.duration`.
225
372
  - `served_by_region` and `served_by_primary`: from the last call (`""` and `true` when D1 doesn't say, as in `wrangler dev`).
226
373
 
227
- When a request ran any query, `Edgeway::Server` logs one line after it, also for a request that raised and was rescued:
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`:
228
376
 
229
- ```
230
- [edgeway] db=3 rows_read=12 rows_written=1 db_ms=2.46
377
+ ```json
378
+ {"level":"info","message":"query stats","queries":3,"rows_read":12,"rows_written":1,"db_ms":2.46}
231
379
  ```
232
380
 
233
381
  The Memory and SQLite adapters report zeros and log nothing.
@@ -298,7 +446,7 @@ Todo.eager_load(:comments, :tags) # several at once, still one query
298
446
 
299
447
  ### Multiple databases
300
448
 
301
- A model uses the primary database unless it names another, declared in `config/database.yml` (see "Configuration"):
449
+ A model uses the primary database unless it names another, declared in `config/database.yml` (see [Configuration](#configuration)):
302
450
 
303
451
  ```ruby
304
452
  class Event < ApplicationModel
@@ -318,8 +466,8 @@ Edgeway::Model.execute("DELETE FROM events", [], database: :analytics)
318
466
  `eager_load` across databases raises `ArgumentError`: a `JOIN` can't cross them.
319
467
  - A `Model.batch` belongs to the database of its first write; a write for another one raises `ArgumentError`
320
468
  (D1 batches are per database). Use one `batch` per database.
321
- - `Edgeway::Model.query_stats(:analytics)` is that database's stats. The request log has one line per database that ran queries,
322
- and the extra ones start with `database=analytics`.
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.
323
471
 
324
472
  ### Read replication (Sessions)
325
473
 
@@ -357,8 +505,8 @@ Every query and `Model.batch` of such a database then goes through `env.DB.withS
357
505
  | `batch()` | `Model.batch` (and `destroy` with dependents) |
358
506
  | `exec()` | `Model.exec` |
359
507
  | `withSession()`, `getBookmark()` | `sessions: true` ([Read replication](#read-replication-sessions)) |
360
- | `result.meta` | `Model.query_stats` and the `db=…` log line |
361
- | `first()`, `raw()` | not used: `find_by`/`first` send `LIMIT 1` through `all()`, which also keeps `meta`; `raw()` made the JSON smaller but rebuilding the rows in Ruby cost as much as it saved |
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()`) |
362
510
  | `dump()` | not supported (alpha databases only) |
363
511
 
364
512
  Time Travel, export/import and migrations are `wrangler d1` commands, outside the Worker API.
@@ -406,161 +554,61 @@ so it sees the action's `@variables`, `params` and the controller's methods.
406
554
 
407
555
  Not supported yet: partials (`render partial:`), layouts other than `application`, and templates other than `.html.erb`.
408
556
 
409
- ## Usage
410
-
411
- ```sh
412
- gem install edgeway
413
-
414
- edgeway new myapp
415
-
416
- # mise, bundle, pnpm and the local D1 migrations
417
- cd myapp && bin/setup
418
-
419
- # the app's tests, natively (needs libsqlite3-dev, see "Testing an app")
420
- bundle exec edgeway test # or bin/test [FILE...] in a generated app
421
-
422
- # edgeway build: writes build/app.wasm and build/edgeway/ (the JS).
423
- # A development build: Spinel's --incremental caches the generated C in units,
424
- # so a rebuild recompiles only what changed (no inlining across units)
425
- bin/build
426
-
427
- pnpm exec wrangler dev
428
- edgeway routes
429
-
430
- # bin/build --release (one unit, without --incremental), remote D1 migrations,
431
- # then pnpm exec wrangler deploy
432
- bin/deploy
433
- ```
434
-
435
- `edgeway` commands: `new`, `build`, `test`, `generate` (`g`), `routes`, `version` (`edgeway help` lists them).
436
-
437
- Before `bin/deploy`:
438
-
439
- 1. Log in (`pnpm exec wrangler login`).
440
- 2. Create the D1 database (`pnpm exec wrangler d1 create <name>-db`).
441
- 3. Put its `database_id` in `wrangler.toml`.
442
-
443
- `bin/setup` prints these steps when they are missing.
444
-
445
- - A generated app's Gemfile takes `edgeway` and `spinel_test-unit` from RubyGems.
446
- - A generated app's `.github/workflows/ci.yml` installs libsqlite3-dev, Ruby (from `.ruby-version`, which `edgeway new` writes with the Ruby that ran it), and mise's tools, then runs `bin/test` and `bin/build --release`.
447
- - An app uses [pnpm](https://pnpm.io/) for wrangler (`package.json`, `bin/setup`, `mise.toml`);
448
- `edgeway new` names it in one place (`Edgeway::CLI::PackageManager`).
449
- - `SPINEL=/path/to/spinel bin/build` uses a Spinel other than the one on `PATH`.
450
- - `bin/deploy` applies pending remote D1 migrations (`wrangler d1 migrations apply <name>-db --remote`) before deploying.
451
-
452
- ### Generators
453
-
454
- Inside an app made by `edgeway new`, `edgeway generate` (or `edgeway g`) writes the files and never overwrites one:
455
-
456
- ```sh
457
- # app/models/todo.rb, db/migrations/0001_create_todos.sql, test/models/todo_test.rb
458
- edgeway generate model Todo title:string done:boolean
459
-
460
- # app/controllers/todos_controller.rb, test/controllers/todos_test.rb
461
- edgeway generate controller Todos index show
462
-
463
- # the model's files, a JSON API controller (index, show, create, update, destroy) and its test
464
- edgeway generate scaffold Todo title:string done:boolean
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/*"]
465
581
  ```
466
582
 
467
- - Field types are `string` (the default), `text`, `integer`, `float`, `boolean` and `datetime`.
468
- - A model gets `timestamps` and an `id`, `created_at` and `updated_at` column.
469
- - The migration is numbered one above the highest in `db/migrations`.
470
- - Each action of a controller is a stub that renders `{}`.
471
- - `config/routes.rb` is not changed by `model` and `controller`: the command prints the route to add (`r.resources :todos`).
472
- - `model` and `scaffold` stop if a `db/migrations/*_create_todos.sql` already exists (a rerun would add a second table).
473
- - `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`.
474
- - The scaffold's controller permits the given fields (`params.require(:todo).permit(...)`), and with no fields it takes no params.
475
-
476
- ### Building
477
-
478
- `edgeway build` needs Spinel and the WASI SDK.
479
-
480
- - An app made by `edgeway new` has a `mise.toml` for both (`mise install`):
481
- Spinel comes from the [mise-spinel](https://github.com/y-yagi/mise-spinel) plugin and is put on `PATH`,
482
- and the WASI SDK is pointed at by `WASI_SDK`.
483
- - Without mise, set `SPINEL` (or put `spinel` on `PATH`) and `WASI_SDK`.
484
- - `SPINEL` wins over `spinel` on `PATH`, which is how to use a Spinel checkout instead of a release.
485
-
486
- See `todo-api` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples) for an example (checked with its `check.sh`).
583
+ ## Logging
487
584
 
488
- ### Spinel version
489
-
490
- Edgeway needs a Spinel newer than the 2026.09.12 release
491
- (that release refuses `send` with a computed name, which `Callbacks#run_before_actions` uses).
492
-
493
- - `mise.toml` (Edgeway's own and the one `edgeway new` writes) pins a commit of [matz/spinel](https://github.com/matz/spinel),
494
- `ec0ad13e4`, which mise-spinel builds from source.
495
- A commit rather than `master`, so that an install is the Spinel that was tested.
496
- - The WASI SDK (`wasi-sdk-34`) is installed first,
497
- because mise-spinel builds Spinel's wasm32-wasi runtime only when it finds one.
498
- - Changing the pin means re-running `rake test` and `todo-api/check.sh` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples).
499
-
500
- ### Known limitations
501
-
502
- - Only the subset of Ruby that Spinel supports can be used.
503
- - `has_many` does not work with namespaced models (`Admin::Post`);
504
- `belongs_to` and `has_many` look models up by their full name, and that case is not handled.
505
- - Cloudflare Access is not supported in this release: its token comes in `Cf-Access-Jwt-Assertion`,
506
- and only `Authorization: Bearer` is read (see [Authentication](#authentication)).
507
- - Tests run natively, not in wasm: D1 and JSPI (`Edgeway::Cloudflare::JWTVerifier`) are not covered by them,
508
- and the production wasm never loads the SQLite adapter (see [Testing an app](#testing-an-app)).
509
- - Not verified on a production Cloudflare deploy yet: CPU and wall time on a cold start and on a JWKS cache miss,
510
- and read replication with real replicas (local D1 has none, and D1 itself accepts any bookmark there). Everything was checked under `wrangler dev`.
511
- - Integers are 32 bits in a wasm build: use `:bigint` for larger ones and pass them as Strings,
512
- and keep them below 2^53, as D1 hands rows over as JavaScript numbers (see [Attribute types](#attribute-types)).
513
- - A `Model.batch` runs on one database: there is no transaction across databases ([Multiple databases](#multiple-databases)).
514
- - `Model.exec` does not work on a database with `sessions: true`: a D1 session has no `exec()` ([Queries](#queries)).
515
- - Spinel can't build a Range of Times: use ISO 8601 Strings or a SQL fragment in `where` ([Queries](#queries)).
516
- - `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)).
517
-
518
- ## Configuration
519
-
520
- Two files configure the database, each for its own side:
521
-
522
- - `config/database.yml` is Edgeway's: for each environment (`edgeway build --env ENV`),
523
- the adapter (`d1`, or `memory` for tests) and the name of the D1 binding the app uses. It is compiled into the wasm.
524
- - `wrangler.toml` is Cloudflare's: which D1 database the binding is (`database_name`, `database_id`),
525
- the migrations, and the Worker. `binding` in it must match `binding` in `config/database.yml`.
526
-
527
- Another database goes under `databases:` of an environment, with its own binding in `wrangler.toml`:
528
-
529
- ```yaml
530
- production:
531
- adapter: d1
532
- binding: DB
533
- databases:
534
- analytics:
535
- binding: ANALYTICS
585
+ ```ruby
586
+ Edgeway.logger.info("order placed", order_id: 42, total: 12.5)
587
+ Edgeway.logger.error("payment failed", order_id: 42)
536
588
  ```
537
589
 
538
- Its migrations are in `db/analytics/migrations` (`migrations_dir` of its `[[d1_databases]]`; apply them per database).
539
- A name is lowercase letters, digits and `_`, and not `primary`; `binding:` is required.
540
- The extra databases use the environment's adapter.
541
- `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 (Sessions)".
542
-
543
- `database_id` is not a secret: it only names the database, and using it takes an API token of the account,
544
- so it is committed with `wrangler.toml` as Cloudflare's own examples do.
545
- Secrets the app needs go in Worker secrets (`pnpm exec wrangler secret put NAME`),
546
- which the app reads with `Edgeway.config("NAME")`.
547
-
548
- Not every Worker variable reaches the app. Only these are passed into the wasm:
549
-
550
- - the framework's own settings (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_LEEWAY`, `JWT_ALGORITHMS`, `JWT_JWKS_URL`)
551
- - the variables whose name starts with `APP_`
552
-
553
- Name your own settings `APP_...` (`APP_PAGE_SIZE = "20"` in `[vars]`, `Edgeway.config("APP_PAGE_SIZE", "20")` in Ruby),
554
- and likewise your secrets (`wrangler secret put APP_NAME`).
555
- Anything else, such as a key the Worker holds for another purpose, is never visible to the app.
556
- `JWT_SECRET` is never passed.
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.
557
605
 
558
606
  ## Authentication
559
607
 
560
608
  Edgeway verifies JWTs that an external IdP issued (Auth0, Firebase, Cognito, ...) and that come in `Authorization: Bearer`;
561
609
  it does not issue them.
562
610
 
563
- - Cloudflare Access, which sends its token in `Cf-Access-Jwt-Assertion`, is not supported in the first release,
611
+ - Cloudflare Access, which sends its token in `Cf-Access-Jwt-Assertion`, is not supported,
564
612
  nor are Cognito access tokens, which carry `client_id` instead of `aud` (Cognito ID tokens work).
565
613
  - `sub` is required and must be a non-empty string.
566
614
  - `auth_time`, when present, must not be in the future (leeway applies).
@@ -583,19 +631,29 @@ Without a valid `Authorization: Bearer <token>`, the request is answered 401 wit
583
631
  - a bad token: `{"error":"invalid token"}` with `WWW-Authenticate: Bearer error="invalid_token"` (`Edgeway::InvalidToken`)
584
632
 
585
633
  `rescue_from` them to answer differently.
586
- The reason ("token expired", "unknown kid", ...) is only in the log (stderr), never the token,
634
+ The reason ("token expired", "unknown kid", ...) is only in the log, never the token,
587
635
  so a client learns nothing from the wording.
588
636
 
589
637
  Settings are Worker variables in `wrangler.toml`, and the secret is a Worker secret (`.dev.vars` locally):
590
638
 
591
639
  | Variable | |
592
640
  |---|---|
641
+ | `JWT_ISSUER`, `JWT_AUDIENCE` | **required**: the token's `iss` and `aud` must match |
593
642
  | `JWT_ALGORITHMS` | allowed algorithms, comma-separated (default `RS256`; `HS256/384/512`, `RS*`, `PS*`, `ES256/384`, `EdDSA`). `none` is never accepted |
594
- | `JWT_JWKS_URL` | the IdP's JWKS, for the asymmetric algorithms. Must be `https` (`http` only for `localhost`, `127.0.0.1`, `[::1]`), otherwise the request is answered 500. Cached for 10 minutes, fetched again when a `kid` is unknown (at most every 30 seconds); an expired JWKS is never used, so when the fetch fails the answer is 500. The fetch has a 5 second timeout, follows no redirect, needs status 200 and a body of at most 64 KB. A token needs a `kid`; a JWK with `key_ops` must list `verify` |
595
- | `JWT_SECRET` | the shared secret, for `HS*` (a secret; it never enters the wasm). At least 32 bytes for `HS256`, 48 for `HS384`, 64 for `HS512` (UTF-8 bytes, RFC 7518 3.2), otherwise the request is answered 500 |
596
- | `JWT_ISSUER`, `JWT_AUDIENCE` | **required**: `iss` and `aud` of the token must match. If either is empty the request is answered 500 (a misconfiguration, details only in the log), never let through; a token without `iss` or `aud` is 401. Without them a token the IdP issued for another API would pass: Firebase's signing keys are shared by all Firebase projects (so any project's user token), and an Auth0 Management API token of the same tenant. Applies to every algorithm, `HS*` too |
597
- | `JWT_LEEWAY` | seconds of clock skew allowed for `exp`, `nbf`, `iat` and `auth_time` (default 5; `0` for none; a whole number up to 300, anything else is answered 500) |
598
-
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).
599
657
  - `exp` is required, and a token with a `crit` header is refused (no extension is understood, RFC 7515 4.1.11).
600
658
  - In tests, use `Edgeway::Auth::StubVerifier` (`edgeway/http/auth/stub`; see `test/auth.rb`).
601
659
  - Like D1, this needs JSPI.
@@ -621,19 +679,291 @@ Settings are Worker variables in `wrangler.toml`:
621
679
 
622
680
  | Variable | |
623
681
  |---|---|
624
- | `CORS_ORIGINS` | allowed origins, comma-separated, compared as exact strings with the request's `Origin` (`https://app.example, http://localhost:3000`; no trailing slash). `*` allows any origin. Unset or empty means CORS is off: nothing is added and `OPTIONS` goes to the app. The origin `null` (sandboxed iframes, `file:` pages) is never allowed, even with `*` |
625
- | `CORS_HEADERS` | request headers a preflight allows (default `Authorization, Content-Type`). Listed one by one, as `*` does not cover `Authorization`. `X-D1-Bookmark` is added when a database has `sessions: true` |
626
- | `CORS_MAX_AGE` | seconds a browser may cache a preflight (default `600`). Not a non-negative integer: every request is answered 500, as with a bad JWT setting |
627
- | `CORS_EXPOSE_HEADERS` | response headers the page may read (`Access-Control-Expose-Headers`, e.g. `X-Total-Count`); none by default, except `X-D1-Bookmark` when a database has `sessions: true` (it is also merged into the app's own `Access-Control-Expose-Headers`) |
628
-
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.
629
692
  - A preflight from an allowed origin is answered 204 with `Access-Control-Allow-Origin` (the request's origin),
630
693
  `Access-Control-Allow-Methods` (`GET, HEAD, POST, PUT, PATCH, DELETE`), `Access-Control-Allow-Headers` and `Access-Control-Max-Age`.
631
694
  - A preflight from another origin is 403 `{"error":"origin not allowed"}`.
632
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)).
633
697
  - Cookies (credentials) are not supported: `Access-Control-Allow-Credentials` is never sent,
634
698
  and a token that `fetch` puts in `Authorization` does not need it.
635
699
 
636
- 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
+ ```
637
967
 
638
968
  ## Testing an app
639
969
 
@@ -643,6 +973,9 @@ bin/test [FILE...] # in a generated app: the same, all of test/ w
643
973
  ```
644
974
 
645
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)).
646
979
 
647
980
  - `spinel_test-unit` is the test framework (on RubyGems;
648
981
  `edgeway new` puts `gem "spinel_test-unit", "~> 0.1", group: :test` in the Gemfile).
@@ -654,16 +987,53 @@ bin/test [FILE...] # in a generated app: the same, all of test/ w
654
987
 
655
988
  - Tests `require "edgeway/test"` in `test/test_helper.rb`.
656
989
  - Model tests inherit `Edgeway::UnitTest` (the database, no requests).
657
- - `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).
658
991
  - Both inherit `SpinelTest::Unit::TestCase`.
659
992
 
660
- Each test runs against a real in-memory SQLite database (`Edgeway::DB::SQLiteAdapter`, through Spinel's FFI)
661
- with `db/migrations/*.sql` applied in file name order, so requests really write and read rows.
662
- Each database under `databases:` (in any environment) gets its own, with `db/NAME/migrations`; `db(:analytics)` answers it, and `db` the primary.
993
+ Outgoing HTTP (see [Outbound HTTP](#outbound-http)) never reaches the network in a test:
663
994
 
664
- ### SQLite
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
+ ```
665
1001
 
666
- Running the tests requires SQLite (sqlite3). This needs the SQLite library at link time:
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:
667
1037
  `libsqlite3-dev` on Debian/Ubuntu, `sqlite-devel` on Fedora (preinstalled on macOS).
668
1038
 
669
1039
  - `edgeway test` first tries to link `-lsqlite3` with `$CC` (or `cc`/`gcc`/`clang`) and stops with the fix if that fails.
@@ -674,22 +1044,50 @@ Edgeway's own tests use `MemoryAdapter`, which stores nothing; `SQLiteAdapter` i
674
1044
 
675
1045
  ### Differences from production
676
1046
 
677
- The tests are native builds, while production is wasm32-wasi on Workers.
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.
678
1058
 
679
1059
  - D1 (`Edgeway::Cloudflare::D1Adapter`) and JSPI are not exercised,
680
1060
  and neither is a JWT's signature check (use `Edgeway::Auth::StubVerifier`).
681
- - `SQLiteAdapter` is loaded only by `edgeway/test`: `edgeway build` never requires `edgeway/db/sqlite`,
1061
+ - `SQLiteAdapter` is loaded only by `edgeway test`: `edgeway build` never requires `edgeway/db/sqlite`,
682
1062
  because libsqlite3 can't be linked into the wasm.
683
1063
  - SQLite and D1 (itself SQLite) take the same SQL, but D1's own limits
684
1064
  (such as the number of bound parameters) are not reproduced.
685
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
+
686
1084
  ## Development
687
1085
 
688
1086
  ```sh
689
1087
  # mise install (Spinel, the WASI SDK) and bundle install
690
1088
  bin/setup
691
1089
 
692
- # needs libsqlite3-dev (test/spinel/sqlite.rb);
1090
+ # needs libsqlite3-dev (test/spinel/sqlite.rb) and, for the Node.js tests, node;
693
1091
  # runs test/*.rb under CRuby and as Spinel native builds, and compares with .expected
694
1092
  bundle exec rake test
695
1093