@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
package/bin/dev.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hono routes; Vite compiles. The route table is the directory tree. See
|
|
3
|
+
// src/routes.js for the rules.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { getRequestListener } from '@hono/node-server';
|
|
9
|
+
import { publicFiles as publicHandler } from '../src/public-files.js';
|
|
10
|
+
import { createServer as createViteServer } from 'vite';
|
|
11
|
+
import {
|
|
12
|
+
ACTION_METHODS,
|
|
13
|
+
hasRegion,
|
|
14
|
+
absoluteFrom,
|
|
15
|
+
methodsOf,
|
|
16
|
+
renderFragment,
|
|
17
|
+
renderRoute,
|
|
18
|
+
responseOf,
|
|
19
|
+
runAction,
|
|
20
|
+
withEnvelope,
|
|
21
|
+
} from '../src/document.js';
|
|
22
|
+
import { clientEntryUrl, pageModuleId } from '../src/plugin.js';
|
|
23
|
+
import { resolveRoutesDir, scanRoutes } from '../src/routes.js';
|
|
24
|
+
import { baseApp, endpointMethods, runEndpoint, SERVER_FILE } from '../src/server.js';
|
|
25
|
+
import { randomBytes } from 'node:crypto';
|
|
26
|
+
import { cookiesOf } from '../src/cookies.js';
|
|
27
|
+
import { loadProject, portOf } from '../src/project.js';
|
|
28
|
+
import { includeContext } from '../src/include.js';
|
|
29
|
+
import { nodeLookup } from '../src/lookup.js';
|
|
30
|
+
|
|
31
|
+
const { root, config } = await loadProject();
|
|
32
|
+
const routesDir = resolveRoutesDir(path.join(root, config.appDir), config.routesDir);
|
|
33
|
+
const PORT = portOf(config, process.env.PORT);
|
|
34
|
+
|
|
35
|
+
// Built the same way the production server and the build build theirs. Dev used
|
|
36
|
+
// to get only half of it, so a route include worked in production and threw
|
|
37
|
+
// here.
|
|
38
|
+
let include = null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A signing secret for this process only, when the config has none.
|
|
42
|
+
*
|
|
43
|
+
* Dev, and dev alone. Signed cookies stop working across a restart, which is a
|
|
44
|
+
* fair price for a fresh clone that runs. Production does *not* do this: a server
|
|
45
|
+
* that invents a secret invalidates every session whenever it restarts and shares
|
|
46
|
+
* none with a second instance, and finding that out in production is worse than
|
|
47
|
+
* being told at the first signed cookie.
|
|
48
|
+
*/
|
|
49
|
+
const cookieSecret = config.cookieSecret ?? randomBytes(32).toString('hex');
|
|
50
|
+
if (!config.cookieSecret) {
|
|
51
|
+
console.log('[transclude] no cookieSecret, so signing with a random one for this process');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const publicRoot = config.publicDir
|
|
55
|
+
? path.join(root, config.appDir, config.publicDir)
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
// The same handler production mounts, over the source directory rather than the
|
|
59
|
+
// copy in dist. Not `precompressed`: nothing has written a .br next to these yet.
|
|
60
|
+
const publicFiles =
|
|
61
|
+
publicRoot && fs.existsSync(publicRoot)
|
|
62
|
+
? publicHandler(path.relative(process.cwd(), publicRoot) || '.')
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
// Built before Vite, because Vite needs it: in middleware mode with no `hmr`
|
|
66
|
+
// option Vite starts its own WebSocket server on another port, the browser
|
|
67
|
+
// refuses that socket as cross-origin, and every edit needs a manual reload.
|
|
68
|
+
// Handing it this server puts the socket on the same origin as the page.
|
|
69
|
+
const server = http.createServer();
|
|
70
|
+
|
|
71
|
+
const vite = await createViteServer({
|
|
72
|
+
root,
|
|
73
|
+
appType: 'custom',
|
|
74
|
+
server: { middlewareMode: true, hmr: { server } },
|
|
75
|
+
// Vite would serve these itself, ahead of Hono, and production would serve
|
|
76
|
+
// them a different way, which is how dev and production come to disagree. One
|
|
77
|
+
// way instead: `baseApp` mounts Hono's static middleware in both.
|
|
78
|
+
publicDir: false,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What every loader and action is handed. `request` is the platform's own
|
|
83
|
+
* `Request` rather than the server's wrapper. The framework should not be the
|
|
84
|
+
* reason an author has to learn a router's API to read a form.
|
|
85
|
+
*/
|
|
86
|
+
const contextFor = (route, c, extra = {}) => ({
|
|
87
|
+
url: c.req.url,
|
|
88
|
+
// Only ask Hono for params when the route declares them: on the not-found
|
|
89
|
+
// path nothing matched, and c.req.param() has no stash to read.
|
|
90
|
+
params: route.params.length ? c.req.param() : {},
|
|
91
|
+
route: { id: route.id, pattern: route.pattern, path: c.req.path },
|
|
92
|
+
request: c.req.raw,
|
|
93
|
+
// The region this request asked for, or null for a whole document. An action
|
|
94
|
+
// needs it to decide whether a redirect is even an answer: post/redirect/get
|
|
95
|
+
// is right for a form, and wrong for a caller that asked for markup.
|
|
96
|
+
fragment: fragmentOf(c),
|
|
97
|
+
action: null,
|
|
98
|
+
...withResponse(c, extra),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The envelope and the cookies that write into it, built together. The cookie
|
|
103
|
+
* helpers hold the same `response` the server will read, so a `set` in a loader
|
|
104
|
+
* lands on the way out.
|
|
105
|
+
*/
|
|
106
|
+
function withResponse(c, extra) {
|
|
107
|
+
const response = responseOf();
|
|
108
|
+
return {
|
|
109
|
+
response,
|
|
110
|
+
cookies: cookiesOf(c.req.raw, response, cookieSecret),
|
|
111
|
+
absolute: absoluteFrom(config.metadataBase, c.req.url),
|
|
112
|
+
...extra,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whatever the loaders and actions put on `ctx.response`, on the way out. */
|
|
117
|
+
const sendWith = (c, { response }, html, status) => {
|
|
118
|
+
for (const [name, value] of response.headers) c.header(name, value);
|
|
119
|
+
return c.html(html, status ?? response.status);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const renderPage = async (route, c, status = null, extra = {}) => {
|
|
123
|
+
const page = await vite.ssrLoadModule(pageModuleId(route.id));
|
|
124
|
+
const ctx = contextFor(route, c, extra);
|
|
125
|
+
|
|
126
|
+
const html = await renderRoute(page, ctx, {
|
|
127
|
+
clientEntry: page.client.needed ? clientEntryUrl(route.id) : null,
|
|
128
|
+
// No query param: Vite content-negotiates, and a <link> sends
|
|
129
|
+
// `Accept: text/css`, so the plain path returns the stylesheet.
|
|
130
|
+
stylesheet: config.stylesheet ? `/${config.stylesheet}` : null,
|
|
131
|
+
csp: config.csp,
|
|
132
|
+
lang: config.lang,
|
|
133
|
+
include,
|
|
134
|
+
});
|
|
135
|
+
// A loader answered for itself: a redirect, or something that is not a page.
|
|
136
|
+
if (html instanceof Response) return withEnvelope(html, ctx);
|
|
137
|
+
|
|
138
|
+
return sendWith(c, ctx, await vite.transformIndexHtml(c.req.path, html), status);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The region this request is asking for, or null for the whole document. An
|
|
143
|
+
* empty value (`?fragment`) is the page's own body without its layouts.
|
|
144
|
+
*/
|
|
145
|
+
const fragmentOf = (c) => {
|
|
146
|
+
if (!config.fragmentParam) return null;
|
|
147
|
+
const value = c.req.query(config.fragmentParam);
|
|
148
|
+
return value === undefined ? null : value;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const sendFragment = async (route, c, region, extra = {}) => {
|
|
152
|
+
const page = await vite.ssrLoadModule(pageModuleId(route.id));
|
|
153
|
+
const ctx = contextFor(route, c, extra);
|
|
154
|
+
const html = await renderFragment(page, ctx, { region: region || null, include });
|
|
155
|
+
|
|
156
|
+
if (html instanceof Response) return withEnvelope(html, ctx);
|
|
157
|
+
if (html === null) return c.text(`no fragment "${region}" on ${route.rel}`, 404);
|
|
158
|
+
// No Vite transform: a fragment is inserted into a document that already ran
|
|
159
|
+
// the client entry, and injecting the HMR preamble again would run it twice.
|
|
160
|
+
return sendWith(c, ctx, html);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A form submission, or anything else that is not a GET.
|
|
165
|
+
*
|
|
166
|
+
* The action runs first, then the request is answered the same way a GET is: the
|
|
167
|
+
* whole document, or one region if the URL asked for one. That last part is what
|
|
168
|
+
* regions are for. POST a form to `?fragment=list` and what comes back is
|
|
169
|
+
* the list, already rendered, by the same compiled region the page uses.
|
|
170
|
+
*/
|
|
171
|
+
const handleAction = async (route, c) => {
|
|
172
|
+
const page = await vite.ssrLoadModule(pageModuleId(route.id));
|
|
173
|
+
const region = fragmentOf(c);
|
|
174
|
+
|
|
175
|
+
// Before the action, not after: a request nobody can answer should not have
|
|
176
|
+
// mutated anything on its way to saying so.
|
|
177
|
+
if (region !== null && !hasRegion(page, region)) {
|
|
178
|
+
return c.text(`no fragment "${region}" on ${route.rel}`, 404);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ctx = contextFor(route, c);
|
|
182
|
+
const outcome = await runAction(page, ctx, c.req.method);
|
|
183
|
+
|
|
184
|
+
if (!outcome) {
|
|
185
|
+
return c.text(`${c.req.method} not allowed on ${route.rel}`, 405, {
|
|
186
|
+
Allow: methodsOf(page).join(', '),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (outcome.response) return withEnvelope(outcome.response, ctx);
|
|
190
|
+
|
|
191
|
+
const extra = { action: outcome.action, cookies: ctx.cookies, response: ctx.response };
|
|
192
|
+
return region === null
|
|
193
|
+
? renderPage(route, c, 200, extra)
|
|
194
|
+
: sendFragment(route, c, region, extra);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const onError = (c, err) => {
|
|
198
|
+
// Before anything reads the stack: Vite's transform means the raw one points
|
|
199
|
+
// at generated code, and a reporter given that is worse than none.
|
|
200
|
+
vite.ssrFixStacktrace(err);
|
|
201
|
+
console.error(err);
|
|
202
|
+
|
|
203
|
+
// The same seam production has, so a reporter is exercised while you are the
|
|
204
|
+
// one looking at it rather than first on a live site.
|
|
205
|
+
if (typeof config.onError === 'function') {
|
|
206
|
+
try {
|
|
207
|
+
config.onError(err, { request: c.req.raw, url: c.req.url, method: c.req.method });
|
|
208
|
+
} catch (failed) {
|
|
209
|
+
console.error('[transclude] onError itself threw:', failed);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return c.text(`${err.name}: ${err.message}\n\n${err.stack ?? ''}`, 500);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const serverFile = path.join(root, config.appDir, SERVER_FILE);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The app's own middleware, through Vite so an edit to it is picked up the same
|
|
220
|
+
* way an edit to a page is. Production gets the same module out of the SSR
|
|
221
|
+
* bundle instead, because that server reads `dist` and nothing else.
|
|
222
|
+
*
|
|
223
|
+
* Invalidated first, and not for tidiness: the watcher below and Vite's own
|
|
224
|
+
* invalidation are separate handlers on the same event, and if this runs before
|
|
225
|
+
* Vite's, `ssrLoadModule` hands back the module as it was. Measured: the first
|
|
226
|
+
* edit was ignored and the second appeared to work.
|
|
227
|
+
*/
|
|
228
|
+
async function loadMiddleware() {
|
|
229
|
+
if (!fs.existsSync(serverFile)) return null;
|
|
230
|
+
|
|
231
|
+
const url = `/${config.appDir}/${SERVER_FILE}`;
|
|
232
|
+
const node = await vite.moduleGraph.getModuleByUrl(url, true);
|
|
233
|
+
if (node) vite.moduleGraph.invalidateModule(node);
|
|
234
|
+
|
|
235
|
+
const mod = await vite.ssrLoadModule(url);
|
|
236
|
+
return mod.default ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function buildApp() {
|
|
240
|
+
const { routes, endpoints, notFound } = scanRoutes(routesDir);
|
|
241
|
+
|
|
242
|
+
include = includeContext({
|
|
243
|
+
config,
|
|
244
|
+
routes,
|
|
245
|
+
pageFor: (id) => vite.ssrLoadModule(pageModuleId(id)),
|
|
246
|
+
lookup: nodeLookup(),
|
|
247
|
+
});
|
|
248
|
+
const app = baseApp({
|
|
249
|
+
csrf: config.csrf,
|
|
250
|
+
trailingSlash: config.trailingSlash,
|
|
251
|
+
publicFiles,
|
|
252
|
+
middleware: await loadMiddleware(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Already ordered most-specific first, so registration order is deterministic
|
|
256
|
+
// rather than something to reason about per-router.
|
|
257
|
+
for (const route of routes) {
|
|
258
|
+
app.get(route.pattern, async (c) => {
|
|
259
|
+
try {
|
|
260
|
+
const fragment = fragmentOf(c);
|
|
261
|
+
return fragment === null ? await renderPage(route, c) : await sendFragment(route, c, fragment);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return onError(c, err);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Registered for every route rather than only the ones with an action, so
|
|
268
|
+
// a POST to a page that has none is a 405 with an `Allow` header instead of
|
|
269
|
+
// the not-found page. The URL exists, the method does not.
|
|
270
|
+
app.on(ACTION_METHODS, route.pattern, async (c) => {
|
|
271
|
+
try {
|
|
272
|
+
return await handleAction(route, c);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return onError(c, err);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// `app.all`, because "every verb" is the point: the module decides which it
|
|
280
|
+
// answers, and anything it does not is a 405 rather than a 404.
|
|
281
|
+
for (const route of endpoints) {
|
|
282
|
+
const url = '/' + path.relative(root, route.file).split(path.sep).join('/');
|
|
283
|
+
app.all(route.pattern, async (c) => {
|
|
284
|
+
try {
|
|
285
|
+
const mod = await vite.ssrLoadModule(url);
|
|
286
|
+
// The same envelope every other path gets. An endpoint that sets a
|
|
287
|
+
// cookie and returns a redirect is an ordinary thing to write, and the
|
|
288
|
+
// `Set-Cookie` was dropped without it.
|
|
289
|
+
const ctx = contextFor(route, c);
|
|
290
|
+
const out = await runEndpoint(mod, ctx, c.req.method);
|
|
291
|
+
if (out) return withEnvelope(out, ctx);
|
|
292
|
+
return c.text(`${c.req.method} not allowed on ${route.rel}`, 405, {
|
|
293
|
+
Allow: endpointMethods(mod).join(', '),
|
|
294
|
+
});
|
|
295
|
+
} catch (err) {
|
|
296
|
+
return onError(c, err);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
app.notFound(async (c) => {
|
|
302
|
+
if (!notFound) return c.text('not found', 404);
|
|
303
|
+
try {
|
|
304
|
+
return await renderPage(notFound, c, 404);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
return onError(c, err);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
console.log(
|
|
311
|
+
`[routes]\n${[...routes, ...endpoints].map((r) => ` ${r.pattern.padEnd(24)} ${r.rel}`).join('\n')}` +
|
|
312
|
+
(notFound ? `\n ${'(not found)'.padEnd(24)} ${notFound.rel}` : ''),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
return app;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let app = await buildApp();
|
|
319
|
+
|
|
320
|
+
// Adding or removing a page changes the route table, not just a module.
|
|
321
|
+
vite.watcher.on('all', async (event, file) => {
|
|
322
|
+
// `.js` as well as `.html`: an endpoint is a route too, and watching only for
|
|
323
|
+
// pages meant adding one needed a restart, with a 404 as the only hint.
|
|
324
|
+
const extension = path.extname(file);
|
|
325
|
+
const routing =
|
|
326
|
+
file.startsWith(routesDir) &&
|
|
327
|
+
(extension === '.html' || extension === '.js') &&
|
|
328
|
+
event !== 'change';
|
|
329
|
+
// Middleware is registered once when the app is built, so a change to it needs
|
|
330
|
+
// the app rebuilt, unlike a page, which is loaded per request.
|
|
331
|
+
if (!routing && file !== serverFile) return;
|
|
332
|
+
try {
|
|
333
|
+
app = await buildApp();
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.error(err.message);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const hono = getRequestListener((request) => app.fetch(request));
|
|
340
|
+
|
|
341
|
+
// Vite owns /@id/, /@vite/client and /src/*; Hono gets everything else.
|
|
342
|
+
server.on('request', (req, res) => {
|
|
343
|
+
vite.middlewares(req, res, () => hono(req, res));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
server.listen(PORT, () => {
|
|
347
|
+
console.log(`http://localhost:${PORT}`);
|
|
348
|
+
});
|
package/bin/release.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Prepares a release. It does not publish: it sets the version in both packages,
|
|
3
|
+
// runs everything, and writes a tag. Pushing that tag is what publishes, and
|
|
4
|
+
// that happens in CI where the token lives and provenance can be signed.
|
|
5
|
+
//
|
|
6
|
+
// Both packages move together and share a version. They are one project split
|
|
7
|
+
// for a packaging reason, and a new project's dependency on `@transclude/core`
|
|
8
|
+
// is written by `@transclude/create` from its own version, so two numbers that
|
|
9
|
+
// drift would write a dependency that does not exist.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
17
|
+
const MANIFESTS = ['package.json', 'create/package.json'];
|
|
18
|
+
|
|
19
|
+
const run = (command, args, options = {}) =>
|
|
20
|
+
execFileSync(command, args, { cwd: root, encoding: 'utf8', stdio: 'pipe', ...options });
|
|
21
|
+
|
|
22
|
+
const read = (rel) => JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
23
|
+
|
|
24
|
+
function usage() {
|
|
25
|
+
return [
|
|
26
|
+
'Usage: node bin/release.js <version|major|minor|patch> [--dry-run]',
|
|
27
|
+
'',
|
|
28
|
+
' Sets the version in both packages, verifies, commits and tags.',
|
|
29
|
+
' Pushing the tag is what publishes. Nothing here talks to a registry.',
|
|
30
|
+
'',
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `1.2.3`, or what `major`/`minor`/`patch` makes of the current one. */
|
|
35
|
+
function nextVersion(current, asked) {
|
|
36
|
+
if (/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(asked)) return asked;
|
|
37
|
+
|
|
38
|
+
const [major, minor, patch] = current.split('.').map(Number);
|
|
39
|
+
if (asked === 'major') return `${major + 1}.0.0`;
|
|
40
|
+
if (asked === 'minor') return `${major}.${minor + 1}.0`;
|
|
41
|
+
if (asked === 'patch') return `${major}.${minor}.${patch + 1}`;
|
|
42
|
+
|
|
43
|
+
throw new Error(`${JSON.stringify(asked)} is not a version or major/minor/patch`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Refuses to release from a tree that is not exactly what was tested.
|
|
48
|
+
*
|
|
49
|
+
* A dirty tree means the tag would name a commit that does not hold what was
|
|
50
|
+
* verified, and the tarball CI builds comes from the tag rather than from here.
|
|
51
|
+
*/
|
|
52
|
+
function assertReleasable() {
|
|
53
|
+
if (run('git', ['status', '--porcelain']).trim()) {
|
|
54
|
+
throw new Error('the working tree has changes. Commit or stash them first.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
58
|
+
if (branch !== 'main') throw new Error(`on ${branch}, and a release is cut from main`);
|
|
59
|
+
|
|
60
|
+
return read('package.json').version;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Refuses a version that already has a tag.
|
|
65
|
+
*
|
|
66
|
+
* Re-tagging is how two different tarballs come to claim one version: the
|
|
67
|
+
* registry keeps the first and the tag names the second, and nothing afterwards
|
|
68
|
+
* says which one anybody installed.
|
|
69
|
+
*/
|
|
70
|
+
function assertUntagged(version) {
|
|
71
|
+
const tags = run('git', ['tag', '--list']).split('\n').map((t) => t.trim());
|
|
72
|
+
if (tags.includes(`v${version}`)) {
|
|
73
|
+
throw new Error(`v${version} is already tagged. Releasing it again would move the tag.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function setVersion(version) {
|
|
78
|
+
for (const rel of MANIFESTS) {
|
|
79
|
+
const file = path.join(root, rel);
|
|
80
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
81
|
+
manifest.version = version;
|
|
82
|
+
fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Everything, in the order that fails cheapest first. */
|
|
87
|
+
function verify() {
|
|
88
|
+
const steps = [
|
|
89
|
+
// `check:src` is not here. It exits non-zero on any diagnostic, and most of
|
|
90
|
+
// what it reports is a pattern TypeScript cannot model rather than a defect.
|
|
91
|
+
// The part that is a gate is a test, and `npm test` runs it.
|
|
92
|
+
['the framework', 'npm', ['test']],
|
|
93
|
+
['the demo', 'npm', ['run', 'test:examples']],
|
|
94
|
+
['the docs', 'npm', ['test', '--prefix', 'docs']],
|
|
95
|
+
['the docs types', 'npm', ['run', 'check', '--prefix', 'docs']],
|
|
96
|
+
['the docs build', 'npm', ['run', 'build', '--prefix', 'docs']],
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const [what, command, args] of steps) {
|
|
100
|
+
process.stdout.write(` ${what} … `);
|
|
101
|
+
try {
|
|
102
|
+
run(command, args);
|
|
103
|
+
process.stdout.write('ok\n');
|
|
104
|
+
} catch (error) {
|
|
105
|
+
process.stdout.write('failed\n\n');
|
|
106
|
+
throw new Error(`${what} failed:\n\n${error.stdout ?? ''}${error.stderr ?? ''}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What each package would ship, so a missing `files` entry is seen before a tag. */
|
|
112
|
+
function packed() {
|
|
113
|
+
for (const dir of [root, path.join(root, 'create')]) {
|
|
114
|
+
const listing = run('npm', ['pack', '--dry-run', '--json'], { cwd: dir });
|
|
115
|
+
const [{ name, files }] = JSON.parse(listing);
|
|
116
|
+
process.stdout.write(` ${name}: ${files.length} files\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function main() {
|
|
121
|
+
const args = process.argv.slice(2);
|
|
122
|
+
const dryRun = args.includes('--dry-run');
|
|
123
|
+
const asked = args.find((a) => !a.startsWith('-'));
|
|
124
|
+
|
|
125
|
+
if (!asked || args.includes('--help')) {
|
|
126
|
+
process.stdout.write(usage());
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const current = assertReleasable();
|
|
131
|
+
const version = nextVersion(current, asked);
|
|
132
|
+
assertUntagged(version);
|
|
133
|
+
process.stdout.write(`\n${current} -> ${version}\n\n`);
|
|
134
|
+
|
|
135
|
+
setVersion(version);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
verify();
|
|
139
|
+
packed();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// Put the versions back. A failed release should leave nothing behind.
|
|
142
|
+
setVersion(current);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (dryRun) {
|
|
147
|
+
setVersion(current);
|
|
148
|
+
process.stdout.write('\nDry run. Versions put back, nothing committed.\n\n');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
run('git', ['add', ...MANIFESTS]);
|
|
153
|
+
|
|
154
|
+
// A first release at the version the manifests already carry changes nothing,
|
|
155
|
+
// and an empty commit is not worth making. The tag is the release either way.
|
|
156
|
+
const staged = run('git', ['diff', '--cached', '--name-only']).trim();
|
|
157
|
+
if (staged) run('git', ['commit', '-m', `Release ${version}`]);
|
|
158
|
+
|
|
159
|
+
run('git', ['tag', '-a', `v${version}`, '-m', `Release ${version}`]);
|
|
160
|
+
|
|
161
|
+
process.stdout.write(
|
|
162
|
+
[
|
|
163
|
+
`\nTagged v${version}. Nothing has been published yet.\n\n`,
|
|
164
|
+
' git push --follow-tags\n\n',
|
|
165
|
+
'That is what publishes. The workflow builds from the tag and signs\n',
|
|
166
|
+
'provenance against it.\n\n',
|
|
167
|
+
].join(''),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
main();
|
|
173
|
+
} catch (error) {
|
|
174
|
+
process.stderr.write(`\n${error.message}\n`);
|
|
175
|
+
process.exitCode = 1;
|
|
176
|
+
}
|
package/bin/serve.bun.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Bun adapter. `bun framework/bin/serve.bun.js`
|
|
2
|
+
//
|
|
3
|
+
// Bun serves whatever a module default-exports with a `fetch`, so there is no
|
|
4
|
+
// listener to write. The app already is one.
|
|
5
|
+
|
|
6
|
+
import { app, noBuild, port, summary } from '../src/production.js';
|
|
7
|
+
|
|
8
|
+
if (noBuild) {
|
|
9
|
+
console.error('No build found. Run `npm run build` first.');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
summary(port);
|
|
14
|
+
|
|
15
|
+
export default { fetch: app.fetch, port };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Deno adapter. `deno run -A framework/bin/serve.deno.js`
|
|
2
|
+
//
|
|
3
|
+
// `Deno.serve` takes the same (Request) => Response the other two do.
|
|
4
|
+
|
|
5
|
+
import { app, noBuild, port as configured, summary } from '../src/production.js';
|
|
6
|
+
|
|
7
|
+
if (noBuild) {
|
|
8
|
+
console.error('No build found. Run `npm run build` first.');
|
|
9
|
+
Deno.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Deno reads its own environment, so PORT is applied here rather than
|
|
13
|
+
// through the `process` shim.
|
|
14
|
+
const port = Number(Deno.env.get('PORT') ?? configured);
|
|
15
|
+
Deno.serve({ port, onListen: () => summary(port) }, app.fetch);
|
package/bin/serve.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Node adapter. The app is in src/production.js; this listens with it.
|
|
3
|
+
|
|
4
|
+
import { serve } from '@hono/node-server';
|
|
5
|
+
import { app, noBuild, port, summary } from '../src/production.js';
|
|
6
|
+
|
|
7
|
+
if (noBuild) {
|
|
8
|
+
console.error('No build found. Run `npm run build` first.');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
serve({ fetch: app.fetch, port }, ({ port }) => summary(port));
|