@torpor/build 0.4.14 → 1.0.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 (176) hide show
  1. package/README.md +20 -2
  2. package/dist/Jsonify-Dg6gChd3.d.mts +10 -0
  3. package/dist/Jsonify-Dg6gChd3.d.mts.map +1 -0
  4. package/dist/ParseRouteParams-DBmqlmLh.d.mts +42 -0
  5. package/dist/ParseRouteParams-DBmqlmLh.d.mts.map +1 -0
  6. package/dist/{RouteType-B6BMOyYw.mjs → Router-C4ADUfhv.mjs} +30 -42
  7. package/dist/Router-C4ADUfhv.mjs.map +1 -0
  8. package/dist/{Server-CF5wDJp6.d.mts → Server-C_IKav_e.d.mts} +44 -16
  9. package/dist/Server-C_IKav_e.d.mts.map +1 -0
  10. package/dist/{ServerEvent-CxPwA4lH.mjs → ServerEvent-Dljn5i7A.mjs} +37 -11
  11. package/dist/ServerEvent-Dljn5i7A.mjs.map +1 -0
  12. package/dist/Site-CMa153FA.d.mts +565 -0
  13. package/dist/Site-CMa153FA.d.mts.map +1 -0
  14. package/dist/StandardSchema-D28L8_zZ.d.mts +100 -0
  15. package/dist/StandardSchema-D28L8_zZ.d.mts.map +1 -0
  16. package/dist/TypedResponse-6lznqN4U.d.mts +22 -0
  17. package/dist/TypedResponse-6lznqN4U.d.mts.map +1 -0
  18. package/dist/{_page-BQs4WvCO.mjs → _page-4cQKs_Dn.mjs} +3 -5
  19. package/dist/_page-4cQKs_Dn.mjs.map +1 -0
  20. package/dist/bin/index.d.mts +1 -1
  21. package/dist/bin/index.js +15 -11
  22. package/dist/bin/index.js.map +1 -1
  23. package/dist/flattenHeaders-C_YYLdOq.mjs +114 -0
  24. package/dist/flattenHeaders-C_YYLdOq.mjs.map +1 -0
  25. package/dist/form.d.mts +35 -0
  26. package/dist/form.d.mts.map +1 -0
  27. package/dist/form.mjs +48 -0
  28. package/dist/form.mjs.map +1 -0
  29. package/dist/index.d.mts +72 -156
  30. package/dist/index.d.mts.map +1 -1
  31. package/dist/index.mjs +158 -26
  32. package/dist/index.mjs.map +1 -1
  33. package/dist/nav.d.mts +58 -5
  34. package/dist/nav.d.mts.map +1 -1
  35. package/dist/nav.mjs +116 -38
  36. package/dist/nav.mjs.map +1 -1
  37. package/dist/openapi.d.mts +144 -0
  38. package/dist/openapi.d.mts.map +1 -0
  39. package/dist/openapi.mjs +2 -0
  40. package/dist/pathTrie-D4Ax2Mu9.mjs +160 -0
  41. package/dist/pathTrie-D4Ax2Mu9.mjs.map +1 -0
  42. package/dist/plugin-D8Www11B.mjs +202 -0
  43. package/dist/plugin-D8Www11B.mjs.map +1 -0
  44. package/dist/{response-C5TtAsh1.mjs → response-Bg4w2S1q.mjs} +16 -39
  45. package/dist/response-Bg4w2S1q.mjs.map +1 -0
  46. package/dist/response.d.mts +309 -289
  47. package/dist/response.d.mts.map +1 -1
  48. package/dist/response.mjs +3 -4
  49. package/dist/run.d.mts +11 -4
  50. package/dist/run.d.mts.map +1 -1
  51. package/dist/run.mjs +2 -8
  52. package/dist/runOpenApi-BUi1RH1l.mjs +1381 -0
  53. package/dist/runOpenApi-BUi1RH1l.mjs.map +1 -0
  54. package/dist/schema.d.mts +32 -0
  55. package/dist/schema.d.mts.map +1 -0
  56. package/dist/schema.mjs +2 -0
  57. package/dist/server.d.mts +10 -8
  58. package/dist/server.d.mts.map +1 -1
  59. package/dist/server.mjs +132 -4
  60. package/dist/server.mjs.map +1 -0
  61. package/dist/state.d.mts.map +1 -1
  62. package/dist/state.mjs +2 -3
  63. package/dist/test.d.mts +8 -10
  64. package/dist/test.d.mts.map +1 -1
  65. package/dist/test.mjs +283 -79
  66. package/dist/test.mjs.map +1 -1
  67. package/dist/{seeOther-B4Yhu9iq.mjs → unprocessable-DAZbrDeR.mjs} +34 -13
  68. package/dist/unprocessable-DAZbrDeR.mjs.map +1 -0
  69. package/dist/validate-Ok4krlkX.mjs +40 -0
  70. package/dist/validate-Ok4krlkX.mjs.map +1 -0
  71. package/package.json +28 -17
  72. package/src/bin/index.ts +13 -2
  73. package/src/dev.ts +10 -0
  74. package/src/form/formDataToRecord.ts +23 -0
  75. package/src/form/readForm.ts +71 -0
  76. package/src/form.ts +4 -0
  77. package/src/index.ts +53 -1
  78. package/src/nav/api.test-d.ts +79 -0
  79. package/src/nav/api.ts +104 -0
  80. package/src/nav/formSubmit.ts +18 -4
  81. package/src/nav/navigate.ts +40 -15
  82. package/src/nav/route.ts +38 -0
  83. package/src/nav.ts +3 -1
  84. package/src/openapi/docsHtml.ts +25 -0
  85. package/src/openapi/document.ts +154 -0
  86. package/src/openapi/plugin.ts +78 -0
  87. package/src/openapi/types.ts +82 -0
  88. package/src/openapi.ts +14 -0
  89. package/src/response/TypedResponse.ts +17 -0
  90. package/src/response/badRequest.ts +14 -2
  91. package/src/response/created.ts +13 -2
  92. package/src/response/found.ts +2 -2
  93. package/src/response/movedPermanently.ts +2 -2
  94. package/src/response/notModified.ts +2 -2
  95. package/src/response/ok.ts +13 -4
  96. package/src/response/response.ts +4 -4
  97. package/src/response/unprocessable.ts +14 -2
  98. package/src/run/depCache.ts +175 -0
  99. package/src/run/devPlugin.ts +128 -0
  100. package/src/run/prepareTemplate.ts +6 -3
  101. package/src/run/run.ts +63 -39
  102. package/src/run/runBuild.ts +63 -12
  103. package/src/run/runDev.ts +146 -58
  104. package/src/run/runOpenApi.ts +52 -0
  105. package/src/run/runPreview.ts +17 -26
  106. package/src/run/staleTorpCopies.ts +100 -0
  107. package/src/run.ts +2 -1
  108. package/src/schema.ts +7 -0
  109. package/src/server/CookieHelper.ts +17 -7
  110. package/src/server/Server.ts +54 -30
  111. package/src/server/ServerEvent.ts +24 -1
  112. package/src/server/connect/connectMiddleware.ts +44 -40
  113. package/src/server/connect/flattenHeaders.ts +1 -23
  114. package/src/server/connect/requestToNodeMessage.ts +6 -3
  115. package/src/server/types/MiddlewareFunction.ts +18 -4
  116. package/src/site/Router.ts +52 -41
  117. package/src/site/Site.ts +184 -10
  118. package/src/site/checkLayoutSlots.ts +114 -0
  119. package/src/site/checkRoutes.ts +435 -0
  120. package/src/site/clientEntry.ts +22 -9
  121. package/src/site/layoutSlots.ts +53 -0
  122. package/src/site/manifest.ts +125 -6
  123. package/src/site/serverEntry.ts +341 -132
  124. package/src/state/$page.ts +2 -1
  125. package/src/state/$serverPage.ts +22 -0
  126. package/src/test/runTest.ts +306 -118
  127. package/src/types/Adapter.ts +12 -0
  128. package/src/types/Jsonify.ts +20 -0
  129. package/src/types/PageData.test-d.ts +136 -0
  130. package/src/types/PageData.ts +35 -0
  131. package/src/types/PageEndPoint.ts +25 -9
  132. package/src/types/PageForm.test-d.ts +78 -0
  133. package/src/types/PageForm.ts +29 -0
  134. package/src/types/PageLoadEvent.ts +16 -4
  135. package/src/types/PageLoadReturn.ts +13 -0
  136. package/src/types/PageProps.ts +14 -0
  137. package/src/types/PageServerAction.ts +8 -2
  138. package/src/types/PageServerEndPoint.test-d.ts +105 -0
  139. package/src/types/PageServerEndPoint.ts +99 -5
  140. package/src/types/PageServerLoad.ts +11 -3
  141. package/src/types/ParseRouteParams.test-d.ts +84 -0
  142. package/src/types/ParseRouteParams.ts +64 -0
  143. package/src/types/Route.ts +20 -1
  144. package/src/types/RouteHandler.ts +8 -1
  145. package/src/types/ServerEndPoint.test-d.ts +77 -0
  146. package/src/types/ServerEndPoint.ts +148 -16
  147. package/src/types/ServerHook.ts +10 -3
  148. package/src/types/ServerLoadEvent.ts +48 -3
  149. package/src/types/ServerRequest.ts +10 -2
  150. package/src/types/SitePlugin.ts +24 -0
  151. package/src/types/StandardSchema.ts +109 -0
  152. package/src/utils/pathToRegex.ts +4 -2
  153. package/src/utils/pathTrie.ts +182 -0
  154. package/src/utils/searchParamsToRecord.ts +18 -0
  155. package/src/utils/torporPackages.ts +150 -0
  156. package/src/utils/tsconfigAliases.ts +90 -0
  157. package/src/validation/ValidationError.ts +18 -0
  158. package/src/validation/endpoint.ts +83 -0
  159. package/src/validation/validate.ts +26 -0
  160. package/dist/RouteType-B6BMOyYw.mjs.map +0 -1
  161. package/dist/Server-CF5wDJp6.d.mts.map +0 -1
  162. package/dist/ServerEvent-CxPwA4lH.mjs.map +0 -1
  163. package/dist/Site-DgC6WWn1.d.mts +0 -78
  164. package/dist/Site-DgC6WWn1.d.mts.map +0 -1
  165. package/dist/_page-BQs4WvCO.mjs.map +0 -1
  166. package/dist/connectMiddleware-D7nehjj3.mjs +0 -252
  167. package/dist/connectMiddleware-D7nehjj3.mjs.map +0 -1
  168. package/dist/pathToRegex-TUAMc3zS.mjs +0 -11
  169. package/dist/pathToRegex-TUAMc3zS.mjs.map +0 -1
  170. package/dist/response-C5TtAsh1.mjs.map +0 -1
  171. package/dist/run-BPFtSYA3.mjs +0 -332
  172. package/dist/run-BPFtSYA3.mjs.map +0 -1
  173. package/dist/seeOther-B4Yhu9iq.mjs.map +0 -1
  174. package/src/server/Routerx.ts +0 -72
  175. package/src/server/connect/bufferToArrayBuffer.ts +0 -8
  176. package/src/server/connect/readableToBuffer.ts +0 -16
package/src/run/runDev.ts CHANGED
@@ -1,26 +1,67 @@
1
1
  import estorpor from "@torpor/unplugin/esbuild";
2
2
  import torpor from "@torpor/unplugin/vite";
3
3
  import { configDotenv } from "dotenv";
4
- import { promises as fs } from "node:fs";
4
+ import { readFileSync } from "node:fs";
5
5
  import path from "node:path";
6
- import { createServer as createViteServer } from "vite";
7
- import { serverError } from "../response.ts";
8
- import Server from "../server/Server.ts";
9
- import connectMiddleware from "../server/connect/connectMiddleware.ts";
6
+ import {
7
+ type AliasOptions,
8
+ createServer as createViteServer,
9
+ type Plugin,
10
+ type ViteDevServer,
11
+ } from "vite";
10
12
  import Site from "../site/Site.ts";
13
+ import {
14
+ checkApiCallSource,
15
+ checkApiCalls,
16
+ checkRoute,
17
+ checkRoutes,
18
+ createApiCallCheck,
19
+ type RouteCheckIssue,
20
+ reportRouteIssues,
21
+ } from "../site/checkRoutes";
11
22
  import manifest from "../site/manifest.ts";
12
- import prepareTemplate from "./prepareTemplate.ts";
23
+ import { checkLayoutSlot, checkLayoutSlots } from "../site/checkLayoutSlots";
24
+ import { LAYOUT_ROUTE } from "../types/RouteType";
25
+ import tsconfigAliases, { type AliasEntry } from "../utils/tsconfigAliases";
26
+ import { addTorporPackageConfig } from "../utils/torporPackages";
27
+ import devPlugin from "./devPlugin.ts";
28
+ import { clearStaleDepCache } from "./depCache";
29
+ import { reportStaleTorpCopies } from "./staleTorpCopies";
13
30
 
14
31
  export default async function runDev(site: Site): Promise<void> {
15
- const server = new Server();
16
-
17
- // Create the Vite server in middleware mode and configure the app type as
18
- // "custom", disabling Vite's own HTML serving logic so the parent server
19
- // can take control
32
+ // Create the Vite dev server. Unlike the previous middleware-mode setup, we
33
+ // let Vite own the HTTP server (so HMR/websockets work natively) and let the
34
+ // adapter's `dev()` plugin(s) handle SSR via configureServer.
20
35
  const config = structuredClone(site.viteConfig ?? {});
21
- config.server = { middlewareMode: true };
22
36
  config.appType = "custom";
23
- config.plugins = [manifest(site, true), torpor({ dev: true }), ...site.plugins];
37
+ config.server ??= {};
38
+ config.server.host ??= "localhost";
39
+ config.server.port ??= 7059;
40
+ // Resolve tsconfig path aliases (e.g. `@/*`). Previously provided by the
41
+ // default `vite-tsconfig-paths` plugin on Site; vite-plus handles it inline
42
+ config.resolve ??= {};
43
+ config.resolve.tsconfigPaths ??= true;
44
+ // Resolve `@torpor/view` (runtime, compiler, SSR) from source in dev mode
45
+ // so changes to the compiler take effect without rebuilding dist.
46
+ config.resolve.conditions ??= [];
47
+ config.resolve.conditions.push("development");
48
+ // vite-plus' `tsconfigPaths` isn't honored by the SSR module-runner's
49
+ // externalization path (it hardcodes tsconfigPaths:false), so dev SSR can't
50
+ // resolve path aliases. Mirror tsconfig `compilerOptions.paths` as Vite
51
+ // `resolve.alias` so they resolve during transform for both client and SSR.
52
+ config.resolve.alias = [...asAliasArray(config.resolve.alias), ...tsconfigAliases(site.root)];
53
+
54
+ // The adapter provides the dev-runtime plugin(s); fall back to the
55
+ // framework's Node-runtime plugin when it doesn't.
56
+ const adapterDev = site.adapter.dev?.(site);
57
+ const devPlugins = normalizePlugins(adapterDev ?? devPlugin(site));
58
+
59
+ config.plugins = [
60
+ manifest(site, true),
61
+ torpor({ dev: true }),
62
+ ...devPlugins,
63
+ ...site.vitePlugins,
64
+ ];
24
65
 
25
66
  // HACK: To be able to import `.torp` files from barrel files in
26
67
  // node_modules, we need to add their libraries to `ssr.noExternal` in
@@ -28,62 +69,109 @@ export default async function runDev(site: Site): Promise<void> {
28
69
  config.optimizeDeps ??= {};
29
70
  config.optimizeDeps.extensions ??= [];
30
71
  config.optimizeDeps.extensions.push(".torp");
31
- config.optimizeDeps.esbuildOptions ??= {};
32
- config.optimizeDeps.esbuildOptions.plugins ??= [];
33
- config.optimizeDeps.esbuildOptions.plugins.push(estorpor());
72
+ config.optimizeDeps.rolldownOptions ??= {};
73
+ config.optimizeDeps.rolldownOptions.plugins ??= [estorpor()];
74
+ // TODO: config.optimizeDeps.rolldownOptions.plugins.push(estorpor());
75
+
76
+ // Packages that ship `.torp` files (e.g. icon libraries) need the torpor
77
+ // compiler: dep optimization would parse them as plain JavaScript and
78
+ // fail, and SSR externalization would leave raw `.torp` imports that
79
+ // Node/workerd can't load. Exclude them from optimization and mark them
80
+ // for SSR bundling so they go through the torpor plugin
81
+ addTorporPackageConfig(site.root, config);
82
+
83
+ // Workspace packages may have been rebuilt since the last dev run; drop
84
+ // Vite's dep cache so SSR never executes stale prebundled modules
85
+ clearStaleDepCache(site.root, (message) => console.log(`\n${message}`));
86
+
87
+ // A workspace package with dist .torp copies (e.g. @torpor/ui) whose src
88
+ // changed without a rebuild serves OLD components to the site, silently;
89
+ // report the drift so it can be fixed with a rebuild
90
+ reportStaleTorpCopies(site.root, (message) => console.log(`\n${message}`));
91
+
92
+ // Load environment variables from a `.env` file, with defaults if not set
93
+ configDotenv();
94
+
95
+ process.env.PROTOCOL ??= "http:";
96
+ process.env.HOST ??= "localhost";
97
+ process.env.PORT ??= "7059";
98
+
99
+ const connectingUrl = `${process.env.PROTOCOL}//${process.env.HOST}:${process.env.PORT}`;
100
+ console.log(`\nConnecting to ${connectingUrl}`);
34
101
 
35
102
  const vite = await createViteServer(config);
103
+ await vite.listen();
36
104
 
37
- // Read site.html
38
- // It's called site.html because @torpor/build builds the site (html, routes
39
- // etc) while the user builds the app (components etc)
40
- const templateFile = path.resolve(site.root, "src/site.html");
41
- let template = await fs.readFile(templateFile, "utf-8");
105
+ watchRouteTypes(site, vite);
42
106
 
43
- // Apply Vite HTML transforms. This injects the Vite HMR client, and also
44
- // applies HTML transforms from Vite plugins. Note that we only support a
45
- // universal transform, not individual transforms for each route
46
- template = await vite.transformIndexHtml("", template);
107
+ const listeningUrl = vite.resolvedUrls?.local?.[0] ?? connectingUrl;
108
+ console.log(`Listening on ${listeningUrl}\n`);
109
+ }
47
110
 
48
- const siteFolder = path.resolve(site.root, "./node_modules/@torpor/build/src/site/");
49
- let clientScript = path.join(siteFolder, "clientEntry.ts");
50
- let clientDevScript = path.join(siteFolder, "clientEntryDev.ts");
51
- let serverScript = path.join(siteFolder, "serverEntry.ts");
111
+ /**
112
+ * Reports route type issues at startup, then re-checks route files and
113
+ * `makeApi` calls as they change so annotation problems surface during
114
+ * development without failing the dev server.
115
+ */
116
+ function watchRouteTypes(site: Site, vite: ViteDevServer): void {
117
+ reportRouteIssues(checkRoutes(site));
118
+ reportRouteIssues(checkApiCalls(site));
119
+ reportRouteIssues(checkLayoutSlots(site));
52
120
 
53
- // Prepare site.html so that we can just splice components into it
54
- template = prepareTemplate(template, clientScript, clientDevScript);
121
+ // Map absolute route file paths to their manifest entries, so changed
122
+ // files can be re-checked against their derived route. Layout route files
123
+ // are included for their slot check (a .torp layout is its own component)
124
+ const routeFiles = new Map(
125
+ site.routes
126
+ .filter(
127
+ (r) => r.file?.endsWith(".ts") || (r.file?.endsWith(".torp") && r.type === LAYOUT_ROUTE),
128
+ )
129
+ .map((r) => [path.resolve(site.root, r.file!), r]),
130
+ );
131
+ const apiCheck = createApiCallCheck(site);
132
+ // Only report when a file's issues change, to avoid repeating the same
133
+ // warnings on every save
134
+ const reported = new Map<string, string>();
55
135
 
56
- // Use Vite's Connect instance as middleware. We need to wrap it with
57
- // createMiddlewareHandler that converts Connect middleware to
58
- // Request/Response web handlers
59
- server.use(connectMiddleware(vite.middlewares));
136
+ const report = (file: string, issues: RouteCheckIssue[]) => {
137
+ const key = JSON.stringify(issues);
138
+ if (reported.get(file) === key) return;
139
+ reported.set(file, key);
140
+ reportRouteIssues(issues);
141
+ };
60
142
 
61
- // Every request (GET, POST, etc) goes through loadEndPoint
62
- server.add("*", async (ev) => {
143
+ const recheck = (file: string): void => {
144
+ const route = routeFiles.get(file);
145
+ if (route) {
146
+ report(
147
+ file,
148
+ route.type === LAYOUT_ROUTE ? checkLayoutSlot(site, route) : checkRoute(site, route),
149
+ );
150
+ return;
151
+ }
152
+ if (!/\.(ts|js|torp)$/.test(file)) return;
153
+ let source: string;
63
154
  try {
64
- // Load the server entry. ssrLoadModule automatically transforms ESM
65
- // source code to be usable in Node.js. No bundling is required, and
66
- // it provides efficient invalidation similar to HMR
67
- const { load } = await vite.ssrLoadModule(serverScript);
68
-
69
- // Render the app HTML (or fetch server data etc) via serverEntry's
70
- // exported `load` function
71
- return await load(ev, template);
72
- } catch (e: any) {
73
- // If an error is caught, let Vite fix the stack trace so it maps
74
- // back to your actual source code
75
- vite.ssrFixStacktrace(e);
76
- return serverError(e);
155
+ source = readFileSync(file, "utf8");
156
+ } catch {
157
+ return;
77
158
  }
78
- });
159
+ report(file, checkApiCallSource(source, file, apiCheck));
160
+ };
79
161
 
80
- // Load environment variables from a `.env` file, with defaults if not set
81
- configDotenv();
162
+ vite.watcher.on("change", recheck);
163
+ vite.watcher.on("add", recheck);
164
+ }
82
165
 
83
- process.env.PROTOCOL ??= "http:";
84
- process.env.HOST ??= "localhost";
85
- process.env.PORT ??= "7059";
166
+ function normalizePlugins(plugins: Plugin | Plugin[] | void): Plugin[] {
167
+ if (!plugins) return [];
168
+ return Array.isArray(plugins) ? plugins : [plugins];
169
+ }
86
170
 
87
- // Serve the site
88
- site.adapter.serve(server, site);
171
+ function asAliasArray(alias: AliasOptions | undefined): AliasEntry[] {
172
+ // Normalize an existing `resolve.alias` value (array | object | undefined)
173
+ // into an array so we can concatenate our tsconfig-derived aliases.
174
+ if (!alias) return [];
175
+ if (Array.isArray(alias)) return alias;
176
+ return Object.entries(alias).map(([find, replacement]) => ({ find, replacement }));
89
177
  }
@@ -0,0 +1,52 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { ViteDevServer } from "vite";
4
+ import buildOpenApiDocument from "../openapi/document";
5
+ import { OPEN_API_STATE_KEY } from "../openapi/plugin";
6
+ import type { OpenApiEndPoint, OpenApiRouteEntry, ResolvedOpenApiOptions } from "../openapi/types";
7
+ import type Site from "../site/Site";
8
+ import { SERVER_ROUTE } from "../types/RouteType";
9
+
10
+ /**
11
+ * Writes an OpenAPI document for the site's +server endpoints to a file.
12
+ * Route files are loaded through the supplied Vite server, so this must be
13
+ * called while the server from `loadSite` is still open.
14
+ */
15
+ export default async function runOpenApi(
16
+ site: Site,
17
+ vite: ViteDevServer,
18
+ outFile = "openapi.json",
19
+ ): Promise<void> {
20
+ const options = site.pluginState.get(OPEN_API_STATE_KEY) as ResolvedOpenApiOptions | undefined;
21
+ if (!options) {
22
+ throw new Error(
23
+ 'No OpenAPI options found. Add the openApi() plugin to site.plugins to use "tb --openapi"',
24
+ );
25
+ }
26
+
27
+ const entries: OpenApiRouteEntry[] = [];
28
+ for (const route of site.routes) {
29
+ if (route.type !== SERVER_ROUTE) {
30
+ continue;
31
+ }
32
+ // Don't document the docs page itself
33
+ if (options.docs && route.path === options.docs) {
34
+ continue;
35
+ }
36
+ if (route.endPoint && !route.file) {
37
+ // Inline endpoint
38
+ entries.push({ path: route.path, endPoint: route.endPoint as OpenApiEndPoint });
39
+ } else if (route.file) {
40
+ const file = path.resolve(site.root, route.file);
41
+ const mod = await vite.ssrLoadModule(file);
42
+ entries.push({ path: route.path, endPoint: mod.default });
43
+ }
44
+ }
45
+
46
+ const doc = buildOpenApiDocument(entries, options);
47
+ await writeFile(path.resolve(site.root, outFile), JSON.stringify(doc, null, 2) + "\n");
48
+
49
+ console.log(
50
+ `OpenAPI document written to ${outFile} (${Object.keys(doc.paths ?? {}).length} paths)`,
51
+ );
52
+ }
@@ -16,23 +16,24 @@ export default async function runPreview(site: Site): Promise<void> {
16
16
  const clientFolder = path.join(distFolder, "client");
17
17
  const serverFolder = path.join(distFolder, "server");
18
18
 
19
- // Find the client script file in /assets
20
- let clientScript = (await fs.readdir(path.join(clientFolder, "assets"))).find((f) =>
21
- f.startsWith("clientEntry-"),
22
- );
23
- if (!clientScript) {
24
- throw new Error("clientEntry.js not found");
25
- }
26
- clientScript = `/assets/${clientScript}`;
19
+ // Read site.html if present, and find the client script file in /assets.
20
+ // An endpoints-only site has no site.html, and no template is needed
21
+ let template: string | undefined;
22
+ const siteHtml = path.join(clientFolder, "site.html");
23
+ if (existsSync(siteHtml)) {
24
+ let clientScript = (await fs.readdir(path.join(clientFolder, "assets"))).find((f) =>
25
+ f.startsWith("clientEntry-"),
26
+ );
27
+ if (!clientScript) {
28
+ throw new Error("clientEntry.js not found");
29
+ }
30
+ clientScript = `/assets/${clientScript}`;
27
31
 
28
- // Read site.html
29
- // It's called site.html because @torpor/build builds the site (html, routes
30
- // etc) while the user builds the app (components etc)
31
- let siteHtml = path.join(clientFolder, "site.html");
32
- let template = await fs.readFile(siteHtml, "utf-8");
32
+ template = await fs.readFile(siteHtml, "utf-8");
33
33
 
34
- // Prepare site.html so that we can just splice components into it
35
- template = prepareTemplate(template, clientScript);
34
+ // Prepare site.html so that we can just splice components into it
35
+ template = prepareTemplate(template, clientScript);
36
+ }
36
37
 
37
38
  // Configure the server
38
39
  const server = new Server();
@@ -43,8 +44,7 @@ export default async function runPreview(site: Site): Promise<void> {
43
44
  const url = new URL(ev.request.url);
44
45
  const file = path.join(site.root, "dist", "client", url.pathname);
45
46
  if (existsSync(file)) {
46
- // TODO: Stream the data?
47
- const body = bufferToArrayBuffer(await fs.readFile(file));
47
+ const body = await fs.readFile(file);
48
48
  return new Response(body, {
49
49
  status: 200,
50
50
  headers: {
@@ -82,12 +82,3 @@ export default async function runPreview(site: Site): Promise<void> {
82
82
  // Serve the site
83
83
  site.adapter.serve(server, site);
84
84
  }
85
-
86
- // From https://stackoverflow.com/a/79345620
87
- // No idea why it is needed
88
- function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
89
- return buffer.buffer.slice(
90
- buffer.byteOffset,
91
- buffer.byteOffset + buffer.byteLength,
92
- ) as ArrayBuffer;
93
- }
@@ -0,0 +1,100 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { PackageJson } from "../utils/torporPackages";
4
+ import { findWorkspaceRoot, workspaceDependencyDirs } from "./depCache";
5
+
6
+ /**
7
+ * Workspace packages that ship copies of their .torp components (e.g.
8
+ * @torpor/ui copies its src .torp files into dist/ during its build) can go
9
+ * stale: a site resolves the package to its dist folder, so edits to src
10
+ * silently keep serving the OLD components until the package is rebuilt.
11
+ * The symptom is confusing -- e.g. a TypeError about a renamed slot prop
12
+ * after a component's API changed.
13
+ *
14
+ * reportStaleTorpCopies runs at `tb --dev` startup: for every workspace
15
+ * dependency with both src and dist folders, it compares the .torp files and
16
+ * reports anything missing from dist, differing from src, or left over in
17
+ * dist without a src file.
18
+ */
19
+
20
+ /**
21
+ * Reports stale dist .torp copies in the site's workspace dependencies.
22
+ * Never throws -- a broken check must not break dev startup.
23
+ */
24
+ export function reportStaleTorpCopies(siteRoot: string, log?: (message: string) => void): void {
25
+ try {
26
+ const workspaceRoot = findWorkspaceRoot(siteRoot);
27
+ if (!workspaceRoot) return;
28
+
29
+ for (const dir of workspaceDependencyDirs(siteRoot, workspaceRoot)) {
30
+ const srcFolder = path.join(dir, "src");
31
+ const distFolder = path.join(dir, "dist");
32
+ // Only packages that ship dist copies of src .torp files are
33
+ // checked; packages without the folders (or with neither .torp
34
+ // set) are skipped
35
+ if (!existsSync(srcFolder) || !existsSync(distFolder)) continue;
36
+ const src = collectTorpFiles(srcFolder);
37
+ const dist = collectTorpFiles(distFolder);
38
+ if (src.size === 0 && dist.size === 0) continue;
39
+
40
+ const problems: string[] = [];
41
+ for (const [rel, content] of src) {
42
+ const distContent = dist.get(rel);
43
+ if (distContent === undefined) {
44
+ problems.push(`${rel} (not copied to dist)`);
45
+ } else if (distContent !== content) {
46
+ problems.push(`${rel} (differs from src)`);
47
+ }
48
+ }
49
+ for (const rel of dist.keys()) {
50
+ if (!src.has(rel)) {
51
+ problems.push(`${rel} (left over in dist, no src file)`);
52
+ }
53
+ }
54
+ if (problems.length === 0) continue;
55
+
56
+ const shown = problems
57
+ .slice(0, 5)
58
+ .map((p) => ` ${p}`)
59
+ .join("\n");
60
+ const more = problems.length > 5 ? `\n ... and ${problems.length - 5} more` : "";
61
+ log?.(
62
+ `${packageLabel(dir, workspaceRoot)}'s dist .torp copies are stale, so the site is ` +
63
+ `serving OLD components:\n${shown}${more}\n` +
64
+ `Rebuild the package (e.g. \`pnpm build\` in ` +
65
+ `${path.relative(workspaceRoot, dir) || "."}) and restart \`tb --dev\`.`,
66
+ );
67
+ }
68
+ } catch (e) {
69
+ if (process.env.TORPOR_DEBUG_CACHE) throw e;
70
+ }
71
+ }
72
+
73
+ function collectTorpFiles(folder: string): Map<string, string> {
74
+ const files = new Map<string, string>();
75
+ collect(folder, folder, files);
76
+ return files;
77
+ }
78
+
79
+ function collect(folder: string, root: string, files: Map<string, string>): void {
80
+ for (const entry of readdirSync(folder, { withFileTypes: true })) {
81
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
82
+ const full = path.join(folder, entry.name);
83
+ if (entry.isDirectory()) {
84
+ collect(full, root, files);
85
+ continue;
86
+ }
87
+ if (!entry.isFile() || !entry.name.endsWith(".torp")) continue;
88
+ files.set(path.relative(root, full).replaceAll("\\", "/"), readFileSync(full, "utf8"));
89
+ }
90
+ }
91
+
92
+ function packageLabel(dir: string, workspaceRoot: string): string {
93
+ try {
94
+ const pkg: PackageJson = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
95
+ if (pkg.name) return pkg.name;
96
+ } catch {
97
+ // Fall through to the relative path
98
+ }
99
+ return path.relative(workspaceRoot, dir) || dir;
100
+ }
package/src/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import run from "./run/run";
2
2
  import runBuild from "./run/runBuild";
3
3
  import runDev from "./run/runDev";
4
+ import runOpenApi from "./run/runOpenApi";
4
5
  import runPreview from "./run/runPreview";
5
6
 
6
- export { run, runDev, runBuild, runPreview };
7
+ export { run, runDev, runBuild, runPreview, runOpenApi };
package/src/schema.ts ADDED
@@ -0,0 +1,7 @@
1
+ import validate from "./validation/validate";
2
+
3
+ export { validate };
4
+
5
+ export { default as ValidationError } from "./validation/ValidationError";
6
+
7
+ export type { StandardSchemaV1 } from "./types/StandardSchema";
@@ -1,4 +1,4 @@
1
- import { SerializeOptions, parse, serialize } from "cookie";
1
+ import { type SerializeOptions, parseCookie, stringifySetCookie } from "cookie";
2
2
 
3
3
  export default class CookieHelper {
4
4
  request: Request;
@@ -11,11 +11,14 @@ export default class CookieHelper {
11
11
  get(name: string): string | undefined {
12
12
  // If it's been set, return the value from the map
13
13
  if (this.cookies.has(name)) {
14
- return parse(this.cookies.get(name)!)[name];
14
+ // The stored value is a serialized Set-Cookie header; pull the
15
+ // "name=value" prefix off and re-parse to get just the value
16
+ return parseCookie(this.cookies.get(name)!)[name];
15
17
  }
18
+
16
19
  // Otherwise, return the value from the `cookie` header
17
- const header = this.request.headers.get("cookie");
18
- return parse(header ?? "")[name];
20
+ const header = this.request.headers.get("Cookie");
21
+ return parseCookie(header ?? "")[name];
19
22
  }
20
23
 
21
24
  set(name: string, value: string, options?: SerializeOptions): void {
@@ -24,13 +27,20 @@ export default class CookieHelper {
24
27
  options.httpOnly ??= true;
25
28
  options.secure ??= true;
26
29
  options.sameSite ??= "lax";
27
- this.cookies.set(name, serialize(name, value, options));
30
+
31
+ this.cookies.set(name, stringifySetCookie({ name, value, ...options }));
28
32
  }
29
33
 
30
34
  delete(name: string, options?: SerializeOptions): void {
31
- // Set a cookie header with an immediate expiry date
35
+ // Set a cookie header with an empty value and an immediate expiry date.
36
+ // Apply the same secure defaults as `set` so a previously-set Secure
37
+ // cookie is actually overridden by the deletion.
32
38
  options ??= {};
39
+ options.httpOnly ??= true;
40
+ options.secure ??= true;
41
+ options.sameSite ??= "lax";
33
42
  options.expires = new Date(0);
34
- this.cookies.set(name, serialize(name, "", options));
43
+
44
+ this.cookies.set(name, stringifySetCookie({ name, value: "", ...options }));
35
45
  }
36
46
  }
@@ -1,16 +1,16 @@
1
- import pathToRegex from "../utils/pathToRegex";
1
+ import PathTrie from "../utils/pathTrie";
2
2
  import ServerEvent from "./ServerEvent";
3
3
  import type MiddlewareFunction from "./types/MiddlewareFunction";
4
4
  import type ServerFunction from "./types/ServerFunction";
5
5
 
6
- const noop = () => {};
7
-
8
6
  /**
9
7
  * A Fetch API Request/Response server.
10
8
  */
11
9
  export default class Server {
12
10
  // TODO: We don't use any of the routes, just add("*") in runDev/runBuild...
13
11
  routes: RouteHandler[] = [];
12
+ // Match index for the routes above
13
+ #trie = new PathTrie<RouteHandler>();
14
14
  // TODO: We only use the Vite middleware here
15
15
  middleware: MiddlewareFunction[] = [];
16
16
 
@@ -25,29 +25,55 @@ export default class Server {
25
25
  * @returns
26
26
  */
27
27
  fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
28
- const request = new Request(input, init);
28
+ // Use the request as-is when we were given one, so that the adapter
29
+ // doesn't pay for constructing it twice
30
+ const request = input instanceof Request && !init ? input : new Request(input, init);
29
31
  const url = new URL(request.url);
30
32
  const match = this.match(url.pathname);
31
- let ev = new ServerEvent(request, match?.params);
33
+ let ev = new ServerEvent(request, match?.params, url);
32
34
 
33
35
  if (this.middleware.length) {
34
36
  // TODO: Get middleware that applies to this route only?
35
- await this.middleware[0](ev, buildNext(this.middleware, 0));
36
- function buildNext(middleware: MiddlewareFunction[], i: number): () => void | Promise<void> {
37
- if (ev.response) {
38
- return noop;
37
+ let entered = 0;
38
+ let response: Response | undefined = undefined;
39
+ let error: unknown = undefined;
40
+ try {
41
+ for (; entered < this.middleware.length && !response; entered++) {
42
+ // A middleware returns a Response to short-circuit the request
43
+ const result = await this.middleware[entered].enter?.(ev);
44
+ if (result) {
45
+ response = result;
46
+ }
39
47
  }
40
-
41
- if (i < middleware.length - 1) {
42
- let next = middleware[i + 1];
43
- return async () => next(ev, buildNext(middleware, i + 1));
44
- } else {
45
- return match
46
- ? async () => {
47
- ev.response = await match.fn(ev);
48
- }
49
- : noop;
48
+ if (!response && match) {
49
+ const result = await match.fn(ev);
50
+ if (result) {
51
+ response = result;
52
+ }
53
+ }
54
+ } catch (e) {
55
+ // Exits can handle the error by returning a Response; if none
56
+ // does, it is rethrown below
57
+ error = e;
58
+ ev.error = e;
59
+ } finally {
60
+ if (response) {
61
+ ev.response = response;
50
62
  }
63
+ // Run exit hooks in reverse order for the middleware that were
64
+ // entered. An exit hook can return a Response to replace the
65
+ // response, or to handle ev.error
66
+ while (entered > 0) {
67
+ entered--;
68
+ const result = await this.middleware[entered].exit?.(ev);
69
+ if (result) {
70
+ response = result;
71
+ ev.response = result;
72
+ }
73
+ }
74
+ }
75
+ if (error && !response) {
76
+ throw error;
51
77
  }
52
78
  } else if (match) {
53
79
  ev.response = await match.fn(ev);
@@ -69,20 +95,20 @@ export default class Server {
69
95
  * @returns
70
96
  */
71
97
  add(path: string, fn: ServerFunction): Server {
72
- this.routes.push(new RouteHandler(path, fn));
98
+ let handler = new RouteHandler(path, fn);
99
+ this.routes.push(handler);
100
+ this.#trie.insert(path, handler);
73
101
  this.#sortRoutes();
74
102
  return this;
75
103
  }
76
104
 
77
105
  match(path: string): Match | undefined {
78
- for (let handler of this.routes) {
79
- let match = path.match(handler.regex);
80
- if (match) {
81
- return {
82
- fn: handler.fn,
83
- params: match.groups,
84
- };
85
- }
106
+ const found = this.#trie.match(path);
107
+ if (found) {
108
+ return {
109
+ fn: found.value.fn,
110
+ params: found.params,
111
+ };
86
112
  }
87
113
  console.log(`Server path not found: ${path}`);
88
114
  }
@@ -116,12 +142,10 @@ export default class Server {
116
142
 
117
143
  class RouteHandler {
118
144
  path: string;
119
- regex: RegExp;
120
145
  fn: ServerFunction;
121
146
 
122
147
  constructor(path: string, fn: ServerFunction) {
123
148
  this.path = path;
124
- this.regex = pathToRegex(path);
125
149
  this.fn = fn;
126
150
  }
127
151
  }