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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +784 -125
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare-kv.js +47 -0
  6. data/js/cloudflare-ratelimit.js +31 -0
  7. data/js/cloudflare-test-database.js +112 -0
  8. data/js/cloudflare-test-runner.js +76 -0
  9. data/js/cloudflare.js +144 -22
  10. data/js/cors.js +15 -16
  11. data/js/fetch.js +113 -0
  12. data/js/headers.js +16 -0
  13. data/js/jspi.js +32 -0
  14. data/js/jwt.js +6 -40
  15. data/js/runtime.js +42 -5
  16. data/lib/edgeway/application.rb +21 -9
  17. data/lib/edgeway/cache/limits.rb +8 -0
  18. data/lib/edgeway/cache/memory_store.rb +24 -0
  19. data/lib/edgeway/cache/null_store.rb +13 -0
  20. data/lib/edgeway/cache/store.rb +189 -0
  21. data/lib/edgeway/cache.rb +13 -0
  22. data/lib/edgeway/cli/build/app_config.rb +39 -0
  23. data/lib/edgeway/cli/build/app_files.rb +40 -0
  24. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  25. data/lib/edgeway/cli/build/database_config.rb +96 -0
  26. data/lib/edgeway/cli/build/js_files.rb +30 -0
  27. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  28. data/lib/edgeway/cli/build.rb +50 -18
  29. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  30. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  31. data/lib/edgeway/cli/generate.rb +174 -26
  32. data/lib/edgeway/cli/new.rb +1 -1
  33. data/lib/edgeway/cli/test.rb +28 -9
  34. data/lib/edgeway/cli/toolchain.rb +5 -0
  35. data/lib/edgeway/cli.rb +7 -4
  36. data/lib/edgeway/controller/base.rb +32 -3
  37. data/lib/edgeway/controller/caching.rb +140 -0
  38. data/lib/edgeway/controller/callbacks.rb +5 -1
  39. data/lib/edgeway/controller/dispatcher.rb +11 -1
  40. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  41. data/lib/edgeway/controller/rendering.rb +24 -4
  42. data/lib/edgeway/db/adapter.rb +38 -0
  43. data/lib/edgeway/db/memory.rb +9 -2
  44. data/lib/edgeway/db/query_stats.rb +42 -0
  45. data/lib/edgeway/db/sqlite.rb +73 -9
  46. data/lib/edgeway/errors.rb +43 -0
  47. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  48. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  49. data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
  50. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  51. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  52. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  53. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  54. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  55. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  56. data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
  57. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  58. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  59. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
  60. data/lib/edgeway/http/auth/stub.rb +1 -3
  61. data/lib/edgeway/http/client.rb +80 -0
  62. data/lib/edgeway/http/request.rb +7 -22
  63. data/lib/edgeway/http/stub_transport.rb +38 -0
  64. data/lib/edgeway/inflector.rb +19 -0
  65. data/lib/edgeway/logger.rb +33 -5
  66. data/lib/edgeway/model/attributes.rb +93 -10
  67. data/lib/edgeway/model/base.rb +50 -5
  68. data/lib/edgeway/model/connection_registry.rb +25 -0
  69. data/lib/edgeway/model/macros.rb +5 -1
  70. data/lib/edgeway/model/persistence.rb +18 -11
  71. data/lib/edgeway/model/relation.rb +106 -56
  72. data/lib/edgeway/model/validations.rb +29 -23
  73. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  74. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  75. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  76. data/lib/edgeway/rate_limit.rb +2 -0
  77. data/lib/edgeway/routing/route.rb +1 -6
  78. data/lib/edgeway/server.rb +13 -0
  79. data/lib/edgeway/service/cloudflare/d1.rb +44 -30
  80. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  81. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  82. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  83. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  84. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  85. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  86. data/lib/edgeway/service/fetch.rb +26 -0
  87. data/lib/edgeway/service/import_result.rb +24 -0
  88. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  89. data/lib/edgeway/test.rb +86 -26
  90. data/lib/edgeway/version.rb +1 -1
  91. data/lib/edgeway/view.rb +24 -0
  92. data/lib/edgeway.rb +9 -0
  93. metadata +50 -4
  94. data/lib/edgeway/cli/app_config.rb +0 -35
  95. 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
- const reader = res.body.getReader();
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 fetch(url, { redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
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 (!jwksUrlAllowed(url)) throw new JwtError("JWT_JWKS_URL must be https (http only for localhost)", true);
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 D1, a call answers the byte length of the result and take() copies it
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
- let err = "";
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: (s) => (err += s),
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
- console.error("app trapped:", e, err);
125
+ stderr.flush();
126
+ console.error("app trapped:", e);
90
127
  return internalError();
91
128
  }
92
- if (err) console.error(err);
129
+ stderr.flush();
93
130
  if (status !== 0) {
94
131
  console.error(`app exited with ${status}`);
95
132
  return internalError();
@@ -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 "401 #{e.message}"
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 "401 #{e.message}"
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 "409 #{e.message}"
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 "404 #{e.message}"
50
+ Edgeway.logger.info(e.message, status: 404)
44
51
  message(404, "not found")
45
52
  else
46
- # The details go to the log (stderr), never to the client.
47
- Edgeway.logger.error "#{e.class}: #{e.message}"
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
- # .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 }))
75
+ Controller::Rendering.json_response(status, JSON.generate({ "error" => text }))
64
76
  end
65
77
  end
66
78
 
@@ -0,0 +1,8 @@
1
+ module Edgeway
2
+ module Cache
3
+ # A year. In wasm, Integers are 32 bits: epoch seconds plus an expires_in
4
+ # stop fitting in 2038, so a longer expiry is refused rather than allowed
5
+ # to overflow.
6
+ MAX_EXPIRES_IN = 31_536_000
7
+ end
8
+ end
@@ -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,13 @@
1
+ require_relative "store"
2
+
3
+ module Edgeway
4
+ module Cache
5
+ class NullStore < Store
6
+ def read_text(key) = nil
7
+
8
+ def write_text(key, text, expires_in_seconds) = true
9
+
10
+ def delete_text(key) = true
11
+ end
12
+ end
13
+ 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,13 @@
1
+ require_relative "cache/store"
2
+ require_relative "cache/null_store"
3
+ require_relative "cache/memory_store"
4
+
5
+ module Edgeway
6
+ @cache = Cache::NullStore.new
7
+
8
+ def self.cache = @cache
9
+
10
+ def self.cache=(store)
11
+ @cache = store
12
+ end
13
+ 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