edgeway 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +602 -204
  4. data/js/cloudflare-kv.js +47 -0
  5. data/js/cloudflare-ratelimit.js +31 -0
  6. data/js/cloudflare-test-database.js +112 -0
  7. data/js/cloudflare-test-runner.js +76 -0
  8. data/js/cloudflare.js +25 -4
  9. data/js/fetch.js +113 -0
  10. data/js/jspi.js +32 -0
  11. data/js/jwt.js +5 -39
  12. data/js/runtime.js +42 -5
  13. data/lib/edgeway/application.rb +16 -7
  14. data/lib/edgeway/cache/limits.rb +8 -0
  15. data/lib/edgeway/cache/memory_store.rb +24 -0
  16. data/lib/edgeway/cache/null_store.rb +13 -0
  17. data/lib/edgeway/cache/store.rb +189 -0
  18. data/lib/edgeway/cache.rb +13 -0
  19. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  20. data/lib/edgeway/cli/build/database_config.rb +4 -0
  21. data/lib/edgeway/cli/build/js_files.rb +30 -0
  22. data/lib/edgeway/cli/build.rb +17 -10
  23. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  24. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  25. data/lib/edgeway/cli/test.rb +25 -9
  26. data/lib/edgeway/cli/toolchain.rb +5 -0
  27. data/lib/edgeway/cli.rb +2 -0
  28. data/lib/edgeway/controller/base.rb +22 -3
  29. data/lib/edgeway/controller/caching.rb +140 -0
  30. data/lib/edgeway/controller/callbacks.rb +5 -1
  31. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  32. data/lib/edgeway/db/query_stats.rb +2 -6
  33. data/lib/edgeway/errors.rb +21 -0
  34. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +3 -3
  35. data/lib/edgeway/generators/app/templates/README.md.tt +3 -1
  36. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  37. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  38. data/lib/edgeway/generators/app/templates/config/database.yml +8 -2
  39. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  40. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +34 -2
  41. data/lib/edgeway/http/client.rb +80 -0
  42. data/lib/edgeway/http/request.rb +5 -0
  43. data/lib/edgeway/http/stub_transport.rb +38 -0
  44. data/lib/edgeway/logger.rb +33 -5
  45. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  46. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  47. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  48. data/lib/edgeway/rate_limit.rb +2 -0
  49. data/lib/edgeway/server.rb +3 -2
  50. data/lib/edgeway/service/cloudflare/d1.rb +6 -21
  51. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  52. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  53. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  54. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  55. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  56. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  57. data/lib/edgeway/service/fetch.rb +26 -0
  58. data/lib/edgeway/service/import_result.rb +24 -0
  59. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  60. data/lib/edgeway/test.rb +72 -33
  61. data/lib/edgeway/version.rb +1 -1
  62. data/lib/edgeway.rb +6 -0
  63. metadata +34 -1
@@ -0,0 +1,47 @@
1
+ import { jspiHelpers } from "./jspi.js";
2
+
3
+ export const MIN_EXPIRATION_TTL = 60;
4
+
5
+ export function putOptions(expiresIn) {
6
+ if (!(expiresIn > 0)) return undefined;
7
+ return { expirationTtl: Math.max(expiresIn, MIN_EXPIRATION_TTL) };
8
+ }
9
+
10
+ // An R2 bucket also has get, put and delete; only a KV namespace has getWithMetadata.
11
+ export const isKvNamespace = (value) => Boolean(value) && ["get", "put", "delete", "getWithMetadata"].every((name) => typeof value[name] === "function");
12
+
13
+ export function kvImports(env, getInstance) {
14
+ const { readCString, setResult, take } = jspiHelpers(getInstance);
15
+
16
+ const namespaceOf = (bindingName) => {
17
+ const namespace = env[bindingName];
18
+ if (!isKvNamespace(namespace)) throw new Error(`no KV binding ${bindingName}`);
19
+ return namespace;
20
+ };
21
+
22
+ const suspending = (operation) => new WebAssembly.Suspending(async (bindingPointer, keyPointer, ...rest) => {
23
+ let result;
24
+ try {
25
+ const namespace = namespaceOf(readCString(bindingPointer));
26
+ result = await operation(namespace, readCString(keyPointer), ...rest);
27
+ } catch (error) {
28
+ result = { error: String(error?.message ?? error) };
29
+ }
30
+ return setResult(result);
31
+ });
32
+
33
+ return {
34
+ edgeway_kv: {
35
+ get: suspending(async (namespace, key) => ({ value: await namespace.get(key, "text") })),
36
+ put: suspending(async (namespace, key, textPointer, expiresIn) => {
37
+ await namespace.put(key, readCString(textPointer), putOptions(expiresIn));
38
+ return { ok: true };
39
+ }),
40
+ delete: suspending(async (namespace, key) => {
41
+ await namespace.delete(key);
42
+ return { ok: true };
43
+ }),
44
+ take,
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,31 @@
1
+ import { jspiHelpers } from "./jspi.js";
2
+
3
+ const isRateLimit = (value) => Boolean(value) && typeof value.limit === "function";
4
+
5
+ export function rateLimitImports(env, getInstance) {
6
+ const { readCString, setResult, take } = jspiHelpers(getInstance);
7
+
8
+ const check = new WebAssembly.Suspending(async (bindingPointer, keyPointer) => {
9
+ let result;
10
+ try {
11
+ const bindingName = readCString(bindingPointer);
12
+ const rateLimit = env[bindingName];
13
+ if (isRateLimit(rateLimit)) {
14
+ const { success } = await rateLimit.limit({ key: readCString(keyPointer) });
15
+ result = { success: success !== false };
16
+ } else {
17
+ result = { misconfigured: `no rate limit binding ${bindingName}` };
18
+ }
19
+ } catch (error) {
20
+ result = { error: String(error?.message ?? error) };
21
+ }
22
+ return setResult(result);
23
+ });
24
+
25
+ return {
26
+ edgeway_ratelimit: {
27
+ check,
28
+ take,
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,112 @@
1
+ // The databases of `edgeway test` on a local D1: dropped and migrated again
2
+ // before each test, so every test starts from the migrations' state.
3
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ // What wrangler and D1 keep in the database themselves.
7
+ const KEPT_OBJECT = /^(sqlite_|_cf_)|^d1_migrations$/;
8
+
9
+ const quoted = (name) => `"${name.replaceAll('"', '""')}"`;
10
+
11
+ const COMMENT = /--[^\n]*|\/\*[\s\S]*?\*\//g;
12
+ const withoutComments = (sql) => sql.replace(COMMENT, " ");
13
+ const startsTrigger = (statement) => /^\s*create\s+(temp(orary)?\s+)?trigger\b/i.test(withoutComments(statement));
14
+ // A trigger's body is statements ending in ';', and the trigger ends at the
15
+ // END after the last of them (sqlite3_complete's rule): not at the END of a
16
+ // CASE, nor a column named end.
17
+ const triggerEnded = (statement) => /;\s*end\s*$/i.test(withoutComments(statement));
18
+
19
+ // SQL text split into statements at the ';' outside strings, comments and a
20
+ // trigger's body. Each statement is run with prepare(), which takes one.
21
+ // Text with nothing but comments is no statement (D1 refuses it).
22
+ export function splitStatements(sql) {
23
+ const statements = [];
24
+ let current = "";
25
+ let at = 0;
26
+ const copyUntil = (terminator) => {
27
+ const stop = sql.indexOf(terminator, at + 1);
28
+ const end = stop === -1 ? sql.length : stop + terminator.length;
29
+ current += sql.slice(at, end);
30
+ at = end;
31
+ };
32
+ const endsStatement = () => !startsTrigger(current) || triggerEnded(current);
33
+ while (at < sql.length) {
34
+ const char = sql[at];
35
+ if (char === "'" || char === '"' || char === "`") copyUntil(char);
36
+ else if (char === "[") copyUntil("]");
37
+ else if (sql.startsWith("--", at)) copyUntil("\n");
38
+ else if (sql.startsWith("/*", at)) copyUntil("*/");
39
+ else if (char === ";" && endsStatement()) {
40
+ statements.push(current.trim());
41
+ current = "";
42
+ at++;
43
+ } else {
44
+ current += char;
45
+ at++;
46
+ }
47
+ }
48
+ statements.push(current.trim());
49
+ return statements.filter((statement) => withoutComments(statement).trim() !== "");
50
+ }
51
+
52
+ // The files of a migrations directory in file name order, each as
53
+ // { name, statements }. A directory that is not there has none.
54
+ export function migrationFiles(directory, { readdir = readdirSync, read = readFileSync, exists = existsSync } = {}) {
55
+ if (!exists(directory)) return [];
56
+ return readdir(directory)
57
+ .filter((name) => name.endsWith(".sql"))
58
+ .sort()
59
+ .map((name) => ({ name, statements: splitStatements(read(join(directory, name), "utf8")) }));
60
+ }
61
+
62
+ // databases: [{ binding, migrations: directory }]
63
+ export function loadMigrations(databases, options) {
64
+ return databases.map(({ binding, migrations }) => ({ binding, files: migrationFiles(migrations, options) }));
65
+ }
66
+
67
+ // The statements that empty a database, from the rows of sqlite_master
68
+ // ({ type, name }). Dropping a table drops its indexes and triggers. Foreign
69
+ // keys are enforced, so the checks wait for the end of the batch.
70
+ export function dropStatements(objects) {
71
+ const dropped = objects.filter(({ name }) => !KEPT_OBJECT.test(name));
72
+ const views = dropped.filter(({ type }) => type === "view");
73
+ const tables = dropped.filter(({ type }) => type === "table");
74
+ if (views.length + tables.length === 0) return [];
75
+ return [
76
+ "PRAGMA defer_foreign_keys = on",
77
+ ...views.map(({ name }) => `DROP VIEW IF EXISTS ${quoted(name)}`),
78
+ ...tables.map(({ name }) => `DROP TABLE IF EXISTS ${quoted(name)}`),
79
+ ];
80
+ }
81
+
82
+ async function dropEverything(database) {
83
+ const { results } = await database.prepare("SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view')").all();
84
+ const statements = dropStatements(results);
85
+ if (statements.length > 0) await database.batch(statements.map((sql) => database.prepare(sql)));
86
+ }
87
+
88
+ async function migrate(database, files) {
89
+ for (const { name, statements } of files) {
90
+ if (statements.length === 0) continue;
91
+ try {
92
+ await database.batch(statements.map((sql) => database.prepare(sql)));
93
+ } catch (error) {
94
+ throw new Error(`migration ${name}: ${error?.message ?? error}`);
95
+ }
96
+ }
97
+ }
98
+
99
+ // migrations: loadMigrations' answer. Entries that share a binding are one
100
+ // database: it is emptied once, then every entry's files are applied in order.
101
+ export async function resetDatabases(env, migrations) {
102
+ const filesByBinding = new Map();
103
+ for (const { binding, files } of migrations) {
104
+ filesByBinding.set(binding, [...(filesByBinding.get(binding) ?? []), ...files]);
105
+ }
106
+ for (const [binding, files] of filesByBinding) {
107
+ const database = env[binding];
108
+ if (!database) throw new Error(`no D1 binding ${binding} in wrangler.toml`);
109
+ await dropEverything(database);
110
+ await migrate(database, files);
111
+ }
112
+ }
@@ -0,0 +1,76 @@
1
+ // Runs the wasm build of an app's tests, on a local in-memory D1:
2
+ //
3
+ // node cloudflare-test-runner.js TEST.wasm [ARGS...]
4
+ //
5
+ // `edgeway test` starts it (with --experimental-wasm-jspi and --experimental-wasm-exnref on Node.js 24); ARGS
6
+ // go to the program (-n NAME). The environment is wrangler's getPlatformProxy
7
+ // with persist: false, so the state in .wrangler/state is never touched. The
8
+ // wasm calls edgeway_test.reset before each test, and the exit status is its own.
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import config from "./config.js";
12
+ import { WASI } from "./wasi-shim.js";
13
+ import { lineSplitter, parseLogLine } from "./runtime.js";
14
+ import { d1Imports, varsOf } from "./cloudflare.js";
15
+ import { jwtImports } from "./jwt.js";
16
+ import { fetchImports } from "./fetch.js";
17
+ import { kvImports } from "./cloudflare-kv.js";
18
+ import { rateLimitImports } from "./cloudflare-ratelimit.js";
19
+ import { D1Sessions } from "./cloudflare-d1-sessions.js";
20
+ import { loadMigrations, resetDatabases } from "./cloudflare-test-database.js";
21
+
22
+ function requireJspi() {
23
+ if (typeof WebAssembly.Suspending === "function") return;
24
+ throw new Error("this Node.js has no WebAssembly JSPI; Node.js 24 needs --experimental-wasm-jspi (`edgeway test` passes it)");
25
+ }
26
+
27
+ function logText(line) {
28
+ const { level, message, ...fields } = parseLogLine(line) ?? {};
29
+ if (level === undefined) return line;
30
+ const pairs = Object.entries(fields).map(([name, value]) => ` ${name}=${typeof value === "string" ? value : JSON.stringify(value)}`);
31
+ return `[edgeway] ${level.toUpperCase()} ${message}${pairs.join("")}`;
32
+ }
33
+
34
+ async function runTests(wasmPath, args, env) {
35
+ const migrations = loadMigrations(config.databases);
36
+ const stderr = lineSplitter((line) => process.stderr.write(`${logText(line)}\n`));
37
+ const wasi = new WASI({
38
+ args: ["test", ...args],
39
+ env: { ...varsOf(env), EDGEWAY_ENV: config.env },
40
+ onStdout: (text) => process.stdout.write(text),
41
+ onStderr: (text) => stderr.push(text),
42
+ });
43
+ const reset = new WebAssembly.Suspending(() => resetDatabases(env, migrations));
44
+ let instance;
45
+ instance = new WebAssembly.Instance(await WebAssembly.compile(readFileSync(wasmPath)), {
46
+ wasi_snapshot_preview1: wasi.wasiImport,
47
+ ...d1Imports(env, () => instance, new D1Sessions(env, undefined, config.sessions)),
48
+ ...jwtImports(env, () => instance),
49
+ ...fetchImports(() => instance),
50
+ ...kvImports(env, () => instance),
51
+ ...rateLimitImports(env, () => instance),
52
+ edgeway_test: { reset },
53
+ });
54
+ try {
55
+ return await wasi.startAsync(instance);
56
+ } finally {
57
+ stderr.flush();
58
+ }
59
+ }
60
+
61
+ async function main() {
62
+ const [wasmPath, ...args] = process.argv.slice(2);
63
+ requireJspi();
64
+ const { getPlatformProxy } = await import("wrangler");
65
+ const proxy = await getPlatformProxy({ configPath: join(config.appRoot, "wrangler.toml"), persist: false });
66
+ try {
67
+ process.exitCode = await runTests(wasmPath, args, proxy.env);
68
+ } finally {
69
+ await proxy.dispose();
70
+ }
71
+ }
72
+
73
+ main().catch((error) => {
74
+ console.error(`edgeway test: ${error instanceof WebAssembly.RuntimeError ? error.stack : error?.message ?? error}`);
75
+ process.exitCode = 1;
76
+ });
data/js/cloudflare.js CHANGED
@@ -10,6 +10,9 @@
10
10
  import { runCgi } from "./runtime.js";
11
11
  import config from "./config.js";
12
12
  import { jwtImports } from "./jwt.js";
13
+ import { fetchImports } from "./fetch.js";
14
+ import { kvImports } from "./cloudflare-kv.js";
15
+ import { rateLimitImports } from "./cloudflare-ratelimit.js";
13
16
  import { withCors } from "./cors.js";
14
17
  import { BOOKMARK_HEADER, D1Sessions } from "./cloudflare-d1-sessions.js";
15
18
 
@@ -165,6 +168,7 @@ export function d1Imports(env, getInstance, sessions = new D1Sessions(env)) {
165
168
  // out.
166
169
  const FRAMEWORK_VARS = new Set([
167
170
  "JWT_ISSUER", "JWT_AUDIENCE", "JWT_LEEWAY", "JWT_ALGORITHMS", "JWT_JWKS_URL",
171
+ "LOG_LEVEL",
168
172
  ]);
169
173
 
170
174
  // The Worker's variables (strings) become CGI environment variables, which
@@ -182,9 +186,19 @@ export function varsOf(env) {
182
186
  return vars;
183
187
  }
184
188
 
189
+ // Not CORS-safelisted, so a page reads it on a 429 or 503 only when exposed.
190
+ const RETRY_AFTER_HEADER = "Retry-After";
191
+
185
192
  export function corsOptions(sessionBindings) {
186
- const names = sessionBindings.length > 0 ? [BOOKMARK_HEADER] : [];
187
- return { exposeHeaders: names, allowHeaders: names };
193
+ const bookmark = sessionBindings.length > 0 ? [BOOKMARK_HEADER] : [];
194
+ return { exposeHeaders: [RETRY_AFTER_HEADER, ...bookmark], allowHeaders: bookmark };
195
+ }
196
+
197
+ // What identifies the request in every log line: the ray id (absent in local
198
+ // development).
199
+ export function logFieldsOf(request) {
200
+ const ray = request.headers.get("cf-ray");
201
+ return ray ? { ray } : {};
188
202
  }
189
203
 
190
204
  // appModule: the WebAssembly.Module that `import app from "...app.wasm"` gives.
@@ -194,9 +208,16 @@ export function createWorker(appModule) {
194
208
  const sessions = new D1Sessions(env, request, config.sessions ?? []);
195
209
  const response = await runCgi(appModule, request, {
196
210
  extraEnv: { EDGEWAY_ENV: config.env, ...varsOf(env) },
197
- // Both modules are always supplied, whether or not the app uses them:
211
+ logFields: logFieldsOf(request),
212
+ // Every module is always supplied, whether or not the app uses it:
198
213
  // the wasm instance needs every import it declares.
199
- imports: (getInstance) => ({ ...d1Imports(env, getInstance, sessions), ...jwtImports(env, getInstance) }),
214
+ imports: (getInstance) => ({
215
+ ...d1Imports(env, getInstance, sessions),
216
+ ...jwtImports(env, getInstance),
217
+ ...fetchImports(getInstance),
218
+ ...kvImports(env, getInstance),
219
+ ...rateLimitImports(env, getInstance),
220
+ }),
200
221
  });
201
222
  return sessions.annotate(response);
202
223
  }, corsOptions(config.sessions ?? [])),
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/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);
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,33 +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")
41
43
  elsif e.is_a?(DatabaseUnavailable)
42
- Edgeway.logger.error "503 #{e.message}"
44
+ Edgeway.logger.error(e.message, status: 503)
43
45
  response = message(503, "database unavailable")
44
46
  response.headers["Retry-After"] = "1"
45
47
  response
46
48
  elsif e.is_a?(RoutingError) || e.is_a?(RecordNotFound)
47
49
  # The message can hold the path or an id from the request: the log only.
48
- Edgeway.logger.info "404 #{e.message}"
50
+ Edgeway.logger.info(e.message, status: 404)
49
51
  message(404, "not found")
50
52
  else
51
- # The details go to the log (stderr), never to the client.
52
- 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)
53
55
  message(500, "internal error")
54
56
  end
55
57
  end
56
58
 
57
59
  private
58
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
+
59
68
  def unauthorized(text, challenge)
60
69
  response = message(401, text)
61
70
  response.headers["WWW-Authenticate"] = challenge