@transclude/core 0.6.0 → 0.8.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/bin/build.js +13 -41
- package/package.json +1 -1
- package/skills/transclude/references/elements.md +25 -0
- package/skills/transclude/references/server.md +74 -0
- package/src/after.js +67 -0
- package/src/app.js +11 -1
- package/src/compiler/codegen.js +41 -0
- package/src/cookies.js +15 -0
- package/src/defaults.js +51 -0
- package/src/prerender.js +110 -0
- package/src/project.js +6 -18
- package/src/typecheck.js +4 -2
- package/src/worker.js +57 -0
package/bin/build.js
CHANGED
|
@@ -16,7 +16,8 @@ import { pathToFileURL } from 'node:url';
|
|
|
16
16
|
import { build } from 'vite';
|
|
17
17
|
import transclude from '../src/plugin.js';
|
|
18
18
|
import { loadProject } from '../src/project.js';
|
|
19
|
-
import {
|
|
19
|
+
import { renderRoute, urlFor } from '../src/document.js';
|
|
20
|
+
import { prerenderContext, refusePrerender } from '../src/prerender.js';
|
|
20
21
|
import { feed, feedPath } from '../src/feed.js';
|
|
21
22
|
import { includeContext } from '../src/include.js';
|
|
22
23
|
import { nodeLookup } from '../src/lookup.js';
|
|
@@ -25,7 +26,6 @@ import { etagOf, loadAssets, loadStatic } from '../src/static-cache.js';
|
|
|
25
26
|
import { buildSprite, readLibraries, refuseSpriteClash, spritePath } from '../src/icons.js';
|
|
26
27
|
import { PRECACHE_PATH, precacheDocument, precacheList } from '../src/precache.js';
|
|
27
28
|
import { speculateSettings, speculationRules } from '../src/speculate.js';
|
|
28
|
-
import { cookiesOf } from '../src/cookies.js';
|
|
29
29
|
import { pool } from '../src/pool.js';
|
|
30
30
|
import { precompress } from '../src/compress.js';
|
|
31
31
|
|
|
@@ -148,24 +148,19 @@ async function urlsFor(route) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
151
|
+
* One page, rendered to the markup that gets written.
|
|
152
|
+
*
|
|
153
|
+
* What it is allowed to be, and the `ctx` it is given, are both in
|
|
154
|
+
* `src/prerender.js`, where they can be tested. Nothing imports this file.
|
|
155
155
|
*/
|
|
156
156
|
async function render(route, { url, params }) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
url
|
|
157
|
+
const ctx = prerenderContext({
|
|
158
|
+
route,
|
|
159
|
+
url,
|
|
160
160
|
params,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
action: null,
|
|
165
|
-
response,
|
|
166
|
-
cookies: cookiesOf(null, response, config.cookieSecret),
|
|
167
|
-
absolute: absoluteFrom(config.metadataBase, null),
|
|
168
|
-
};
|
|
161
|
+
cookieSecret: config.cookieSecret,
|
|
162
|
+
metadataBase: config.metadataBase,
|
|
163
|
+
});
|
|
169
164
|
|
|
170
165
|
const html = await renderRoute(pages[route.id], ctx, {
|
|
171
166
|
clientEntry: assets.get(route.id) ?? null,
|
|
@@ -176,30 +171,7 @@ async function render(route, { url, params }) {
|
|
|
176
171
|
include,
|
|
177
172
|
});
|
|
178
173
|
|
|
179
|
-
|
|
180
|
-
throw new Error(`answered with ${html.status} instead of markup, so it cannot be prerendered`);
|
|
181
|
-
}
|
|
182
|
-
if (ctx.response.status !== 200) {
|
|
183
|
-
throw new Error(`answered ${ctx.response.status}, which no file can carry`);
|
|
184
|
-
}
|
|
185
|
-
// A file carries no headers either. A Set-Cookie or a Cache-Control written here
|
|
186
|
-
// would be thrown away, which is worse than being told.
|
|
187
|
-
const [header] = [...ctx.response.headers.keys()];
|
|
188
|
-
if (header) {
|
|
189
|
-
throw new Error(`set a ${header} header, which no file can carry`);
|
|
190
|
-
}
|
|
191
|
-
// Reading a cookie is what makes a page personal, and there is no request
|
|
192
|
-
// here to read one from. Whatever this file says about the reader is what a
|
|
193
|
-
// reader with no cookies would have seen, and every visitor gets that copy.
|
|
194
|
-
// A layout or an included route can do this without the page mentioning it,
|
|
195
|
-
// which is what makes it worth saying out loud.
|
|
196
|
-
if (ctx.cookies.personal) {
|
|
197
|
-
throw new Error(
|
|
198
|
-
`read a cookie, so it is different for each visitor and cannot be one file. ` +
|
|
199
|
-
`Give it \`export const prerender = false\`, or stop reading the cookie ` +
|
|
200
|
-
`here or in what it includes`,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
174
|
+
refusePrerender(ctx, html);
|
|
203
175
|
return html;
|
|
204
176
|
}
|
|
205
177
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -215,6 +215,31 @@ document.addEventListener(
|
|
|
215
215
|
The element is then a real form field: it submits, resets and validates with the
|
|
216
216
|
rest of them.
|
|
217
217
|
|
|
218
|
+
### Saying it is invalid
|
|
219
|
+
|
|
220
|
+
`host.internals` is the handle the platform hands out, so `setValidity` works the
|
|
221
|
+
way it does on an input. `:invalid` matches, a submit is blocked, and the browser
|
|
222
|
+
shows its own message.
|
|
223
|
+
|
|
224
|
+
```html
|
|
225
|
+
<script>
|
|
226
|
+
export const prototype = {
|
|
227
|
+
updated() {
|
|
228
|
+
const empty = !this.value;
|
|
229
|
+
this.internals.setValidity(
|
|
230
|
+
empty ? { valueMissing: true } : {},
|
|
231
|
+
empty ? 'Pick at least one tag.' : '',
|
|
232
|
+
this,
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
</script>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Call it whenever the value changes, and pass no arguments to clear it. A field
|
|
240
|
+
that cannot say it is wrong is not really a field, and this is the part most
|
|
241
|
+
custom controls leave out.
|
|
242
|
+
|
|
218
243
|
## An icon element
|
|
219
244
|
|
|
220
245
|
The framework compiles `app/icons/` into one `/icons.svg` and defines no element
|
|
@@ -154,6 +154,80 @@ something built on Wasm fails there and nowhere else. A prerendered page never
|
|
|
154
154
|
runs its loader in production, so this stays hidden until something asks for a
|
|
155
155
|
fragment.
|
|
156
156
|
|
|
157
|
+
### worker.js
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
import { workerFrom } from '@transclude/core/worker';
|
|
161
|
+
import * as bundle from './dist/server/assets.js';
|
|
162
|
+
import * as entry from './dist/server/entry.js';
|
|
163
|
+
import manifest from './dist/routes.json';
|
|
164
|
+
import config from './transclude.config.js';
|
|
165
|
+
|
|
166
|
+
export default workerFrom({ config, manifest, entry, bundle });
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The imports stay in the app: a bundler needs a literal path. `workerFrom` builds
|
|
170
|
+
the app on the first request, which is when `env` exists, and takes
|
|
171
|
+
`cookieSecret` from `env.COOKIE_SECRET`. For anything else, call `createApp`
|
|
172
|
+
from `@transclude/core/app` directly.
|
|
173
|
+
|
|
174
|
+
### Bindings
|
|
175
|
+
|
|
176
|
+
`ctx` has no `env`. It carries nothing that names one runtime, and `env` names
|
|
177
|
+
one: the other three fill that slot with something else. A KV namespace, a D1
|
|
178
|
+
database or a secret reaches a loader through the app instead.
|
|
179
|
+
|
|
180
|
+
`worker.js` belongs to the app and does get `env`. Hold it in a module and
|
|
181
|
+
import that.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
// app/lib/bindings.js — no `node:` imports, this ends up in the worker bundle
|
|
185
|
+
/** @type {Env|null} */
|
|
186
|
+
let current = null;
|
|
187
|
+
|
|
188
|
+
export const hold = (env) => (current = env);
|
|
189
|
+
|
|
190
|
+
export const bindings = () => {
|
|
191
|
+
if (!current) throw new Error('No bindings: this app is running off workerd.');
|
|
192
|
+
return current;
|
|
193
|
+
};
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`worker.js` calls `hold(env)` inside `appFor`, before the `app ??=` line. A
|
|
197
|
+
loader then imports `bindings` and reads `bindings().DB`.
|
|
198
|
+
|
|
199
|
+
Module scope is safe here because `env` is one object for the life of the
|
|
200
|
+
isolate. Holding a `Request` the same way is not, since two visitors would share
|
|
201
|
+
whichever arrived last.
|
|
202
|
+
|
|
203
|
+
**A page that reads a binding needs `prerender = false`.** The build runs on
|
|
204
|
+
Node, where nothing calls `hold`, so the build runs that loader and throws.
|
|
205
|
+
|
|
206
|
+
### ctx.after
|
|
207
|
+
|
|
208
|
+
`after(work)` takes a promise the reader does not wait for.
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
export const prerender = false;
|
|
212
|
+
|
|
213
|
+
export default async ({ after, url }) => {
|
|
214
|
+
after(recordView(url));
|
|
215
|
+
return { notes: await notes.all() };
|
|
216
|
+
};
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
On workerd this is `waitUntil`, which keeps the isolate up past the response.
|
|
220
|
+
Node, Bun and Deno keep running anyway, so it changes nothing there.
|
|
221
|
+
|
|
222
|
+
It takes the promise, not a function returning one. A function is refused, since
|
|
223
|
+
wrapping one would resolve to the function and run nothing.
|
|
224
|
+
|
|
225
|
+
A rejection goes to `onError` with the request that started it. Nothing awaits
|
|
226
|
+
this work, so leaving it would end the process on Node.
|
|
227
|
+
|
|
228
|
+
Calling it from a prerendered page stops the build: a file has no response to
|
|
229
|
+
outlive. `prerender = false` is the fix, as it is for a binding.
|
|
230
|
+
|
|
157
231
|
## Testing
|
|
158
232
|
|
|
159
233
|
The app is a function from a request to a response.
|
package/src/after.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Work that outlives the response.
|
|
2
|
+
//
|
|
3
|
+
// Every runtime here keeps a promise running after a response, except the one
|
|
4
|
+
// that matters most for this. Node, Bun and Deno are processes, and a promise
|
|
5
|
+
// nobody awaits finishes on its own. workerd is not: the isolate is allowed to
|
|
6
|
+
// stop once the response is sent, and anything still running stops with it.
|
|
7
|
+
// `waitUntil` is how a worker asks to stay up, and it exists nowhere else.
|
|
8
|
+
//
|
|
9
|
+
// So `ctx.after` is a capability rather than a runtime's API. It means the same
|
|
10
|
+
// thing on all four: this work does not have to finish before the reader is
|
|
11
|
+
// served. That is the bar `env` failed, which is why there is no `ctx.env`.
|
|
12
|
+
//
|
|
13
|
+
// No `node:` imports.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The runtime's `ExecutionContext`, or null when it has none.
|
|
17
|
+
*
|
|
18
|
+
* Hono's getter throws rather than answering undefined, so the only way to ask
|
|
19
|
+
* is to try. The `try` covers the getter and nothing else: a `waitUntil` that
|
|
20
|
+
* throws is a real failure and belongs to the caller.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} c a Hono context
|
|
23
|
+
* @returns {{ waitUntil: (work: Promise<unknown>) => void }|null}
|
|
24
|
+
*/
|
|
25
|
+
export function executionCtxOf(c) {
|
|
26
|
+
try {
|
|
27
|
+
return c.executionCtx;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `ctx.after` for one request.
|
|
35
|
+
*
|
|
36
|
+
* The rejection handling is the reason this is not one line at the call site.
|
|
37
|
+
* Nothing is awaiting this work, so a throw inside it reaches the runtime's
|
|
38
|
+
* unhandled rejection handler, which on Node ends the process. It goes to the
|
|
39
|
+
* same place a failed request goes instead, with the request that started it.
|
|
40
|
+
*
|
|
41
|
+
* `report` is called with the error alone. Whoever builds this already knows
|
|
42
|
+
* which request it belongs to.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} c a Hono context
|
|
45
|
+
* @param {(error: unknown) => void} report where a failure goes
|
|
46
|
+
* @returns {(work: Promise<unknown>) => void}
|
|
47
|
+
*/
|
|
48
|
+
export function afterFor(c, report) {
|
|
49
|
+
return (work) => {
|
|
50
|
+
// A function is the mistake worth naming. `after(() => log())` would be
|
|
51
|
+
// wrapped by `Promise.resolve`, resolve to the function itself, and do
|
|
52
|
+
// nothing at all, which is the one shape that fails without a symptom.
|
|
53
|
+
if (typeof work?.then !== 'function') {
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
'[transclude] ctx.after takes a promise. Call the work and pass what it ' +
|
|
56
|
+
'returns, as in `after(log(url))` rather than `after(() => log(url))`.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const settled = Promise.resolve(work).catch(report);
|
|
61
|
+
|
|
62
|
+
// Only workerd has one. Everywhere else the promise is already running and
|
|
63
|
+
// the process is still there to finish it.
|
|
64
|
+
const execution = executionCtxOf(c);
|
|
65
|
+
if (execution) execution.waitUntil(settled);
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/app.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
import { pickEncoding } from './negotiate.js';
|
|
30
30
|
import { baseApp, endpointMethods, runEndpoint } from './server.js';
|
|
31
31
|
import { cookiesOf } from './cookies.js';
|
|
32
|
+
import { afterFor } from './after.js';
|
|
33
|
+
import { withDefaults } from './defaults.js';
|
|
32
34
|
|
|
33
35
|
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
|
34
36
|
const REVALIDATE = 'public, max-age=0, must-revalidate';
|
|
@@ -142,7 +144,11 @@ function isShareable(html, ctx) {
|
|
|
142
144
|
* @returns {object} a Hono app, ready to serve
|
|
143
145
|
*/
|
|
144
146
|
export function createApp({
|
|
145
|
-
|
|
147
|
+
// Filled in here rather than by the caller, because only one of the four
|
|
148
|
+
// runtimes has a `loadProject` to fill them in on the way. A worker imports
|
|
149
|
+
// `transclude.config.js` and hands over exactly what the author wrote, so
|
|
150
|
+
// every key they left out was undefined until this line.
|
|
151
|
+
config: written,
|
|
146
152
|
manifest,
|
|
147
153
|
pages,
|
|
148
154
|
endpoints = {},
|
|
@@ -163,6 +169,7 @@ export function createApp({
|
|
|
163
169
|
// it the proxy's allowlist is the whole defense.
|
|
164
170
|
lookup = null,
|
|
165
171
|
}) {
|
|
172
|
+
const config = withDefaults(written);
|
|
166
173
|
const cache = createCache(config.cache);
|
|
167
174
|
|
|
168
175
|
// One resolver for the app, so several pages including the same document read
|
|
@@ -210,6 +217,9 @@ export function createApp({
|
|
|
210
217
|
cookies: cookiesOf(c.req.raw, response, config.cookieSecret),
|
|
211
218
|
absolute: absoluteFrom(config.metadataBase, c.req.url),
|
|
212
219
|
revalidateTag: cache.revalidateTag,
|
|
220
|
+
// Reported through `report`, so work that fails after the reader is gone
|
|
221
|
+
// is not quieter than work that fails in front of them.
|
|
222
|
+
after: afterFor(c, (error) => report(error, c)),
|
|
213
223
|
...extra,
|
|
214
224
|
};
|
|
215
225
|
};
|
package/src/compiler/codegen.js
CHANGED
|
@@ -820,6 +820,8 @@ class Codegen {
|
|
|
820
820
|
* Date crosses as an ISO string rather than as JSON.
|
|
821
821
|
*/
|
|
822
822
|
emitAttrs(el, out, scope, ref = null) {
|
|
823
|
+
if (this.loops.length) assertUniqueTransitionName(el);
|
|
824
|
+
|
|
823
825
|
for (const attr of el.attrs) {
|
|
824
826
|
if (DIRECTIVES.has(attr.name)) continue;
|
|
825
827
|
|
|
@@ -1000,6 +1002,45 @@ export function gatherChain(nodes, i) {
|
|
|
1000
1002
|
* @param {object} node
|
|
1001
1003
|
* @returns {object[]} a template's live under `.content`, so walking `childNodes` finds nothing
|
|
1002
1004
|
*/
|
|
1005
|
+
/**
|
|
1006
|
+
* A repeated element may not carry a `view-transition-name` written out.
|
|
1007
|
+
*
|
|
1008
|
+
* The name has to be unique in the document. Every copy of a repeated element
|
|
1009
|
+
* would carry the same one, and the browser's answer to that is to run no
|
|
1010
|
+
* transition at all: nothing throws, nothing is logged, the page just stops
|
|
1011
|
+
* animating. That is the whole reason this is a compile error.
|
|
1012
|
+
*
|
|
1013
|
+
* `none` is allowed, because it is the one value that means "not part of a
|
|
1014
|
+
* transition" and is safe to repeat.
|
|
1015
|
+
*
|
|
1016
|
+
* Only the `style` attribute is checked. A name applied through a class is in a
|
|
1017
|
+
* stylesheet this compiler does not read, so this catches the spelling everyone
|
|
1018
|
+
* writes rather than every spelling there is.
|
|
1019
|
+
*/
|
|
1020
|
+
function assertUniqueTransitionName(el) {
|
|
1021
|
+
const style = el.attrs?.find((a) => a.name === 'style')?.value;
|
|
1022
|
+
if (!style) return;
|
|
1023
|
+
|
|
1024
|
+
const at = style.indexOf('view-transition-name');
|
|
1025
|
+
if (at === -1) return;
|
|
1026
|
+
|
|
1027
|
+
// Just this declaration, so `${}` elsewhere in the attribute does not excuse
|
|
1028
|
+
// a name that is still written out.
|
|
1029
|
+
const end = style.indexOf(';', at);
|
|
1030
|
+
const declaration = end === -1 ? style.slice(at) : style.slice(at, end);
|
|
1031
|
+
|
|
1032
|
+
if (declaration.includes('${')) return;
|
|
1033
|
+
if (/:\s*none\s*$/.test(declaration)) return;
|
|
1034
|
+
|
|
1035
|
+
throw new CompileError(
|
|
1036
|
+
`<${el.tagName}> is repeated and writes out a view-transition-name, so every ` +
|
|
1037
|
+
`copy would carry the same one. A name has to be unique in the document, and ` +
|
|
1038
|
+
`a browser that finds two runs no transition at all rather than saying so. ` +
|
|
1039
|
+
`Derive it from the loop, as in "view-transition-name: card-\${item.id}".`,
|
|
1040
|
+
el,
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1003
1044
|
export function childrenOf(node) {
|
|
1004
1045
|
if (node.tagName === 'template' && node.content) return node.content.childNodes ?? [];
|
|
1005
1046
|
return node.childNodes ?? [];
|
package/src/cookies.js
CHANGED
|
@@ -39,6 +39,21 @@ export function cookiesOf(request, response, secret = null) {
|
|
|
39
39
|
|
|
40
40
|
const requireSecret = (what) => {
|
|
41
41
|
if (secret) return secret;
|
|
42
|
+
|
|
43
|
+
// Set-but-empty is worth its own sentence. It happened on a real deploy:
|
|
44
|
+
// `wrangler secret put` took a blank line, so the binding existed, every
|
|
45
|
+
// `typeof` along the way said `string`, and the config carried it all the
|
|
46
|
+
// way here. The only thing that said otherwise was the length. Reading
|
|
47
|
+
// "needs a secret" while looking at a secret that is plainly set sends you
|
|
48
|
+
// hunting through the wiring instead of the value.
|
|
49
|
+
if (typeof secret === 'string') {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[transclude] ${what} needs a secret, and \`cookieSecret\` is set to an ` +
|
|
52
|
+
`empty string. Whatever supplies it handed over nothing: on a worker that ` +
|
|
53
|
+
`is usually a \`wrangler secret put\` that took a blank line`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
throw new Error(
|
|
43
58
|
`[transclude] ${what} needs a secret. Set \`cookieSecret\` in ` +
|
|
44
59
|
`transclude.config.js (read it from the environment there, not from a literal)`,
|
package/src/defaults.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// What a config means when it does not say.
|
|
2
|
+
//
|
|
3
|
+
// Split out of `project.js` because that file reads a disk and this has to run
|
|
4
|
+
// where there is no disk. `loadProject` is Node's way in, and a worker has no
|
|
5
|
+
// equivalent: it imports `transclude.config.js` directly, so the object reaching
|
|
6
|
+
// `createApp` there is exactly what the author wrote and nothing more.
|
|
7
|
+
//
|
|
8
|
+
// That gap was live for a while. `fragmentParam` was only ever filled in by
|
|
9
|
+
// `loadProject`, so on workerd `config.fragmentParam` was undefined, the check
|
|
10
|
+
// for it read as "no parameter configured", and every `?fragment=` request was
|
|
11
|
+
// answered with the whole document. A swap then wrote a second copy of the page
|
|
12
|
+
// into the element it was meant to replace. It looked like a compiler bug and it
|
|
13
|
+
// was a missing default.
|
|
14
|
+
//
|
|
15
|
+
// Applied in `createApp` rather than in each entry, so there is one place and no
|
|
16
|
+
// runtime can skip it.
|
|
17
|
+
//
|
|
18
|
+
// No `node:` imports.
|
|
19
|
+
|
|
20
|
+
/** Every key with a value, and the value it takes when the config is quiet. */
|
|
21
|
+
export const DEFAULTS = {
|
|
22
|
+
appDir: 'app',
|
|
23
|
+
routesDir: 'routes',
|
|
24
|
+
elementsDir: 'elements',
|
|
25
|
+
publicDir: 'public',
|
|
26
|
+
iconsDir: 'icons',
|
|
27
|
+
outDir: 'dist',
|
|
28
|
+
typesFile: 'app/transclude-env.d.ts',
|
|
29
|
+
stylesheet: null,
|
|
30
|
+
lang: 'en',
|
|
31
|
+
fragmentParam: 'fragment',
|
|
32
|
+
trailingSlash: 'never',
|
|
33
|
+
strict: false,
|
|
34
|
+
csrf: true,
|
|
35
|
+
csp: false,
|
|
36
|
+
speculate: false,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A config with every default filled in.
|
|
41
|
+
*
|
|
42
|
+
* A key the author wrote wins, including one written as `null` or `false`. Only
|
|
43
|
+
* an absent key takes the default, which is what lets `fragmentParam: null` turn
|
|
44
|
+
* the parameter off rather than quietly turning it back on.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} [config] whatever `transclude.config.js` exported
|
|
47
|
+
* @returns {object} the same keys, plus the ones it did not mention
|
|
48
|
+
*/
|
|
49
|
+
export function withDefaults(config = {}) {
|
|
50
|
+
return { ...DEFAULTS, ...config };
|
|
51
|
+
}
|
package/src/prerender.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// What a page is allowed to be, if it is going to be a file.
|
|
2
|
+
//
|
|
3
|
+
// A prerendered page is written once and served to everyone. It has no status,
|
|
4
|
+
// no headers and no reader. So a loader that answers with a `Response`, sets a
|
|
5
|
+
// status, writes a header or reads a cookie is saying this URL is not a page you
|
|
6
|
+
// can write down, and the build says so rather than writing a file that lies
|
|
7
|
+
// about it.
|
|
8
|
+
//
|
|
9
|
+
// Split out of `bin/build.js` so the refusals can be tested. Nothing imports
|
|
10
|
+
// that file: it is a script that runs a build the moment it is loaded, so every
|
|
11
|
+
// message here used to be checked by hand or not at all.
|
|
12
|
+
//
|
|
13
|
+
// No `node:` imports, though nothing needs that of this file yet. It is here
|
|
14
|
+
// because the three modules it calls have the same rule.
|
|
15
|
+
|
|
16
|
+
import { absoluteFrom, responseOf } from './document.js';
|
|
17
|
+
import { cookiesOf } from './cookies.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The `ctx` a loader is handed while the build renders it to a file.
|
|
21
|
+
*
|
|
22
|
+
* `revalidateTag` and `after` are refusals rather than absences. Left off the
|
|
23
|
+
* object they are `undefined`, and a loader calling one fails with `x is not a
|
|
24
|
+
* function`, which names neither what the page did nor how to stop. Both stay in
|
|
25
|
+
* the generated type either way, because the checker cannot know which pages
|
|
26
|
+
* become files.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} options
|
|
29
|
+
* @param {{ id: string, pattern?: string }} options.route
|
|
30
|
+
* @param {string} options.url the path being written
|
|
31
|
+
* @param {Record<string, string>} options.params
|
|
32
|
+
* @param {string|null} [options.cookieSecret]
|
|
33
|
+
* @param {string} [options.metadataBase]
|
|
34
|
+
* @returns {object} the same shape a request gets, minus what a file cannot have
|
|
35
|
+
*/
|
|
36
|
+
export function prerenderContext({ route, url, params, cookieSecret = null, metadataBase }) {
|
|
37
|
+
const response = responseOf();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
url: `http://localhost${url}`,
|
|
41
|
+
params,
|
|
42
|
+
route: { id: route.id, pattern: route.pattern ?? '', path: url },
|
|
43
|
+
// Null here and at no other time, which is how a shared layout can skip the
|
|
44
|
+
// part that needs a visitor.
|
|
45
|
+
request: null,
|
|
46
|
+
fragment: null,
|
|
47
|
+
action: null,
|
|
48
|
+
response,
|
|
49
|
+
cookies: cookiesOf(null, response, cookieSecret),
|
|
50
|
+
absolute: absoluteFrom(metadataBase, null),
|
|
51
|
+
|
|
52
|
+
revalidateTag: () => {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`called \`ctx.revalidateTag\`, and a build holds no rendered pages to drop. ` +
|
|
55
|
+
`Give it \`export const prerender = false\`, or move the call to the action ` +
|
|
56
|
+
`or endpoint that changes the data`,
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
after: () => {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`called \`ctx.after\`, and a file has no response for that work to outlive. ` +
|
|
63
|
+
`Give it \`export const prerender = false\`, or start the work from an ` +
|
|
64
|
+
`endpoint or an action instead`,
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Throws unless what was rendered can be written to a file.
|
|
72
|
+
*
|
|
73
|
+
* Called after the render rather than during it, because three of the four are
|
|
74
|
+
* things a loader does on the way past and only the finished `ctx` knows about.
|
|
75
|
+
* Every message continues a sentence whose subject is the page, since that is
|
|
76
|
+
* what the build prints above it.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} ctx the context the render was given
|
|
79
|
+
* @param {string|Response} html what the render answered
|
|
80
|
+
* @throws when this URL cannot be one file
|
|
81
|
+
*/
|
|
82
|
+
export function refusePrerender(ctx, html) {
|
|
83
|
+
if (html instanceof Response) {
|
|
84
|
+
throw new Error(`answered with ${html.status} instead of markup, so it cannot be prerendered`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (ctx.response.status !== 200) {
|
|
88
|
+
throw new Error(`answered ${ctx.response.status}, which no file can carry`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A file carries no headers either. A Set-Cookie or a Cache-Control written
|
|
92
|
+
// here would be thrown away, which is worse than being told.
|
|
93
|
+
const [header] = [...ctx.response.headers.keys()];
|
|
94
|
+
if (header) {
|
|
95
|
+
throw new Error(`set a ${header} header, which no file can carry`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reading a cookie is what makes a page personal, and there is no request here
|
|
99
|
+
// to read one from. Whatever this file says about the reader is what a reader
|
|
100
|
+
// with no cookies would have seen, and every visitor gets that copy. A layout
|
|
101
|
+
// or an included route can do this without the page mentioning it, which is
|
|
102
|
+
// what makes it worth saying out loud.
|
|
103
|
+
if (ctx.cookies.personal) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`read a cookie, so it is different for each visitor and cannot be one file. ` +
|
|
106
|
+
`Give it \`export const prerender = false\`, or stop reading the cookie ` +
|
|
107
|
+
`here or in what it includes`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/project.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
|
+
import { withDefaults } from './defaults.js';
|
|
14
15
|
|
|
15
16
|
export const CONFIG_FILE = 'transclude.config.js';
|
|
16
17
|
|
|
@@ -80,24 +81,11 @@ export function findRoot(from = process.cwd()) {
|
|
|
80
81
|
*
|
|
81
82
|
* `port` is not here. `portOf` already answers it, and it reads the environment
|
|
82
83
|
* first, which a plain default cannot do.
|
|
84
|
+
*
|
|
85
|
+
* They live in `defaults.js` rather than here, because a worker has no
|
|
86
|
+
* `loadProject` and needs the same answers. `createApp` applies them for every
|
|
87
|
+
* runtime, and this file is only Node's way in.
|
|
83
88
|
*/
|
|
84
|
-
const DEFAULTS = {
|
|
85
|
-
appDir: 'app',
|
|
86
|
-
routesDir: 'routes',
|
|
87
|
-
elementsDir: 'elements',
|
|
88
|
-
publicDir: 'public',
|
|
89
|
-
iconsDir: 'icons',
|
|
90
|
-
outDir: 'dist',
|
|
91
|
-
typesFile: 'app/transclude-env.d.ts',
|
|
92
|
-
stylesheet: null,
|
|
93
|
-
lang: 'en',
|
|
94
|
-
fragmentParam: 'fragment',
|
|
95
|
-
trailingSlash: 'never',
|
|
96
|
-
strict: false,
|
|
97
|
-
csrf: true,
|
|
98
|
-
csp: false,
|
|
99
|
-
speculate: false,
|
|
100
|
-
};
|
|
101
89
|
|
|
102
90
|
/**
|
|
103
91
|
* The root and its config together, because nothing needs one without the other.
|
|
@@ -117,7 +105,7 @@ export async function loadProject(from = process.cwd()) {
|
|
|
117
105
|
throw new Error(`[transclude] ${CONFIG_FILE} must export a config object as its default`);
|
|
118
106
|
}
|
|
119
107
|
assertNoSplitDirs(config, file);
|
|
120
|
-
return { root, config:
|
|
108
|
+
return { root, config: withDefaults(config), configFile: file };
|
|
121
109
|
}
|
|
122
110
|
|
|
123
111
|
/**
|
package/src/typecheck.js
CHANGED
|
@@ -289,7 +289,8 @@ export function createChecker({
|
|
|
289
289
|
`route: { id: string; pattern: string; path: string }; ` +
|
|
290
290
|
`request: Request; fragment: string | null; ` +
|
|
291
291
|
`response: { status: number; headers: Headers }; cookies: __Cookies; ` +
|
|
292
|
-
`absolute: (path: string) => string; revalidateTag: (tag: string) => void
|
|
292
|
+
`absolute: (path: string) => string; revalidateTag: (tag: string) => void; ` +
|
|
293
|
+
`after: (work: Promise<unknown>) => void }`;
|
|
293
294
|
|
|
294
295
|
const contextLiteral = (params, layoutType) =>
|
|
295
296
|
`{ url: string; params: { ${params.map((name) => `${name}: string`).join('; ')} }; ` +
|
|
@@ -297,7 +298,8 @@ export function createChecker({
|
|
|
297
298
|
`layout: ${layoutType}; request: Request | null; fragment: string | null; ` +
|
|
298
299
|
`action: unknown; response: { status: number; headers: Headers }; ` +
|
|
299
300
|
`cookies: __Cookies; htmlAttrs: Record<string, string | boolean | null>; ` +
|
|
300
|
-
`absolute: (path: string) => string; revalidateTag: (tag: string) => void
|
|
301
|
+
`absolute: (path: string) => string; revalidateTag: (tag: string) => void; ` +
|
|
302
|
+
`after: (work: Promise<unknown>) => void }`;
|
|
301
303
|
|
|
302
304
|
/**
|
|
303
305
|
* Builds every shim in dependency order: components depend on nothing, a
|
package/src/worker.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// is WebCrypto and therefore async. Those are runtime facts, so they live here.
|
|
6
6
|
// Which modules to import is an app fact, so that stays in the app's own entry.
|
|
7
7
|
|
|
8
|
+
import { createApp } from './app.js';
|
|
9
|
+
|
|
8
10
|
/** base64 in, bytes out. `atob` is in every runtime that has no `Buffer`. */
|
|
9
11
|
const decode = (base64) => Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
10
12
|
|
|
@@ -85,3 +87,58 @@ export async function hash(body) {
|
|
|
85
87
|
for (const byte of bytes) base64 += String.fromCharCode(byte);
|
|
86
88
|
return `"${btoa(base64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '').slice(0, 20)}"`;
|
|
87
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The whole worker, for an app that wants the ordinary wiring.
|
|
93
|
+
*
|
|
94
|
+
* Nine apps in this repository wrote the same forty lines: parse the manifest,
|
|
95
|
+
* wrap each byte map, build the app on the first request because that is when
|
|
96
|
+
* `env` exists, and hand the request on. The imports have to stay in the app's
|
|
97
|
+
* own file, because a bundler needs a literal path to follow. The wiring does
|
|
98
|
+
* not, and this is it.
|
|
99
|
+
*
|
|
100
|
+
* `cookieSecret` comes from `env.COOKIE_SECRET` when there is one, which is the
|
|
101
|
+
* only piece of config a worker cannot read at import time. An app needing
|
|
102
|
+
* something else calls `createApp` itself: this covers the common shape rather
|
|
103
|
+
* than every shape.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} options
|
|
106
|
+
* @param {object} options.config the app's `transclude.config.js`
|
|
107
|
+
* @param {string|object} options.manifest `dist/routes.json`, text or parsed
|
|
108
|
+
* @param {object} options.entry everything `dist/server/entry.js` exports
|
|
109
|
+
* @param {object} options.bundle everything `dist/server/assets.js` exports
|
|
110
|
+
* @returns {{ fetch: (request: Request, env: object, ctx: object) => Response|Promise<Response> }}
|
|
111
|
+
*/
|
|
112
|
+
export function workerFrom({ config, manifest, entry, bundle }) {
|
|
113
|
+
// Built on the first request rather than at import, because that is when
|
|
114
|
+
// `env` exists. There is no `process.env` here, so a secret read any earlier
|
|
115
|
+
// is undefined, and signing refuses.
|
|
116
|
+
let app = null;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
fetch(request, env, ctx) {
|
|
120
|
+
app ??= createApp({
|
|
121
|
+
config: { ...config, cookieSecret: env.COOKIE_SECRET ?? config.cookieSecret },
|
|
122
|
+
// There is no JSON module type in Workers, so the manifest usually
|
|
123
|
+
// arrives as a string. Used as an object it gives a route table of
|
|
124
|
+
// `undefined` and a site of 404s that looks exactly like a routing bug.
|
|
125
|
+
manifest: typeof manifest === 'string' ? JSON.parse(manifest) : manifest,
|
|
126
|
+
pages: entry.pages,
|
|
127
|
+
endpoints: entry.endpoints,
|
|
128
|
+
middleware: entry.middleware,
|
|
129
|
+
statics: bytesFrom(bundle.statics),
|
|
130
|
+
assets: bytesFrom(bundle.assets),
|
|
131
|
+
publicFiles: fileHandler(bundle.publicFiles),
|
|
132
|
+
notFound: pageEntry(bundle.notFound),
|
|
133
|
+
errorPage: pageEntry(bundle.errorPage),
|
|
134
|
+
hash,
|
|
135
|
+
// The edge compresses. Doing it here would be a second pass over bytes
|
|
136
|
+
// already going through one.
|
|
137
|
+
compress: null,
|
|
138
|
+
precache: bundle.precache,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return app.fetch(request, env, ctx);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|