@transclude/core 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.
- package/README.md +22 -3
- package/bin/build.js +9 -3
- package/bin/check.js +30 -8
- package/bin/dev.js +10 -2
- package/package.json +3 -2
- package/skills/transclude/SKILL.md +1 -1
- package/src/address.js +9 -2
- package/src/app.js +110 -81
- package/src/compiler/ambient.js +99 -0
- package/src/compiler/bind.js +34 -27
- package/src/compiler/codegen.js +40 -41
- package/src/compiler/directives.js +29 -0
- package/src/compiler/expr.js +10 -1
- package/src/compiler/html.js +41 -0
- package/src/compiler/index.js +46 -8
- package/src/compiler/script.js +10 -3
- package/src/compiler/shim.js +14 -41
- package/src/compiler/types.js +32 -5
- package/src/csp.js +7 -1
- package/src/document.js +63 -12
- package/src/extract.js +9 -8
- package/src/plugin.js +28 -7
- package/src/precache.js +11 -1
- package/src/proxy.js +9 -1
- package/src/rewrite.js +12 -5
- package/src/runtime/index.js +8 -5
- package/src/sitemap.js +3 -3
- package/src/static-cache.js +4 -1
- package/src/typecheck.js +104 -10
package/README.md
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">transclude</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">An HTML-first server-side web framework.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/transclude-dev/transclude/actions/workflows/ci.yml"
|
|
7
|
+
><img alt="CI" src="https://github.com/transclude-dev/transclude/actions/workflows/ci.yml/badge.svg"
|
|
8
|
+
/></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/@transclude/core"
|
|
10
|
+
><img alt="npm" src="https://img.shields.io/npm/v/%40transclude%2Fcore?color=0b7285"
|
|
11
|
+
/></a>
|
|
12
|
+
<a href="https://transclude.dev/docs/runtimes"
|
|
13
|
+
><img alt="node" src="https://img.shields.io/node/v/%40transclude%2Fcore?color=0b7285"
|
|
14
|
+
/></a>
|
|
15
|
+
<a href="https://github.com/transclude-dev/transclude/blob/main/LICENSE"
|
|
16
|
+
><img alt="MIT" src="https://img.shields.io/npm/l/%40transclude%2Fcore?color=0b7285"
|
|
17
|
+
/></a>
|
|
18
|
+
</p>
|
|
2
19
|
|
|
3
20
|
HTML is the product. A page is an `.html` file, the directory tree is the route
|
|
4
21
|
table, and any fragment of a page is a URL of its own. Nothing has to run in the
|
|
@@ -52,7 +69,7 @@ so a swap cannot drift from the page it replaces part of.
|
|
|
52
69
|
|
|
53
70
|
## What is in it
|
|
54
71
|
|
|
55
|
-
- **Pages and endpoints.** An `.html` file
|
|
72
|
+
- **Pages and endpoints.** An `.html` file responds to GET; its `POST`, `PUT`,
|
|
56
73
|
`PATCH` and `DELETE` exports answer the rest, so a plain `<form method="post">`
|
|
57
74
|
works. A `.js` file in the same tree returns a `Response`.
|
|
58
75
|
- **Fragments.** Mark an element `fragment` and it has a URL of its own. htmx,
|
|
@@ -106,6 +123,7 @@ npm run htmx # the same, driven by htmx, on http://localhost:1965
|
|
|
106
123
|
npm run includes # transclusion on http://localhost:1966
|
|
107
124
|
npm run auth # a guarded section on http://localhost:1967
|
|
108
125
|
npm run live # server-sent events on http://localhost:1968
|
|
126
|
+
npm run elements # light and shadow elements on http://localhost:1969
|
|
109
127
|
npm run check:src # type-check the framework itself
|
|
110
128
|
```
|
|
111
129
|
|
|
@@ -115,7 +133,8 @@ prerendered site with a sitemap and a feed, `search` swaps a fragment into a
|
|
|
115
133
|
page that works without it, `htmx` does the same with htmx and the
|
|
116
134
|
`HX-Target` header, `includes` shows transclusion from three sources,
|
|
117
135
|
`auth` guards a section with a layout and a signed cookie, `live` pushes
|
|
118
|
-
updates over server-sent events,
|
|
136
|
+
updates over server-sent events, `elements` puts a light and a shadow element
|
|
137
|
+
side by side, and `showcase` uses every feature and is where the
|
|
119
138
|
browser checks live, because those need an app to run against. `www/` is the site at transclude.dev: a landing page, the
|
|
120
139
|
documentation under `/docs`, and itself built with the framework.
|
|
121
140
|
|
package/bin/build.js
CHANGED
|
@@ -16,7 +16,7 @@ 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 { absoluteFrom, renderRoute, responseOf } from '../src/document.js';
|
|
19
|
+
import { absoluteFrom, renderRoute, responseOf, urlFor } from '../src/document.js';
|
|
20
20
|
import { feed, feedPath } from '../src/feed.js';
|
|
21
21
|
import { includeContext } from '../src/include.js';
|
|
22
22
|
import { nodeLookup } from '../src/lookup.js';
|
|
@@ -111,9 +111,15 @@ await build({
|
|
|
111
111
|
},
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
// A worker entry imports this bundle, and an editor set to check the app's JS
|
|
115
|
+
// then checks a file nobody wrote. Twenty errors in the showcase, all of them
|
|
116
|
+
// about generated code. The banner is what keeps it out of that program.
|
|
117
|
+
const entry = path.join(dist, 'server/entry.js');
|
|
118
|
+
fs.writeFileSync(entry, `// @ts-nocheck\n${fs.readFileSync(entry, 'utf8')}`);
|
|
119
|
+
|
|
114
120
|
// ---- prerender ------------------------------------------------------------
|
|
115
121
|
|
|
116
|
-
const { pages } = await import(pathToFileURL(
|
|
122
|
+
const { pages } = await import(pathToFileURL(entry).href);
|
|
117
123
|
|
|
118
124
|
/**
|
|
119
125
|
* A static route has one URL. A dynamic route has as many as its `paths` export
|
|
@@ -134,7 +140,7 @@ async function urlsFor(route) {
|
|
|
134
140
|
|
|
135
141
|
const listed = (await paths()) ?? [];
|
|
136
142
|
return listed.map((params) => ({
|
|
137
|
-
url: route
|
|
143
|
+
url: urlFor(route, params),
|
|
138
144
|
params,
|
|
139
145
|
}));
|
|
140
146
|
}
|
package/bin/check.js
CHANGED
|
@@ -23,7 +23,22 @@ if (!fs.existsSync(types) || fs.readFileSync(types, 'utf8') !== next) {
|
|
|
23
23
|
|
|
24
24
|
// Nothing downstream reads this file, so nothing else would notice it being
|
|
25
25
|
// wrong. Parse what we just wrote, or a bad identifier ships silently.
|
|
26
|
-
|
|
26
|
+
//
|
|
27
|
+
// `skipLibCheck` has to be off, and it was on. This is a .d.ts, which is the one
|
|
28
|
+
// kind of file that flag skips, so the guard checked nothing at all: every
|
|
29
|
+
// project shipped a file naming `__Cookies` and declaring it nowhere. An editor
|
|
30
|
+
// missed it too, because a jsconfig.json implies the same flag.
|
|
31
|
+
//
|
|
32
|
+
// `types: []` keeps it to this file: whatever `@types` a project happens to have
|
|
33
|
+
// installed is not what is being checked here, and one of them failing to
|
|
34
|
+
// resolve its own dependency would read as our file being broken.
|
|
35
|
+
const emitted = ts.createProgram([types], {
|
|
36
|
+
noEmit: true,
|
|
37
|
+
skipLibCheck: false,
|
|
38
|
+
types: [],
|
|
39
|
+
target: ts.ScriptTarget.ESNext,
|
|
40
|
+
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
|
|
41
|
+
});
|
|
27
42
|
const broken = [
|
|
28
43
|
...emitted.getSyntacticDiagnostics(),
|
|
29
44
|
...emitted.getSemanticDiagnostics(),
|
|
@@ -63,16 +78,23 @@ for (const file of files) {
|
|
|
63
78
|
const text = lines[line - 1] ?? '';
|
|
64
79
|
const trimmed = text.replace(/^\s+/, '');
|
|
65
80
|
const shift = text.length - trimmed.length;
|
|
81
|
+
// The caret line is drawn under the trimmed source, so the column moves left
|
|
82
|
+
// by however much indentation was cut. A run is capped so one long span does
|
|
83
|
+
// not wrap the terminal.
|
|
84
|
+
const pad = ' '.repeat(Math.max(0, column - shift));
|
|
85
|
+
const run = '~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)));
|
|
86
|
+
|
|
66
87
|
console.log(`\n ${trimmed}`);
|
|
67
|
-
console.log(` ${
|
|
88
|
+
console.log(` ${pad}${run}`);
|
|
68
89
|
}
|
|
69
90
|
}
|
|
70
91
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
);
|
|
92
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
93
|
+
|
|
94
|
+
if (errors + warnings) {
|
|
95
|
+
console.log(`\n${plural(errors, 'error')}, ${plural(warnings, 'warning')} in ${files.length} files`);
|
|
96
|
+
} else {
|
|
97
|
+
console.log(`\nNo type errors in ${files.length} files.`);
|
|
98
|
+
}
|
|
77
99
|
|
|
78
100
|
process.exitCode = errors ? 1 : 0;
|
package/bin/dev.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
runAction,
|
|
20
20
|
withEnvelope,
|
|
21
21
|
} from '../src/document.js';
|
|
22
|
-
import { clientEntryUrl, pageModuleId } from '../src/plugin.js';
|
|
22
|
+
import transclude, { clientEntryUrl, pageModuleId } from '../src/plugin.js';
|
|
23
23
|
import { resolveRoutesDir, scanRoutes } from '../src/routes.js';
|
|
24
24
|
import { baseApp, endpointMethods, runEndpoint, SERVER_FILE } from '../src/server.js';
|
|
25
25
|
import { randomBytes } from 'node:crypto';
|
|
@@ -71,6 +71,11 @@ const server = http.createServer();
|
|
|
71
71
|
const vite = await createViteServer({
|
|
72
72
|
root,
|
|
73
73
|
appType: 'custom',
|
|
74
|
+
// Passed here rather than left to the project's own `vite.config.js`, which is
|
|
75
|
+
// where dev used to get it. A project needs no Vite config at all, and the one
|
|
76
|
+
// built here is the one `loadProject` filled in, so dev compiles against the
|
|
77
|
+
// same config the build does. `configResolved` ignores a second registration.
|
|
78
|
+
plugins: [transclude(config)],
|
|
74
79
|
server: { middlewareMode: true, hmr: { server } },
|
|
75
80
|
// Vite would serve these itself, ahead of Hono, and production would serve
|
|
76
81
|
// them a different way, which is how dev and production come to disagree. One
|
|
@@ -229,7 +234,10 @@ async function loadMiddleware() {
|
|
|
229
234
|
if (!fs.existsSync(serverFile)) return null;
|
|
230
235
|
|
|
231
236
|
const url = `/${config.appDir}/${SERVER_FILE}`;
|
|
232
|
-
|
|
237
|
+
// Vite's second argument is `ssr`. This module is only ever loaded through
|
|
238
|
+
// `ssrLoadModule`, so the SSR graph is the one holding it.
|
|
239
|
+
const ssr = true;
|
|
240
|
+
const node = await vite.moduleGraph.getModuleByUrl(url, ssr);
|
|
233
241
|
if (node) vite.moduleGraph.invalidateModule(node);
|
|
234
242
|
|
|
235
243
|
const mod = await vite.ssrLoadModule(url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
],
|
|
58
58
|
"scripts": {
|
|
59
59
|
"test": "node --test \"test/**/*.test.js\"",
|
|
60
|
-
"test:examples": "npm test --prefix examples/showcase && npm test --prefix examples/todomvc && npm test --prefix examples/blog && npm test --prefix examples/search && npm test --prefix examples/htmx && npm test --prefix examples/includes && npm test --prefix examples/auth && npm test --prefix examples/live",
|
|
60
|
+
"test:examples": "npm test --prefix examples/showcase && npm test --prefix examples/todomvc && npm test --prefix examples/blog && npm test --prefix examples/search && npm test --prefix examples/htmx && npm test --prefix examples/includes && npm test --prefix examples/auth && npm test --prefix examples/live && npm test --prefix examples/elements",
|
|
61
61
|
"test:www": "npm test --prefix www",
|
|
62
62
|
"showcase": "npm run dev --prefix examples/showcase",
|
|
63
63
|
"todomvc": "npm run dev --prefix examples/todomvc",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"includes": "npm run dev --prefix examples/includes",
|
|
68
68
|
"auth": "npm run dev --prefix examples/auth",
|
|
69
69
|
"live": "npm run dev --prefix examples/live",
|
|
70
|
+
"elements": "npm run dev --prefix examples/elements",
|
|
70
71
|
"www": "npm run dev --prefix www",
|
|
71
72
|
"check:src": "tsc -p tsconfig.src.json",
|
|
72
73
|
"release": "node bin/release.js"
|
|
@@ -96,7 +96,7 @@ the render, which is how a layout does a login redirect.
|
|
|
96
96
|
|
|
97
97
|
## Forms and actions
|
|
98
98
|
|
|
99
|
-
A page
|
|
99
|
+
A page responds to GET with its loader. Other verbs are named exports on the same
|
|
100
100
|
file.
|
|
101
101
|
|
|
102
102
|
```html
|
package/src/address.js
CHANGED
|
@@ -69,8 +69,15 @@ export function parseV6(text) {
|
|
|
69
69
|
const halves = body.split('::');
|
|
70
70
|
if (halves.length > 2) return null;
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// Each half of a `::` is groups of up to four hex digits. A group that is not
|
|
73
|
+
// becomes NaN, which the caller checks for rather than throwing here.
|
|
74
|
+
const read = (part) => {
|
|
75
|
+
if (part === '') return [];
|
|
76
|
+
return part.split(':').map((group) => {
|
|
77
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group)) return NaN;
|
|
78
|
+
return parseInt(group, 16);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
74
81
|
|
|
75
82
|
const head = read(halves[0]);
|
|
76
83
|
const rest = halves.length === 2 ? read(halves[1]) : [];
|
package/src/app.js
CHANGED
|
@@ -39,6 +39,71 @@ const encoder = new TextEncoder();
|
|
|
39
39
|
/** Below this, the framing costs more than it saves. A 91 byte file gzips to 120. */
|
|
40
40
|
export const COMPRESSIBLE_FLOOR = 512;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Sends the best representation the client will accept. `Vary` is not optional
|
|
44
|
+
* here: without it a shared cache would serve one encoding to everyone.
|
|
45
|
+
*/
|
|
46
|
+
function send(c, entry, cacheControl, status = 200) {
|
|
47
|
+
// The key list is built once per entry rather than per request. An entry is
|
|
48
|
+
// produced at load time and never changes, so the spread was a fresh array
|
|
49
|
+
// for every hit on the same file.
|
|
50
|
+
entry.encodingList ??= [...entry.encodings.keys()];
|
|
51
|
+
const encoding = pickEncoding(c.req.header('accept-encoding'), entry.encodingList);
|
|
52
|
+
const chosen = encoding ? entry.encodings.get(encoding) : null;
|
|
53
|
+
|
|
54
|
+
const body = chosen?.body ?? entry.body;
|
|
55
|
+
const etag = chosen?.etag ?? entry.etag;
|
|
56
|
+
|
|
57
|
+
c.header('Vary', 'Accept-Encoding');
|
|
58
|
+
c.header('Cache-Control', cacheControl);
|
|
59
|
+
c.header('ETag', etag);
|
|
60
|
+
|
|
61
|
+
if (c.req.header('if-none-match') === etag) return c.body(null, 304);
|
|
62
|
+
|
|
63
|
+
if (chosen) c.header('Content-Encoding', encoding);
|
|
64
|
+
c.header('Content-Type', entry.type);
|
|
65
|
+
c.header('Content-Length', String(body.length));
|
|
66
|
+
return c.body(body, status);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What a page is going to ask for, said in a header.
|
|
71
|
+
*
|
|
72
|
+
* This is not streaming. Render is `__o += …` to the last component, which is
|
|
73
|
+
* what lets an include resolve before it and a prerendered page stay a file,
|
|
74
|
+
* and making it async would tax every component call for something most pages
|
|
75
|
+
* here do not need. So the body still leaves in one piece.
|
|
76
|
+
*
|
|
77
|
+
* What it does buy: the stylesheet and the client entry come from the route
|
|
78
|
+
* table, not from a loader, so they are known before any loader runs. A proxy
|
|
79
|
+
* that reads this sends a 103 and the browser fetches them while the page is
|
|
80
|
+
* still being made. Cloudflare and Fastly do. A browser reading it directly
|
|
81
|
+
* gets less, since these headers arrive with the body anyway.
|
|
82
|
+
*/
|
|
83
|
+
function preloadHeader(stylesheet, clientEntry) {
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (stylesheet) parts.push(`<${stylesheet}>; rel=preload; as=style`);
|
|
86
|
+
if (clientEntry) parts.push(`<${clientEntry}>; rel=preload; as=script; crossorigin`);
|
|
87
|
+
return parts.length ? parts.join(', ') : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether this render can be handed to the next visitor.
|
|
92
|
+
*
|
|
93
|
+
* Three ways it cannot. It answered with a `Response`, it is not 2xx, or it is
|
|
94
|
+
* personal. Personal means a header was written *or* a cookie was read. The
|
|
95
|
+
* second half matters: a page that only reads a cookie and renders a count from
|
|
96
|
+
* it sets no header at all, and holding it would hand one visitor's count to the
|
|
97
|
+
* next.
|
|
98
|
+
*/
|
|
99
|
+
function isShareable(html, ctx) {
|
|
100
|
+
if (html instanceof Response) return false;
|
|
101
|
+
if (ctx.response.status >= 300) return false;
|
|
102
|
+
|
|
103
|
+
const wroteHeader = [...ctx.response.headers.keys()].length > 0;
|
|
104
|
+
return !wroteHeader && !ctx.cookies.personal;
|
|
105
|
+
}
|
|
106
|
+
|
|
42
107
|
/**
|
|
43
108
|
* `statics`, `assets`, `notFound` and `errorPage` are bytes from wherever the
|
|
44
109
|
* runtime keeps them; `publicFiles` is a Hono handler or null; `compress` is null
|
|
@@ -50,6 +115,24 @@ export const COMPRESSIBLE_FLOOR = 512;
|
|
|
50
115
|
* `subtle.digest`. Awaiting costs nothing on the first and is the only way to
|
|
51
116
|
* accept the second.
|
|
52
117
|
*
|
|
118
|
+
* The body is long because the order routes are registered in *is* the behavior,
|
|
119
|
+
* so it is written out once, in that order, rather than split across functions
|
|
120
|
+
* that could be called in a different one. What gets registered, in order:
|
|
121
|
+
*
|
|
122
|
+
* /assets/* hashed, so immutable
|
|
123
|
+
* sitemap, precache, feed, proxy each only if the config asked for it
|
|
124
|
+
* fragments and actions every route, before anything static
|
|
125
|
+
* endpoints before the static handler, which matches
|
|
126
|
+
* on path alone and would answer first
|
|
127
|
+
* prerendered pages bytes from disk
|
|
128
|
+
* pages every route, not only the dynamic ones
|
|
129
|
+
* not found
|
|
130
|
+
*
|
|
131
|
+
* The two rules worth knowing: a fragment or an action has to come before the
|
|
132
|
+
* prerendered handler, and an endpoint's path has no file behind it but
|
|
133
|
+
* `/api/notes` and a prerendered `/api/notes/index.html` look the same to a
|
|
134
|
+
* matcher.
|
|
135
|
+
*
|
|
53
136
|
* @param {{ config: object, manifest: object, pages: Record<string, object>,
|
|
54
137
|
* endpoints?: Record<string, object>, statics?: object, assets?: object,
|
|
55
138
|
* notFound?: object|null, errorPage?: object|null, hash: Function,
|
|
@@ -162,12 +245,6 @@ export function createApp({
|
|
|
162
245
|
*/
|
|
163
246
|
const varyOn = header ? `Accept-Encoding, ${header}` : 'Accept-Encoding';
|
|
164
247
|
|
|
165
|
-
/**
|
|
166
|
-
* Fragments and actions come first, and for every route rather than only the
|
|
167
|
-
* dynamic ones: a page whose document was prerendered still has regions worth
|
|
168
|
-
* asking for and mutations worth accepting, and the prerendered handler below
|
|
169
|
-
* matches on path alone, so it would answer either one with a static document.
|
|
170
|
-
*/
|
|
171
248
|
// Before the route table, like the public files, so a `[...path]` catch-all
|
|
172
249
|
// cannot answer for it.
|
|
173
250
|
if (config.sitemap) {
|
|
@@ -209,6 +286,12 @@ export function createApp({
|
|
|
209
286
|
app.get(PROXY_PATH, (c) => handler(c.req.raw));
|
|
210
287
|
}
|
|
211
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Fragments and actions come first, and for every route rather than only the
|
|
291
|
+
* dynamic ones: a page whose document was prerendered still has regions worth
|
|
292
|
+
* asking for and mutations worth accepting, and the prerendered handler below
|
|
293
|
+
* matches on path alone, so it would answer either one with a static document.
|
|
294
|
+
*/
|
|
212
295
|
for (const route of manifest.routes ?? []) {
|
|
213
296
|
app.get(route.pattern, async (c, next) => {
|
|
214
297
|
const region = regionOf(route, c);
|
|
@@ -313,12 +396,11 @@ export function createApp({
|
|
|
313
396
|
|
|
314
397
|
app.get(route.pattern, async (c) => {
|
|
315
398
|
try {
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
let rendered = null;
|
|
399
|
+
// The cache calls this when it needs a render and nothing calls it on a
|
|
400
|
+
// hit, so what it produced is left here for the lines after. A hit
|
|
401
|
+
// leaves it null, which is the difference the code below reads.
|
|
402
|
+
let last = null;
|
|
403
|
+
|
|
322
404
|
const render = async () => {
|
|
323
405
|
const ctx = contextFor(route, c);
|
|
324
406
|
const html = await renderRoute(pages[route.id], ctx, {
|
|
@@ -329,32 +411,27 @@ export function createApp({
|
|
|
329
411
|
include,
|
|
330
412
|
});
|
|
331
413
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
// Three ways a page is not a shared answer: it answered with a
|
|
335
|
-
// Response, it is not 2xx, or it is personal. Personal means a header
|
|
336
|
-
// was written *or* a cookie was read. The second half matters: a page
|
|
337
|
-
// that only reads a cookie and renders a count from it sets no header
|
|
338
|
-
// at all, and holding it would hand one visitor's count to the next.
|
|
339
|
-
const shared = [...ctx.response.headers.keys()].length === 0 && !ctx.cookies.personal;
|
|
340
|
-
return { html, cacheable: ok && shared };
|
|
414
|
+
last = { ctx, html };
|
|
415
|
+
return { html, cacheable: isShareable(html, ctx) };
|
|
341
416
|
};
|
|
342
417
|
|
|
343
|
-
if (window) {
|
|
344
|
-
|
|
418
|
+
if (!window) {
|
|
419
|
+
await render();
|
|
420
|
+
const { ctx, html } = last;
|
|
421
|
+
if (html instanceof Response) return withEnvelope(html, ctx);
|
|
422
|
+
return sendRendered(c, html, ctx, preload);
|
|
423
|
+
}
|
|
345
424
|
|
|
346
|
-
|
|
347
|
-
// `Response`. It was not stored, but it is still the answer.
|
|
348
|
-
if (html instanceof Response) return withEnvelope(html, rendered.ctx);
|
|
425
|
+
const html = await cache.read(cacheKey(c.req.url), window, render);
|
|
349
426
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
427
|
+
// A miss rendered through the cache, and that render can answer with a
|
|
428
|
+
// `Response`. It was not stored, but it is still the answer.
|
|
429
|
+
if (html instanceof Response) return withEnvelope(html, last.ctx);
|
|
354
430
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
431
|
+
// A hit ran no loader, so there is no envelope to carry: a page with a
|
|
432
|
+
// header was never stored. It still needs a context to send with.
|
|
433
|
+
const ctx = last ? last.ctx : contextFor(route, c);
|
|
434
|
+
return sendRendered(c, html, ctx, preload);
|
|
358
435
|
} catch (err) {
|
|
359
436
|
return internalError(c, err);
|
|
360
437
|
}
|
|
@@ -388,27 +465,6 @@ export function createApp({
|
|
|
388
465
|
}
|
|
389
466
|
}
|
|
390
467
|
|
|
391
|
-
/**
|
|
392
|
-
* What a page is going to ask for, said in a header.
|
|
393
|
-
*
|
|
394
|
-
* This is not streaming. Render is `__o += …` to the last component, which is
|
|
395
|
-
* what lets an include resolve before it and a prerendered page stay a file,
|
|
396
|
-
* and making it async would tax every component call for something most pages
|
|
397
|
-
* here do not need. So the body still leaves in one piece.
|
|
398
|
-
*
|
|
399
|
-
* What it does buy: the stylesheet and the client entry come from the route
|
|
400
|
-
* table, not from a loader, so they are known before any loader runs. A proxy
|
|
401
|
-
* that reads this sends a 103 and the browser fetches them while the page is
|
|
402
|
-
* still being made. Cloudflare and Fastly do. A browser reading it directly
|
|
403
|
-
* gets less, since these headers arrive with the body anyway.
|
|
404
|
-
*/
|
|
405
|
-
function preloadHeader(stylesheet, clientEntry) {
|
|
406
|
-
const parts = [];
|
|
407
|
-
if (stylesheet) parts.push(`<${stylesheet}>; rel=preload; as=style`);
|
|
408
|
-
if (clientEntry) parts.push(`<${clientEntry}>; rel=preload; as=script; crossorigin`);
|
|
409
|
-
return parts.length ? parts.join(', ') : null;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
468
|
/** Every `catch` above. One place decides what a failed request looks like. */
|
|
413
469
|
function internalError(c, err) {
|
|
414
470
|
report(err, c);
|
|
@@ -421,33 +477,6 @@ export function createApp({
|
|
|
421
477
|
return c.body(errorPage.body, 500);
|
|
422
478
|
}
|
|
423
479
|
|
|
424
|
-
/**
|
|
425
|
-
* Sends the best representation the client will accept. `Vary` is not optional
|
|
426
|
-
* here: without it a shared cache would serve one encoding to everyone.
|
|
427
|
-
*/
|
|
428
|
-
function send(c, entry, cacheControl, status = 200) {
|
|
429
|
-
// The key list is built once per entry rather than per request. An entry is
|
|
430
|
-
// produced at load time and never changes, so the spread was a fresh array
|
|
431
|
-
// for every hit on the same file.
|
|
432
|
-
entry.encodingList ??= [...entry.encodings.keys()];
|
|
433
|
-
const encoding = pickEncoding(c.req.header('accept-encoding'), entry.encodingList);
|
|
434
|
-
const chosen = encoding ? entry.encodings.get(encoding) : null;
|
|
435
|
-
|
|
436
|
-
const body = chosen?.body ?? entry.body;
|
|
437
|
-
const etag = chosen?.etag ?? entry.etag;
|
|
438
|
-
|
|
439
|
-
c.header('Vary', 'Accept-Encoding');
|
|
440
|
-
c.header('Cache-Control', cacheControl);
|
|
441
|
-
c.header('ETag', etag);
|
|
442
|
-
|
|
443
|
-
if (c.req.header('if-none-match') === etag) return c.body(null, 304);
|
|
444
|
-
|
|
445
|
-
if (chosen) c.header('Content-Encoding', encoding);
|
|
446
|
-
c.header('Content-Type', entry.type);
|
|
447
|
-
c.header('Content-Length', String(body.length));
|
|
448
|
-
return c.body(body, status);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
480
|
/**
|
|
452
481
|
* A response rendered for this request. There is no prebuilt variant to reach
|
|
453
482
|
* for, so the ETag is computed here and the body is compressed on the way out.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The types a shim declares for itself, in one place.
|
|
2
|
+
//
|
|
3
|
+
// A shim writes JSDoc and transclude-env.d.ts writes TypeScript, so the same
|
|
4
|
+
// shape had two spellings and only one of them was ever written. Every context
|
|
5
|
+
// type in the emitted file named `__Cookies` and nothing declared it, which no
|
|
6
|
+
// check reported: a jsconfig.json implies `skipLibCheck`, and the guard in
|
|
7
|
+
// `bin/check.js` written to catch exactly this passes it too, so the file it
|
|
8
|
+
// checks is the one kind of file that flag skips.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Each entry is one type, written the way TypeScript spells it. `params` are the
|
|
12
|
+
* type parameters, which JSDoc writes as `@template` and a `.d.ts` writes in
|
|
13
|
+
* angle brackets.
|
|
14
|
+
*/
|
|
15
|
+
export const AMBIENT = [
|
|
16
|
+
{
|
|
17
|
+
name: '__CookieOptions',
|
|
18
|
+
params: [],
|
|
19
|
+
text:
|
|
20
|
+
"{ path?: string; domain?: string; maxAge?: number; expires?: Date; httpOnly?: boolean; secure?: boolean; sameSite?: 'Strict' | 'Lax' | 'None' }",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: '__Cookies',
|
|
24
|
+
params: [],
|
|
25
|
+
text:
|
|
26
|
+
'{ get(name: string): string | undefined; all(): Record<string, string>; ' +
|
|
27
|
+
'set(name: string, value: string, options?: __CookieOptions): void; ' +
|
|
28
|
+
'delete(name: string, options?: __CookieOptions): void; ' +
|
|
29
|
+
'signed: { get(name: string): Promise<string | undefined>; ' +
|
|
30
|
+
'all(): Promise<Record<string, string>>; ' +
|
|
31
|
+
'set(name: string, value: string, options?: __CookieOptions): Promise<void> } }',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
// The mapping is what keeps `${user.nmae}` an error. TypeScript treats a type
|
|
35
|
+
// that came straight from an object literal in a .js file as open for expando
|
|
36
|
+
// properties, so reading an undeclared one is allowed. Remapping the keys
|
|
37
|
+
// gives an ordinary object type, where it is not.
|
|
38
|
+
//
|
|
39
|
+
// The conditional widens a bare `[]`, which otherwise infers `never[]` and
|
|
40
|
+
// turns "no annotation" from "less checking" into a page of errors about a
|
|
41
|
+
// type nobody wrote.
|
|
42
|
+
name: '__Shape',
|
|
43
|
+
params: ['T'],
|
|
44
|
+
text: '{ [K in keyof T]: T[K] extends never[] ? any[] : T[K] }',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const AMBIENT_NAMES = new Set(AMBIENT.map(({ name }) => name));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The JSDoc a shim carries for the given names. A name JSDoc cannot resolve is
|
|
52
|
+
* `any` rather than an error, so a shim that names one of these without this is
|
|
53
|
+
* checking nothing and saying so nowhere.
|
|
54
|
+
*
|
|
55
|
+
* @param {string[]} names
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function ambientJsdoc(names) {
|
|
59
|
+
return AMBIENT.filter(({ name }) => names.includes(name))
|
|
60
|
+
.map(({ name, params, text }) => {
|
|
61
|
+
const template = params.length ? ` * @template ${params.join(', ')}\n` : '';
|
|
62
|
+
return `/**\n${template} * @typedef {${text}} ${name}\n */\n`;
|
|
63
|
+
})
|
|
64
|
+
.join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The same types as TypeScript declarations, for the emitted file. Only the ones
|
|
69
|
+
* it mentions: an unused type in a generated file is noise.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} body what the file says so far
|
|
72
|
+
* @param {(type: string) => string} [format] how to lay a type out
|
|
73
|
+
* @returns {string[]} the lines to put above it
|
|
74
|
+
*/
|
|
75
|
+
export function ambientDeclarations(body, format = (type) => type) {
|
|
76
|
+
// One of these names another, so keep looking until a pass adds nothing:
|
|
77
|
+
// `__Cookies` alone would leave `__CookieOptions` undeclared, which is the
|
|
78
|
+
// whole bug again one level down.
|
|
79
|
+
const used = [];
|
|
80
|
+
for (let text = body, added = true; added; ) {
|
|
81
|
+
added = false;
|
|
82
|
+
for (const type of AMBIENT) {
|
|
83
|
+
if (used.includes(type) || !new RegExp(`\\b${type.name}\\b`).test(text)) continue;
|
|
84
|
+
used.push(type);
|
|
85
|
+
text += type.text;
|
|
86
|
+
added = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!used.length) return [];
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
'// Declared by the compiler. Every context type below names these.',
|
|
93
|
+
...used.map(({ name, params, text }) => {
|
|
94
|
+
const generics = params.length ? `<${params.join(', ')}>` : '';
|
|
95
|
+
return `type ${name}${generics} = ${format(text)};`;
|
|
96
|
+
}),
|
|
97
|
+
'',
|
|
98
|
+
];
|
|
99
|
+
}
|