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
data/js/wasi-shim.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// A minimal WASI preview 1 shim: stdout/stderr, args, environment, clocks,
|
|
2
|
+
// random. No filesystem (no preopened directories); stdin serves the bytes
|
|
3
|
+
// given as the stdin option (a string or Uint8Array), then EOF.
|
|
4
|
+
// stdout/stderr go to onStdout/onStderr as decoded text, or, when
|
|
5
|
+
// onStdoutBytes/onStderrBytes is given, to it as raw bytes (binary output).
|
|
6
|
+
// Any import not implemented here answers ENOSYS.
|
|
7
|
+
|
|
8
|
+
const ESUCCESS = 0, EBADF = 8, ENOSYS = 52, ESPIPE = 70;
|
|
9
|
+
|
|
10
|
+
export class WasiExit extends Error {
|
|
11
|
+
constructor(code) {
|
|
12
|
+
super(`exit ${code}`);
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class WASI {
|
|
18
|
+
// onStdout / onStderr receive decoded text chunks; onStdoutBytes /
|
|
19
|
+
// onStderrBytes, when given, receive Uint8Array chunks (copies) instead.
|
|
20
|
+
constructor({ args = ["main"], env = {}, stdin = new Uint8Array(0), onStdout = () => {}, onStderr = () => {},
|
|
21
|
+
onStdoutBytes = null, onStderrBytes = null } = {}) {
|
|
22
|
+
this.args = args;
|
|
23
|
+
this.stdin = typeof stdin === "string" ? new TextEncoder().encode(stdin) : stdin;
|
|
24
|
+
this.stdinPos = 0;
|
|
25
|
+
this.env = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
26
|
+
this.out = { 1: onStdout, 2: onStderr };
|
|
27
|
+
this.outBytes = { 1: onStdoutBytes, 2: onStderrBytes };
|
|
28
|
+
this.decoders = { 1: new TextDecoder(), 2: new TextDecoder() };
|
|
29
|
+
this.memory = null;
|
|
30
|
+
this.wasiImport = this.#imports();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// For a reactor module (no _start): hands the shim the memory and runs
|
|
34
|
+
// _initialize, if the module has one. The caller then calls the exports
|
|
35
|
+
// it wants; a proc_exit inside them throws WasiExit.
|
|
36
|
+
initialize(instance) {
|
|
37
|
+
this.memory = instance.exports.memory;
|
|
38
|
+
instance.exports._initialize?.();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Runs the module's _start; answers the exit status.
|
|
42
|
+
start(instance) {
|
|
43
|
+
this.memory = instance.exports.memory;
|
|
44
|
+
try {
|
|
45
|
+
instance.exports._start();
|
|
46
|
+
return 0;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (e instanceof WasiExit) return e.code;
|
|
49
|
+
throw e;
|
|
50
|
+
} finally {
|
|
51
|
+
for (const fd of [1, 2]) {
|
|
52
|
+
const rest = this.decoders[fd].decode();
|
|
53
|
+
if (rest) this.out[fd](rest);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// start for a module whose imports may suspend (JSPI): _start is wrapped
|
|
59
|
+
// with WebAssembly.promising, so a WebAssembly.Suspending import can await.
|
|
60
|
+
async startAsync(instance) {
|
|
61
|
+
this.memory = instance.exports.memory;
|
|
62
|
+
try {
|
|
63
|
+
await WebAssembly.promising(instance.exports._start)();
|
|
64
|
+
return 0;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e instanceof WasiExit) return e.code;
|
|
67
|
+
throw e;
|
|
68
|
+
} finally {
|
|
69
|
+
for (const fd of [1, 2]) {
|
|
70
|
+
const rest = this.decoders[fd].decode();
|
|
71
|
+
if (rest) this.out[fd](rest);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The memory can grow, so views are made per call.
|
|
77
|
+
get #view() { return new DataView(this.memory.buffer); }
|
|
78
|
+
get #bytes() { return new Uint8Array(this.memory.buffer); }
|
|
79
|
+
|
|
80
|
+
#putStrings(strings, ptrs, buf) {
|
|
81
|
+
const enc = new TextEncoder();
|
|
82
|
+
const view = this.#view, bytes = this.#bytes;
|
|
83
|
+
for (const s of strings) {
|
|
84
|
+
const b = enc.encode(s + "\0");
|
|
85
|
+
view.setUint32(ptrs, buf, true);
|
|
86
|
+
bytes.set(b, buf);
|
|
87
|
+
ptrs += 4;
|
|
88
|
+
buf += b.length;
|
|
89
|
+
}
|
|
90
|
+
return ESUCCESS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#sizes(strings, countPtr, sizePtr) {
|
|
94
|
+
const enc = new TextEncoder();
|
|
95
|
+
const view = this.#view;
|
|
96
|
+
view.setUint32(countPtr, strings.length, true);
|
|
97
|
+
view.setUint32(sizePtr, strings.reduce((n, s) => n + enc.encode(s).length + 1, 0), true);
|
|
98
|
+
return ESUCCESS;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#imports() {
|
|
102
|
+
const impl = {
|
|
103
|
+
args_get: (argv, buf) => this.#putStrings(this.args, argv, buf),
|
|
104
|
+
args_sizes_get: (c, s) => this.#sizes(this.args, c, s),
|
|
105
|
+
environ_get: (envp, buf) => this.#putStrings(this.env, envp, buf),
|
|
106
|
+
environ_sizes_get: (c, s) => this.#sizes(this.env, c, s),
|
|
107
|
+
|
|
108
|
+
clock_res_get: (_id, out) => {
|
|
109
|
+
this.#view.setBigUint64(out, 1000n, true);
|
|
110
|
+
return ESUCCESS;
|
|
111
|
+
},
|
|
112
|
+
clock_time_get: (id, _precision, out) => {
|
|
113
|
+
const ns = id === 0
|
|
114
|
+
? BigInt(Date.now()) * 1_000_000n // REALTIME
|
|
115
|
+
: BigInt(Math.round(performance.now() * 1e6)); // MONOTONIC etc.
|
|
116
|
+
this.#view.setBigUint64(out, ns, true);
|
|
117
|
+
return ESUCCESS;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
random_get: (buf, len) => {
|
|
121
|
+
// getRandomValues takes at most 65536 bytes per call.
|
|
122
|
+
for (let off = 0; off < len; off += 65536) {
|
|
123
|
+
crypto.getRandomValues(new Uint8Array(this.memory.buffer, buf + off, Math.min(65536, len - off)));
|
|
124
|
+
}
|
|
125
|
+
return ESUCCESS;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
fd_write: (fd, iovs, iovsLen, nwritten) => {
|
|
129
|
+
if (!(fd in this.out)) return EBADF;
|
|
130
|
+
const view = this.#view;
|
|
131
|
+
let total = 0;
|
|
132
|
+
for (let i = 0; i < iovsLen; i++) {
|
|
133
|
+
const ptr = view.getUint32(iovs + i * 8, true);
|
|
134
|
+
const len = view.getUint32(iovs + i * 8 + 4, true);
|
|
135
|
+
const bytes = new Uint8Array(this.memory.buffer, ptr, len);
|
|
136
|
+
if (this.outBytes[fd]) {
|
|
137
|
+
if (len) this.outBytes[fd](bytes.slice()); // the memory is reused
|
|
138
|
+
} else {
|
|
139
|
+
const text = this.decoders[fd].decode(bytes, { stream: true });
|
|
140
|
+
if (text) this.out[fd](text);
|
|
141
|
+
}
|
|
142
|
+
total += len;
|
|
143
|
+
}
|
|
144
|
+
view.setUint32(nwritten, total, true);
|
|
145
|
+
return ESUCCESS;
|
|
146
|
+
},
|
|
147
|
+
fd_read: (fd, iovs, iovsLen, nread) => {
|
|
148
|
+
if (fd !== 0) return EBADF;
|
|
149
|
+
const view = this.#view, bytes = this.#bytes;
|
|
150
|
+
let total = 0;
|
|
151
|
+
for (let i = 0; i < iovsLen && this.stdinPos < this.stdin.length; i++) {
|
|
152
|
+
const ptr = view.getUint32(iovs + i * 8, true);
|
|
153
|
+
const len = view.getUint32(iovs + i * 8 + 4, true);
|
|
154
|
+
const chunk = this.stdin.subarray(this.stdinPos, this.stdinPos + len);
|
|
155
|
+
bytes.set(chunk, ptr);
|
|
156
|
+
this.stdinPos += chunk.length;
|
|
157
|
+
total += chunk.length;
|
|
158
|
+
}
|
|
159
|
+
view.setUint32(nread, total, true); // 0 = EOF
|
|
160
|
+
return ESUCCESS;
|
|
161
|
+
},
|
|
162
|
+
fd_fdstat_get: (fd, out) => {
|
|
163
|
+
if (fd > 2) return EBADF;
|
|
164
|
+
const view = this.#view;
|
|
165
|
+
view.setUint8(out, 2); // filetype: CHARACTER_DEVICE
|
|
166
|
+
view.setUint16(out + 2, 0, true); // flags
|
|
167
|
+
view.setBigUint64(out + 8, ~0n & 0xffffffffffffffffn, true); // rights_base
|
|
168
|
+
view.setBigUint64(out + 16, ~0n & 0xffffffffffffffffn, true); // rights_inheriting
|
|
169
|
+
return ESUCCESS;
|
|
170
|
+
},
|
|
171
|
+
fd_fdstat_set_flags: (fd) => (fd > 2 ? EBADF : ESUCCESS),
|
|
172
|
+
fd_close: (fd) => (fd > 2 ? EBADF : ESUCCESS),
|
|
173
|
+
fd_seek: (fd) => (fd > 2 ? EBADF : ESPIPE),
|
|
174
|
+
fd_pread: (fd) => (fd > 2 ? EBADF : ESPIPE),
|
|
175
|
+
fd_pwrite: (fd) => (fd > 2 ? EBADF : ESPIPE),
|
|
176
|
+
// EBADF from fd 3 tells wasi-libc there are no preopened directories.
|
|
177
|
+
fd_prestat_get: () => EBADF,
|
|
178
|
+
fd_prestat_dir_name: () => EBADF,
|
|
179
|
+
path_open: () => EBADF,
|
|
180
|
+
path_filestat_get: () => EBADF,
|
|
181
|
+
|
|
182
|
+
poll_oneoff: () => ENOSYS,
|
|
183
|
+
sched_yield: () => ESUCCESS,
|
|
184
|
+
proc_raise: () => ENOSYS,
|
|
185
|
+
proc_exit: (code) => { throw new WasiExit(code); },
|
|
186
|
+
};
|
|
187
|
+
return new Proxy(impl, {
|
|
188
|
+
get: (target, name) => target[name] ?? ((..._) => ENOSYS),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Edgeway
|
|
2
|
+
# The app's entry point: Edgeway.application.call(request) -> response.
|
|
3
|
+
# How a request gets in and the response out (CGI, later a reactor) is the
|
|
4
|
+
# job of Edgeway::Server.
|
|
5
|
+
class Application
|
|
6
|
+
attr_reader :routes
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@routes = Routing::RouteSet.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(request)
|
|
13
|
+
request.read_body
|
|
14
|
+
route, path_params = @routes.recognize(request.method, request.path)
|
|
15
|
+
# Path parameters last: they win over the body's and the query's.
|
|
16
|
+
request.add_path_params(path_params)
|
|
17
|
+
Controller::Dispatcher.dispatch(route, request)
|
|
18
|
+
rescue RecordInvalid => e
|
|
19
|
+
# The error's own list (errors) is the body.
|
|
20
|
+
Controller::Rendering.json_response(422, JSON.generate({ "errors" => e.errors }))
|
|
21
|
+
rescue => e
|
|
22
|
+
rescue_default(e)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# What a request answers when no controller handled the error.
|
|
26
|
+
def rescue_default(e)
|
|
27
|
+
if e.is_a?(BadRequest)
|
|
28
|
+
message(400, e.message)
|
|
29
|
+
elsif e.is_a?(InvalidToken)
|
|
30
|
+
# The reason (e.message) goes to the log only; the client gets a fixed
|
|
31
|
+
# body and challenge, so a probe learns nothing from the wording.
|
|
32
|
+
Edgeway.logger.info "401 #{e.message}"
|
|
33
|
+
unauthorized("invalid token", 'Bearer error="invalid_token"')
|
|
34
|
+
elsif e.is_a?(Unauthorized)
|
|
35
|
+
Edgeway.logger.info "401 #{e.message}"
|
|
36
|
+
unauthorized("authentication required", "Bearer")
|
|
37
|
+
elsif e.is_a?(RecordNotUnique)
|
|
38
|
+
# The message names the table and columns: the log only.
|
|
39
|
+
Edgeway.logger.info "409 #{e.message}"
|
|
40
|
+
message(409, "record not unique")
|
|
41
|
+
elsif e.is_a?(RoutingError) || e.is_a?(RecordNotFound)
|
|
42
|
+
# The message can hold the path or an id from the request: the log only.
|
|
43
|
+
Edgeway.logger.info "404 #{e.message}"
|
|
44
|
+
message(404, "not found")
|
|
45
|
+
else
|
|
46
|
+
# The details go to the log (stderr), never to the client.
|
|
47
|
+
Edgeway.logger.error "#{e.class}: #{e.message}"
|
|
48
|
+
message(500, "internal error")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def unauthorized(text, challenge)
|
|
55
|
+
response = message(401, text)
|
|
56
|
+
response.headers["WWW-Authenticate"] = challenge
|
|
57
|
+
response
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def message(status, text)
|
|
61
|
+
# .dup: since matz/spinel 44119ab30 (Strings shared by reference),
|
|
62
|
+
# Spinel refuses this String as it is, or emits C that doesn't compile.
|
|
63
|
+
Controller::Rendering.json_response(status, JSON.generate({ "error" => text.dup }))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.application
|
|
68
|
+
@application ||= Application.new
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module CLI
|
|
5
|
+
class AppConfig
|
|
6
|
+
# adapter: => the file build/main.rb requires for it.
|
|
7
|
+
ADAPTERS = {
|
|
8
|
+
"d1" => "edgeway/service/cloudflare/d1",
|
|
9
|
+
"memory" => "edgeway/db/memory"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :env, :adapter, :binding_name
|
|
13
|
+
|
|
14
|
+
def self.load(root, env)
|
|
15
|
+
path = File.join(root, "config", "database.yml")
|
|
16
|
+
raise Error, "no config/database.yml in #{root} (is this an Edgeway app?)" unless File.exist?(path)
|
|
17
|
+
|
|
18
|
+
section = (YAML.safe_load_file(path) || {})[env]
|
|
19
|
+
raise Error, "config/database.yml has no #{env} section" unless section.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
new(env, section["adapter"].to_s, (section["binding"] || "DB").to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(env, adapter, binding_name)
|
|
25
|
+
raise Error, "unknown database adapter #{adapter.inspect} (one of #{ADAPTERS.keys.join(", ")})" unless ADAPTERS.key?(adapter)
|
|
26
|
+
|
|
27
|
+
@env = env
|
|
28
|
+
@adapter = adapter
|
|
29
|
+
@binding_name = binding_name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def adapter_file = ADAPTERS.fetch(@adapter)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module CLI
|
|
5
|
+
class AppFiles
|
|
6
|
+
def initialize(root)
|
|
7
|
+
@root = root
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def require_lines
|
|
11
|
+
lines = []
|
|
12
|
+
app = File.join(@root, "app")
|
|
13
|
+
dirs = Dir.exist?(app) ? Dir.children(app).sort.select { |d| File.directory?(File.join(app, d)) } : []
|
|
14
|
+
dirs.each do |d|
|
|
15
|
+
next if %w[models controllers].include?(d)
|
|
16
|
+
|
|
17
|
+
lines.concat(requires("app/#{d}"))
|
|
18
|
+
end
|
|
19
|
+
lines.concat(requires("app/models"))
|
|
20
|
+
lines.concat(requires("app/controllers"))
|
|
21
|
+
lines << 'require_relative "../config/routes"'
|
|
22
|
+
lines
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# require_relative lines for the .rb files under dir, application_*.rb first.
|
|
26
|
+
def requires(dir)
|
|
27
|
+
full = File.join(@root, dir)
|
|
28
|
+
return [] unless Dir.exist?(full)
|
|
29
|
+
|
|
30
|
+
files = Dir.glob("**/*.rb", base: full).map { |f| File.join(dir, f) }
|
|
31
|
+
first, rest = files.partition { |f| File.basename(f).start_with?("application_") }
|
|
32
|
+
# String#dump quotes a path with spaces, quotes or "#{" safely.
|
|
33
|
+
(first.sort + rest.sort).map { |f| "require_relative #{"../#{f.delete_suffix(".rb")}".dump}" }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Edgeway
|
|
7
|
+
module CLI
|
|
8
|
+
# edgeway build: app/, config/ -> build/main.rb -> build/app.wasm, and the
|
|
9
|
+
# gem's js/ -> build/edgeway/.
|
|
10
|
+
#
|
|
11
|
+
# Spinel resolves require at compile time and has no autoloading, so
|
|
12
|
+
# build/main.rb requires every file, in an order where a class body finds
|
|
13
|
+
# what it names (a superclass, an extended module):
|
|
14
|
+
# 1. edgeway, the database adapter config/database.yml names, and the JWT
|
|
15
|
+
# verifier
|
|
16
|
+
# 2. app/*/ other than models/ and controllers/ (services, ...)
|
|
17
|
+
# 3. app/models/
|
|
18
|
+
# 4. app/controllers/
|
|
19
|
+
# 5. config/routes.rb
|
|
20
|
+
# Within each, application_*.rb come first (the base classes), then the
|
|
21
|
+
# rest by path (byte order).
|
|
22
|
+
class Build
|
|
23
|
+
def self.run(args)
|
|
24
|
+
env = "development"
|
|
25
|
+
OptionParser.new do |o|
|
|
26
|
+
o.banner = "Usage: edgeway build [--env ENV]"
|
|
27
|
+
o.on("--env ENV", "environment (default: development)") { |v| env = v }
|
|
28
|
+
end.parse!(args)
|
|
29
|
+
new(Dir.pwd, env).run
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(root, env)
|
|
33
|
+
@root = root
|
|
34
|
+
@env = env
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run
|
|
38
|
+
raise Error, "no config/routes.rb in #{@root}" unless File.exist?(File.join(@root, "config/routes.rb"))
|
|
39
|
+
|
|
40
|
+
config = AppConfig.load(@root, @env)
|
|
41
|
+
FileUtils.mkdir_p(File.join(@root, "build"))
|
|
42
|
+
write_main(config)
|
|
43
|
+
copy_js(config)
|
|
44
|
+
compile
|
|
45
|
+
puts "built build/app.wasm (#{@env})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def write_main(config)
|
|
51
|
+
lines = ["# Generated by `edgeway build` from app/ and config/; do not edit.", 'require "edgeway"']
|
|
52
|
+
lines << "require #{config.adapter_file.dump}"
|
|
53
|
+
# Always: js/cloudflare.js supplies the edgeway_jwt import to every app,
|
|
54
|
+
# and the verifier is only called by an app that authenticates.
|
|
55
|
+
lines << 'require "edgeway/service/cloudflare/jwt"'
|
|
56
|
+
lines << ""
|
|
57
|
+
lines << connection_line(config)
|
|
58
|
+
lines << "Edgeway::Auth.verifier = Edgeway::Cloudflare::JWTVerifier.new"
|
|
59
|
+
lines << ""
|
|
60
|
+
lines.concat(AppFiles.new(@root).require_lines)
|
|
61
|
+
lines << ""
|
|
62
|
+
lines << "Edgeway::Server.run"
|
|
63
|
+
File.write(File.join(@root, "build/main.rb"), lines.join("\n") + "\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def connection_line(config)
|
|
67
|
+
case config.adapter
|
|
68
|
+
when "d1" then %(Edgeway::Model.connection = Edgeway::Cloudflare::D1Adapter.new(binding: #{config.binding_name.inspect}))
|
|
69
|
+
else "Edgeway::Model.connection = Edgeway::DB::MemoryAdapter.new"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def copy_js(config)
|
|
74
|
+
dest = File.join(@root, "build/edgeway")
|
|
75
|
+
FileUtils.mkdir_p(dest)
|
|
76
|
+
Dir.glob(File.join(CLI.gem_dir, "js/*.js")).each { |f| FileUtils.cp(f, dest) }
|
|
77
|
+
js = "// Generated by `edgeway build` from config/database.yml; do not edit.\n" \
|
|
78
|
+
"export default #{JSON.generate({ "env" => config.env, "d1Binding" => config.binding_name })};\n"
|
|
79
|
+
File.write(File.join(dest, "config.js"), js)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def compile
|
|
83
|
+
spinel = Toolchain.spinel || raise(Error, "no Spinel compiler found (install it with mise, or set SPINEL=/path/to/spinel)")
|
|
84
|
+
cmd = [spinel, "-I", File.join(CLI.gem_dir, "lib"), "--target=wasm32-wasi", "build/main.rb", "-o", "build/app.wasm"]
|
|
85
|
+
puts cmd.join(" ")
|
|
86
|
+
system(*cmd, chdir: @root) || raise(Error, "spinel failed")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../inflector"
|
|
4
|
+
require_relative "package_manager"
|
|
5
|
+
|
|
6
|
+
module Edgeway
|
|
7
|
+
module CLI
|
|
8
|
+
class Generate
|
|
9
|
+
USAGE = <<~TEXT
|
|
10
|
+
Usage: edgeway generate model NAME [field:type ...]
|
|
11
|
+
edgeway generate controller NAME [action ...]
|
|
12
|
+
|
|
13
|
+
Types: #{%w[string text integer float boolean datetime].join(", ")} (default string)
|
|
14
|
+
TEXT
|
|
15
|
+
|
|
16
|
+
# field type => [the column's SQL type, its attribute type]
|
|
17
|
+
# D1 is SQLite, which has no datetime/boolean type (only NULL, INTEGER,
|
|
18
|
+
# REAL, TEXT and BLOB): a datetime is TEXT (ISO 8601, as Model.now
|
|
19
|
+
# writes it) and a boolean is an INTEGER 0/1. It is SQLite's, not a
|
|
20
|
+
# Cloudflare, limit.
|
|
21
|
+
TYPES = {
|
|
22
|
+
"string" => ["TEXT", ":string"],
|
|
23
|
+
"text" => ["TEXT", ":string"],
|
|
24
|
+
"integer" => ["INTEGER", ":integer"],
|
|
25
|
+
"float" => ["REAL", ":float"],
|
|
26
|
+
"boolean" => ["INTEGER NOT NULL DEFAULT 0", ":boolean"],
|
|
27
|
+
"datetime" => ["TEXT", ":datetime"]
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
RESOURCE_ACTIONS = %w[index show create update destroy].freeze
|
|
31
|
+
|
|
32
|
+
def self.run(args)
|
|
33
|
+
kind, name, *rest = args
|
|
34
|
+
raise Error, USAGE unless %w[model controller].include?(kind) && name && !name.start_with?("-")
|
|
35
|
+
|
|
36
|
+
generator = new(Dir.pwd)
|
|
37
|
+
kind == "model" ? generator.model(name, rest) : generator.controller(name, rest)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(root)
|
|
41
|
+
@root = root
|
|
42
|
+
return if File.exist?(File.join(root, "config/routes.rb"))
|
|
43
|
+
|
|
44
|
+
raise Error, "no config/routes.rb in #{root}; run this inside an app made by `edgeway new`"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def model(name, fields)
|
|
48
|
+
base = underscore(name)
|
|
49
|
+
table = Inflector.pluralize(base)
|
|
50
|
+
columns = fields.map { |f| parse_field(f) }
|
|
51
|
+
write("app/models/#{base}.rb", model_source(camelize(base), columns))
|
|
52
|
+
write("db/migrations/#{next_migration_number}_create_#{table}.sql", migration_source(table, columns))
|
|
53
|
+
write("test/models/#{base}_test.rb", model_test_source(camelize(base)))
|
|
54
|
+
puts "\nApply the migration locally: #{PackageManager.exec} wrangler d1 migrations apply DB_NAME --local"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def controller(name, actions)
|
|
58
|
+
base = underscore(name).delete_suffix("_controller")
|
|
59
|
+
actions.each { |a| raise Error, "invalid action name: #{a}" unless a.match?(/\A[a-z_][a-z0-9_]*\z/) }
|
|
60
|
+
write("app/controllers/#{base}_controller.rb", controller_source(camelize(base), actions))
|
|
61
|
+
write("test/controllers/#{base}_test.rb", controller_test_source(camelize(base)))
|
|
62
|
+
puts "\nAdd to config/routes.rb:"
|
|
63
|
+
route_hints(base, actions).each { |l| puts " #{l}" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def underscore(name)
|
|
69
|
+
raise Error, "invalid name: #{name}" unless name.match?(/\A[A-Za-z][A-Za-z0-9_]*\z/)
|
|
70
|
+
|
|
71
|
+
name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def camelize(name) = name.split("_").map(&:capitalize).join
|
|
75
|
+
|
|
76
|
+
# "title:string" => ["title", "TEXT", ":string"]
|
|
77
|
+
def parse_field(spec)
|
|
78
|
+
name, type = spec.split(":", 2)
|
|
79
|
+
type ||= "string"
|
|
80
|
+
raise Error, "invalid field name: #{spec}" unless name.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
81
|
+
raise Error, "unknown field type #{type} (#{TYPES.keys.join(", ")})" unless TYPES.key?(type)
|
|
82
|
+
|
|
83
|
+
[name, *TYPES[type]]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def model_source(klass, columns)
|
|
87
|
+
lines = columns.map do |name, _sql, attr|
|
|
88
|
+
attr == ":boolean" ? " attribute :#{name}, :boolean, default: false" : " attribute :#{name}, #{attr}"
|
|
89
|
+
end
|
|
90
|
+
lines << " timestamps"
|
|
91
|
+
"class #{klass} < ApplicationModel\n#{lines.join("\n")}\nend\n"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def migration_source(table, columns)
|
|
95
|
+
lines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"]
|
|
96
|
+
columns.each { |name, sql, _attr| lines << " #{name} #{sql}" }
|
|
97
|
+
lines << " created_at TEXT" << " updated_at TEXT"
|
|
98
|
+
"CREATE TABLE #{table} (\n#{lines.join(",\n")}\n);\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def model_test_source(klass)
|
|
102
|
+
<<~RUBY
|
|
103
|
+
require "edgeway/test"
|
|
104
|
+
require "test_helper"
|
|
105
|
+
|
|
106
|
+
class #{klass}Test < Edgeway::UnitTest
|
|
107
|
+
def test_starts_empty
|
|
108
|
+
assert_equal 0, #{klass}.count
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
RUBY
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def controller_source(klass, actions)
|
|
115
|
+
defs = actions.map { |a| " def #{a} = render json: {}\n" }
|
|
116
|
+
"class #{klass}Controller < ApplicationController\n#{defs.join("\n")}end\n"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def controller_test_source(klass)
|
|
120
|
+
<<~RUBY
|
|
121
|
+
require "edgeway/test"
|
|
122
|
+
require "test_helper"
|
|
123
|
+
|
|
124
|
+
class #{klass}Test < Edgeway::IntegrationTest
|
|
125
|
+
# def test_index
|
|
126
|
+
# get "/#{underscore(klass)}"
|
|
127
|
+
# assert_response 200
|
|
128
|
+
# end
|
|
129
|
+
end
|
|
130
|
+
RUBY
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def route_hints(base, actions)
|
|
134
|
+
return ["r.resources :#{base}"] if actions.empty?
|
|
135
|
+
|
|
136
|
+
standard = actions & RESOURCE_ACTIONS
|
|
137
|
+
# Before the resources: "/todos/archive" would match "/todos/:id" first.
|
|
138
|
+
hints = (actions - standard).map { |a| "r.get \"/#{base}/#{a}\", to: \"#{base}##{a}\"" }
|
|
139
|
+
hints << "r.resources :#{base}, only: [#{standard.map { |a| ":#{a}" }.join(", ")}]" unless standard.empty?
|
|
140
|
+
hints
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# NNNN, one above the highest numbered file in db/migrations (0001 for none).
|
|
144
|
+
def next_migration_number
|
|
145
|
+
dir = File.join(@root, "db/migrations")
|
|
146
|
+
last = Dir.exist?(dir) ? Dir.children(dir).filter_map { |f| f[/\A\d+/]&.to_i }.max.to_i : 0
|
|
147
|
+
format("%04d", last + 1)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def write(rel, text)
|
|
151
|
+
dest = File.join(@root, rel)
|
|
152
|
+
raise Error, "#{rel} already exists" if File.exist?(dest)
|
|
153
|
+
|
|
154
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
155
|
+
File.write(dest, text)
|
|
156
|
+
puts " create #{rel}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Edgeway
|
|
6
|
+
module CLI
|
|
7
|
+
class New
|
|
8
|
+
TEMPLATES = File.expand_path("../generators/app/templates", __dir__)
|
|
9
|
+
|
|
10
|
+
def self.run(args)
|
|
11
|
+
name = args.first
|
|
12
|
+
raise Error, "Usage: edgeway new APP" if name.nil? || name.start_with?("-")
|
|
13
|
+
raise Error, "#{name} already exists" if File.exist?(name)
|
|
14
|
+
|
|
15
|
+
new(name).run
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(dir)
|
|
19
|
+
@dir = dir
|
|
20
|
+
@name = File.basename(File.expand_path(dir)).gsub(/[^A-Za-z0-9_-]/, "-")
|
|
21
|
+
@version = Edgeway::VERSION
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: TEMPLATES).sort.each do |rel|
|
|
26
|
+
src = File.join(TEMPLATES, rel)
|
|
27
|
+
next if File.directory?(src) || File.basename(rel) == "."
|
|
28
|
+
|
|
29
|
+
dest = File.join(@dir, rel.delete_suffix(".tt"))
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
31
|
+
if rel.end_with?(".tt")
|
|
32
|
+
File.write(dest, ERB.new(File.read(src), trim_mode: "-").result(binding))
|
|
33
|
+
else
|
|
34
|
+
FileUtils.cp(src, dest)
|
|
35
|
+
end
|
|
36
|
+
FileUtils.chmod(0o755, dest) if File.executable?(src)
|
|
37
|
+
puts " create #{dest}"
|
|
38
|
+
end
|
|
39
|
+
puts "\nNext: cd #{@dir} && bin/setup && bundle exec edgeway test && bin/build"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module CLI
|
|
5
|
+
# The JavaScript package manager of an app made by `edgeway new`: the one
|
|
6
|
+
# place that names it. The templates (bin/*, README, mise.toml,
|
|
7
|
+
# wrangler.toml) ask it for the commands, so supporting another is a
|
|
8
|
+
# change here (and a way to choose it in New).
|
|
9
|
+
module PackageManager
|
|
10
|
+
NAME = "pnpm"
|
|
11
|
+
|
|
12
|
+
def self.name = NAME
|
|
13
|
+
|
|
14
|
+
# Installs package.json's dependencies (wrangler).
|
|
15
|
+
def self.install = "#{NAME} install"
|
|
16
|
+
|
|
17
|
+
# Runs a binary of the app's node_modules: `#{exec} wrangler dev`.
|
|
18
|
+
def self.exec = "#{NAME} exec"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|