edgeway 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +784 -125
- data/js/cloudflare-d1-sessions.js +68 -0
- data/js/cloudflare-kv.js +47 -0
- data/js/cloudflare-ratelimit.js +31 -0
- data/js/cloudflare-test-database.js +112 -0
- data/js/cloudflare-test-runner.js +76 -0
- data/js/cloudflare.js +144 -22
- data/js/cors.js +15 -16
- data/js/fetch.js +113 -0
- data/js/headers.js +16 -0
- data/js/jspi.js +32 -0
- data/js/jwt.js +6 -40
- data/js/runtime.js +42 -5
- data/lib/edgeway/application.rb +21 -9
- data/lib/edgeway/cache/limits.rb +8 -0
- data/lib/edgeway/cache/memory_store.rb +24 -0
- data/lib/edgeway/cache/null_store.rb +13 -0
- data/lib/edgeway/cache/store.rb +189 -0
- data/lib/edgeway/cache.rb +13 -0
- data/lib/edgeway/cli/build/app_config.rb +39 -0
- data/lib/edgeway/cli/build/app_files.rb +40 -0
- data/lib/edgeway/cli/build/cache_config.rb +108 -0
- data/lib/edgeway/cli/build/database_config.rb +96 -0
- data/lib/edgeway/cli/build/js_files.rb +30 -0
- data/lib/edgeway/cli/build/view_compiler.rb +191 -0
- data/lib/edgeway/cli/build.rb +50 -18
- data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
- data/lib/edgeway/cli/cloudflare_test.rb +120 -0
- data/lib/edgeway/cli/generate.rb +174 -26
- data/lib/edgeway/cli/new.rb +1 -1
- data/lib/edgeway/cli/test.rb +28 -9
- data/lib/edgeway/cli/toolchain.rb +5 -0
- data/lib/edgeway/cli.rb +7 -4
- data/lib/edgeway/controller/base.rb +32 -3
- data/lib/edgeway/controller/caching.rb +140 -0
- data/lib/edgeway/controller/callbacks.rb +5 -1
- data/lib/edgeway/controller/dispatcher.rb +11 -1
- data/lib/edgeway/controller/rate_limiting.rb +145 -0
- data/lib/edgeway/controller/rendering.rb +24 -4
- data/lib/edgeway/db/adapter.rb +38 -0
- data/lib/edgeway/db/memory.rb +9 -2
- data/lib/edgeway/db/query_stats.rb +42 -0
- data/lib/edgeway/db/sqlite.rb +73 -9
- data/lib/edgeway/errors.rb +43 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
- data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
- data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
- data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
- data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
- data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
- data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
- data/lib/edgeway/http/auth/stub.rb +1 -3
- data/lib/edgeway/http/client.rb +80 -0
- data/lib/edgeway/http/request.rb +7 -22
- data/lib/edgeway/http/stub_transport.rb +38 -0
- data/lib/edgeway/inflector.rb +19 -0
- data/lib/edgeway/logger.rb +33 -5
- data/lib/edgeway/model/attributes.rb +93 -10
- data/lib/edgeway/model/base.rb +50 -5
- data/lib/edgeway/model/connection_registry.rb +25 -0
- data/lib/edgeway/model/macros.rb +5 -1
- data/lib/edgeway/model/persistence.rb +18 -11
- data/lib/edgeway/model/relation.rb +106 -56
- data/lib/edgeway/model/validations.rb +29 -23
- data/lib/edgeway/rate_limit/limiter.rb +9 -0
- data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
- data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
- data/lib/edgeway/rate_limit.rb +2 -0
- data/lib/edgeway/routing/route.rb +1 -6
- data/lib/edgeway/server.rb +13 -0
- data/lib/edgeway/service/cloudflare/d1.rb +44 -30
- data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
- data/lib/edgeway/service/cloudflare/kv.rb +45 -0
- data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
- data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
- data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
- data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
- data/lib/edgeway/service/fetch.rb +26 -0
- data/lib/edgeway/service/import_result.rb +24 -0
- data/lib/edgeway/test/sqlite_databases.rb +18 -0
- data/lib/edgeway/test.rb +86 -26
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway/view.rb +24 -0
- data/lib/edgeway.rb +9 -0
- metadata +50 -4
- data/lib/edgeway/cli/app_config.rb +0 -35
- data/lib/edgeway/cli/app_files.rb +0 -37
data/js/fetch.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jspiHelpers } from "./jspi.js";
|
|
2
|
+
|
|
3
|
+
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
4
|
+
export const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
5
|
+
const BASE64_CHUNK = 8192;
|
|
6
|
+
|
|
7
|
+
export function urlAllowed(url) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(url);
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (parsed.protocol === "https:") return true;
|
|
15
|
+
return parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fetchWithTimeout(url, init, timeoutMs, fetch = globalThis.fetch) {
|
|
19
|
+
return fetch(url, { ...init, redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkDeclaredSize(response, maxBytes) {
|
|
23
|
+
const declared = response.headers?.get("content-length");
|
|
24
|
+
if (declared !== null && declared !== undefined && Number(declared) > maxBytes) throw new Error("response is too large");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function joinChunks(chunks, size) {
|
|
28
|
+
const all = new Uint8Array(size);
|
|
29
|
+
let offset = 0;
|
|
30
|
+
for (const chunk of chunks) {
|
|
31
|
+
all.set(chunk, offset);
|
|
32
|
+
offset += chunk.byteLength;
|
|
33
|
+
}
|
|
34
|
+
return all;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A chunked body has no Content-Length, so the size is counted again while reading.
|
|
38
|
+
export async function readCapped(response, maxBytes) {
|
|
39
|
+
checkDeclaredSize(response, maxBytes);
|
|
40
|
+
if (!response.body) return new Uint8Array(0);
|
|
41
|
+
const reader = response.body.getReader();
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let size = 0;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const { done, value } = await reader.read();
|
|
46
|
+
if (done) break;
|
|
47
|
+
size += value.byteLength;
|
|
48
|
+
if (size > maxBytes) {
|
|
49
|
+
await reader.cancel().catch(() => {});
|
|
50
|
+
throw new Error("response is too large");
|
|
51
|
+
}
|
|
52
|
+
chunks.push(value);
|
|
53
|
+
}
|
|
54
|
+
return joinChunks(chunks, size);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function base64Of(bytes) {
|
|
58
|
+
let binary = "";
|
|
59
|
+
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) {
|
|
60
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK));
|
|
61
|
+
}
|
|
62
|
+
return btoa(binary);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const INVALID_REQUEST = "request is not valid UTF-8";
|
|
66
|
+
|
|
67
|
+
export function parseRequest(bytes) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function failure(error) {
|
|
76
|
+
if (error?.name === "TimeoutError") return { error: "request timed out", timeout: true };
|
|
77
|
+
return { error: String(error?.message ?? error) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function performRequest(spec, { fetch = globalThis.fetch, maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
81
|
+
if (!urlAllowed(spec.url)) return { error: "url must be https (http only for localhost)" };
|
|
82
|
+
try {
|
|
83
|
+
const init = { method: spec.method, headers: spec.headers };
|
|
84
|
+
if (spec.body) init.body = spec.body;
|
|
85
|
+
const response = await fetchWithTimeout(spec.url, init, spec.timeout_ms, fetch);
|
|
86
|
+
const bytes = await readCapped(response, maxBytes);
|
|
87
|
+
return { status: response.status, headers: Object.fromEntries(response.headers), body_base64: base64Of(bytes) };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return failure(error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The wasm import module "edgeway_fetch" (lib/edgeway/service/fetch.rb):
|
|
94
|
+
// request(json) -> {"status": n, "headers": {"lower-case-name": "value"}, "body_base64": "..."}
|
|
95
|
+
// | {"error": "message"}
|
|
96
|
+
// | {"error": "message", "timeout": true}
|
|
97
|
+
// json is {"method", "url", "headers", "body", "timeout_ms"}. A redirect is not
|
|
98
|
+
// followed: its response (with the location header) is the answer. A call answers the
|
|
99
|
+
// byte length of the result and take() copies it out; request is wrapped in
|
|
100
|
+
// WebAssembly.Suspending.
|
|
101
|
+
export function fetchImports(getInstance, { fetch = globalThis.fetch, maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
102
|
+
const { readBytes, setResult, take } = jspiHelpers(getInstance);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
edgeway_fetch: {
|
|
106
|
+
request: new WebAssembly.Suspending(async (specPointer) => {
|
|
107
|
+
const spec = parseRequest(readBytes(specPointer));
|
|
108
|
+
return setResult(spec ? await performRequest(spec, { fetch, maxBytes }) : { error: INVALID_REQUEST });
|
|
109
|
+
}),
|
|
110
|
+
take,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
data/js/headers.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const listOf = (value) => (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
2
|
+
|
|
3
|
+
export function withHeaders(list, ...names) {
|
|
4
|
+
const have = listOf(list);
|
|
5
|
+
for (const name of names) {
|
|
6
|
+
if (!have.some((existing) => existing.toLowerCase() === name.toLowerCase())) have.push(name);
|
|
7
|
+
}
|
|
8
|
+
return have.join(", ");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A Vary of * already covers every header.
|
|
12
|
+
export function addVary(headers, ...names) {
|
|
13
|
+
const have = listOf(headers.get("Vary"));
|
|
14
|
+
if (have.includes("*")) return;
|
|
15
|
+
headers.set("Vary", withHeaders(have.join(", "), ...names));
|
|
16
|
+
}
|
data/js/jspi.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const textDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
2
|
+
const textEncoder = new TextEncoder();
|
|
3
|
+
|
|
4
|
+
// What the wasm side of a JSPI import module needs: C strings read from its
|
|
5
|
+
// memory, and a result handed over in two steps, since an import can't return
|
|
6
|
+
// a string. A suspending import answers the byte length of its JSON result and
|
|
7
|
+
// the wasm side then calls take(buffer) to copy it.
|
|
8
|
+
export function jspiHelpers(getInstance) {
|
|
9
|
+
const memory = () => new Uint8Array(getInstance().exports.memory.buffer);
|
|
10
|
+
let pending = new Uint8Array(0);
|
|
11
|
+
|
|
12
|
+
const readBytes = (pointer) => {
|
|
13
|
+
const bytes = memory();
|
|
14
|
+
let end = pointer;
|
|
15
|
+
while (bytes[end]) end++;
|
|
16
|
+
return bytes.subarray(pointer, end);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const readCString = (pointer) => textDecoder.decode(readBytes(pointer));
|
|
20
|
+
|
|
21
|
+
const setResult = (result) => {
|
|
22
|
+
pending = textEncoder.encode(JSON.stringify(result));
|
|
23
|
+
return pending.length;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const take = (buffer) => {
|
|
27
|
+
memory().set(pending, buffer);
|
|
28
|
+
pending = new Uint8Array(0);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return { readBytes, readCString, setResult, take };
|
|
32
|
+
}
|
data/js/jwt.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// (RFC 7515 4.1.11: no extension is understood), and a JWKS key needs a `kid`
|
|
22
22
|
// on the token and, if it has key_ops, "verify" in them.
|
|
23
23
|
|
|
24
|
+
import { fetchWithTimeout, readCapped, urlAllowed } from "./fetch.js";
|
|
25
|
+
|
|
24
26
|
const MAX_TOKEN = 8192;
|
|
25
27
|
// A JWKS is kept this long, and re-fetched early (for an unknown kid) at
|
|
26
28
|
// most this often.
|
|
@@ -90,45 +92,9 @@ function parseJsonPart(s, what) {
|
|
|
90
92
|
// await I/O that another request started.
|
|
91
93
|
const sharedCache = new Map();
|
|
92
94
|
|
|
93
|
-
// Only https, so a JWKS can't be swapped on the way; a local server (tests,
|
|
94
|
-
// `wrangler dev`) may use http.
|
|
95
|
-
function jwksUrlAllowed(url) {
|
|
96
|
-
let u;
|
|
97
|
-
try {
|
|
98
|
-
u = new URL(url);
|
|
99
|
-
} catch {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
if (u.protocol === "https:") return true;
|
|
103
|
-
return u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// The body of res as text, at most MAX_JWKS bytes (checked on Content-Length
|
|
107
|
-
// when there is one, and again while reading, because a chunked body has none).
|
|
108
95
|
async function limitedText(res) {
|
|
109
|
-
const declared = res.headers?.get("content-length");
|
|
110
|
-
if (declared !== null && declared !== undefined && Number(declared) > MAX_JWKS) throw new Error("response is too large");
|
|
111
96
|
if (!res.body) throw new Error("no body");
|
|
112
|
-
|
|
113
|
-
const chunks = [];
|
|
114
|
-
let size = 0;
|
|
115
|
-
for (;;) {
|
|
116
|
-
const { done, value } = await reader.read();
|
|
117
|
-
if (done) break;
|
|
118
|
-
size += value.byteLength;
|
|
119
|
-
if (size > MAX_JWKS) {
|
|
120
|
-
await reader.cancel().catch(() => {});
|
|
121
|
-
throw new Error("response is too large");
|
|
122
|
-
}
|
|
123
|
-
chunks.push(value);
|
|
124
|
-
}
|
|
125
|
-
const all = new Uint8Array(size);
|
|
126
|
-
let at = 0;
|
|
127
|
-
for (const c of chunks) {
|
|
128
|
-
all.set(c, at);
|
|
129
|
-
at += c.byteLength;
|
|
130
|
-
}
|
|
131
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(all);
|
|
97
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(await readCapped(res, MAX_JWKS));
|
|
132
98
|
}
|
|
133
99
|
|
|
134
100
|
export function jwtVerifier(env, { fetch = globalThis.fetch, now = Date.now, cache = sharedCache, timeoutMs = JWKS_TIMEOUT_MS } = {}) {
|
|
@@ -141,7 +107,7 @@ export function jwtVerifier(env, { fetch = globalThis.fetch, now = Date.now, cac
|
|
|
141
107
|
// No stale JWKS: past its TTL (or when a forced refetch fails) a failed
|
|
142
108
|
// fetch is "unavailable", never an old key.
|
|
143
109
|
try {
|
|
144
|
-
const res = await
|
|
110
|
+
const res = await fetchWithTimeout(url, {}, timeoutMs, fetch);
|
|
145
111
|
if (res.status !== 200) throw new Error(`status ${res.status}`);
|
|
146
112
|
const keys = JSON.parse(await limitedText(res)).keys;
|
|
147
113
|
if (!Array.isArray(keys)) throw new Error("no keys");
|
|
@@ -189,7 +155,7 @@ export function jwtVerifier(env, { fetch = globalThis.fetch, now = Date.now, cac
|
|
|
189
155
|
}
|
|
190
156
|
const url = env.JWT_JWKS_URL;
|
|
191
157
|
if (!url) throw new JwtError("JWT_JWKS_URL is not set", true);
|
|
192
|
-
if (!
|
|
158
|
+
if (!urlAllowed(url)) throw new JwtError("JWT_JWKS_URL must be https (http only for localhost)", true);
|
|
193
159
|
if (kid === undefined) throw new JwtError("kid is required");
|
|
194
160
|
let entry = await loadKeys(url, false);
|
|
195
161
|
let jwk = pick(entry, alg, algo, kid);
|
|
@@ -245,7 +211,7 @@ export function jwtVerifier(env, { fetch = globalThis.fetch, now = Date.now, cac
|
|
|
245
211
|
// verify(token) -> {"header": {...}, "payload": {...}}
|
|
246
212
|
// | {"error": "message"} the token is bad
|
|
247
213
|
// | {"error": "message", "unavailable": true} it can't be checked
|
|
248
|
-
// Like
|
|
214
|
+
// Like the other imports, a call answers the byte length of the result and take() copies it
|
|
249
215
|
// out; verify is wrapped in WebAssembly.Suspending (the WebCrypto and fetch
|
|
250
216
|
// Promises are awaited with the wasm stack suspended).
|
|
251
217
|
export function jwtImports(env, getInstance, options = {}) {
|
data/js/runtime.js
CHANGED
|
@@ -44,6 +44,41 @@ async function readBody(request) {
|
|
|
44
44
|
return concatBytes(chunks);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
48
|
+
|
|
49
|
+
export function parseLogLine(line) {
|
|
50
|
+
if (!line.startsWith("{")) return null;
|
|
51
|
+
let entry;
|
|
52
|
+
try {
|
|
53
|
+
entry = JSON.parse(line);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const isObject = entry !== null && typeof entry === "object" && !Array.isArray(entry);
|
|
58
|
+
return isObject && LOG_LEVELS.includes(entry.level) && typeof entry.message === "string" ? entry : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function logLine(line, logFields = {}, sink = console) {
|
|
62
|
+
const entry = parseLogLine(line);
|
|
63
|
+
if (entry) sink[entry.level]({ ...logFields, ...entry });
|
|
64
|
+
else sink.error(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function lineSplitter(onLine) {
|
|
68
|
+
let pending = "";
|
|
69
|
+
return {
|
|
70
|
+
push(text) {
|
|
71
|
+
const lines = (pending + text).split(/\r?\n/);
|
|
72
|
+
pending = lines.pop();
|
|
73
|
+
for (const line of lines) if (line !== "") onLine(line);
|
|
74
|
+
},
|
|
75
|
+
flush() {
|
|
76
|
+
if (pending !== "") onLine(pending);
|
|
77
|
+
pending = "";
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
47
82
|
function internalError() {
|
|
48
83
|
return new Response('{"error":"internal error"}\n', {
|
|
49
84
|
status: 500,
|
|
@@ -55,23 +90,24 @@ function internalError() {
|
|
|
55
90
|
// Response.
|
|
56
91
|
// extraEnv: more CGI environment variables (strings), such as the app's
|
|
57
92
|
// settings and EDGEWAY_ENV.
|
|
93
|
+
// logFields: fields added to every structured log line the app writes.
|
|
58
94
|
// imports: (getInstance) => { moduleName: { name: fn } }, the wasm import
|
|
59
95
|
// modules besides WASI. A function wrapped in
|
|
60
96
|
// WebAssembly.Suspending may await (JSPI).
|
|
61
|
-
export async function runCgi(appModule, request, { extraEnv = {}, imports = () => ({}) } = {}) {
|
|
97
|
+
export async function runCgi(appModule, request, { extraEnv = {}, imports = () => ({}), logFields = {} } = {}) {
|
|
62
98
|
const body = request.method === "GET" || request.method === "HEAD"
|
|
63
99
|
? new Uint8Array(0)
|
|
64
100
|
: await readBody(request);
|
|
65
101
|
if (body === null) return payloadTooLarge();
|
|
66
102
|
|
|
67
103
|
const out = [];
|
|
68
|
-
|
|
104
|
+
const stderr = lineSplitter((line) => logLine(line, logFields));
|
|
69
105
|
const wasi = new WASI({
|
|
70
106
|
args: ["app"],
|
|
71
107
|
env: { ...extraEnv, ...cgiEnv(request, body), SPINEL_GC_SLAB_MB: SLAB_MB },
|
|
72
108
|
stdin: body,
|
|
73
109
|
onStdoutBytes: (b) => out.push(b),
|
|
74
|
-
onStderr: (
|
|
110
|
+
onStderr: (text) => stderr.push(text),
|
|
75
111
|
});
|
|
76
112
|
// Whatever goes wrong inside the app (a trap, a non-zero exit) is logged
|
|
77
113
|
// and answered with a fixed body: stderr and error messages never reach
|
|
@@ -86,10 +122,11 @@ export async function runCgi(appModule, request, { extraEnv = {}, imports = () =
|
|
|
86
122
|
});
|
|
87
123
|
status = await wasi.startAsync(instance);
|
|
88
124
|
} catch (e) {
|
|
89
|
-
|
|
125
|
+
stderr.flush();
|
|
126
|
+
console.error("app trapped:", e);
|
|
90
127
|
return internalError();
|
|
91
128
|
}
|
|
92
|
-
|
|
129
|
+
stderr.flush();
|
|
93
130
|
if (status !== 0) {
|
|
94
131
|
console.error(`app exited with ${status}`);
|
|
95
132
|
return internalError();
|
data/lib/edgeway/application.rb
CHANGED
|
@@ -29,28 +29,42 @@ module Edgeway
|
|
|
29
29
|
elsif e.is_a?(InvalidToken)
|
|
30
30
|
# The reason (e.message) goes to the log only; the client gets a fixed
|
|
31
31
|
# body and challenge, so a probe learns nothing from the wording.
|
|
32
|
-
Edgeway.logger.info
|
|
32
|
+
Edgeway.logger.info(e.message, status: 401)
|
|
33
33
|
unauthorized("invalid token", 'Bearer error="invalid_token"')
|
|
34
34
|
elsif e.is_a?(Unauthorized)
|
|
35
|
-
Edgeway.logger.info
|
|
35
|
+
Edgeway.logger.info(e.message, status: 401)
|
|
36
36
|
unauthorized("authentication required", "Bearer")
|
|
37
|
+
elsif e.is_a?(TooManyRequests)
|
|
38
|
+
too_many_requests(e)
|
|
37
39
|
elsif e.is_a?(RecordNotUnique)
|
|
38
40
|
# The message names the table and columns: the log only.
|
|
39
|
-
Edgeway.logger.info
|
|
41
|
+
Edgeway.logger.info(e.message, status: 409)
|
|
40
42
|
message(409, "record not unique")
|
|
43
|
+
elsif e.is_a?(DatabaseUnavailable)
|
|
44
|
+
Edgeway.logger.error(e.message, status: 503)
|
|
45
|
+
response = message(503, "database unavailable")
|
|
46
|
+
response.headers["Retry-After"] = "1"
|
|
47
|
+
response
|
|
41
48
|
elsif e.is_a?(RoutingError) || e.is_a?(RecordNotFound)
|
|
42
49
|
# The message can hold the path or an id from the request: the log only.
|
|
43
|
-
Edgeway.logger.info
|
|
50
|
+
Edgeway.logger.info(e.message, status: 404)
|
|
44
51
|
message(404, "not found")
|
|
45
52
|
else
|
|
46
|
-
# The details go to the log
|
|
47
|
-
Edgeway.logger.error
|
|
53
|
+
# The details go to the log, never to the client.
|
|
54
|
+
Edgeway.logger.error("#{e.class}: #{e.message}", status: 500)
|
|
48
55
|
message(500, "internal error")
|
|
49
56
|
end
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
private
|
|
53
60
|
|
|
61
|
+
def too_many_requests(error)
|
|
62
|
+
Edgeway.logger.info(error.message, status: 429)
|
|
63
|
+
response = message(429, "rate limit exceeded")
|
|
64
|
+
response.headers["Retry-After"] = error.retry_after.to_s if error.retry_after > 0
|
|
65
|
+
response
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
def unauthorized(text, challenge)
|
|
55
69
|
response = message(401, text)
|
|
56
70
|
response.headers["WWW-Authenticate"] = challenge
|
|
@@ -58,9 +72,7 @@ module Edgeway
|
|
|
58
72
|
end
|
|
59
73
|
|
|
60
74
|
def message(status, text)
|
|
61
|
-
|
|
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 }))
|
|
75
|
+
Controller::Rendering.json_response(status, JSON.generate({ "error" => text }))
|
|
64
76
|
end
|
|
65
77
|
end
|
|
66
78
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require_relative "store"
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module Cache
|
|
5
|
+
class MemoryStore < Store
|
|
6
|
+
def initialize(namespace: nil, expires_in: nil)
|
|
7
|
+
super(namespace: namespace, expires_in: expires_in)
|
|
8
|
+
@texts = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def read_text(key) = @texts[key]
|
|
12
|
+
|
|
13
|
+
def write_text(key, text, expires_in_seconds)
|
|
14
|
+
@texts[key] = text
|
|
15
|
+
true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def delete_text(key)
|
|
19
|
+
@texts.delete(key)
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
require_relative "limits"
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module Cache
|
|
5
|
+
MAX_KEY_BYTES = 512
|
|
6
|
+
KEY_DIGEST_LENGTH = 64
|
|
7
|
+
|
|
8
|
+
def self.utf8?(text) = text.dup.force_encoding("UTF-8").valid_encoding?
|
|
9
|
+
|
|
10
|
+
class Store
|
|
11
|
+
def initialize(namespace: nil, expires_in: nil)
|
|
12
|
+
@namespace = namespace.nil? ? "" : namespace.dup.force_encoding("UTF-8")
|
|
13
|
+
@default_expires_in = expires_in.nil? ? 0 : checked_expires_in(expires_in)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read(key)
|
|
17
|
+
entry = lookup(key)
|
|
18
|
+
entry.nil? ? nil : entry["v"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def exist?(key) = !lookup(key).nil?
|
|
22
|
+
|
|
23
|
+
def write(key, value, expires_in: nil)
|
|
24
|
+
seconds = expires_in_seconds(expires_in)
|
|
25
|
+
check_cacheable(value)
|
|
26
|
+
write_text(expand_key(key), envelope(value, seconds), seconds)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetch(key, expires_in: nil, force: false, skip_nil: false)
|
|
30
|
+
unless block_given?
|
|
31
|
+
raise ArgumentError, "missing block" if force
|
|
32
|
+
|
|
33
|
+
return read(key)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless force
|
|
37
|
+
entry = lookup(key)
|
|
38
|
+
return entry["v"] unless entry.nil?
|
|
39
|
+
end
|
|
40
|
+
value = yield
|
|
41
|
+
write(key, value, expires_in: expires_in) unless skip_nil && value.nil?
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(key) = delete_text(expand_key(key))
|
|
46
|
+
|
|
47
|
+
def read_text(key)
|
|
48
|
+
raise Edgeway::Error, "#{self.class.name} does not read"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write_text(key, text, expires_in_seconds)
|
|
52
|
+
raise Edgeway::Error, "#{self.class.name} does not write"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def delete_text(key)
|
|
56
|
+
raise Edgeway::Error, "#{self.class.name} does not delete"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def current_time = Time.now.to_i
|
|
62
|
+
|
|
63
|
+
def lookup(key)
|
|
64
|
+
text = read_text(expand_key(key))
|
|
65
|
+
return nil if text.nil?
|
|
66
|
+
|
|
67
|
+
entry = parse_entry(text)
|
|
68
|
+
return nil if entry.nil? || expired?(entry)
|
|
69
|
+
|
|
70
|
+
entry
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_entry(text)
|
|
74
|
+
entry = JSON.parse(text)
|
|
75
|
+
entry.is_a?(Hash) && entry.key?("v") ? entry : nil
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expired?(entry)
|
|
81
|
+
entry["e"].is_a?(Integer) && Integer(entry["e"]) <= current_time
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def envelope(value, seconds)
|
|
85
|
+
entry = { "v" => value }
|
|
86
|
+
entry["e"] = current_time + seconds if seconds > 0
|
|
87
|
+
JSON.generate(entry)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def expires_in_seconds(expires_in)
|
|
91
|
+
expires_in.nil? ? @default_expires_in : checked_expires_in(expires_in)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def checked_expires_in(seconds)
|
|
95
|
+
unless seconds.is_a?(Integer) && Integer(seconds) > 0 && Integer(seconds) <= MAX_EXPIRES_IN
|
|
96
|
+
raise ArgumentError, "expires_in must be a whole number of seconds, 1 to #{MAX_EXPIRES_IN}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
seconds
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check_cacheable(value)
|
|
103
|
+
if value.is_a?(String)
|
|
104
|
+
check_text(value)
|
|
105
|
+
elsif value.is_a?(Array)
|
|
106
|
+
value.each { |item| check_cacheable(item) }
|
|
107
|
+
elsif value.is_a?(Hash)
|
|
108
|
+
check_hash(value)
|
|
109
|
+
elsif value.is_a?(Float)
|
|
110
|
+
raise ArgumentError, "a Float to cache must be finite" unless Float(value).finite?
|
|
111
|
+
elsif !json_scalar?(value)
|
|
112
|
+
raise ArgumentError, "#{value.class.name} can't be cached (String, Integer, Float, true, false, nil, Array and Hash can)"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def check_text(text)
|
|
117
|
+
raise ArgumentError, "a String to cache must be valid UTF-8 (a binary String is not)" unless Cache.utf8?(text)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def check_hash(hash)
|
|
121
|
+
names = {}
|
|
122
|
+
hash.each do |name, item|
|
|
123
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
124
|
+
raise ArgumentError, "a Hash to cache needs String or Symbol keys (got #{name.class.name})"
|
|
125
|
+
end
|
|
126
|
+
raise ArgumentError, "a Hash to cache has the same key twice (as a String and a Symbol)" if names.key?(name.to_s)
|
|
127
|
+
|
|
128
|
+
names[name.to_s] = true
|
|
129
|
+
check_cacheable(name.to_s)
|
|
130
|
+
check_cacheable(item)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def json_scalar?(value) = value.nil? || value == true || value == false || value.is_a?(Integer)
|
|
135
|
+
|
|
136
|
+
def expand_key(key)
|
|
137
|
+
text = key_text(key)
|
|
138
|
+
raise ArgumentError, "cache key can't be blank" if text.empty?
|
|
139
|
+
|
|
140
|
+
text = "#{@namespace}:#{text}" unless @namespace.empty?
|
|
141
|
+
check_key_text(text)
|
|
142
|
+
text.bytesize > MAX_KEY_BYTES ? shortened(text) : text
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def check_key_text(text)
|
|
146
|
+
raise ArgumentError, "cache key must be valid UTF-8" unless Cache.utf8?(text)
|
|
147
|
+
raise ArgumentError, "cache key can't contain a NUL byte" if text.include?("\0")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def key_text(key)
|
|
151
|
+
if key.is_a?(Edgeway::Model::Base)
|
|
152
|
+
model_key(key)
|
|
153
|
+
elsif key.is_a?(Array)
|
|
154
|
+
key.map { |part| key_text(part) }.join("/")
|
|
155
|
+
elsif key.is_a?(String)
|
|
156
|
+
utf8_key_part(key)
|
|
157
|
+
elsif key.is_a?(Symbol) || key.is_a?(Integer)
|
|
158
|
+
key.to_s
|
|
159
|
+
else
|
|
160
|
+
raise ArgumentError, "a cache key is a String, Symbol, Integer, model or an Array of them (got #{key.class.name})"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def utf8_key_part(part)
|
|
165
|
+
raise ArgumentError, "cache key must be valid UTF-8" unless Cache.utf8?(part)
|
|
166
|
+
|
|
167
|
+
part.dup.force_encoding("UTF-8")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def model_key(model)
|
|
171
|
+
updated_at = model.as_json["updated_at"].to_s
|
|
172
|
+
raise ArgumentError, "#{model.class.name} has no updated_at (add timestamps)" if updated_at.empty?
|
|
173
|
+
|
|
174
|
+
"#{model.class.table_name}/#{model.id}-#{updated_at}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def shortened(text)
|
|
178
|
+
prefix_bytes = MAX_KEY_BYTES - KEY_DIGEST_LENGTH - 1
|
|
179
|
+
prefix = +""
|
|
180
|
+
text.each_char do |character|
|
|
181
|
+
break if prefix.bytesize + character.bytesize > prefix_bytes
|
|
182
|
+
|
|
183
|
+
prefix << character
|
|
184
|
+
end
|
|
185
|
+
"#{prefix}:#{Digest::SHA256.hexdigest(text)}"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "database_config"
|
|
4
|
+
|
|
5
|
+
module Edgeway
|
|
6
|
+
module CLI
|
|
7
|
+
class Build
|
|
8
|
+
class AppConfig
|
|
9
|
+
# adapter: => the file build/main.rb requires for it.
|
|
10
|
+
ADAPTERS = {
|
|
11
|
+
"d1" => "edgeway/service/cloudflare/d1",
|
|
12
|
+
"memory" => "edgeway/db/memory"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :env, :adapter, :binding_name, :databases, :sessions
|
|
16
|
+
|
|
17
|
+
def self.load(root, env)
|
|
18
|
+
config = DatabaseConfig.new(root)
|
|
19
|
+
section = config.section(env)
|
|
20
|
+
databases = config.databases_in(section, env)
|
|
21
|
+
sessions = config.session_bindings(section, env)
|
|
22
|
+
new(env, section["adapter"].to_s, config.primary_binding(section), databases, sessions)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(env, adapter, binding_name, databases = {}, sessions = [])
|
|
26
|
+
raise Error, "unknown database adapter #{adapter.inspect} (one of #{ADAPTERS.keys.join(", ")})" unless ADAPTERS.key?(adapter)
|
|
27
|
+
|
|
28
|
+
@env = env
|
|
29
|
+
@adapter = adapter
|
|
30
|
+
@binding_name = binding_name
|
|
31
|
+
@databases = databases
|
|
32
|
+
@sessions = adapter == "d1" ? sessions : []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def adapter_file = ADAPTERS.fetch(@adapter)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|