edgeway 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +444 -0
- data/exe/edgeway +6 -0
- data/js/cgi.js +157 -0
- data/js/cloudflare.js +103 -0
- data/js/cors.js +87 -0
- data/js/jwt.js +279 -0
- data/js/runtime.js +99 -0
- data/js/wasi-shim.js +191 -0
- data/lib/edgeway/application.rb +70 -0
- data/lib/edgeway/cli/app_config.rb +35 -0
- data/lib/edgeway/cli/app_files.rb +37 -0
- data/lib/edgeway/cli/build.rb +90 -0
- data/lib/edgeway/cli/generate.rb +160 -0
- data/lib/edgeway/cli/new.rb +43 -0
- data/lib/edgeway/cli/package_manager.rb +21 -0
- data/lib/edgeway/cli/precheck.rb +45 -0
- data/lib/edgeway/cli/routes.rb +22 -0
- data/lib/edgeway/cli/test.rb +33 -0
- data/lib/edgeway/cli/toolchain.rb +35 -0
- data/lib/edgeway/cli.rb +59 -0
- data/lib/edgeway/config.rb +9 -0
- data/lib/edgeway/controller/authentication.rb +42 -0
- data/lib/edgeway/controller/base.rb +95 -0
- data/lib/edgeway/controller/callbacks.rb +29 -0
- data/lib/edgeway/controller/dispatcher.rb +39 -0
- data/lib/edgeway/controller/rendering.rb +65 -0
- data/lib/edgeway/controller/rescuable.rb +26 -0
- data/lib/edgeway/db/adapter.rb +32 -0
- data/lib/edgeway/db/memory.rb +73 -0
- data/lib/edgeway/db/sqlite.rb +190 -0
- data/lib/edgeway/errors.rb +61 -0
- data/lib/edgeway/generators/app/templates/.gitignore +6 -0
- data/lib/edgeway/generators/app/templates/Gemfile.tt +4 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +22 -0
- data/lib/edgeway/generators/app/templates/app/controllers/application_controller.rb +9 -0
- data/lib/edgeway/generators/app/templates/app/controllers/health_controller.rb +3 -0
- data/lib/edgeway/generators/app/templates/app/models/application_model.rb +3 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +7 -0
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +10 -0
- data/lib/edgeway/generators/app/templates/bin/setup.tt +38 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +14 -0
- data/lib/edgeway/generators/app/templates/config/routes.rb +4 -0
- data/lib/edgeway/generators/app/templates/db/migrations/.keep +0 -0
- data/lib/edgeway/generators/app/templates/mise.toml.tt +24 -0
- data/lib/edgeway/generators/app/templates/package.json.tt +7 -0
- data/lib/edgeway/generators/app/templates/src/worker.js +4 -0
- data/lib/edgeway/generators/app/templates/test/controllers/health_test.rb +16 -0
- data/lib/edgeway/generators/app/templates/test/test_helper.rb +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +23 -0
- data/lib/edgeway/http/auth/jwt.rb +127 -0
- data/lib/edgeway/http/auth/stub.rb +29 -0
- data/lib/edgeway/http/json_body.rb +37 -0
- data/lib/edgeway/http/parameters.rb +42 -0
- data/lib/edgeway/http/request.rb +115 -0
- data/lib/edgeway/http/response.rb +46 -0
- data/lib/edgeway/inflector.rb +16 -0
- data/lib/edgeway/logger.rb +21 -0
- data/lib/edgeway/model/attributes.rb +85 -0
- data/lib/edgeway/model/base.rb +173 -0
- data/lib/edgeway/model/macros.rb +60 -0
- data/lib/edgeway/model/persistence.rb +154 -0
- data/lib/edgeway/model/relation.rb +295 -0
- data/lib/edgeway/model/validations.rb +136 -0
- data/lib/edgeway/routing/mapper.rb +57 -0
- data/lib/edgeway/routing/route.rb +39 -0
- data/lib/edgeway/routing/route_set.rb +27 -0
- data/lib/edgeway/server.rb +12 -0
- data/lib/edgeway/service/cloudflare/d1.rb +101 -0
- data/lib/edgeway/service/cloudflare/jwt.rb +44 -0
- data/lib/edgeway/test.rb +97 -0
- data/lib/edgeway/version.rb +5 -0
- data/lib/edgeway.rb +30 -0
- metadata +115 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f7271c053efc7a3ce130edd6347262ddc8166650a6bc70cc172c30befe3c5372
|
|
4
|
+
data.tar.gz: d0b25d4d34619575a7933aad50bed1c4885da6d8078bfa2308e0c8004da6ab8a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7859d0d22cb99f390c079bdd265567da3cba5c10f0a417a185f62551f322b118972107be922a79960c95b39b0be718a0999acb2130ddccf51fa38ede989ee391
|
|
7
|
+
data.tar.gz: 8da347d44a4e0debb86aa4b0aa3a247fdca6063da45315b3bfddc28ac112a966c1ef2dd2020165f7ba98c1f5e789a8929424eef9a1d0c37efb607a70305d15db
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuji Yaginuma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# Edgeway
|
|
2
|
+
|
|
3
|
+
A library for running Ruby compiled to wasm by Spinel on Edge Workers.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# config/routes.rb
|
|
7
|
+
Edgeway.application.routes.draw do |r|
|
|
8
|
+
r.resources :todos, only: [:index, :show, :create] do |t|
|
|
9
|
+
t.resources :comments, only: [:index, :create]
|
|
10
|
+
end
|
|
11
|
+
r.get "/health", to: "health#show"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# app/models/application_model.rb
|
|
15
|
+
class ApplicationModel < Edgeway::Model::Base
|
|
16
|
+
extend Edgeway::Model::Macros
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# app/models/todo.rb
|
|
20
|
+
class Todo < ApplicationModel
|
|
21
|
+
attribute :title, :string
|
|
22
|
+
attribute :done, :boolean, default: false
|
|
23
|
+
timestamps
|
|
24
|
+
has_many :comments
|
|
25
|
+
validates :title, presence: true, length: { maximum: 200 }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# app/controllers/todos_controller.rb
|
|
29
|
+
class TodosController < ApplicationController
|
|
30
|
+
def index = render json: Todo.order(:id).limit(50)
|
|
31
|
+
|
|
32
|
+
def create
|
|
33
|
+
todo = Todo.create!(params.require(:todo).permit(:title, :done))
|
|
34
|
+
render json: todo, status: :created
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Contents
|
|
40
|
+
|
|
41
|
+
- [Models](#models)
|
|
42
|
+
- [Strong parameters](#strong-parameters)
|
|
43
|
+
- [Uniqueness](#uniqueness)
|
|
44
|
+
- [Ordering and deleting](#ordering-and-deleting)
|
|
45
|
+
- [Batch](#batch)
|
|
46
|
+
- [Associations](#associations)
|
|
47
|
+
- [Eager loading](#eager-loading)
|
|
48
|
+
- [Usage](#usage)
|
|
49
|
+
- [Generators](#generators)
|
|
50
|
+
- [Building](#building)
|
|
51
|
+
- [Spinel version](#spinel-version)
|
|
52
|
+
- [Known limitations](#known-limitations)
|
|
53
|
+
- [Configuration](#configuration)
|
|
54
|
+
- [Authentication](#authentication)
|
|
55
|
+
- [CORS](#cors)
|
|
56
|
+
- [Testing an app](#testing-an-app)
|
|
57
|
+
- [Development](#development)
|
|
58
|
+
- [License](#license)
|
|
59
|
+
|
|
60
|
+
## Models
|
|
61
|
+
|
|
62
|
+
### Strong parameters
|
|
63
|
+
|
|
64
|
+
Params must go through `permit` before a model takes them.
|
|
65
|
+
|
|
66
|
+
`Todo.new`, `create`, `update` and their bang forms raise `Edgeway::ForbiddenAttributes`
|
|
67
|
+
(a 500, the details only in the log) for `params` or for `params.require(:todo)` alone.
|
|
68
|
+
A Hash is assigned as is.
|
|
69
|
+
|
|
70
|
+
### Uniqueness
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# Unique per owner, as UNIQUE (owner_id, name) in the table:
|
|
74
|
+
class Project < ApplicationModel
|
|
75
|
+
attribute :owner_id, :string
|
|
76
|
+
attribute :name, :string
|
|
77
|
+
# scope: a Symbol or an Array
|
|
78
|
+
validates :name, presence: true, uniqueness: { scope: :owner_id }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The record if there is one, else a new saved one
|
|
82
|
+
# (find_or_create_by! raises RecordInvalid instead of answering the unsaved record).
|
|
83
|
+
# It also works on a relation, which sets owner_id too.
|
|
84
|
+
Project.where(owner_id: uid).find_or_create_by(name: "default")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`uniqueness:` is a query, so two requests can both pass it. What guarantees it is the table's `UNIQUE` index:
|
|
88
|
+
|
|
89
|
+
- A write it refuses raises `Edgeway::RecordNotUnique` (a `DatabaseError`),
|
|
90
|
+
which `find_or_create_by` handles by finding the row the other request made.
|
|
91
|
+
- Left alone, it is answered 409 `{"error":"record not unique"}`;
|
|
92
|
+
the database's message (table and columns) goes only to the log.
|
|
93
|
+
- A controller can `rescue_from Edgeway::RecordNotUnique` to answer otherwise.
|
|
94
|
+
|
|
95
|
+
### Ordering and deleting
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# order adds terms, reorder replaces them: ORDER BY updated_at DESC, id DESC
|
|
99
|
+
Todo.order(:updated_at, :desc).order(:id, :desc)
|
|
100
|
+
Todo.order(:title).reorder(:id)
|
|
101
|
+
|
|
102
|
+
# One DELETE (no records, no dependents); the number of rows deleted.
|
|
103
|
+
# limit/offset raise ArgumentError.
|
|
104
|
+
Todo.where(done: true).delete_all
|
|
105
|
+
|
|
106
|
+
# Loads the records and destroys each, so dependents run; the destroyed records.
|
|
107
|
+
Todo.where(done: true).destroy_all
|
|
108
|
+
|
|
109
|
+
# destroy deletes the comments first
|
|
110
|
+
# (dependent: :destroy runs their own dependents; :delete_all is one DELETE).
|
|
111
|
+
# The comments' SELECTs run at once and all the DELETEs go in one Model.batch (below),
|
|
112
|
+
# so it is atomic.
|
|
113
|
+
class Todo < ApplicationModel
|
|
114
|
+
has_many :comments, dependent: :destroy
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Batch
|
|
119
|
+
|
|
120
|
+
`Edgeway::Model.batch` makes several writes atomic.
|
|
121
|
+
|
|
122
|
+
D1 has no interactive transactions, but its `batch()` runs a list of statements as one SQL transaction,
|
|
123
|
+
so the writes in the block are queued and sent together at the end:
|
|
124
|
+
all of them are applied, or none (a failure raises, such as `RecordNotUnique`).
|
|
125
|
+
It answers the results, one `{"changes" => n, "last_insert_id" => id}` per write.
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
Edgeway::Model.batch do
|
|
129
|
+
from.update!(balance: from.balance - 10)
|
|
130
|
+
to.update!(balance: to.balance + 10)
|
|
131
|
+
Transfer.where(expired: true).delete_all
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- Only writes (`update`, `destroy`, `delete_all`) are in the batch. Reads run at once and don't see the queued writes.
|
|
136
|
+
- A `create` is refused with `ArgumentError`, because it needs the new row's id.
|
|
137
|
+
- The counts of a queued write are not known (`delete_all` answers 0).
|
|
138
|
+
- A block that raises sends nothing, and a nested `batch` joins the outer one.
|
|
139
|
+
|
|
140
|
+
### Associations
|
|
141
|
+
|
|
142
|
+
`has_many` and `belongs_to` take `foreign_key:` and `primary_key:` for columns
|
|
143
|
+
that don't follow the `todo_id` / `id` convention.
|
|
144
|
+
|
|
145
|
+
- `has_many` uses `foreign_key:` on the other model and `primary_key:` on this one.
|
|
146
|
+
- `belongs_to` uses `foreign_key:` on this model and `primary_key:` on the parent.
|
|
147
|
+
|
|
148
|
+
A foreign key is an integer unless you declare it first, which is how to make a String one:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
class Device < ApplicationModel
|
|
152
|
+
attribute :owner_uid, :string # declared first, so it stays a String
|
|
153
|
+
belongs_to :account, foreign_key: :owner_uid, primary_key: :uid
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
class Account < ApplicationModel
|
|
157
|
+
attribute :uid, :string
|
|
158
|
+
has_many :devices, foreign_key: :owner_uid, primary_key: :uid, dependent: :destroy
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Eager loading
|
|
163
|
+
|
|
164
|
+
`eager_load(:name, ...)` loads one or more `has_many` or `belongs_to` for the whole list with one query,
|
|
165
|
+
always a `LEFT JOIN` per association (there is no separate preload query).
|
|
166
|
+
Calls accumulate (`eager_load(:a).eager_load(:b)` is `eager_load(:a, :b)`).
|
|
167
|
+
It chains with `where`, `order` and `limit`, which apply to the model's own records:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
Todo.where(done: false).order(:id).limit(20).eager_load(:comments).each do |todo|
|
|
171
|
+
todo.comments.to_a # from memory, no query
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
Todo.eager_load(:comments, :tags) # several at once, still one query
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- Joining two `has_many` repeats rows per parent; each parent still gets each child once, in id order.
|
|
178
|
+
- An unknown name raises `ArgumentError`.
|
|
179
|
+
- Only `to_a` and what is built on it (`each`, `map`, `size`) read the loaded records;
|
|
180
|
+
`comments.where(...)`, `count` and `first` query as usual.
|
|
181
|
+
|
|
182
|
+
## Usage
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
gem install edgeway
|
|
186
|
+
|
|
187
|
+
edgeway new myapp
|
|
188
|
+
|
|
189
|
+
# mise, bundle, pnpm and the local D1 migrations
|
|
190
|
+
cd myapp && bin/setup
|
|
191
|
+
|
|
192
|
+
# the app's tests, natively (needs libsqlite3-dev, see "Testing an app")
|
|
193
|
+
bundle exec edgeway test
|
|
194
|
+
|
|
195
|
+
# edgeway build: writes build/app.wasm and build/edgeway/ (the JS)
|
|
196
|
+
bin/build
|
|
197
|
+
|
|
198
|
+
pnpm exec wrangler dev
|
|
199
|
+
edgeway routes
|
|
200
|
+
|
|
201
|
+
# bin/build, remote D1 migrations, then pnpm exec wrangler deploy
|
|
202
|
+
bin/deploy
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`edgeway` commands: `new`, `build`, `test`, `generate` (`g`), `routes`, `version` (`edgeway help` lists them).
|
|
206
|
+
|
|
207
|
+
Before `bin/deploy`:
|
|
208
|
+
|
|
209
|
+
1. Log in (`pnpm exec wrangler login`).
|
|
210
|
+
2. Create the D1 database (`pnpm exec wrangler d1 create <name>-db`).
|
|
211
|
+
3. Put its `database_id` in `wrangler.toml`.
|
|
212
|
+
|
|
213
|
+
`bin/setup` prints these steps when they are missing.
|
|
214
|
+
|
|
215
|
+
- A generated app's Gemfile takes `edgeway` and `spinel_test-unit` from RubyGems.
|
|
216
|
+
- An app uses [pnpm](https://pnpm.io/) for wrangler (`package.json`, `bin/setup`, `mise.toml`);
|
|
217
|
+
`edgeway new` names it in one place (`Edgeway::CLI::PackageManager`).
|
|
218
|
+
- `SPINEL=/path/to/spinel bin/build` uses a Spinel other than the one on `PATH`.
|
|
219
|
+
- `bin/deploy` applies pending remote D1 migrations (`wrangler d1 migrations apply <name>-db --remote`) before deploying.
|
|
220
|
+
|
|
221
|
+
### Generators
|
|
222
|
+
|
|
223
|
+
Inside an app made by `edgeway new`, `edgeway generate` (or `edgeway g`) writes the files and never overwrites one:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
# app/models/todo.rb, db/migrations/0001_create_todos.sql, test/models/todo_test.rb
|
|
227
|
+
edgeway generate model Todo title:string done:boolean
|
|
228
|
+
|
|
229
|
+
# app/controllers/todos_controller.rb, test/controllers/todos_test.rb
|
|
230
|
+
edgeway generate controller Todos index show
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- Field types are `string` (the default), `text`, `integer`, `float`, `boolean` and `datetime`.
|
|
234
|
+
- A model gets `timestamps` and an `id`, `created_at` and `updated_at` column.
|
|
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`).
|
|
250
|
+
|
|
251
|
+
### Spinel version
|
|
252
|
+
|
|
253
|
+
Edgeway needs a Spinel newer than the 2026.09.12 release
|
|
254
|
+
(that release refuses `send` with a computed name, which `Callbacks#run_before_actions` uses).
|
|
255
|
+
|
|
256
|
+
- `mise.toml` (Edgeway's own and the one `edgeway new` writes) pins a commit of [matz/spinel](https://github.com/matz/spinel),
|
|
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).
|
|
262
|
+
|
|
263
|
+
### Known limitations
|
|
264
|
+
|
|
265
|
+
- Only the subset of Ruby that Spinel supports can be used.
|
|
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`.
|
|
274
|
+
|
|
275
|
+
## Configuration
|
|
276
|
+
|
|
277
|
+
Two files configure the database, each for its own side:
|
|
278
|
+
|
|
279
|
+
- `config/database.yml` is Edgeway's: for each environment (`edgeway build --env ENV`),
|
|
280
|
+
the adapter (`d1`, or `memory` for tests) and the name of the D1 binding the app uses. It is compiled into the wasm.
|
|
281
|
+
- `wrangler.toml` is Cloudflare's: which D1 database the binding is (`database_name`, `database_id`),
|
|
282
|
+
the migrations, and the Worker. `binding` in it must match `binding` in `config/database.yml`.
|
|
283
|
+
|
|
284
|
+
`database_id` is not a secret: it only names the database, and using it takes an API token of the account,
|
|
285
|
+
so it is committed with `wrangler.toml` as Cloudflare's own examples do.
|
|
286
|
+
Secrets the app needs go in Worker secrets (`pnpm exec wrangler secret put NAME`),
|
|
287
|
+
which the app reads with `Edgeway.config("NAME")`.
|
|
288
|
+
|
|
289
|
+
Not every Worker variable reaches the app. Only these are passed into the wasm:
|
|
290
|
+
|
|
291
|
+
- the framework's own settings (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_LEEWAY`, `JWT_ALGORITHMS`, `JWT_JWKS_URL`)
|
|
292
|
+
- the variables whose name starts with `APP_`
|
|
293
|
+
|
|
294
|
+
Name your own settings `APP_...` (`APP_PAGE_SIZE = "20"` in `[vars]`, `Edgeway.config("APP_PAGE_SIZE", "20")` in Ruby),
|
|
295
|
+
and likewise your secrets (`wrangler secret put APP_NAME`).
|
|
296
|
+
Anything else, such as a key the Worker holds for another purpose, is never visible to the app.
|
|
297
|
+
`JWT_SECRET` is never passed.
|
|
298
|
+
|
|
299
|
+
## Authentication
|
|
300
|
+
|
|
301
|
+
Edgeway verifies JWTs that an external IdP issued (Auth0, Firebase, Cognito, ...) and that come in `Authorization: Bearer`;
|
|
302
|
+
it does not issue them.
|
|
303
|
+
|
|
304
|
+
- Cloudflare Access, which sends its token in `Cf-Access-Jwt-Assertion`, is not supported in the first release,
|
|
305
|
+
nor are Cognito access tokens, which carry `client_id` instead of `aud` (Cognito ID tokens work).
|
|
306
|
+
- `sub` is required and must be a non-empty string.
|
|
307
|
+
- `auth_time`, when present, must not be in the future (leeway applies).
|
|
308
|
+
|
|
309
|
+
The signature is checked in JS with WebCrypto, the claims in Ruby:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
class ApplicationController < Edgeway::Controller::Base
|
|
313
|
+
before_action :authenticate! # or only: / except:
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
class MeController < ApplicationController
|
|
317
|
+
def show = render json: current_claims # the token's payload, a Hash
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Without a valid `Authorization: Bearer <token>`, the request is answered 401 with a fixed body:
|
|
322
|
+
|
|
323
|
+
- no token: `{"error":"authentication required"}` with `WWW-Authenticate: Bearer` (`Edgeway::Unauthorized`)
|
|
324
|
+
- a bad token: `{"error":"invalid token"}` with `WWW-Authenticate: Bearer error="invalid_token"` (`Edgeway::InvalidToken`)
|
|
325
|
+
|
|
326
|
+
`rescue_from` them to answer differently.
|
|
327
|
+
The reason ("token expired", "unknown kid", ...) is only in the log (stderr), never the token,
|
|
328
|
+
so a client learns nothing from the wording.
|
|
329
|
+
|
|
330
|
+
Settings are Worker variables in `wrangler.toml`, and the secret is a Worker secret (`.dev.vars` locally):
|
|
331
|
+
|
|
332
|
+
| Variable | |
|
|
333
|
+
|---|---|
|
|
334
|
+
| `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. 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` |
|
|
336
|
+
| `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 |
|
|
337
|
+
| `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 |
|
|
338
|
+
| `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) |
|
|
339
|
+
|
|
340
|
+
- `exp` is required, and a token with a `crit` header is refused (no extension is understood, RFC 7515 4.1.11).
|
|
341
|
+
- In tests, use `Edgeway::Auth::StubVerifier` (`edgeway/http/auth/stub`; see `test/auth.rb`).
|
|
342
|
+
- Like D1, this needs JSPI.
|
|
343
|
+
- An example is `GET /me` in `todo-api` of [edgeway-examples](https://github.com/y-yagi/edgeway-examples).
|
|
344
|
+
|
|
345
|
+
> **Note:** the todos/tags/comments endpoints of `todo-api` (in [edgeway-examples](https://github.com/y-yagi/edgeway-examples))
|
|
346
|
+
> are intentionally public, to keep the example small.
|
|
347
|
+
> A real app should `before_action :authenticate!` and scope records by the token's `sub`
|
|
348
|
+
> (for example an `owner_uid` attribute, queried with `where("owner_uid" => current_claims["sub"])` and set on create).
|
|
349
|
+
> See the comment in `todo-api/app/controllers/application_controller.rb`.
|
|
350
|
+
|
|
351
|
+
## CORS
|
|
352
|
+
|
|
353
|
+
A browser app on another origin needs CORS to call the API with `Authorization: Bearer`.
|
|
354
|
+
Edgeway does it in the Worker, before the app:
|
|
355
|
+
|
|
356
|
+
- A preflight (`OPTIONS` with `Origin` and `Access-Control-Request-Method`) is answered without starting the app,
|
|
357
|
+
so it needs no `OPTIONS` routes.
|
|
358
|
+
- The headers are also added to responses the Worker makes itself (the 500 of a trap),
|
|
359
|
+
so the browser sees the real status.
|
|
360
|
+
|
|
361
|
+
Settings are Worker variables in `wrangler.toml`:
|
|
362
|
+
|
|
363
|
+
| Variable | |
|
|
364
|
+
|---|---|
|
|
365
|
+
| `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 `*` |
|
|
366
|
+
| `CORS_HEADERS` | request headers a preflight allows (default `Authorization, Content-Type`). Listed one by one, as `*` does not cover `Authorization` |
|
|
367
|
+
| `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 |
|
|
368
|
+
| `CORS_EXPOSE_HEADERS` | response headers the page may read (`Access-Control-Expose-Headers`, e.g. `X-Total-Count`); none by default |
|
|
369
|
+
|
|
370
|
+
- A preflight from an allowed origin is answered 204 with `Access-Control-Allow-Origin` (the request's origin),
|
|
371
|
+
`Access-Control-Allow-Methods` (`GET, HEAD, POST, PUT, PATCH, DELETE`), `Access-Control-Allow-Headers` and `Access-Control-Max-Age`.
|
|
372
|
+
- A preflight from another origin is 403 `{"error":"origin not allowed"}`.
|
|
373
|
+
- Other responses get `Access-Control-Allow-Origin` when the `Origin` is allowed, and `Origin` is added to `Vary`.
|
|
374
|
+
- Cookies (credentials) are not supported: `Access-Control-Allow-Credentials` is never sent,
|
|
375
|
+
and a token that `fetch` puts in `Authorization` does not need it.
|
|
376
|
+
|
|
377
|
+
Serving the frontend from the same origin as the API avoids CORS altogether.
|
|
378
|
+
|
|
379
|
+
## Testing an app
|
|
380
|
+
|
|
381
|
+
```sh
|
|
382
|
+
bundle exec edgeway test [ARGS] # ARGS go to spinel_test-unit
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`edgeway test` is the only supported way to run an app's tests.
|
|
386
|
+
|
|
387
|
+
- `spinel_test-unit` is the test framework (on RubyGems;
|
|
388
|
+
`edgeway new` puts `gem "spinel_test-unit", "~> 0.1", group: :test` in the Gemfile).
|
|
389
|
+
- It rewrites `tmp/test_app.rb` (git-ignored; `build/` belongs to `edgeway build`) on every run,
|
|
390
|
+
listing the app's files in build order, because Spinel resolves `require` at compile time.
|
|
391
|
+
Running `spinel_test-unit` directly fails when that file is missing and uses a stale one when app files changed.
|
|
392
|
+
|
|
393
|
+
### Writing tests
|
|
394
|
+
|
|
395
|
+
- Tests `require "edgeway/test"` in `test/test_helper.rb`.
|
|
396
|
+
- Model tests inherit `Edgeway::UnitTest` (the database, no requests).
|
|
397
|
+
- `Edgeway::IntegrationTest` inherits it and adds `get`/`post`/..., `json` and `assert_response`.
|
|
398
|
+
- Both inherit `SpinelTest::Unit::TestCase`.
|
|
399
|
+
|
|
400
|
+
Each test runs against a real in-memory SQLite database (`Edgeway::DB::SQLiteAdapter`, through Spinel's FFI)
|
|
401
|
+
with `db/migrations/*.sql` applied in file name order, so requests really write and read rows.
|
|
402
|
+
|
|
403
|
+
### SQLite
|
|
404
|
+
|
|
405
|
+
Running the tests requires SQLite (sqlite3). This needs the SQLite library at link time:
|
|
406
|
+
`libsqlite3-dev` on Debian/Ubuntu, `sqlite-devel` on Fedora (preinstalled on macOS).
|
|
407
|
+
|
|
408
|
+
- `edgeway test` first tries to link `-lsqlite3` with `$CC` (or `cc`/`gcc`/`clang`) and stops with the fix if that fails.
|
|
409
|
+
- Point `LIBRARY_PATH` at a directory holding `libsqlite3.so` if it lives elsewhere.
|
|
410
|
+
- Set `EDGEWAY_SKIP_PRECHECK=1` to skip the check.
|
|
411
|
+
|
|
412
|
+
Edgeway's own tests use `MemoryAdapter`, which stores nothing; `SQLiteAdapter` is Spinel-only (`test/spinel/*.rb`).
|
|
413
|
+
|
|
414
|
+
### Differences from production
|
|
415
|
+
|
|
416
|
+
The tests are native builds, while production is wasm32-wasi on Workers.
|
|
417
|
+
|
|
418
|
+
- D1 (`Edgeway::Cloudflare::D1Adapter`) and JSPI are not exercised,
|
|
419
|
+
and neither is a JWT's signature check (use `Edgeway::Auth::StubVerifier`).
|
|
420
|
+
- `SQLiteAdapter` is loaded only by `edgeway/test`: `edgeway build` never requires `edgeway/db/sqlite`,
|
|
421
|
+
because libsqlite3 can't be linked into the wasm.
|
|
422
|
+
- SQLite and D1 (itself SQLite) take the same SQL, but D1's own limits
|
|
423
|
+
(such as the number of bound parameters) are not reproduced.
|
|
424
|
+
|
|
425
|
+
## Development
|
|
426
|
+
|
|
427
|
+
```sh
|
|
428
|
+
# mise install (Spinel, the WASI SDK) and bundle install
|
|
429
|
+
bin/setup
|
|
430
|
+
|
|
431
|
+
# needs libsqlite3-dev (test/spinel/sqlite.rb);
|
|
432
|
+
# runs test/*.rb under CRuby and as Spinel native builds, and compares with .expected
|
|
433
|
+
bundle exec rake test
|
|
434
|
+
|
|
435
|
+
# CRuby only
|
|
436
|
+
bundle exec rake test:cruby
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
CI runs `rake test` (both legs, the CLI and the JS tests) on Ubuntu and macOS, with Spinel from `mise.toml`.
|
|
440
|
+
To test against a Spinel checkout instead of the release, set `SPINEL` in a `mise.local.toml` (see `mise.toml`).
|
|
441
|
+
|
|
442
|
+
## License
|
|
443
|
+
|
|
444
|
+
MIT
|
data/exe/edgeway
ADDED
data/js/cgi.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Turns a CGI program's stdout into a Response. The output is kept as bytes:
|
|
2
|
+
// "Name: value" header lines (LF or CRLF), a blank line, then the body,
|
|
3
|
+
// which goes out unchanged, so binary bodies (images, gzip...) survive.
|
|
4
|
+
// A "Status: 404 Not Found" line sets the status, and a Location line
|
|
5
|
+
// without one means 302; without a blank line the whole output is the body.
|
|
6
|
+
// A non-empty body without a Content-Type is sent as UTF-8 text, so binary
|
|
7
|
+
// output needs its own Content-Type line.
|
|
8
|
+
// Bad output never throws: an invalid status answers a fixed JSON 500, and a header line
|
|
9
|
+
// Headers refuses is skipped.
|
|
10
|
+
|
|
11
|
+
// Joins Uint8Array chunks into one.
|
|
12
|
+
export function concatBytes(chunks) {
|
|
13
|
+
if (chunks.length === 1) return chunks[0];
|
|
14
|
+
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
|
15
|
+
let pos = 0;
|
|
16
|
+
for (const c of chunks) {
|
|
17
|
+
out.set(c, pos);
|
|
18
|
+
pos += c.length;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Answers [end of the headers, start of the body], or null when there is no
|
|
24
|
+
// blank line. "\n\n", "\r\n\r\n" and a mix of the two all end the headers;
|
|
25
|
+
// a blank first line means no headers.
|
|
26
|
+
function headerEnd(out) {
|
|
27
|
+
if (out[0] === 10) return [0, 1];
|
|
28
|
+
if (out[0] === 13 && out[1] === 10) return [0, 2];
|
|
29
|
+
for (let i = out.indexOf(10); i >= 0; i = out.indexOf(10, i + 1)) {
|
|
30
|
+
if (out[i + 1] === 10) return [i, i + 2];
|
|
31
|
+
if (out[i + 1] === 13 && out[i + 2] === 10) return [i, i + 3];
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Headers hold byte strings, so each byte becomes one char (not UTF-8, and
|
|
37
|
+
// not TextDecoder("latin1"), which is windows-1252): any bytes go out as is.
|
|
38
|
+
function byteString(bytes) {
|
|
39
|
+
let s = "";
|
|
40
|
+
for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Statuses whose response must have no body.
|
|
45
|
+
const NULL_BODY = new Set([204, 205, 304]);
|
|
46
|
+
const TEXT = "text/plain; charset=utf-8";
|
|
47
|
+
|
|
48
|
+
export function cgiResponse(out) {
|
|
49
|
+
const end = headerEnd(out);
|
|
50
|
+
if (!end) return new Response(out, { headers: out.length ? { "Content-Type": TEXT } : {} });
|
|
51
|
+
const headers = new Headers();
|
|
52
|
+
let status = null;
|
|
53
|
+
for (const line of byteString(out.subarray(0, end[0])).split(/\r?\n/)) {
|
|
54
|
+
const i = line.indexOf(":");
|
|
55
|
+
if (i < 0) continue;
|
|
56
|
+
const name = line.slice(0, i).trim(), value = line.slice(i + 1).trim();
|
|
57
|
+
if (name.toLowerCase() === "status") {
|
|
58
|
+
// Only the first Status counts: a later one is a header injected into
|
|
59
|
+
// the app's output.
|
|
60
|
+
if (status === null) status = Number.parseInt(value, 10);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
headers.append(name, value);
|
|
65
|
+
} catch {
|
|
66
|
+
console.error(`cgi: skipped a bad header line: ${JSON.stringify(line)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
status ??= headers.has("Location") ? 302 : 200;
|
|
70
|
+
if (!(Number.isInteger(status) && status >= 200 && status <= 599)) {
|
|
71
|
+
console.error(`cgi: bad status ${status}`);
|
|
72
|
+
return new Response('{"error":"internal error"}\n', { status: 500, headers: { "Content-Type": "application/json" } });
|
|
73
|
+
}
|
|
74
|
+
if (NULL_BODY.has(status)) return new Response(null, { status, headers });
|
|
75
|
+
const body = out.subarray(end[1]);
|
|
76
|
+
if (body.length && !headers.has("Content-Type")) headers.set("Content-Type", TEXT);
|
|
77
|
+
return new Response(body, { status, headers });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- The request side: a Request as CGI meta-variables (RFC 3875) ----
|
|
81
|
+
|
|
82
|
+
// PATH_INFO is not URL-encoded in CGI, so %XX is decoded. Two escapes stay
|
|
83
|
+
// as they are: %2F (decoding it would make "a%2Fb" look like "a/b") and %00
|
|
84
|
+
// (a NUL cannot live in an environment string). A run of escapes that is not
|
|
85
|
+
// valid UTF-8 also stays encoded. Nothing here throws.
|
|
86
|
+
function decodePath(path) {
|
|
87
|
+
return path.replace(/(?:%[0-9a-fA-F]{2})+/g, (run) =>
|
|
88
|
+
run.split(/(%2f|%00)/i).map((piece, i) => {
|
|
89
|
+
if (i % 2) return piece;
|
|
90
|
+
try {
|
|
91
|
+
return decodeURIComponent(piece);
|
|
92
|
+
} catch {
|
|
93
|
+
return piece;
|
|
94
|
+
}
|
|
95
|
+
}).join(""));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The request.cf fields passed on, as non-standard CF_* variables.
|
|
99
|
+
const CF_FIELDS = {
|
|
100
|
+
CF_COUNTRY: "country", CF_CONTINENT: "continent", CF_REGION: "region", CF_REGION_CODE: "regionCode",
|
|
101
|
+
CF_CITY: "city", CF_TIMEZONE: "timezone", CF_COLO: "colo", CF_ASN: "asn",
|
|
102
|
+
};
|
|
103
|
+
// Precise location (latitude, longitude, postal code) is deliberately not passed;
|
|
104
|
+
// an app that needs it can read request.cf in the worker.
|
|
105
|
+
|
|
106
|
+
// The CGI environment for a request: an object of strings (no undefined or
|
|
107
|
+
// null; WASI env needs strings). `body` is the request body as a Uint8Array.
|
|
108
|
+
// - REMOTE_ADDR comes from CF-Connecting-IP; it is "" when that is missing
|
|
109
|
+
// (local wrangler dev may not send it), so the variable is always set.
|
|
110
|
+
// - CONTENT_LENGTH is always set ("0" without a body); CONTENT_TYPE only
|
|
111
|
+
// when the request has one.
|
|
112
|
+
// - Header values go in as the JS strings workerd gives (a UTF-8 value from the
|
|
113
|
+
// client arrives decoded; the WASI shim encodes env as UTF-8, so the app
|
|
114
|
+
// sees the client's bytes). Content-Type and Content-Length are not HTTP_* (RFC 3875); every other
|
|
115
|
+
// header is HTTP_<NAME> (upper case, "-" -> "_"; a name that already holds
|
|
116
|
+
// "_" is dropped, as nginx and Apache do). Repeated headers come
|
|
117
|
+
// joined with ", ". Cookie and Authorization are passed too: the app is
|
|
118
|
+
// the one that handles the request.
|
|
119
|
+
// - The Proxy header is not passed: HTTP_PROXY is read by HTTP clients as
|
|
120
|
+
// proxy config (httpoxy, CVE-2016-5385).
|
|
121
|
+
// - CF_* are not CGI; they come from request.cf, which is absent locally
|
|
122
|
+
// in some setups, and absent fields are skipped.
|
|
123
|
+
export function cgiEnv(request, body) {
|
|
124
|
+
const url = new URL(request.url);
|
|
125
|
+
const https = url.protocol === "https:";
|
|
126
|
+
const env = {
|
|
127
|
+
GATEWAY_INTERFACE: "CGI/1.1",
|
|
128
|
+
SERVER_SOFTWARE: "Cloudflare-Workers",
|
|
129
|
+
SERVER_PROTOCOL: request.cf?.httpProtocol ? String(request.cf.httpProtocol) : "HTTP/1.1",
|
|
130
|
+
SERVER_NAME: url.hostname,
|
|
131
|
+
SERVER_PORT: url.port || (https ? "443" : "80"),
|
|
132
|
+
REQUEST_SCHEME: https ? "https" : "http",
|
|
133
|
+
REQUEST_METHOD: request.method,
|
|
134
|
+
SCRIPT_NAME: "",
|
|
135
|
+
PATH_INFO: decodePath(url.pathname),
|
|
136
|
+
QUERY_STRING: url.search.slice(1),
|
|
137
|
+
REQUEST_URI: url.pathname + url.search,
|
|
138
|
+
REMOTE_ADDR: request.headers.get("cf-connecting-ip") ?? "",
|
|
139
|
+
CONTENT_LENGTH: String(body.length),
|
|
140
|
+
};
|
|
141
|
+
if (https) env.HTTPS = "on";
|
|
142
|
+
const type = request.headers.get("content-type");
|
|
143
|
+
if (type != null) env.CONTENT_TYPE = type;
|
|
144
|
+
request.headers.forEach((value, name) => {
|
|
145
|
+
if (name === "content-type" || name === "content-length") return;
|
|
146
|
+
if (name === "proxy") return; // httpoxy: HTTP_PROXY would be read as proxy config
|
|
147
|
+
// "X_Foo" and "X-Foo" would both become HTTP_X_FOO, and a client could
|
|
148
|
+
// pass off one as the other; like nginx and Apache, drop names with "_".
|
|
149
|
+
if (name.includes("_")) return;
|
|
150
|
+
env["HTTP_" + name.toUpperCase().replace(/-/g, "_")] = value;
|
|
151
|
+
});
|
|
152
|
+
for (const [key, field] of Object.entries(CF_FIELDS)) {
|
|
153
|
+
const v = request.cf?.[field];
|
|
154
|
+
if (v != null && v !== "") env[key] = String(v);
|
|
155
|
+
}
|
|
156
|
+
return env;
|
|
157
|
+
}
|