@transclude/core 0.7.0 → 0.8.2
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 +24 -10
- package/bin/dev.js +58 -0
- package/package.json +1 -1
- package/skills/transclude/references/server.md +27 -4
- package/src/app.js +7 -1
- package/src/compiler/index.js +25 -0
- package/src/cookies.js +15 -0
- package/src/defaults.js +51 -0
- package/src/project.js +6 -18
- package/src/worker.js +57 -0
package/README.md
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
<a href="https://github.com/transclude-dev/transclude/blob/main/LICENSE"
|
|
16
16
|
><img alt="MIT" src="https://img.shields.io/npm/l/%40transclude%2Fcore?color=0b7285"
|
|
17
17
|
/></a>
|
|
18
|
+
<a href="https://bsky.app/profile/transclude.dev" rel="me"
|
|
19
|
+
><img alt="Bluesky" src="https://img.shields.io/badge/Bluesky-transclude.dev-0b7285"
|
|
20
|
+
/></a>
|
|
18
21
|
</p>
|
|
19
22
|
|
|
20
23
|
HTML is the product. A page is an `.html` file, the directory tree is the route
|
|
@@ -23,7 +26,10 @@ table, and any fragment of a page is a URL of its own.
|
|
|
23
26
|
The same app runs on Node, Bun, Deno and workerd, the runtime behind
|
|
24
27
|
Cloudflare Workers, and ships no client JavaScript by default.
|
|
25
28
|
|
|
26
|
-
**[transclude.dev](https://transclude.dev)** has the documentation
|
|
29
|
+
**[transclude.dev](https://transclude.dev)** has the documentation, and
|
|
30
|
+
[every example is running](https://transclude.dev/docs/examples).
|
|
31
|
+
[todomvc.transclude.dev](https://todomvc.transclude.dev) ships zero bytes of
|
|
32
|
+
JavaScript, which is easier to believe with the page open.
|
|
27
33
|
|
|
28
34
|
```sh
|
|
29
35
|
npm create @transclude my-app
|
|
@@ -127,15 +133,23 @@ npm run check:src # type-check the framework itself
|
|
|
127
133
|
```
|
|
128
134
|
|
|
129
135
|
`examples/` holds apps built against this package the same way any other project
|
|
130
|
-
would be.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
would be. Every one of them is deployed, from the same source you would clone.
|
|
137
|
+
|
|
138
|
+
| Example | What it proves | Running |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| `todomvc` | Seven actions, one POST handler, zero bytes of JavaScript | [todomvc.transclude.dev](https://todomvc.transclude.dev) |
|
|
141
|
+
| `search` | A fragment that is a substring of the document it came from | [search.transclude.dev](https://search.transclude.dev) |
|
|
142
|
+
| `blog` | Markdown to files, with a sitemap and a feed | [blog.transclude.dev](https://blog.transclude.dev) |
|
|
143
|
+
| `htmx` | htmx and this framework, each doing its own half | [htmx.transclude.dev](https://htmx.transclude.dev) |
|
|
144
|
+
| `includes` | One piece of markup rendered in more than one place | [includes.transclude.dev](https://includes.transclude.dev) |
|
|
145
|
+
| `auth` | A section behind a layout and a signed cookie | [auth.transclude.dev](https://auth.transclude.dev) |
|
|
146
|
+
| `live` | Server-sent events into a fragment | [live.transclude.dev](https://live.transclude.dev) |
|
|
147
|
+
| `elements` | A light element and a shadow one, side by side | [elements.transclude.dev](https://elements.transclude.dev) |
|
|
148
|
+
| `showcase` | Every feature at once, and where the browser checks live | [showcase.transclude.dev](https://showcase.transclude.dev) |
|
|
149
|
+
|
|
150
|
+
The browser checks are in `showcase` because they need an app to run against.
|
|
151
|
+
`www/` is the site at transclude.dev: a landing page, the documentation under
|
|
152
|
+
`/docs`, the writing under `/blog`, and itself built with the framework.
|
|
139
153
|
|
|
140
154
|
### Trying the CLI against this checkout
|
|
141
155
|
|
package/bin/dev.js
CHANGED
|
@@ -28,6 +28,10 @@ import { cookiesOf } from '../src/cookies.js';
|
|
|
28
28
|
import { loadProject, portOf } from '../src/project.js';
|
|
29
29
|
import { includeContext } from '../src/include.js';
|
|
30
30
|
import { nodeLookup } from '../src/lookup.js';
|
|
31
|
+
import { feed, feedPath, feedType } from '../src/feed.js';
|
|
32
|
+
import { sitemap } from '../src/sitemap.js';
|
|
33
|
+
import { documentStore, PROXY_PATH, proxyHandler } from '../src/proxy.js';
|
|
34
|
+
import { afterFor } from '../src/after.js';
|
|
31
35
|
|
|
32
36
|
const { root, config } = await loadProject();
|
|
33
37
|
const routesDir = resolveRoutesDir(path.join(root, config.appDir), config.routesDir);
|
|
@@ -139,6 +143,17 @@ const contextFor = (route, c, extra = {}) => ({
|
|
|
139
143
|
// is right for a form, and wrong for a caller that asked for markup.
|
|
140
144
|
fragment: fragmentOf(c),
|
|
141
145
|
action: null,
|
|
146
|
+
|
|
147
|
+
// Nothing is held between requests here, so there is nothing to drop. A
|
|
148
|
+
// no-op rather than an omission: an action calling this is correct code, and
|
|
149
|
+
// it should not throw in dev and work in production.
|
|
150
|
+
revalidateTag: () => {},
|
|
151
|
+
|
|
152
|
+
// Node keeps running after a response, so this only has to handle the
|
|
153
|
+
// rejection. `console.error` rather than `config.onError`, which the dev
|
|
154
|
+
// server does not use for anything else either.
|
|
155
|
+
after: afterFor(c, (error) => console.error('[transclude] ctx.after:', error)),
|
|
156
|
+
|
|
142
157
|
...withResponse(c, extra),
|
|
143
158
|
});
|
|
144
159
|
|
|
@@ -312,6 +327,49 @@ async function buildApp() {
|
|
|
312
327
|
});
|
|
313
328
|
}
|
|
314
329
|
|
|
330
|
+
// Before the route table, the same way production registers them, so a
|
|
331
|
+
// catch-all route cannot answer for one of these.
|
|
332
|
+
//
|
|
333
|
+
// They were missing here entirely. `createApp` mounts them and the dev server
|
|
334
|
+
// builds its own app, so `/feed.xml` and `/sitemap.xml` were 404 in dev and
|
|
335
|
+
// correct in the build. That is the worst shape a difference can take: the
|
|
336
|
+
// thing you check by hand is the thing that was never wired.
|
|
337
|
+
if (config.sitemap) {
|
|
338
|
+
app.get('/sitemap.xml', async (c) => {
|
|
339
|
+
// Only a parameter route ever reads its module, for `paths`. Loading the
|
|
340
|
+
// rest would compile the whole site to answer one request.
|
|
341
|
+
const pages = {};
|
|
342
|
+
for (const route of routes) {
|
|
343
|
+
if (route.params.length) pages[route.id] = await vite.ssrLoadModule(pageModuleId(route.id));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const xml = await sitemap({ routes }, pages, config.sitemap, c.req.query('p') ?? null);
|
|
347
|
+
return c.body(xml, 200, { 'Content-Type': 'application/xml; charset=utf-8' });
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (config.feed) {
|
|
352
|
+
app.get(feedPath(config.feed), async (c) => {
|
|
353
|
+
const xml = await feed(config.feed);
|
|
354
|
+
return c.body(xml, 200, { 'Content-Type': feedType(config.feed) });
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The browser calls this one, so a page using an external include worked in
|
|
359
|
+
// the build and 404ed here. Default deny is the config's doing either way: no
|
|
360
|
+
// `proxy` key, no route.
|
|
361
|
+
//
|
|
362
|
+
// `/precache.json` is deliberately not here. It names hashed asset filenames,
|
|
363
|
+
// which only the build knows, and a service worker holding anything in dev is
|
|
364
|
+
// a bug rather than a feature. It is build output and stays that way.
|
|
365
|
+
if (config.proxy) {
|
|
366
|
+
const handler = proxyHandler(config.proxy, {
|
|
367
|
+
lookup: config.proxy.lookup ?? nodeLookup(),
|
|
368
|
+
store: documentStore(config.proxy.cache),
|
|
369
|
+
});
|
|
370
|
+
app.get(PROXY_PATH, (c) => handler(c.req.raw));
|
|
371
|
+
}
|
|
372
|
+
|
|
315
373
|
// Already ordered most-specific first, so registration order is deterministic
|
|
316
374
|
// rather than something to reason about per-router.
|
|
317
375
|
for (const route of routes) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.2",
|
|
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
|
|
@@ -165,12 +182,18 @@ import that.
|
|
|
165
182
|
|
|
166
183
|
```js
|
|
167
184
|
// app/lib/bindings.js — no `node:` imports, this ends up in the worker bundle
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
// A symbol in the global registry, not a module variable. The build inlines a
|
|
186
|
+
// copy of this file into the server bundle and wrangler bundles `worker.js`
|
|
187
|
+
// with a second one, so a module variable is written in one copy and read in
|
|
188
|
+
// the other.
|
|
189
|
+
const SLOT = Symbol.for('app.bindings');
|
|
190
|
+
|
|
191
|
+
export const hold = (env) => {
|
|
192
|
+
globalThis[SLOT] = env;
|
|
193
|
+
};
|
|
172
194
|
|
|
173
195
|
export const bindings = () => {
|
|
196
|
+
const current = globalThis[SLOT];
|
|
174
197
|
if (!current) throw new Error('No bindings: this app is running off workerd.');
|
|
175
198
|
return current;
|
|
176
199
|
};
|
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
|
-
|
|
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/compiler/index.js
CHANGED
|
@@ -91,6 +91,25 @@ export function readFlags(source, label = 'element') {
|
|
|
91
91
|
return resolveFlags(fromProps, fromClient, label);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Script types the browser executes. Everything else is a data block.
|
|
96
|
+
*
|
|
97
|
+
* The list is the HTML spec's, minus the legacy spellings nobody writes. An
|
|
98
|
+
* absent `type` is a classic script, `module` is a module, and both are code.
|
|
99
|
+
* `importmap` is not executed either, but the browser reads it, which is the
|
|
100
|
+
* same reason to emit it rather than compile it.
|
|
101
|
+
*
|
|
102
|
+
* @param {{ attrs?: Array<{ name: string, value: string }> }} node
|
|
103
|
+
* @returns {boolean} whether this script holds data rather than code
|
|
104
|
+
*/
|
|
105
|
+
function isDataBlock(node) {
|
|
106
|
+
const type = node.attrs?.find((attr) => attr.name === 'type')?.value;
|
|
107
|
+
if (type === undefined) return false;
|
|
108
|
+
|
|
109
|
+
const JS = ['', 'module', 'text/javascript', 'application/javascript'];
|
|
110
|
+
return !JS.includes(type.trim().toLowerCase());
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
/**
|
|
95
114
|
* Top-level <script>/<style> blocks are pulled out; everything else is template.
|
|
96
115
|
*
|
|
@@ -162,6 +181,12 @@ export function splitBlocks(source) {
|
|
|
162
181
|
// with nothing said. A nested `<script src>` was always markup; only a
|
|
163
182
|
// top-level one went missing.
|
|
164
183
|
else if (attrs.has('src')) out.nodes.push(node);
|
|
184
|
+
// A type the browser does not run as JavaScript makes this a data block,
|
|
185
|
+
// which is markup for the same reason a `src` is: there is no code here to
|
|
186
|
+
// compile. Import maps, JSON-LD, speculation rules written by hand and
|
|
187
|
+
// whatever a library reads out of the document were all swallowed, because
|
|
188
|
+
// anything without a marker was read as a client module.
|
|
189
|
+
else if (isDataBlock(node)) out.nodes.push(node);
|
|
165
190
|
else out.client.push(block);
|
|
166
191
|
continue;
|
|
167
192
|
}
|
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/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/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
|
+
}
|