@transclude/core 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.7.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",
@@ -154,6 +154,23 @@ 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
+
157
174
  ### Bindings
158
175
 
159
176
  `ctx` has no `env`. It carries nothing that names one runtime, and `env` names
package/src/app.js CHANGED
@@ -30,6 +30,7 @@ import { pickEncoding } from './negotiate.js';
30
30
  import { baseApp, endpointMethods, runEndpoint } from './server.js';
31
31
  import { cookiesOf } from './cookies.js';
32
32
  import { afterFor } from './after.js';
33
+ import { withDefaults } from './defaults.js';
33
34
 
34
35
  const IMMUTABLE = 'public, max-age=31536000, immutable';
35
36
  const REVALIDATE = 'public, max-age=0, must-revalidate';
@@ -143,7 +144,11 @@ function isShareable(html, ctx) {
143
144
  * @returns {object} a Hono app, ready to serve
144
145
  */
145
146
  export function createApp({
146
- config,
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,
147
152
  manifest,
148
153
  pages,
149
154
  endpoints = {},
@@ -164,6 +169,7 @@ export function createApp({
164
169
  // it the proxy's allowlist is the whole defense.
165
170
  lookup = null,
166
171
  }) {
172
+ const config = withDefaults(written);
167
173
  const cache = createCache(config.cache);
168
174
 
169
175
  // One resolver for the app, so several pages including the same document read
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)`,
@@ -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/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: { ...DEFAULTS, ...config }, configFile: file };
108
+ return { root, config: withDefaults(config), configFile: file };
121
109
  }
122
110
 
123
111
  /**
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
+ }