@transclude/core 0.10.1 → 0.10.2
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/package.json +1 -1
- package/src/app.js +6 -1
- package/src/cache.js +52 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "An HTML-first server framework. A page is an .html file, the directory tree is the route table, and any fragment of a page is a URL of its own. Runs on Node, Bun, Deno and workerd, and ships no client JavaScript by default.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"html",
|
package/src/app.js
CHANGED
|
@@ -444,7 +444,12 @@ export function createApp({
|
|
|
444
444
|
return sendRendered(c, html, ctx, preload);
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
|
|
447
|
+
// The fourth argument is what holds the background rebuild. Without it
|
|
448
|
+
// workerd stops the rebuild when this response is sent, and the entry it
|
|
449
|
+
// leaves in the in-flight map answers every later request with a dead
|
|
450
|
+
// promise.
|
|
451
|
+
const after = afterFor(c, (error) => report(error, c));
|
|
452
|
+
const html = await cache.read(cacheKey(c.req.url), window, render, after);
|
|
448
453
|
|
|
449
454
|
// A miss rendered through the cache, and that render can answer with a
|
|
450
455
|
// `Response`. It was not stored, but it is still the answer.
|
package/src/cache.js
CHANGED
|
@@ -62,6 +62,35 @@ export function memoryStore({ max = 1000 } = {}) {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* How long an unfinished render may hold a key.
|
|
67
|
+
*
|
|
68
|
+
* The map exists so one render happens per key. It assumed every promise it held
|
|
69
|
+
* would settle. On workerd one may not: the isolate is allowed to stop when the
|
|
70
|
+
* response is sent, and it stops the work with it. The `finally` never runs, the
|
|
71
|
+
* entry stays, and every later request for that key waits on a promise that is
|
|
72
|
+
* already dead. The page hangs for as long as the isolate lives.
|
|
73
|
+
*
|
|
74
|
+
* `after` is the fix, and this is the bound on it being wrong. A render slower
|
|
75
|
+
* than this loses its claim on the key rather than keeping it forever.
|
|
76
|
+
*/
|
|
77
|
+
const ABANDONED_MS = 30_000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keeps the background rebuild alive, and its failure off the unhandled path.
|
|
81
|
+
*
|
|
82
|
+
* `after` is `waitUntil` on workerd and a no-op elsewhere, and it attaches its
|
|
83
|
+
* own catch. Without one, the catch here is all there is: a rebuild that throws
|
|
84
|
+
* must not take down a process that was only serving a stale page.
|
|
85
|
+
*
|
|
86
|
+
* @param {Promise<unknown>} work
|
|
87
|
+
* @param {((work: Promise<unknown>) => void)|null} after
|
|
88
|
+
*/
|
|
89
|
+
function hold(work, after) {
|
|
90
|
+
if (after) after(work);
|
|
91
|
+
else work.catch(() => {});
|
|
92
|
+
}
|
|
93
|
+
|
|
65
94
|
/**
|
|
66
95
|
* One route's cache, wrapped around the render.
|
|
67
96
|
*
|
|
@@ -80,8 +109,11 @@ export function createCache(store = memoryStore(), { now = () => Date.now() } =
|
|
|
80
109
|
// and every request behind it each start their own.
|
|
81
110
|
const inFlight = new Map();
|
|
82
111
|
|
|
83
|
-
const refresh =
|
|
84
|
-
|
|
112
|
+
const refresh = (key, window, render) => {
|
|
113
|
+
const current = inFlight.get(key);
|
|
114
|
+
if (current && now() - current.at < ABANDONED_MS) return current.work;
|
|
115
|
+
|
|
116
|
+
const started = now();
|
|
85
117
|
|
|
86
118
|
const work = (async () => {
|
|
87
119
|
const result = await render();
|
|
@@ -97,15 +129,25 @@ export function createCache(store = memoryStore(), { now = () => Date.now() } =
|
|
|
97
129
|
store.delete(key);
|
|
98
130
|
}
|
|
99
131
|
return result;
|
|
100
|
-
})().finally(() =>
|
|
132
|
+
})().finally(() => {
|
|
133
|
+
// Only if this is still the entry made above. A render that ran past
|
|
134
|
+
// ABANDONED_MS was replaced, and it must not delete its replacement.
|
|
135
|
+
if (inFlight.get(key)?.at === started) inFlight.delete(key);
|
|
136
|
+
});
|
|
101
137
|
|
|
102
|
-
inFlight.set(key, work);
|
|
138
|
+
inFlight.set(key, { work, at: started });
|
|
103
139
|
return work;
|
|
104
140
|
};
|
|
105
141
|
|
|
106
142
|
return {
|
|
107
|
-
/**
|
|
108
|
-
|
|
143
|
+
/**
|
|
144
|
+
* `null` when the caller should just render, which is every uncached route.
|
|
145
|
+
*
|
|
146
|
+
* `after` is the request's `ctx.after`. Only the stale path uses it, and a
|
|
147
|
+
* caller that leaves it out gets a rebuild nothing holds, which is what this
|
|
148
|
+
* used to do everywhere.
|
|
149
|
+
*/
|
|
150
|
+
async read(key, window, render, after = null) {
|
|
109
151
|
if (!window) return null;
|
|
110
152
|
|
|
111
153
|
const hit = store.get(key);
|
|
@@ -116,7 +158,10 @@ export function createCache(store = memoryStore(), { now = () => Date.now() } =
|
|
|
116
158
|
// Stale. Answer with it now and rebuild behind the response. A failed
|
|
117
159
|
// rebuild leaves the stale entry in place rather than emptying the cache
|
|
118
160
|
// because one render threw.
|
|
119
|
-
|
|
161
|
+
//
|
|
162
|
+
// `after` is what keeps the rebuild alive. On workerd the isolate may stop
|
|
163
|
+
// the moment the response is sent, and work nothing holds stops with it.
|
|
164
|
+
hold(refresh(key, window, render), after);
|
|
120
165
|
return hit.html;
|
|
121
166
|
},
|
|
122
167
|
|