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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +602 -204
- 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 +25 -4
- data/js/fetch.js +113 -0
- data/js/jspi.js +32 -0
- data/js/jwt.js +5 -39
- data/js/runtime.js +42 -5
- data/lib/edgeway/application.rb +16 -7
- 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/cache_config.rb +108 -0
- data/lib/edgeway/cli/build/database_config.rb +4 -0
- data/lib/edgeway/cli/build/js_files.rb +30 -0
- data/lib/edgeway/cli/build.rb +17 -10
- 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/test.rb +25 -9
- data/lib/edgeway/cli/toolchain.rb +5 -0
- data/lib/edgeway/cli.rb +2 -0
- data/lib/edgeway/controller/base.rb +22 -3
- data/lib/edgeway/controller/caching.rb +140 -0
- data/lib/edgeway/controller/callbacks.rb +5 -1
- data/lib/edgeway/controller/rate_limiting.rb +145 -0
- data/lib/edgeway/db/query_stats.rb +2 -6
- data/lib/edgeway/errors.rb +21 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +3 -3
- data/lib/edgeway/generators/app/templates/README.md.tt +3 -1
- data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
- data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +8 -2
- data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +34 -2
- data/lib/edgeway/http/client.rb +80 -0
- data/lib/edgeway/http/request.rb +5 -0
- data/lib/edgeway/http/stub_transport.rb +38 -0
- data/lib/edgeway/logger.rb +33 -5
- 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/server.rb +3 -2
- data/lib/edgeway/service/cloudflare/d1.rb +6 -21
- 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 +72 -33
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway.rb +6 -0
- metadata +34 -1
data/js/cloudflare-kv.js
ADDED
|
@@ -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
|
|
187
|
-
return { exposeHeaders:
|
|
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
|
-
|
|
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) => ({
|
|
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
|
-
|
|
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);
|
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,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
|
|
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")
|
|
41
43
|
elsif e.is_a?(DatabaseUnavailable)
|
|
42
|
-
Edgeway.logger.error
|
|
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
|
|
50
|
+
Edgeway.logger.info(e.message, status: 404)
|
|
49
51
|
message(404, "not found")
|
|
50
52
|
else
|
|
51
|
-
# The details go to the log
|
|
52
|
-
Edgeway.logger.error
|
|
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
|