@transclude/core 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/bin/build.js +469 -0
  4. package/bin/check.js +78 -0
  5. package/bin/dev.js +348 -0
  6. package/bin/release.js +176 -0
  7. package/bin/serve.bun.js +15 -0
  8. package/bin/serve.deno.js +15 -0
  9. package/bin/serve.js +12 -0
  10. package/editor/server.js +172 -0
  11. package/editor/vscode/extension.js +49 -0
  12. package/editor/vscode/package.json +32 -0
  13. package/editor/vscode/syntaxes/transclude.injection.json +41 -0
  14. package/package.json +82 -0
  15. package/src/address.js +183 -0
  16. package/src/app.js +492 -0
  17. package/src/cache.js +137 -0
  18. package/src/compiler/bind.js +496 -0
  19. package/src/compiler/codegen.js +1061 -0
  20. package/src/compiler/expr.js +221 -0
  21. package/src/compiler/index.js +964 -0
  22. package/src/compiler/interp.js +82 -0
  23. package/src/compiler/script.js +620 -0
  24. package/src/compiler/shim.js +756 -0
  25. package/src/compiler/sourcemap.js +140 -0
  26. package/src/compiler/types.js +163 -0
  27. package/src/compress.js +104 -0
  28. package/src/cookies.js +157 -0
  29. package/src/csp.js +192 -0
  30. package/src/document.js +604 -0
  31. package/src/extract.js +339 -0
  32. package/src/feed.js +194 -0
  33. package/src/include.js +89 -0
  34. package/src/lookup.js +49 -0
  35. package/src/negotiate.js +95 -0
  36. package/src/plugin.js +423 -0
  37. package/src/pool.js +29 -0
  38. package/src/precache.js +68 -0
  39. package/src/production.js +159 -0
  40. package/src/project.js +110 -0
  41. package/src/proxy.js +319 -0
  42. package/src/public-files.js +77 -0
  43. package/src/rewrite.js +281 -0
  44. package/src/routes.js +199 -0
  45. package/src/runtime/index.js +1345 -0
  46. package/src/server.js +183 -0
  47. package/src/sitemap.js +124 -0
  48. package/src/static-cache.js +170 -0
  49. package/src/typecheck.js +492 -0
  50. package/src/worker.js +87 -0
package/src/extract.js ADDED
@@ -0,0 +1,339 @@
1
+ // A fragment cut out of a document this framework did not compile.
2
+ //
3
+ // A page here declares its own regions: `<div id="x" fragment>` compiles to its
4
+ // own render function and the same markup serves the region inline and alone.
5
+ // Nothing in that path parses HTML, and nothing in this file runs against it.
6
+ //
7
+ // This is for the other case, a document somebody else wrote, where there is no
8
+ // attribute to read and no compiler output to reuse. Extent is worked out from
9
+ // what HTML already encodes: heading rank and definition-list structure. Nothing
10
+ // else is guessed. An id says what a thing is, not where it ends.
11
+
12
+ import { parse, serializeOuter } from 'parse5';
13
+
14
+ /** Inline elements that may be standing in for the thing after them. */
15
+ const INLINE = new Set(['a', 'span', 'em', 'i', 'b', 'strong', 'small', 'sub', 'sup']);
16
+
17
+ /** Content that makes an element worth returning even with no text in it. */
18
+ const EMBEDDED = new Set([
19
+ 'img', 'svg', 'video', 'audio', 'iframe', 'canvas', 'object', 'picture', 'input',
20
+ ]);
21
+
22
+ /** Elements that mean nothing outside a particular parent. */
23
+ const CONTEXTUAL = new Set([
24
+ 'li', 'dd', 'dt', 'td', 'th', 'tr', 'thead', 'tbody', 'tfoot', 'caption', 'col',
25
+ 'colgroup', 'option', 'optgroup', 'figcaption', 'legend', 'summary', 'source',
26
+ 'track', 'param',
27
+ ]);
28
+
29
+ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
30
+
31
+ // ---- node access -----------------------------------------------------------
32
+ // Everything that touches a parse5 node is here, so a second tree shape is a
33
+ // change to this block rather than to the rules below it.
34
+
35
+ const isElement = (node) => typeof node.tagName === 'string';
36
+ const tagOf = (node) => node.tagName ?? null;
37
+ const attrOf = (node, name) => node.attrs?.find((a) => a.name === name)?.value ?? null;
38
+
39
+ /**
40
+ * A `<template>`'s children live on `.content`, not `childNodes`, and they are
41
+ * inert: nothing in there is rendered. A fragment URL that returned template
42
+ * content would return something the source document never showed, so this does
43
+ * not descend into one and an id inside a template is not addressable.
44
+ */
45
+ const kidsOf = (node) => (tagOf(node) === 'template' ? [] : (node.childNodes ?? []));
46
+
47
+ const rankOf = (node) => (HEADINGS.has(tagOf(node)) ? Number(tagOf(node)[1]) : null);
48
+
49
+ function textOf(node) {
50
+ if (node.nodeName === '#text') return node.value ?? '';
51
+ let out = '';
52
+ for (const child of kidsOf(node)) out += textOf(child);
53
+ return out;
54
+ }
55
+
56
+ function hasEmbedded(node) {
57
+ if (EMBEDDED.has(tagOf(node))) return true;
58
+ return kidsOf(node).some(hasEmbedded);
59
+ }
60
+
61
+ /** Every element in document order, template content excluded. */
62
+ function* walk(node) {
63
+ for (const child of kidsOf(node)) {
64
+ if (isElement(child)) yield child;
65
+ yield* walk(child);
66
+ }
67
+ }
68
+
69
+ // ---- slugs -----------------------------------------------------------------
70
+
71
+ /**
72
+ * A heading's text as an id, the way GitHub and MDN write one.
73
+ *
74
+ * Unicode letters and numbers are kept rather than folded to ASCII: a document
75
+ * whose headings are not in English should still be addressable in its own
76
+ * script.
77
+ *
78
+ * @param {string} text
79
+ * @returns {string} lowercased, punctuation dropped, spaces hyphenated
80
+ */
81
+ export function slugify(text) {
82
+ return text
83
+ .toLowerCase()
84
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
85
+ .trim()
86
+ .replace(/\s+/g, '-')
87
+ .replace(/^-+|-+$/g, '');
88
+ }
89
+
90
+ /**
91
+ * Every id the document offers, explicit and generated, worked out in one pass.
92
+ *
93
+ * The whole table is built before any single fragment is resolved. Computing a
94
+ * slug on demand would make the suffix a heading gets depend on which fragment
95
+ * was asked for, so the same URL would mean different things on different
96
+ * requests.
97
+ *
98
+ * @param {string} html
99
+ * @returns {object} the indexed document
100
+ */
101
+ export function readDocument(html) {
102
+ return indexDocument(parse(html));
103
+ }
104
+
105
+ /**
106
+ * The same table, over a tree that has already been parsed.
107
+ *
108
+ * The proxy sanitizes and rewrites a foreign document before indexing it, and
109
+ * indexing first would leave the table naming elements the cleaning removed.
110
+ *
111
+ * @param {object} root a parse5 tree
112
+ * @returns {object} the same root, with its id table
113
+ */
114
+ export function indexDocument(root) {
115
+ const ids = new Map();
116
+ const duplicates = new Set();
117
+ const headings = [];
118
+ const order = [];
119
+ const position = new Map();
120
+
121
+ for (const element of walk(root)) {
122
+ position.set(element, position.size);
123
+ const id = attrOf(element, 'id');
124
+ if (id) {
125
+ if (ids.has(id)) duplicates.add(id);
126
+ else {
127
+ ids.set(id, element);
128
+ order.push({ id, element, implicit: false });
129
+ }
130
+ }
131
+ if (HEADINGS.has(tagOf(element))) headings.push(element);
132
+ }
133
+
134
+ // Explicit ids win outright, so they are all reserved before the first slug
135
+ // is handed out.
136
+ const taken = new Set(ids.keys());
137
+ const slugs = new Map();
138
+
139
+ for (const heading of headings) {
140
+ if (attrOf(heading, 'id')) continue;
141
+
142
+ const base = slugify(textOf(heading));
143
+ if (!base) continue;
144
+
145
+ let slug = base;
146
+ for (let n = 1; taken.has(slug); n += 1) slug = `${base}-${n}`;
147
+
148
+ taken.add(slug);
149
+ slugs.set(slug, heading);
150
+ order.push({ id: slug, element: heading, implicit: true });
151
+ }
152
+
153
+ // `order` gathered explicit ids in document order and then appended the
154
+ // generated ones, so it is sorted once here rather than interleaved above.
155
+ order.sort((a, b) => position.get(a.element) - position.get(b.element));
156
+
157
+ return { root, ids, slugs, duplicates, order };
158
+ }
159
+
160
+ // ---- extent ----------------------------------------------------------------
161
+
162
+ const siblingsOf = (node) => (node.parentNode ? kidsOf(node.parentNode) : []);
163
+
164
+ /**
165
+ * The rank a sibling terminates a run at, or null if it does not terminate one.
166
+ *
167
+ * An `<hgroup>` counts as the heading it holds, so a run started before one
168
+ * stops at it the way it would stop at a bare heading.
169
+ */
170
+ function terminatingRank(node) {
171
+ if (!isElement(node)) return null;
172
+ if (HEADINGS.has(tagOf(node))) return rankOf(node);
173
+ if (tagOf(node) !== 'hgroup') return null;
174
+
175
+ const ranks = [...walk(node)].filter((n) => HEADINGS.has(tagOf(n))).map(rankOf);
176
+ return ranks.length ? Math.min(...ranks) : null;
177
+ }
178
+
179
+ /** A heading and everything under it, up to the next heading of its rank or above. */
180
+ function headingRun(heading) {
181
+ // An `<hgroup>` is the unit: the run starts at the group's own position among
182
+ // its siblings, not at the heading's position inside the group.
183
+ const start = tagOf(heading.parentNode) === 'hgroup' ? heading.parentNode : heading;
184
+ const rank = rankOf(heading);
185
+
186
+ const siblings = siblingsOf(start);
187
+ const from = siblings.indexOf(start);
188
+ const nodes = [start];
189
+
190
+ for (const node of siblings.slice(from + 1)) {
191
+ const stop = terminatingRank(node);
192
+ if (stop !== null && stop <= rank) break;
193
+ nodes.push(node);
194
+ }
195
+ return nodes;
196
+ }
197
+
198
+ /** A `<dt>` and its definitions, up to the next term. */
199
+ function termRun(dt) {
200
+ const siblings = siblingsOf(dt);
201
+ const nodes = [dt];
202
+
203
+ for (const node of siblings.slice(siblings.indexOf(dt) + 1)) {
204
+ if (isElement(node) && tagOf(node) === 'dt') break;
205
+ nodes.push(node);
206
+ }
207
+ return nodes;
208
+ }
209
+
210
+ /**
211
+ * An empty inline element is a bookmark for what follows it, not the thing
212
+ * being addressed. `<a id="install"></a><h2>Installing</h2>` is everywhere in
213
+ * documents old enough to predate ids on headings.
214
+ *
215
+ * One hop only. Chaining would walk past real content whenever two bookmarks
216
+ * sit together.
217
+ */
218
+ function throughBookmark(element) {
219
+ if (!INLINE.has(tagOf(element))) return element;
220
+ if (textOf(element).trim() !== '') return element;
221
+ if (hasEmbedded(element)) return element;
222
+
223
+ const siblings = siblingsOf(element);
224
+ const after = siblings.slice(siblings.indexOf(element) + 1).find(isElement);
225
+ return after ?? element;
226
+ }
227
+
228
+ function ancestorsOf(element) {
229
+ const chain = [];
230
+ for (let node = element.parentNode; node && isElement(node); node = node.parentNode) {
231
+ if (tagOf(node) === 'html' || tagOf(node) === 'body') break;
232
+ chain.unshift(tagOf(node));
233
+ }
234
+ return chain;
235
+ }
236
+
237
+ // ---- what a caller gets ----------------------------------------------------
238
+
239
+ /**
240
+ * The fragment named by `id`, or null if the document does not offer one.
241
+ *
242
+ * `nodes` is a list, not one element. A heading run is several siblings, and
243
+ * wrapping them in a container would mean the fetched fragment did not match
244
+ * what the source document renders in that place.
245
+ *
246
+ * @param {string|object} input the HTML, or an already indexed document
247
+ * @param {string} id
248
+ * @returns {{ id: string, implicit: boolean, nodes: object[], html: string,
249
+ * kind: string, standalone: boolean, diagnostics: object[] }|null} null when
250
+ * nothing answers to that id
251
+ */
252
+ export function resolveFragment(input, id) {
253
+ const doc = typeof input === 'string' ? readDocument(input) : input;
254
+ const diagnostics = [];
255
+
256
+ const explicit = doc.ids.get(id);
257
+ const found = explicit ?? doc.slugs.get(id);
258
+ if (!found) return null;
259
+
260
+ if (doc.duplicates.has(id)) {
261
+ diagnostics.push({
262
+ code: 'duplicate-id',
263
+ message: `more than one element has id "${id}"; the first in document order was taken`,
264
+ });
265
+ }
266
+
267
+ // A generated slug names its heading, which is already the thing to return.
268
+ const element = explicit ? throughBookmark(found) : found;
269
+ if (element !== found) {
270
+ diagnostics.push({
271
+ code: 'bookmark',
272
+ message: `"${id}" is an empty <${tagOf(found)}>, so <${tagOf(element)}> after it was taken`,
273
+ });
274
+ }
275
+
276
+ let nodes;
277
+ let kind;
278
+
279
+ if (HEADINGS.has(tagOf(element))) {
280
+ nodes = headingRun(element);
281
+ kind = 'heading-run';
282
+ } else if (tagOf(element) === 'dt') {
283
+ nodes = termRun(element);
284
+ kind = 'dt-run';
285
+ } else {
286
+ nodes = [element];
287
+ kind = 'element';
288
+ }
289
+
290
+ const outer = tagOf(nodes[0]);
291
+ const standalone = !CONTEXTUAL.has(outer);
292
+ const ancestors = standalone ? [] : ancestorsOf(nodes[0]);
293
+
294
+ if (!standalone) {
295
+ diagnostics.push({
296
+ code: 'not-standalone',
297
+ message:
298
+ `<${outer}> means nothing on its own; it needs ` +
299
+ `${ancestors.length ? ancestors.join(' > ') : 'a parent'} around it`,
300
+ ancestors,
301
+ });
302
+ }
303
+
304
+ return {
305
+ id,
306
+ implicit: !explicit,
307
+ nodes,
308
+ html: nodes.map((node) => serializeOuter(node)).join(''),
309
+ kind,
310
+ standalone,
311
+ diagnostics,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Every fragment the document offers, in document order.
317
+ *
318
+ * This is the claim that the rules work on markup nobody wrote for us: run it
319
+ * over a page and the result should read like that page's outline.
320
+ *
321
+ * @param {string|object} input
322
+ * @returns {Array<{ id: string, implicit: boolean, tag: string, rank: number, kind: string, text: string }>}
323
+ */
324
+ export function listFragments(input) {
325
+ const doc = typeof input === 'string' ? readDocument(input) : input;
326
+
327
+ return doc.order.map(({ id, element, implicit }) => {
328
+ const tag = tagOf(element);
329
+ const rank = rankOf(element);
330
+ return {
331
+ id,
332
+ implicit,
333
+ tag,
334
+ rank,
335
+ kind: rank ? 'heading-run' : tag === 'dt' ? 'dt-run' : 'element',
336
+ text: textOf(element).replace(/\s+/g, ' ').trim().slice(0, 120),
337
+ };
338
+ });
339
+ }
package/src/feed.js ADDED
@@ -0,0 +1,194 @@
1
+ // GET /feed.xml, from a list the app supplies.
2
+ //
3
+ // A sitemap comes from the route table, because a URL is all it lists. A feed
4
+ // needs a title, a date and something to read, and none of that is in the route
5
+ // table, so the app passes the items in. Everything else here is the format.
6
+
7
+ /** How many items ship. A reader wants the recent ones, not the archive. */
8
+ const LIMIT = 50;
9
+
10
+ const escape = (text) =>
11
+ String(text)
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ .replace(/'/g, '&apos;');
17
+
18
+ /**
19
+ * HTML, kept as HTML.
20
+ *
21
+ * `]]>` is the one sequence a CDATA section cannot hold, and it can appear in
22
+ * ordinary markup: `<script>if (a[b[c]]>0)</script>`. Splitting it across two
23
+ * sections is what the parser puts back together as the original three
24
+ * characters.
25
+ */
26
+ const cdata = (html) => `<![CDATA[${String(html).replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
27
+
28
+ const date = (value) => {
29
+ if (!value) return null;
30
+ const parsed = value instanceof Date ? value : new Date(value);
31
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
32
+ };
33
+
34
+ /** An absolute URL, since a feed is read somewhere else by definition. */
35
+ const absolute = (hostname, path) => `${hostname.replace(/\/$/, '')}${path}`;
36
+
37
+ /**
38
+ * The items, newest first.
39
+ *
40
+ * Sorting is stable, so items with no date keep the order they were given in
41
+ * rather than being shuffled among the dated ones.
42
+ */
43
+ /**
44
+ * @param {{ items?: object[] | (() => object[] | Promise<object[]>), limit?: number }} config
45
+ * `items` may be a function, so an app builds them from its own data
46
+ * @returns {Promise<object[]>}
47
+ */
48
+ async function itemsOf({ items = [], limit = LIMIT }) {
49
+ const list = typeof items === 'function' ? await items() : await items;
50
+
51
+ const dated = list.map((item, index) => ({ item, index, at: date(item.date) }));
52
+ dated.sort((a, b) => {
53
+ if (a.at && b.at && a.at.getTime() !== b.at.getTime()) return b.at - a.at;
54
+ if (a.at && !b.at) return -1;
55
+ if (!a.at && b.at) return 1;
56
+ return a.index - b.index;
57
+ });
58
+
59
+ return dated.slice(0, limit).map(({ item, at }) => ({ ...item, at }));
60
+ }
61
+
62
+ /** The feed's own timestamp: the newest thing in it. */
63
+ const newest = (items) => items.find((item) => item.at)?.at ?? null;
64
+
65
+ function rss(items, config, stamp) {
66
+ const { hostname, title, description = '', path, language } = config;
67
+ const self = absolute(hostname, path);
68
+
69
+ const body = items.map((item) => {
70
+ const url = absolute(hostname, item.path);
71
+ const parts = [
72
+ ` <title>${escape(item.title)}</title>`,
73
+ ` <link>${escape(url)}</link>`,
74
+ ` <guid isPermaLink="true">${escape(item.id ?? url)}</guid>`,
75
+ ];
76
+ if (item.at) parts.push(` <pubDate>${item.at.toUTCString()}</pubDate>`);
77
+ if (item.description) parts.push(` <description>${cdata(item.description)}</description>`);
78
+ if (item.content) {
79
+ parts.push(` <content:encoded>${cdata(item.content)}</content:encoded>`);
80
+ }
81
+ return ` <item>\n${parts.join('\n')}\n </item>`;
82
+ });
83
+
84
+ const head = [
85
+ ` <title>${escape(title)}</title>`,
86
+ ` <link>${escape(absolute(hostname, '/'))}</link>`,
87
+ ` <description>${escape(description)}</description>`,
88
+ ` <atom:link href="${escape(self)}" rel="self" type="application/rss+xml"/>`,
89
+ ];
90
+ if (language) head.push(` <language>${escape(language)}</language>`);
91
+ if (stamp) head.push(` <lastBuildDate>${stamp.toUTCString()}</lastBuildDate>`);
92
+
93
+ return (
94
+ `<?xml version="1.0" encoding="utf-8"?>\n` +
95
+ `<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" ` +
96
+ `xmlns:content="http://purl.org/rss/1.0/modules/content/">\n` +
97
+ ` <channel>\n${head.join('\n')}\n${body.join('\n')}\n </channel>\n</rss>\n`
98
+ );
99
+ }
100
+
101
+ function atom(items, config, stamp) {
102
+ const { hostname, title, description = '', path, author } = config;
103
+ const self = absolute(hostname, path);
104
+
105
+ const person = (who) =>
106
+ `<author><name>${escape(who.name)}</name>` +
107
+ (who.email ? `<email>${escape(who.email)}</email>` : '') +
108
+ `</author>`;
109
+
110
+ const body = items.map((item) => {
111
+ const url = absolute(hostname, item.path);
112
+ const parts = [
113
+ ` <title>${escape(item.title)}</title>`,
114
+ ` <link href="${escape(url)}"/>`,
115
+ ` <id>${escape(item.id ?? url)}</id>`,
116
+ ` <updated>${(item.at ?? stamp).toISOString()}</updated>`,
117
+ ];
118
+ if (item.author) parts.push(` ${person(item.author)}`);
119
+ if (item.description) parts.push(` <summary>${escape(item.description)}</summary>`);
120
+ if (item.content) {
121
+ parts.push(` <content type="html">${cdata(item.content)}</content>`);
122
+ }
123
+ return ` <entry>\n${parts.join('\n')}\n </entry>`;
124
+ });
125
+
126
+ const head = [
127
+ ` <title>${escape(title)}</title>`,
128
+ ` <link href="${escape(absolute(hostname, '/'))}"/>`,
129
+ ` <link rel="self" href="${escape(self)}"/>`,
130
+ ` <id>${escape(absolute(hostname, '/'))}</id>`,
131
+ ` <updated>${stamp.toISOString()}</updated>`,
132
+ ];
133
+ if (description) head.push(` <subtitle>${escape(description)}</subtitle>`);
134
+ if (author) head.push(` ${person(author)}`);
135
+
136
+ return (
137
+ `<?xml version="1.0" encoding="utf-8"?>\n` +
138
+ `<feed xmlns="http://www.w3.org/2005/Atom">\n${head.join('\n')}\n${body.join('\n')}\n</feed>\n`
139
+ );
140
+ }
141
+
142
+ /**
143
+ * What the response is served as. A reader picks the parser from this.
144
+ *
145
+ * @param {object|null|undefined} config
146
+ * @returns {string}
147
+ */
148
+ export const feedType = (config) =>
149
+ config?.format === 'atom'
150
+ ? 'application/atom+xml; charset=utf-8'
151
+ : 'application/rss+xml; charset=utf-8';
152
+
153
+ /**
154
+ * Where it is mounted, and where the build writes it.
155
+ *
156
+ * @param {object|null|undefined} config
157
+ * @returns {string}
158
+ */
159
+ export const feedPath = (config) => config?.path ?? '/feed.xml';
160
+
161
+ /**
162
+ * @param {object} [config] the `feed` block, plus its `items`
163
+ * @returns {Promise<string>} an RSS or Atom document
164
+ * @throws when Atom is asked for without an author or a date
165
+ */
166
+ export async function feed(config = {}) {
167
+ const { hostname, title, format = 'rss', author, updated } = config;
168
+
169
+ if (!hostname) throw new Error('[transclude] feed needs a hostname, since every link is absolute');
170
+ if (!title) throw new Error('[transclude] feed needs a title');
171
+
172
+ const items = await itemsOf(config);
173
+
174
+ // Nothing here may read the clock. A prerendered feed is written once and
175
+ // compressed once, and a timestamp from the build would change the bytes on
176
+ // every run for a file whose contents did not change.
177
+ const stamp = date(updated) ?? newest(items);
178
+
179
+ if (format === 'atom') {
180
+ if (!author && !items.every((item) => item.author)) {
181
+ throw new Error(
182
+ '[transclude] an Atom feed needs an author: give the feed one, or every item its own',
183
+ );
184
+ }
185
+ if (!stamp) {
186
+ throw new Error(
187
+ '[transclude] an Atom feed needs a date: give an item a `date`, or the feed an `updated`',
188
+ );
189
+ }
190
+ return atom(items, { ...config, path: feedPath(config) }, stamp);
191
+ }
192
+
193
+ return rss(items, { ...config, path: feedPath(config) }, stamp);
194
+ }
package/src/include.js ADDED
@@ -0,0 +1,89 @@
1
+ // What a `<transclude>` is resolved against, built once.
2
+ //
3
+ // Three servers render pages: the production app, the dev server and the build.
4
+ // Each used to be handed a piece of this and the dev server was handed less than
5
+ // the others, so an include that worked in production threw in dev with nothing
6
+ // to suggest why. One function answers it for all three.
7
+
8
+ import { paramsFor, renderFragment } from './document.js';
9
+ import { includeResolver } from './proxy.js';
10
+
11
+ /**
12
+ * `{ resolve, route }` for `renderRoute`.
13
+ *
14
+ * `resolve` reads another site and needs `proxy.allow`; without it an external
15
+ * include says so. `route` reads another route of this app and needs nothing,
16
+ * because nothing leaves the server.
17
+ *
18
+ * @param routes the route table, as `{ id, pattern }`
19
+ * @param pageFor a route id to its compiled module, possibly a promise
20
+ */
21
+ export function includeContext({ config, routes = [], pageFor, lookup = null }) {
22
+ const foreign = config.proxy
23
+ ? includeResolver(config.proxy, { lookup: config.proxy.lookup ?? lookup ?? null })
24
+ : null;
25
+
26
+ const context = {
27
+ resolve: foreign?.resolve ?? null,
28
+
29
+ route: async (path, id, ctx, options) => {
30
+ const memo = options?.includeMemo;
31
+ const key = `${path}#${id}`;
32
+ if (memo?.has(key)) return memo.get(key);
33
+
34
+ const answer = render(path, id, ctx, options);
35
+ // The promise is held, not the result: two includes of one route that
36
+ // start together should still be one render.
37
+ memo?.set(key, answer);
38
+ return answer;
39
+ },
40
+ };
41
+
42
+ /**
43
+ * Rendered rather than fetched. It is the same process, so a request to
44
+ * ourselves would run the whole middleware stack to answer something we can
45
+ * answer directly, and would need a URL the server may not know behind a
46
+ * proxy.
47
+ *
48
+ * The host's `ctx` goes through, cookies and all. That is what makes reading
49
+ * contagious: a page including a route whose loader reads a cookie is personal
50
+ * too, and the cache has to know.
51
+ */
52
+ async function render(path, id, ctx, options) {
53
+ const url = new URL(path, ctx.url);
54
+
55
+ let found = null;
56
+ for (const route of routes) {
57
+ const params = paramsFor(route, url.pathname);
58
+ if (params) {
59
+ found = { route, params };
60
+ break;
61
+ }
62
+ }
63
+ if (!found) throw new Error(`[transclude] no route answers ${path}`);
64
+
65
+ const page = await pageFor(found.route.id);
66
+ if (!page) throw new Error(`[transclude] ${path} is not a page`);
67
+
68
+ const html = await renderFragment(
69
+ page,
70
+ {
71
+ ...ctx,
72
+ url: url.href,
73
+ params: found.params,
74
+ route: { id: found.route.id, pattern: found.route.pattern, path: url.pathname },
75
+ // The include is not the request. A loader that branches on the region
76
+ // asked for should see this render for what it is.
77
+ fragment: null,
78
+ action: null,
79
+ },
80
+ { region: id, ...options, include: context },
81
+ );
82
+
83
+ // A loader answering for itself has nothing to say here: the including page
84
+ // asked for markup, not for a redirect.
85
+ return html instanceof Response ? null : html;
86
+ }
87
+
88
+ return context;
89
+ }
package/src/lookup.js ADDED
@@ -0,0 +1,49 @@
1
+ // Resolving a hostname, which is the one check the core cannot make.
2
+ //
3
+ // `src/address.js` decides what an address means and imports nothing. This
4
+ // turns a name into addresses, which needs the runtime: Node and Deno have a
5
+ // resolver, and workerd has none at all.
6
+ //
7
+ // Not reachable from `src/app.js`. It is passed in by the servers that can
8
+ // supply it, and left out by the one that cannot.
9
+
10
+ import { promises as dns } from 'node:dns';
11
+
12
+ import { blockedAddress } from './address.js';
13
+
14
+ /**
15
+ * A `lookup` for the proxy: the reason a host may not be fetched, or null.
16
+ *
17
+ * Every address a name resolves to is checked, not just the first. A name that
18
+ * answers with one public address and one private one is a way in, and which of
19
+ * the two a connection uses is not ours to decide.
20
+ *
21
+ * This still leaves a gap that no amount of checking here closes: the name is
22
+ * resolved once for the check and again by the connection, and a record whose
23
+ * TTL expires in between can change. The allowlist is what actually holds, and
24
+ * this is defense behind it.
25
+ *
26
+ * @param {{ resolver?: object }} [deps] injected so a test needs no DNS
27
+ * @returns {(hostname: string) => Promise<string[]>} every address the name answers with
28
+ */
29
+ export function nodeLookup({ resolver = dns } = {}) {
30
+ return async (hostname) => {
31
+ // A literal is already decided by `checkUrl`, which runs before this.
32
+ if (blockedAddress(hostname)) return blockedAddress(hostname);
33
+
34
+ let addresses;
35
+ try {
36
+ addresses = await resolver.lookup(hostname, { all: true });
37
+ } catch {
38
+ // A name that does not resolve is not this check's refusal to make. The
39
+ // fetch will fail on its own and say so in its own words.
40
+ return null;
41
+ }
42
+
43
+ for (const { address } of addresses) {
44
+ const why = blockedAddress(address);
45
+ if (why) return `${address}, which is ${why}`;
46
+ }
47
+ return null;
48
+ };
49
+ }