@sproutboat/runtime 0.45.0 → 0.46.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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/asset-rules.test.js +51 -30
- package/src/native-fetch-prelude.js +960 -56
- package/src/transport-embedded.js +310 -47
- package/test/fixtures/asset-redirect-cases.ts +28 -0
- package/test/fixtures/do-evict-app.js +172 -0
- package/test/native-asset-redirects.ts +80 -0
- package/test/native-cc.ts +178 -0
- package/test/native-compile.ts +53 -22
- package/test/native-db-table.ts +244 -0
- package/test/native-do-evict.ts +345 -0
- package/test/native-lto.ts +79 -0
|
@@ -27,6 +27,7 @@ Porffor.c`
|
|
|
27
27
|
#include <string.h>
|
|
28
28
|
#include <strings.h>
|
|
29
29
|
#include <sys/time.h>
|
|
30
|
+
#include <time.h>
|
|
30
31
|
#include <pthread.h>
|
|
31
32
|
#include <bearssl.h>
|
|
32
33
|
|
|
@@ -65,6 +66,11 @@ extern int sqlite3_extended_errcode(sqlite3*);
|
|
|
65
66
|
extern int sqlite3_error_offset(sqlite3*);
|
|
66
67
|
extern const char* sqlite3_sql(sqlite3_stmt*);
|
|
67
68
|
extern int sqlite3_total_changes(sqlite3*);
|
|
69
|
+
extern int sqlite3_get_autocommit(sqlite3*);
|
|
70
|
+
extern int sqlite3_db_status(sqlite3*, int, int*, int*, int);
|
|
71
|
+
extern int sqlite3_db_release_memory(sqlite3*);
|
|
72
|
+
extern long long sqlite3_soft_heap_limit64(long long);
|
|
73
|
+
extern long long sqlite3_memory_used(void);
|
|
68
74
|
|
|
69
75
|
// baronunread/sproutboat#176 — defined in render.js, alongside
|
|
70
76
|
// porf_native_fetch_read_value (which every inline-C block below already
|
|
@@ -79,7 +85,7 @@ extern int porf_native_fetch_read_raw_bytes(jsval value, const char** out_buf, s
|
|
|
79
85
|
// SQLITE_STATIC: "this buffer is yours to read and outlives the statement".
|
|
80
86
|
// Worth using for an object body — the alternative is sqlite copying it.
|
|
81
87
|
#define SB_SQLITE_STATIC ((void*)0)
|
|
82
|
-
#define SB_MAX_DB
|
|
88
|
+
#define SB_MAX_DB 64
|
|
83
89
|
|
|
84
90
|
static sqlite3* sb_dbs[SB_MAX_DB];
|
|
85
91
|
static char sb_db_names[SB_MAX_DB][256];
|
|
@@ -1173,8 +1179,9 @@ static int sb_ltx_opted(const char *path) {
|
|
|
1173
1179
|
}
|
|
1174
1180
|
// END LTX NATIVE
|
|
1175
1181
|
|
|
1176
|
-
// One handle per path,
|
|
1177
|
-
//
|
|
1182
|
+
// One handle per path, kept open until the slot is needed (Durable Object SQL) or for the process lifetime
|
|
1183
|
+
// (everything else). Returns an index, or -1.
|
|
1184
|
+
// BEGIN DB TABLE NATIVE: also compiled directly by the handle-table regression (packages/runtime/test/native-db-table.ts).
|
|
1178
1185
|
// Create every parent directory of path, like mkdir -p. A standalone binary
|
|
1179
1186
|
// has no launcher to prepare its data directory, and sqlite3_open creates files
|
|
1180
1187
|
// but never the directories above them.
|
|
@@ -1191,13 +1198,31 @@ static void sb_mkdirs(const char* path) {
|
|
|
1191
1198
|
}
|
|
1192
1199
|
}
|
|
1193
1200
|
|
|
1194
|
-
// Durable Objects SQL (storage.sql): one database per object, under "/do-sql/". At most
|
|
1195
|
-
//
|
|
1196
|
-
|
|
1201
|
+
// Durable Objects SQL (storage.sql): one database per object, under "/do-sql/". At most sb_do_sql_open of them
|
|
1202
|
+
// stay open (8 until the memory limit is known, then clamp(T / 160 KiB, 8, 48); 48 with no limit); the one used
|
|
1203
|
+
// longest ago, and not inside a transaction, is closed for the next. A Durable Object instance leaving memory closes
|
|
1204
|
+
// its handle at once (op do.sql.close).
|
|
1205
|
+
static int sb_do_sql_open = 8;
|
|
1197
1206
|
static unsigned long sb_db_used[SB_MAX_DB];
|
|
1198
1207
|
static unsigned long sb_db_tick = 0;
|
|
1208
|
+
static int sb_db_last = -1;
|
|
1199
1209
|
static int sb_is_do_sql(const char* path) { return strstr(path, "/do-sql/") != 0; }
|
|
1200
|
-
static
|
|
1210
|
+
static int sb_db_close(int idx);
|
|
1211
|
+
|
|
1212
|
+
// SQLite page caches (plan 016 section 5 as plan 018 phase 1 delivers it). Every database starts at 256 KiB. T is the
|
|
1213
|
+
// target for all of them together: SB_MEMORY_LIMIT / 8 (4 MiB at 32 MiB), none without a limit. Each database may
|
|
1214
|
+
// grow, on evidence, to min(2 MiB, T / open databases); when a new one opens and the sizes no longer fit under T the
|
|
1215
|
+
// stalest ones go back to their share. SB_SQLITE_CACHE_MB, when set, is the old fixed size and turns all of this off.
|
|
1216
|
+
#define SB_CACHE_START_KB 256
|
|
1217
|
+
#define SB_CACHE_MIN_KB 64
|
|
1218
|
+
#define SB_CACHE_MAX_KB 2048
|
|
1219
|
+
static long sb_cache_target_kb = 0;
|
|
1220
|
+
static int sb_cache_fixed_kb = 0;
|
|
1221
|
+
static int sb_cache_kb[SB_MAX_DB];
|
|
1222
|
+
static unsigned long sb_db_calls[SB_MAX_DB];
|
|
1223
|
+
static long sb_db_changes0[SB_MAX_DB];
|
|
1224
|
+
static unsigned long sb_stat_closes, sb_stat_busy, sb_stat_ckpt_fail, sb_stat_grow, sb_stat_shrink, sb_stat_opens;
|
|
1225
|
+
static long sb_stat_open_us_last, sb_stat_open_us_max;
|
|
1201
1226
|
|
|
1202
1227
|
// SQLite allocates 20 pages in one block for every page cache it creates, and a statement that
|
|
1203
1228
|
// builds a temporary table (INSERT ... RETURNING, a sort) creates one: an 88 KiB mmap and munmap
|
|
@@ -1209,33 +1234,113 @@ static void sb_sqlite_boot(void) {
|
|
|
1209
1234
|
if (sb_sqlite_booted) return;
|
|
1210
1235
|
sb_sqlite_booted = 1;
|
|
1211
1236
|
sqlite3_config(7 /* SQLITE_CONFIG_PAGECACHE */, (void*)0, 0, 0);
|
|
1237
|
+
const char* mb = getenv("SB_SQLITE_CACHE_MB");
|
|
1238
|
+
long mib = mb && *mb ? strtol(mb, 0, 10) : 0;
|
|
1239
|
+
if (mib >= 1 && mib <= 4096) sb_cache_fixed_kb = (int)(mib * 1024);
|
|
1240
|
+
const char* limit = getenv("SB_MEMORY_LIMIT");
|
|
1241
|
+
unsigned long long bytes = limit && *limit ? strtoull(limit, 0, 10) : 0;
|
|
1242
|
+
if (bytes >= 8ull << 20) {
|
|
1243
|
+
sb_cache_target_kb = (long)(bytes / 8 / 1024);
|
|
1244
|
+
long slots = sb_cache_target_kb / 160;
|
|
1245
|
+
sb_do_sql_open = slots < 8 ? 8 : slots > 48 ? 48 : (int)slots;
|
|
1246
|
+
// SQLite frees cache pages of every database before it goes over T; one statement that needs more still succeeds.
|
|
1247
|
+
sqlite3_soft_heap_limit64((long long)(bytes / 8));
|
|
1248
|
+
} else sb_do_sql_open = 48;
|
|
1249
|
+
}
|
|
1250
|
+
static int sb_open_dbs(void) {
|
|
1251
|
+
int n = 0;
|
|
1252
|
+
for (int i = 0; i < sb_db_count; i++) if (sb_dbs[i]) n++;
|
|
1253
|
+
return n;
|
|
1254
|
+
}
|
|
1255
|
+
// The most one database may hold now: min(2 MiB, T / open databases), and never under 64 KiB.
|
|
1256
|
+
static int sb_cache_share_kb(void) {
|
|
1257
|
+
if (!sb_cache_target_kb) return SB_CACHE_MAX_KB;
|
|
1258
|
+
int open = sb_open_dbs();
|
|
1259
|
+
long share = sb_cache_target_kb / (open ? open : 1);
|
|
1260
|
+
return share < SB_CACHE_MIN_KB ? SB_CACHE_MIN_KB : share > SB_CACHE_MAX_KB ? SB_CACHE_MAX_KB : (int)share;
|
|
1261
|
+
}
|
|
1262
|
+
static void sb_cache_set(int idx, int kb) {
|
|
1263
|
+
char pragma[48];
|
|
1264
|
+
snprintf(pragma, sizeof(pragma), "PRAGMA cache_size=-%d", kb);
|
|
1265
|
+
sqlite3_exec(sb_dbs[idx], pragma, 0, 0, 0);
|
|
1266
|
+
sb_cache_kb[idx] = kb;
|
|
1267
|
+
}
|
|
1268
|
+
// After a database opens: while the sizes together are over T, the stalest database above its share goes back to it.
|
|
1269
|
+
static void sb_cache_rebalance(int keep) {
|
|
1270
|
+
if (!sb_cache_target_kb || sb_cache_fixed_kb) return;
|
|
1271
|
+
for (;;) {
|
|
1272
|
+
long sum = 0;
|
|
1273
|
+
int share = sb_cache_share_kb(), victim = -1;
|
|
1274
|
+
for (int i = 0; i < sb_db_count; i++) {
|
|
1275
|
+
if (!sb_dbs[i]) continue;
|
|
1276
|
+
sum += sb_cache_kb[i];
|
|
1277
|
+
if (i != keep && sb_cache_kb[i] > share && (victim < 0 || sb_db_used[i] < sb_db_used[victim])) victim = i;
|
|
1278
|
+
}
|
|
1279
|
+
if (sum <= sb_cache_target_kb || victim < 0) return;
|
|
1280
|
+
sb_cache_set(victim, share);
|
|
1281
|
+
sqlite3_exec(sb_dbs[victim], "PRAGMA shrink_memory", 0, 0, 0);
|
|
1282
|
+
sb_stat_shrink++;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
// Every 64th use of a database: over 90% full and over 10% misses since the last look doubles its cache, up to its share.
|
|
1286
|
+
static void sb_cache_tune(int idx) {
|
|
1287
|
+
if (sb_cache_fixed_kb || sb_cache_kb[idx] >= SB_CACHE_MAX_KB) return;
|
|
1288
|
+
int used = 0, hit = 0, miss = 0, high = 0;
|
|
1289
|
+
sqlite3_db_status(sb_dbs[idx], 1 /* CACHE_USED */, &used, &high, 0);
|
|
1290
|
+
sqlite3_db_status(sb_dbs[idx], 7 /* CACHE_HIT */, &hit, &high, 1);
|
|
1291
|
+
sqlite3_db_status(sb_dbs[idx], 8 /* CACHE_MISS */, &miss, &high, 1);
|
|
1292
|
+
if ((long long)used * 10 < (long long)sb_cache_kb[idx] * 1024 * 9 || (long long)miss * 10 <= (long long)(hit + miss)) return;
|
|
1293
|
+
int share = sb_cache_share_kb(), next = sb_cache_kb[idx] * 2;
|
|
1294
|
+
if (next > share) next = share;
|
|
1295
|
+
if (next <= sb_cache_kb[idx]) return;
|
|
1296
|
+
sb_cache_set(idx, next);
|
|
1297
|
+
sb_stat_grow++;
|
|
1298
|
+
sb_cache_rebalance(idx);
|
|
1299
|
+
}
|
|
1300
|
+
static void sb_db_touch(int i) {
|
|
1301
|
+
sb_db_used[i] = ++sb_db_tick;
|
|
1302
|
+
sb_db_last = i;
|
|
1303
|
+
if ((++sb_db_calls[i] & 63) == 0) sb_cache_tune(i);
|
|
1304
|
+
}
|
|
1305
|
+
static long sb_now_us(void) {
|
|
1306
|
+
struct timespec t;
|
|
1307
|
+
clock_gettime(CLOCK_MONOTONIC, &t);
|
|
1308
|
+
return (long)t.tv_sec * 1000000L + t.tv_nsec / 1000;
|
|
1212
1309
|
}
|
|
1213
1310
|
static int sb_db_for(const char* path) {
|
|
1311
|
+
if (sb_db_last >= 0 && sb_dbs[sb_db_last] && strcmp(sb_db_names[sb_db_last], path) == 0) {
|
|
1312
|
+
sb_db_touch(sb_db_last);
|
|
1313
|
+
return sb_ltx_states[sb_db_last].failed ? -1 : sb_db_last;
|
|
1314
|
+
}
|
|
1214
1315
|
for (int i = 0; i < sb_db_count; i++) {
|
|
1215
1316
|
if (sb_dbs[i] && strcmp(sb_db_names[i], path) == 0) {
|
|
1216
|
-
|
|
1317
|
+
sb_db_touch(i);
|
|
1217
1318
|
return sb_ltx_states[i].failed ? -1 : i;
|
|
1218
1319
|
}
|
|
1219
1320
|
}
|
|
1220
1321
|
sb_sqlite_boot();
|
|
1322
|
+
const long t0 = sb_now_us();
|
|
1221
1323
|
const int do_sql = sb_is_do_sql(path);
|
|
1222
1324
|
// A name that would not fit could never be found again.
|
|
1223
1325
|
if (do_sql && strlen(path) >= sizeof(sb_db_names[0])) return -1;
|
|
1224
1326
|
int replicated = do_sql ? 0 : sb_ltx_opted(path), lock = -1;
|
|
1225
1327
|
if (replicated < 0 || (replicated && strlen(path) >= sizeof(sb_db_names[0]))) return -1;
|
|
1226
|
-
// A Durable Object SQL database
|
|
1227
|
-
//
|
|
1228
|
-
int slot =
|
|
1328
|
+
// A closed slot is reused first. A Durable Object SQL database takes the slot of the one used longest ago
|
|
1329
|
+
// (outside a transaction) once sb_do_sql_open are open; any other database does the same when every slot is in use.
|
|
1330
|
+
int slot = -1, lru = -1, open_do_sql = 0;
|
|
1229
1331
|
for (int i = 0; i < sb_db_count; i++) {
|
|
1230
|
-
if (!sb_dbs[i]
|
|
1332
|
+
if (!sb_dbs[i]) { if (slot < 0) slot = i; continue; }
|
|
1333
|
+
if (!sb_is_do_sql(sb_db_names[i])) continue;
|
|
1231
1334
|
open_do_sql++;
|
|
1232
|
-
if (lru < 0 || sb_db_used[i] < sb_db_used[lru]) lru = i;
|
|
1335
|
+
if (sqlite3_get_autocommit(sb_dbs[i]) && (lru < 0 || sb_db_used[i] < sb_db_used[lru])) lru = i;
|
|
1233
1336
|
}
|
|
1234
|
-
if (
|
|
1235
|
-
|
|
1337
|
+
if (slot < 0 && sb_db_count < SB_MAX_DB) slot = sb_db_count;
|
|
1338
|
+
if (!replicated && ((do_sql && open_do_sql >= sb_do_sql_open) || slot < 0)) {
|
|
1339
|
+
// Every candidate inside a transaction, or none open: the new database cannot be opened now.
|
|
1340
|
+
if (lru < 0 || sb_db_close(lru)) return -1;
|
|
1236
1341
|
slot = lru;
|
|
1237
1342
|
}
|
|
1238
|
-
if (slot
|
|
1343
|
+
if (slot < 0) return -1;
|
|
1239
1344
|
sqlite3* db = 0;
|
|
1240
1345
|
sb_mkdirs(path);
|
|
1241
1346
|
if (replicated) {
|
|
@@ -1266,16 +1371,18 @@ static int sb_db_for(const char* path) {
|
|
|
1266
1371
|
// The transport itself only reads the variable, so a host that embeds it (sproutd, the broker) stays
|
|
1267
1372
|
// in shared mode unless it is set. A standalone binary sets it to 1 at startup (patch.ts, the shim's
|
|
1268
1373
|
// constructor) unless the operator has set it, so SB_SQLITE_EXCLUSIVE=0 opts a standalone binary out.
|
|
1269
|
-
//
|
|
1374
|
+
// The page cache: see the sizing comment above; SB_SQLITE_CACHE_MB is a fixed size in MiB.
|
|
1270
1375
|
{
|
|
1271
1376
|
const char* excl = getenv("SB_SQLITE_EXCLUSIVE");
|
|
1272
1377
|
if (excl && excl[0] == '1' && !excl[1] && !replicated) sqlite3_exec(db, "PRAGMA locking_mode=EXCLUSIVE", 0, 0, 0);
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1378
|
+
}
|
|
1379
|
+
sb_dbs[slot] = db;
|
|
1380
|
+
snprintf(sb_db_names[slot], 256, "%s", path);
|
|
1381
|
+
if (slot >= sb_db_count) sb_db_count = slot + 1; // before the sizing below, which counts open databases
|
|
1382
|
+
{
|
|
1383
|
+
const int share = sb_cache_share_kb();
|
|
1384
|
+
const int kb = sb_cache_fixed_kb ? sb_cache_fixed_kb : (SB_CACHE_START_KB < share ? SB_CACHE_START_KB : share);
|
|
1385
|
+
sb_cache_set(slot, kb);
|
|
1279
1386
|
}
|
|
1280
1387
|
sqlite3_exec(db,
|
|
1281
1388
|
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;"
|
|
@@ -1283,9 +1390,10 @@ static int sb_db_for(const char* path) {
|
|
|
1283
1390
|
0, 0, 0);
|
|
1284
1391
|
// Durable Object SQLite enforces foreign keys, as in workerd.
|
|
1285
1392
|
if (do_sql) sqlite3_exec(db, "PRAGMA foreign_keys=ON", 0, 0, 0);
|
|
1393
|
+
sb_db_changes0[slot] = sqlite3_total_changes(db);
|
|
1394
|
+
sb_db_calls[slot] = 0;
|
|
1286
1395
|
sb_db_used[slot] = ++sb_db_tick;
|
|
1287
|
-
|
|
1288
|
-
snprintf(sb_db_names[slot], 256, "%s", path);
|
|
1396
|
+
sb_db_last = slot;
|
|
1289
1397
|
if (replicated && sb_ltx_start(slot, db, path, lock)) {
|
|
1290
1398
|
sb_ltx_states[slot].failed = 1;
|
|
1291
1399
|
fprintf(stderr, "LTX initialization failed for %s\n", path);
|
|
@@ -1293,10 +1401,12 @@ static int sb_db_for(const char* path) {
|
|
|
1293
1401
|
if (sb_ltx_states[slot].read_fd >= 0) close(sb_ltx_states[slot].read_fd);
|
|
1294
1402
|
sb_ltx_states[slot].read_fd = -1;
|
|
1295
1403
|
sb_dbs[slot] = 0;
|
|
1296
|
-
if (slot == sb_db_count) sb_db_count++;
|
|
1297
1404
|
close(lock); return -1;
|
|
1298
1405
|
}
|
|
1299
|
-
|
|
1406
|
+
sb_stat_opens++;
|
|
1407
|
+
sb_cache_rebalance(slot);
|
|
1408
|
+
sb_stat_open_us_last = sb_now_us() - t0;
|
|
1409
|
+
if (sb_stat_open_us_last > sb_stat_open_us_max) sb_stat_open_us_max = sb_stat_open_us_last;
|
|
1300
1410
|
return slot;
|
|
1301
1411
|
}
|
|
1302
1412
|
|
|
@@ -1308,7 +1418,7 @@ static int sb_db_for(const char* path) {
|
|
|
1308
1418
|
// ponytail: FIFO eviction over 32 slots, linear scan, no lock (the runtime
|
|
1309
1419
|
// serves one turn at a time). Move to LRU + a hash if a real app keeps >32 hot
|
|
1310
1420
|
// statements per db, or if execution ever overlaps. Entries are never finalized
|
|
1311
|
-
// on shutdown
|
|
1421
|
+
// on shutdown; sb_db_close finalizes a database's entries when its handle goes.
|
|
1312
1422
|
#define SB_STMT_CACHE 32
|
|
1313
1423
|
typedef struct { char* sql; sqlite3_stmt* st; } sb_cached_stmt;
|
|
1314
1424
|
static sb_cached_stmt sb_stmt_cache[SB_MAX_DB][SB_STMT_CACHE];
|
|
@@ -1336,9 +1446,18 @@ static sqlite3_stmt* sb_stmt_get(int idx, sqlite3* db, const char* sql) {
|
|
|
1336
1446
|
return st;
|
|
1337
1447
|
}
|
|
1338
1448
|
|
|
1339
|
-
//
|
|
1449
|
+
// The one place a handle is closed: the slot LRU, a Durable Object instance leaving memory (do.sql.close), and later
|
|
1450
|
+
// a capturing handle (plan 013 phase D extends it at the two marked steps and removes the LRU's capture guard; it must not
|
|
1451
|
+
// add a second close path). The order is a contract:
|
|
1340
1452
|
static unsigned char sb_quota_frozen[SB_MAX_DB];
|
|
1341
|
-
|
|
1453
|
+
// Returns 0 when the handle is closed (or was not open), -1 when it is left open: a statement outlives its call
|
|
1454
|
+
// (SQLITE_BUSY: counted, never sqlite3_close_v2), or the connection is inside a transaction.
|
|
1455
|
+
static int sb_db_close(int idx) {
|
|
1456
|
+
sqlite3* db = sb_dbs[idx];
|
|
1457
|
+
if (!db) return 0;
|
|
1458
|
+
// A savepoint or transaction held across calls: closing would roll it back silently.
|
|
1459
|
+
if (!sqlite3_get_autocommit(db)) return -1;
|
|
1460
|
+
// 1. finalize the slot's cached statements;
|
|
1342
1461
|
for (int i = 0; i < SB_STMT_CACHE; i++) {
|
|
1343
1462
|
if (sb_stmt_cache[idx][i].st) sqlite3_finalize(sb_stmt_cache[idx][i].st);
|
|
1344
1463
|
free(sb_stmt_cache[idx][i].sql);
|
|
@@ -1346,11 +1465,61 @@ static void sb_db_evict(int idx) {
|
|
|
1346
1465
|
sb_stmt_cache[idx][i].sql = 0;
|
|
1347
1466
|
}
|
|
1348
1467
|
sb_stmt_fifo[idx] = 0;
|
|
1349
|
-
|
|
1468
|
+
// 2. (phase D inserts: unhook capture, write what is pending)
|
|
1469
|
+
// 3. if the connection wrote since it was opened, a TRUNCATE checkpoint and its result. With synchronous=NORMAL this is
|
|
1470
|
+
// the fsync that makes the data durable on the local disk. A read-only open skips it; sqlite3_close below still
|
|
1471
|
+
// checkpoints a WAL that holds schema changes (they do not count as changes), as it does for the last connection.
|
|
1472
|
+
if (sqlite3_total_changes(db) != sb_db_changes0[idx]) {
|
|
1473
|
+
int frames = 0, done = 0;
|
|
1474
|
+
if (sqlite3_wal_checkpoint_v2(db, "main", 3 /* TRUNCATE */, &frames, &done)) sb_stat_ckpt_fail++;
|
|
1475
|
+
}
|
|
1476
|
+
// 4. (phase D inserts: the clean position record)
|
|
1477
|
+
// 5. sqlite3_close must return SQLITE_OK. SQLITE_BUSY means a leaked statement: the slot stays open.
|
|
1478
|
+
if (sqlite3_close(db)) {
|
|
1479
|
+
sb_stat_busy++;
|
|
1480
|
+
return -1;
|
|
1481
|
+
}
|
|
1482
|
+
// 6. only then close any raw descriptor on the same inode (capture's read_fd and lock): fcntl locks belong to the
|
|
1483
|
+
// process, so closing a descriptor of the file while the SQLite handle is open would drop SQLite's locks. Object
|
|
1484
|
+
// SQL has none; the order is written so that phase D cannot get it wrong.
|
|
1485
|
+
// 7. clear the slot.
|
|
1486
|
+
if (sb_ltx_states[idx].db) memset(&sb_ltx_states[idx], 0, sizeof(sb_ltx_states[idx]));
|
|
1350
1487
|
sb_dbs[idx] = 0;
|
|
1351
1488
|
sb_db_names[idx][0] = 0;
|
|
1352
1489
|
sb_quota_frozen[idx] = 0;
|
|
1490
|
+
sb_cache_kb[idx] = 0;
|
|
1491
|
+
sb_db_calls[idx] = 0;
|
|
1492
|
+
if (sb_db_last == idx) sb_db_last = -1;
|
|
1493
|
+
sb_stat_closes++;
|
|
1494
|
+
return 0;
|
|
1353
1495
|
}
|
|
1496
|
+
// do.sql.close: the handle of one path. 0: not open, 1: closed, -1: left open.
|
|
1497
|
+
static int sb_db_close_path(const char* path) {
|
|
1498
|
+
for (int i = 0; i < sb_db_count; i++)
|
|
1499
|
+
if (sb_dbs[i] && strcmp(sb_db_names[i], path) == 0) return sb_db_close(i) ? -1 : 1;
|
|
1500
|
+
return 0;
|
|
1501
|
+
}
|
|
1502
|
+
// Hands SQLite's cached pages back, from every open database; returns the bytes SQLite memory fell by.
|
|
1503
|
+
static long long sb_db_release_all(void) {
|
|
1504
|
+
long long before = sqlite3_memory_used();
|
|
1505
|
+
for (int i = 0; i < sb_db_count; i++) if (sb_dbs[i]) sqlite3_db_release_memory(sb_dbs[i]);
|
|
1506
|
+
return before - sqlite3_memory_used();
|
|
1507
|
+
}
|
|
1508
|
+
// The handle table's counters as one JSON line, for the host note (plan 018) and the tests.
|
|
1509
|
+
static int sb_db_stats(char* out, size_t cap) {
|
|
1510
|
+
int open = 0, open_do = 0;
|
|
1511
|
+
long cache_kb = 0;
|
|
1512
|
+
for (int i = 0; i < sb_db_count; i++) {
|
|
1513
|
+
if (!sb_dbs[i]) continue;
|
|
1514
|
+
open++;
|
|
1515
|
+
cache_kb += sb_cache_kb[i];
|
|
1516
|
+
if (sb_is_do_sql(sb_db_names[i])) open_do++;
|
|
1517
|
+
}
|
|
1518
|
+
return snprintf(out, cap,
|
|
1519
|
+
"{\"open\":%d,\"doOpen\":%d,\"doSlots\":%d,\"cacheKb\":%ld,\"targetKb\":%ld,\"memUsed\":%lld,\"opens\":%lu,\"closes\":%lu,\"busy\":%lu,\"ckptFail\":%lu,\"grow\":%lu,\"shrink\":%lu,\"openUsLast\":%ld,\"openUsMax\":%ld}",
|
|
1520
|
+
open, open_do, sb_do_sql_open, cache_kb, sb_cache_target_kb, sqlite3_memory_used(), sb_stat_opens, sb_stat_closes, sb_stat_busy, sb_stat_ckpt_fail, sb_stat_grow, sb_stat_shrink, sb_stat_open_us_last, sb_stat_open_us_max);
|
|
1521
|
+
}
|
|
1522
|
+
// END DB TABLE NATIVE
|
|
1354
1523
|
|
|
1355
1524
|
// --- a growable output buffer, for building the reply JSON ------------------
|
|
1356
1525
|
typedef struct { char* p; size_t len; size_t cap; } sb_buf;
|
|
@@ -2232,7 +2401,7 @@ static void sb_xfer_download_close(int handle) {
|
|
|
2232
2401
|
// every write is refused (a same-size UPDATE or a DELETE still adds an LTX file to the
|
|
2233
2402
|
// spool, which counts toward the quota). Reads and transaction control still run.
|
|
2234
2403
|
#define SB_QUOTA_FULL "storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota"
|
|
2235
|
-
// sb_quota_frozen (per database slot) is declared with
|
|
2404
|
+
// sb_quota_frozen (per database slot) is declared with sb_db_close, which clears it.
|
|
2236
2405
|
static int sb_db_capture_failed(const char* path) {
|
|
2237
2406
|
for (int i = 0; i < sb_db_count; i++) if (strcmp(sb_db_names[i], path) == 0) return sb_ltx_states[i].failed;
|
|
2238
2407
|
return 0;
|
|
@@ -2549,6 +2718,7 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
2549
2718
|
|
|
2550
2719
|
// The storage quota (docs: an app's data directory is capped): bytes in regular files under
|
|
2551
2720
|
// dir, symlinks not followed. Bounded by depth; an unreadable entry counts as 0.
|
|
2721
|
+
static long sb_dir_files; // regular files counted by the last walk (the quota scan's interval grows with it)
|
|
2552
2722
|
static long long sb_dir_bytes(const char* dir, int depth) {
|
|
2553
2723
|
DIR* d = opendir(dir);
|
|
2554
2724
|
if (!d || depth > 16) { if (d) closedir(d); return 0; }
|
|
@@ -2560,7 +2730,7 @@ static long long sb_dir_bytes(const char* dir, int depth) {
|
|
|
2560
2730
|
if (snprintf(path, sizeof(path), "%s/%s", dir, e->d_name) >= (int)sizeof(path)) continue;
|
|
2561
2731
|
struct stat st;
|
|
2562
2732
|
if (lstat(path, &st)) continue;
|
|
2563
|
-
if (S_ISREG(st.st_mode)) total += (long long)st.st_size;
|
|
2733
|
+
if (S_ISREG(st.st_mode)) { total += (long long)st.st_size; sb_dir_files++; }
|
|
2564
2734
|
else if (S_ISDIR(st.st_mode)) total += sb_dir_bytes(path, depth + 1);
|
|
2565
2735
|
}
|
|
2566
2736
|
closedir(d);
|
|
@@ -4610,7 +4780,7 @@ function __sbSqlScriptRaw(path, sql) {
|
|
|
4610
4780
|
return res;
|
|
4611
4781
|
}
|
|
4612
4782
|
|
|
4613
|
-
// The bytes under a directory, as
|
|
4783
|
+
// The bytes under a directory and the number of regular files, as "BYTES FILES" (sb_dir_bytes).
|
|
4614
4784
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4615
4785
|
function __sbDirBytesRaw(dir) {
|
|
4616
4786
|
let res = "";
|
|
@@ -4620,13 +4790,50 @@ function __sbDirBytesRaw(dir) {
|
|
|
4620
4790
|
porf_native_fetch_read_value(dir, &__d, &__dl, &__do);
|
|
4621
4791
|
char* __dir = (char*)malloc(__dl + 1); memcpy(__dir, __d, __dl); __dir[__dl] = 0;
|
|
4622
4792
|
if (__do) free(__do);
|
|
4623
|
-
char __n[
|
|
4793
|
+
char __n[64]; sb_dir_files = 0; long long __b = sb_dir_bytes(__dir, 0); int __k = snprintf(__n, sizeof(__n), "%lld %ld", __b, sb_dir_files);
|
|
4624
4794
|
free(__dir);
|
|
4625
4795
|
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__n, (size_t)__k), 195);
|
|
4626
4796
|
`;
|
|
4627
4797
|
return res;
|
|
4628
4798
|
}
|
|
4629
4799
|
|
|
4800
|
+
// Closes the handle of one database path (sb_db_close_path): 1 closed, 0 not open, -1 left open.
|
|
4801
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4802
|
+
function __sbDbCloseRaw(path) {
|
|
4803
|
+
let status = 0;
|
|
4804
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4805
|
+
Porffor.c`
|
|
4806
|
+
const char* __p; size_t __pl; char* __po = 0;
|
|
4807
|
+
porf_native_fetch_read_value(path, &__p, &__pl, &__po);
|
|
4808
|
+
char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
|
|
4809
|
+
if (__po) free(__po);
|
|
4810
|
+
status = (f64)sb_db_close_path(__path);
|
|
4811
|
+
free(__path);
|
|
4812
|
+
`;
|
|
4813
|
+
return status;
|
|
4814
|
+
}
|
|
4815
|
+
|
|
4816
|
+
// The handle table's counters, one JSON line (sb_db_stats).
|
|
4817
|
+
function __sbDbStatsRaw() {
|
|
4818
|
+
let res = "";
|
|
4819
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4820
|
+
Porffor.c`
|
|
4821
|
+
char __n[512]; int __k = sb_db_stats(__n, sizeof(__n));
|
|
4822
|
+
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__n, (size_t)__k), 195);
|
|
4823
|
+
`;
|
|
4824
|
+
return res;
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4827
|
+
// Hands SQLite's cached pages back (sb_db_release_all); returns the bytes released.
|
|
4828
|
+
function __sbDbReleaseRaw() {
|
|
4829
|
+
let freed = 0;
|
|
4830
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4831
|
+
Porffor.c`
|
|
4832
|
+
freed = (f64)sb_db_release_all();
|
|
4833
|
+
`;
|
|
4834
|
+
return freed;
|
|
4835
|
+
}
|
|
4836
|
+
|
|
4630
4837
|
// "captured" of __sbD1DbRaw as a number: no text made in C and parsed here, on every D1 query.
|
|
4631
4838
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4632
4839
|
function __sbD1Captured_(path) {
|
|
@@ -4972,6 +5179,9 @@ function __sbDir() {
|
|
|
4972
5179
|
var __sbQuotaMax = -1;
|
|
4973
5180
|
var __sbQuotaUsed = 0;
|
|
4974
5181
|
var __sbQuotaAt = -1;
|
|
5182
|
+
// Milliseconds between scans of the data directory: one lstat per file, so 1 s per 1,000 files and at most 10 s (7 ms of
|
|
5183
|
+
// walking at 5,000 files, 20 ms at 15,000, measured). Writes can overshoot the quota by up to that long.
|
|
5184
|
+
var __sbQuotaEvery = 1000;
|
|
4975
5185
|
function __sbQuotaLimit() {
|
|
4976
5186
|
if (__sbQuotaMax < 0) {
|
|
4977
5187
|
const v = Number(__sbEnv("SB_STORAGE_MAX_BYTES"));
|
|
@@ -4981,8 +5191,10 @@ function __sbQuotaLimit() {
|
|
|
4981
5191
|
}
|
|
4982
5192
|
function __sbQuotaFull() {
|
|
4983
5193
|
const now = Date.now();
|
|
4984
|
-
if (__sbQuotaAt < 0 || now - __sbQuotaAt >=
|
|
4985
|
-
|
|
5194
|
+
if (__sbQuotaAt < 0 || now - __sbQuotaAt >= __sbQuotaEvery) {
|
|
5195
|
+
const scan = String(__sbDirBytesRaw(__sbDir())).split(" ");
|
|
5196
|
+
__sbQuotaUsed = Number(scan[0]) || 0;
|
|
5197
|
+
__sbQuotaEvery = Math.min(10000, Math.max(1000, Math.ceil((Number(scan[1]) || 0) / 1000) * 1000));
|
|
4986
5198
|
__sbQuotaAt = now;
|
|
4987
5199
|
}
|
|
4988
5200
|
return __sbQuotaUsed >= __sbQuotaLimit();
|
|
@@ -5339,10 +5551,8 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
5339
5551
|
let path = String(msg.path || "/");
|
|
5340
5552
|
if (path.charAt(0) !== "/") path = "/" + path;
|
|
5341
5553
|
const redirects = (bundle.manifest && bundle.manifest.redirects) || [];
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
if (location !== null) return { ok: true, found: false, status: redirects[i].status, location };
|
|
5345
|
-
}
|
|
5554
|
+
const redirect = __sbFindAssetRedirect(redirects, path);
|
|
5555
|
+
if (redirect !== null) return { ok: true, found: false, status: redirect.status, location: redirect.location };
|
|
5346
5556
|
|
|
5347
5557
|
let key = null;
|
|
5348
5558
|
if (path.charAt(path.length - 1) === "/") {
|
|
@@ -5505,7 +5715,7 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
5505
5715
|
// run like a strict D1 query (BLOBs as hex, D1_ERROR text the shim rewords).
|
|
5506
5716
|
if (op === "do.sql") {
|
|
5507
5717
|
const path = __sbDOSqlPath(msg.cls, msg.file);
|
|
5508
|
-
// Its handle may have been closed and reopened since (
|
|
5718
|
+
// Its handle may have been closed and reopened since (the slot LRU, an eviction), which drops the
|
|
5509
5719
|
// size cap: set it again.
|
|
5510
5720
|
delete __sbD1Capped[path];
|
|
5511
5721
|
__sbD1Quota(path);
|
|
@@ -5514,6 +5724,16 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
5514
5724
|
return { ok: true, columns: r.cols, rows: r.rows, meta: { changes: r.changes } };
|
|
5515
5725
|
}
|
|
5516
5726
|
|
|
5727
|
+
// A Durable Object instance leaving memory closes its SQLite handle (plan 018): the last connection checkpoints, so the
|
|
5728
|
+
// evicted object is one clean file. A no-op when it is not open; closed: -1 means a transaction or a statement kept it open.
|
|
5729
|
+
if (op === "do.sql.close") {
|
|
5730
|
+
const path = __sbDOSqlPath(msg.cls, msg.file);
|
|
5731
|
+
return { ok: true, closed: __sbDbCloseRaw(path) };
|
|
5732
|
+
}
|
|
5733
|
+
|
|
5734
|
+
if (op === "do.stats") return { ok: true, stats: JSON.parse(__sbDbStatsRaw()) };
|
|
5735
|
+
if (op === "do.release") return { ok: true, freed: __sbDbReleaseRaw() };
|
|
5736
|
+
|
|
5517
5737
|
if (op === "d1.backup") {
|
|
5518
5738
|
const src = __sbD1Path(String(msg.db));
|
|
5519
5739
|
// A caller-supplied name must be a plain basename; anything else falls back
|
|
@@ -6397,7 +6617,7 @@ async function __sbQueuePass(handlers, queues) {
|
|
|
6397
6617
|
// From 2026-11-05 a consumer that runs past the 30 s lease keeps its messages: the lease
|
|
6398
6618
|
// is renewed while it runs, so they are not delivered a second time or lose their ack.
|
|
6399
6619
|
const beat = __sbQueuesFixed()
|
|
6400
|
-
?
|
|
6620
|
+
? __sbSetInterval(() => {
|
|
6401
6621
|
__sbSql(store, "UPDATE mq SET visible_at=? WHERE queue=? AND claim_token=? AND visible_at>?", [Date.now() + 30000, name, claim, Date.now()]);
|
|
6402
6622
|
}, 10000)
|
|
6403
6623
|
: null;
|
|
@@ -6407,7 +6627,7 @@ async function __sbQueuePass(handlers, queues) {
|
|
|
6407
6627
|
if (result && __sbIsFn(result.then)) result = await result;
|
|
6408
6628
|
if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
|
|
6409
6629
|
} finally {
|
|
6410
|
-
if (beat !== null)
|
|
6630
|
+
if (beat !== null) __sbClearInterval(beat);
|
|
6411
6631
|
}
|
|
6412
6632
|
__sbQueueSettle(store, name, claim, result);
|
|
6413
6633
|
}
|
|
@@ -6520,6 +6740,9 @@ async function __sbAlarmPass() {
|
|
|
6520
6740
|
__sbSql(store, "ROLLBACK", []);
|
|
6521
6741
|
throw error;
|
|
6522
6742
|
}
|
|
6743
|
+
// From the eviction date the claimed batch runs at once (each alarm through its own object's gate; one alarm() per object at a
|
|
6744
|
+
// time is the gate and the single row), the claim is renewed while it runs, and the row's attempts reach alarm().
|
|
6745
|
+
if (__sbDOEvictOn()) return __sbAlarmRunBatch(store, claim, due.rows);
|
|
6523
6746
|
for (let i = 0; i < due.rows.length; i++) {
|
|
6524
6747
|
const cls = due.rows[i][0];
|
|
6525
6748
|
const id = due.rows[i][1];
|
|
@@ -6558,6 +6781,46 @@ async function __sbAlarmPass() {
|
|
|
6558
6781
|
}
|
|
6559
6782
|
}
|
|
6560
6783
|
|
|
6784
|
+
async function __sbAlarmRunBatch(store, claim, rows) {
|
|
6785
|
+
const beat = __sbSetInterval(() => {
|
|
6786
|
+
__sbSql(store, "UPDATE do_alarm_delivery SET expires=? WHERE token=? AND expires>?", [Date.now() + 30000, claim, Date.now()]);
|
|
6787
|
+
}, 10000);
|
|
6788
|
+
try {
|
|
6789
|
+
const runs = [];
|
|
6790
|
+
for (let i = 0; i < rows.length; i++) runs.push(__sbAlarmDeliver(store, claim, rows[i]));
|
|
6791
|
+
for (let i = 0; i < runs.length; i++) await runs[i];
|
|
6792
|
+
} finally {
|
|
6793
|
+
__sbClearInterval(beat);
|
|
6794
|
+
}
|
|
6795
|
+
}
|
|
6796
|
+
|
|
6797
|
+
// One claimed alarm: construct the object if it is evicted (its constructor runs before alarm()), run alarm() through its gate, and
|
|
6798
|
+
// settle the delivery row. A call that could not get a slot within the wait is not a failed attempt: the row is released uncounted.
|
|
6799
|
+
async function __sbAlarmDeliver(store, claim, row) {
|
|
6800
|
+
const cls = row[0];
|
|
6801
|
+
const id = row[1];
|
|
6802
|
+
try {
|
|
6803
|
+
await __sbDORun(cls, id, undefined, (instance) => (__sbIsFn(instance.alarm) ? __sbDOAlarmCall(instance, cls, id, row[2], row[3]) : undefined), true);
|
|
6804
|
+
__sbSql(store, "DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND token=? AND expires>?", [cls, id, claim, Date.now()]);
|
|
6805
|
+
} catch (error) {
|
|
6806
|
+
if (error && error.overloaded === true) {
|
|
6807
|
+
__sbSql(store, "UPDATE do_alarm_delivery SET expires=?, attempts=attempts-1 WHERE cls=? AND id=? AND token=? AND expires>?", [
|
|
6808
|
+
Date.now() + 1000,
|
|
6809
|
+
cls,
|
|
6810
|
+
id,
|
|
6811
|
+
claim,
|
|
6812
|
+
Date.now(),
|
|
6813
|
+
]);
|
|
6814
|
+
return;
|
|
6815
|
+
}
|
|
6816
|
+
const scheduled = __sbSql(store, "SELECT 1 FROM do_alarm WHERE cls=? AND id=?", [cls, id]);
|
|
6817
|
+
if (scheduled.rows.length)
|
|
6818
|
+
__sbSql(store, "DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND token=? AND expires>?", [cls, id, claim, Date.now()]);
|
|
6819
|
+
else
|
|
6820
|
+
__sbSql(store, "UPDATE do_alarm_delivery SET expires=? WHERE cls=? AND id=? AND token=? AND expires>?", [Date.now() + 5000, cls, id, claim, Date.now()]);
|
|
6821
|
+
}
|
|
6822
|
+
}
|
|
6823
|
+
|
|
6561
6824
|
// B22: on a host that delivers triggers (sproutd), what this worker consumes. Set by
|
|
6562
6825
|
// __sbStartLocalTriggers; null when the host does not drive this worker's triggers.
|
|
6563
6826
|
var __sbLocal = null;
|
|
@@ -6644,7 +6907,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
6644
6907
|
}
|
|
6645
6908
|
|
|
6646
6909
|
if (scheduled) {
|
|
6647
|
-
|
|
6910
|
+
__sbSetInterval(async function () {
|
|
6648
6911
|
const now = new Date();
|
|
6649
6912
|
const stamp =
|
|
6650
6913
|
now.getUTCFullYear() +
|
|
@@ -6684,7 +6947,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
|
|
|
6684
6947
|
}, 15000);
|
|
6685
6948
|
}
|
|
6686
6949
|
|
|
6687
|
-
if (queues.length > 0)
|
|
6950
|
+
if (queues.length > 0) __sbSetInterval(() => __sbQueuePass(handlers, queues), 500);
|
|
6688
6951
|
|
|
6689
|
-
if (dos.length > 0)
|
|
6952
|
+
if (dos.length > 0) __sbSetInterval(() => __sbAlarmPass(), 500);
|
|
6690
6953
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** What a `_redirects` matcher must answer, from Cloudflare's rules engine: [file text, request path, [status, location] or null]. */
|
|
2
|
+
export type RedirectCase = [text: string, path: string, want: [status: number, location: string] | null];
|
|
3
|
+
|
|
4
|
+
export const REDIRECT_CASES: RedirectCase[] = [
|
|
5
|
+
["/compare /compare/ 308", "/compare", [308, "/compare/"]],
|
|
6
|
+
["/compare /compare/ 308", "/compare/", null],
|
|
7
|
+
["/compare/ /compare 308", "/compare/", [308, "/compare"]],
|
|
8
|
+
["/compare/ /compare 308", "/compare", null],
|
|
9
|
+
["/user/:id /profile/:id", "/user/42", [302, "/profile/42"]],
|
|
10
|
+
["/user/:id /profile/:id", "/user/42/", null],
|
|
11
|
+
["/user/:id /profile/:id", "/user/", null],
|
|
12
|
+
["/user/:id /profile/:id", "/user", null],
|
|
13
|
+
["/blog/* /posts/:splat 301", "/blog/", [301, "/posts/"]],
|
|
14
|
+
["/blog/* /posts/:splat 301", "/blog", null],
|
|
15
|
+
["/blog/* /posts/:splat 301", "/blog/a/b/", [301, "/posts/a/b/"]],
|
|
16
|
+
["/a/*/c /x/:splat", "/a/b/b2/c", [302, "/x/b/b2"]],
|
|
17
|
+
["/a/*/c /x/:splat", "/a/c", null],
|
|
18
|
+
["/:lang/docs /docs/:lang 301", "/fr/docs", [301, "/docs/fr"]],
|
|
19
|
+
["/:lang/docs /docs/:lang 301", "/fr/docs/", null],
|
|
20
|
+
["/files/:name.html /pages/:name", "/files/about.html", [302, "/pages/about"]],
|
|
21
|
+
["/:a-:b /x/:b/:a", "/one-two", [302, "/x/two/one"]],
|
|
22
|
+
["/:a/:a /x", "/p/p", null],
|
|
23
|
+
["/*/*/ /x", "/a/b/", null],
|
|
24
|
+
["/a:1 /b", "/a:1", [302, "/b"]],
|
|
25
|
+
["/blog/* /dynamic/:splat\n/blog/old /static\n/blog/old /static-2", "/blog/old", [302, "/static"]],
|
|
26
|
+
["/blog/* /dynamic/:splat\n/blog/old /static", "/blog/new", [302, "/dynamic/new"]],
|
|
27
|
+
["/x/:a /first\n/x/:b /second", "/x/1", [302, "/first"]],
|
|
28
|
+
];
|