@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.10.1",
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
- const html = await cache.read(cacheKey(c.req.url), window, render);
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 = async (key, window, render) => {
84
- if (inFlight.has(key)) return inFlight.get(key);
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(() => inFlight.delete(key));
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
- /** `null` when the caller should just render, which is every uncached route. */
108
- async read(key, window, render) {
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
- refresh(key, window, render).catch(() => {});
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