@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/csp.js ADDED
@@ -0,0 +1,192 @@
1
+ // A Content-Security-Policy for the document, built from what the document
2
+ // actually inlines.
3
+ //
4
+ // Hashes rather than nonces, which is the opposite of what most frameworks do,
5
+ // and the right way round here. A nonce has to be fresh per request, so a page
6
+ // carrying one cannot be cached, and every prerendered page here is a file that
7
+ // is written once, compressed once, and sent as bytes. Swapping a nonce into the
8
+ // body on the way out would mean decompressing and recompressing every response.
9
+ //
10
+ // A hash needs none of that. The inline content is fixed when the page is
11
+ // rendered, so the policy is fixed too: computed once, written into the file,
12
+ // and correct forever after with no server involved. A static host serves it
13
+ // unchanged.
14
+
15
+ /**
16
+ * What a page gets when the config says `true`.
17
+ *
18
+ * `script-src` is hashed and `style-src` is not, which looks inconsistent and is
19
+ * the only combination that works. A hash never covers an attribute: `style="…"`
20
+ * on an element is checked against `style-src`, and the spec says a hash there
21
+ * applies to `<style>` blocks only. Worse, `'unsafe-inline'` is *ignored* in a
22
+ * directive that carries any hash, so listing both allows nothing extra.
23
+ *
24
+ * So hashing styles means no element may carry a `style` attribute, and
25
+ * `style="view-transition-name: …"` is an ordinary thing to write here. Script
26
+ * is where the protection matters: CSS cannot run code, and `script-src` stays
27
+ * strict. Put `'hashes'` in `style-src` yourself if your pages have no inline
28
+ * style attributes at all.
29
+ */
30
+ export const CSP_DEFAULTS = {
31
+ 'default-src': ["'self'"],
32
+ 'script-src': ["'self'", "'hashes'"],
33
+ 'style-src': ["'self'", "'unsafe-inline'"],
34
+ 'img-src': ["'self'", 'data:'],
35
+ 'object-src': ["'none'"],
36
+ 'base-uri': ["'self'"],
37
+ 'form-action': ["'self'"],
38
+ // Clickjacking. A meta tag cannot carry it, so it rides in the header the
39
+ // server sets, which is the reason there is a header at all.
40
+ 'frame-ancestors': ["'self'"],
41
+ };
42
+
43
+ /**
44
+ * `<script>` and `<style>` bodies in a document, in source order.
45
+ *
46
+ * Both are raw text elements: the browser ends them at the first closing tag
47
+ * whatever the content, so matching that way is not an approximation of the
48
+ * parser, it is the same rule. A `<script src>` runs a file rather than a body
49
+ * and is covered by `'self'`, so it is skipped.
50
+ *
51
+ * @param {string} html the rendered document
52
+ * @returns {Array<{ kind: string, body: string }>} every inline block, in source
53
+ * order, each saying which kind it is
54
+ */
55
+ export function inlineSources(html) {
56
+ const found = [];
57
+
58
+ for (const [, attrs, body] of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/g)) {
59
+ if (!/\ssrc\s*=/.test(attrs)) found.push({ kind: 'script', body });
60
+ }
61
+ for (const [, , body] of html.matchAll(/<style([^>]*)>([\s\S]*?)<\/style>/g)) {
62
+ found.push({ kind: 'style', body });
63
+ }
64
+
65
+ return found;
66
+ }
67
+
68
+ /**
69
+ * `'sha256-…'`, the form a policy takes.
70
+ *
71
+ * WebCrypto rather than the injected `hash`: that one is an ETag, documented as
72
+ * a cache key and not a signature, and a test hands it a fake. This has to be a
73
+ * real digest or the browser refuses the script. `crypto.subtle` and `btoa` are
74
+ * globals on all four runtimes, so neither costs the core a `node:` import.
75
+ */
76
+ async function sha256(source) {
77
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
78
+
79
+ let binary = '';
80
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
81
+
82
+ return `'sha256-${btoa(binary)}'`;
83
+ }
84
+
85
+ /**
86
+ * The policy for one document.
87
+ *
88
+ * `'hashes'` in a source list is where this page's own digests go, so an author
89
+ * replacing the defaults decides which directives get them. Empty means the
90
+ * literal is dropped rather than left in, because `script-src 'self' 'hashes'`
91
+ * with nothing to substitute is a policy naming a source that does not exist.
92
+ *
93
+ * @param {string} html
94
+ * @param {{ directives?: Record<string, string[]> }} [options]
95
+ * @returns {Promise<string>} the policy, with every `'hashes'` replaced
96
+ */
97
+ export async function policyFor(html, { directives = CSP_DEFAULTS } = {}) {
98
+ const inline = inlineSources(html);
99
+
100
+ const scripts = await Promise.all(
101
+ inline.filter((one) => one.kind === 'script').map((one) => sha256(one.body)),
102
+ );
103
+ const styles = await Promise.all(
104
+ inline.filter((one) => one.kind === 'style').map((one) => sha256(one.body)),
105
+ );
106
+
107
+ const parts = [];
108
+ for (const [name, sources] of Object.entries(directives)) {
109
+ const hashes = name === 'script-src' ? scripts : name === 'style-src' ? styles : [];
110
+ const resolved = sources.flatMap((source) => (source === "'hashes'" ? hashes : [source]));
111
+
112
+ // A directive left with nothing is dropped. An empty source list means
113
+ // "allow nothing", which is `'none'`, and saying that by accident would
114
+ // break the page rather than protect it.
115
+ if (resolved.length) parts.push(`${name} ${[...new Set(resolved)].join(' ')}`);
116
+ }
117
+
118
+ return parts.join('; ');
119
+ }
120
+
121
+ /**
122
+ * The policy as a `<meta>`, inserted just before `</head>`.
123
+ *
124
+ * A meta tag rather than a header, because a prerendered page is a file and
125
+ * `dist/static` is meant to be servable by a host that knows nothing about this
126
+ * framework. `frame-ancestors`, `report-uri`, `report-to` and `sandbox` cannot
127
+ * be delivered this way and are dropped; set those as real headers at the host.
128
+ */
129
+ const META_ONLY = new Set(['frame-ancestors', 'report-uri', 'report-to', 'sandbox']);
130
+
131
+ /**
132
+ * The directives a meta tag cannot carry, as a header.
133
+ *
134
+ * None of them names a hash, so this string is the same for every page: it can
135
+ * be set once as middleware, ahead of any route, and it costs a prerendered page
136
+ * nothing. That is why the split is worth having rather than reading the meta
137
+ * back out of a body that is already compressed.
138
+ *
139
+ * `null` when there are none, which is the default. Adding a header nobody asked
140
+ * for is a header to explain later.
141
+ *
142
+ * @param {object|boolean|null} config
143
+ * @returns {{ name: string, value: string }|null} the directives a meta tag
144
+ * cannot carry, and which header carries them
145
+ */
146
+ export function headerPolicy(config) {
147
+ if (!config) return null;
148
+
149
+ const options = config === true ? {} : config;
150
+ const directives = options.directives ?? CSP_DEFAULTS;
151
+
152
+ const parts = Object.entries(directives)
153
+ .filter(([name]) => META_ONLY.has(name))
154
+ .map(([name, sources]) => `${name} ${[...new Set(sources)].join(' ')}`);
155
+
156
+ if (!parts.length) return null;
157
+
158
+ return {
159
+ name: options.reportOnly
160
+ ? 'Content-Security-Policy-Report-Only'
161
+ : 'Content-Security-Policy',
162
+ value: parts.join('; '),
163
+ };
164
+ }
165
+
166
+ /**
167
+ * @param {string} html
168
+ * @param {object|boolean|null} config
169
+ * @returns {Promise<string>} the document with its meta tag, or unchanged when off
170
+ */
171
+ export async function withPolicy(html, config) {
172
+ if (!config) return html;
173
+
174
+ const options = config === true ? {} : config;
175
+ const directives = options.directives ?? CSP_DEFAULTS;
176
+
177
+ const deliverable = Object.fromEntries(
178
+ Object.entries(directives).filter(([name]) => !META_ONLY.has(name)),
179
+ );
180
+
181
+ const policy = await policyFor(html, { directives: deliverable });
182
+ if (!policy) return html;
183
+
184
+ const name = options.reportOnly
185
+ ? 'content-security-policy-report-only'
186
+ : 'content-security-policy';
187
+ const meta = `<meta http-equiv="${name}" content="${policy.replace(/"/g, '&quot;')}">`;
188
+
189
+ // One `</head>` in a document this built. Inserted last so a hash covers every
190
+ // inline block above it, and the meta itself carries none to cover.
191
+ return html.replace('</head>', `${meta}\n</head>`);
192
+ }