@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
@@ -1,13 +1,15 @@
1
1
  import manifest from "@torpor/build/manifest";
2
2
  import { type ServerComponent, type ServerSlotRender } from "@torpor/view/ssr";
3
+ import formDataToRecord from "../form/formDataToRecord.ts";
3
4
  import notFound from "../response/notFound.ts";
4
5
  import ok from "../response/ok.ts";
5
6
  import seeOther from "../response/seeOther.ts";
6
7
  import ServerEvent from "../server/ServerEvent.ts";
7
- import $page from "../state/$page";
8
+ import $page from "../state/$serverPage.ts";
8
9
  import type PageEndPoint from "../types/PageEndPoint.ts";
9
10
  import type PageServerEndPoint from "../types/PageServerEndPoint.ts";
10
11
  import type RouteHandler from "../types/RouteHandler.ts";
12
+ import type ServerRequest from "../types/ServerRequest.ts";
11
13
  import {
12
14
  ERROR_ROUTE,
13
15
  HOOK_ROUTE,
@@ -21,6 +23,15 @@ import {
21
23
  import type ServerEndPoint from "../types/ServerEndPoint.ts";
22
24
  import type ServerHook from "../types/ServerHook.ts";
23
25
  import type ServerLoadEvent from "../types/ServerLoadEvent.ts";
26
+ import searchParamsToRecord from "../utils/searchParamsToRecord.ts";
27
+ import validate from "../validation/validate.ts";
28
+ import ValidationError from "../validation/ValidationError.ts";
29
+ import {
30
+ endpointSchema,
31
+ validateEndpointParams,
32
+ validationErrorResponse,
33
+ } from "../validation/endpoint.ts";
34
+ import { findMissingSlotLayout, warnMissingSlotContent } from "./layoutSlots.ts";
24
35
  import Router from "./Router.ts";
25
36
 
26
37
  // Build the router from the Site object created by the user
@@ -29,8 +40,8 @@ router.addPages(manifest.routes);
29
40
 
30
41
  //console.log(`routes:\n ${router.routes.map((r) => r.path).join("\n ")}`);
31
42
 
32
- export async function load(ev: ServerEvent, template: string): Promise<Response> {
33
- const url = new URL(ev.request.url);
43
+ export async function load(ev: ServerEvent, template?: string): Promise<Response> {
44
+ const url = ev.url;
34
45
  const path = url.pathname;
35
46
  const query = url.searchParams;
36
47
 
@@ -88,8 +99,8 @@ export async function load(ev: ServerEvent, template: string): Promise<Response>
88
99
  if (ev.request.method === "GET") {
89
100
  return await loadData(ev, url, handler, "load", params);
90
101
  } else if (ev.request.method === "POST") {
91
- const serverEndPoint: PageServerEndPoint | undefined =
92
- handler.endPoint && (await handler.endPoint()).default;
102
+ const serverEndPoint: PageServerEndPoint | undefined = (await resolveModule(handler))
103
+ .default;
93
104
  return handleResponse(
94
105
  await runAction(ev, url, handler, serverEndPoint, params, query, template),
95
106
  true,
@@ -114,6 +125,22 @@ export async function load(ev: ServerEvent, template: string): Promise<Response>
114
125
  return notFound();
115
126
  }
116
127
 
128
+ /**
129
+ * Resolves the route's endpoint module, caching it on the handler so warm
130
+ * requests don't pay for the loader promise.
131
+ */
132
+ async function resolveModule(handler: RouteHandler): Promise<any> {
133
+ return (handler.resolvedModule ??= await (handler.modulePromise ??= handler.endPoint()));
134
+ }
135
+
136
+ async function loadServerHooks(handler: RouteHandler): Promise<ServerHook[]> {
137
+ if (!handler.serverHooks) return [];
138
+ // Resolve each hook module once and cache the result on the handler
139
+ return (handler.resolvedHooks ??= Promise.all(
140
+ handler.serverHooks.map(async (load) => (await load()).default),
141
+ ));
142
+ }
143
+
117
144
  async function handleResponse(response: Response, fromForm = false): Promise<Response> {
118
145
  $page.status = response.status;
119
146
 
@@ -155,151 +182,272 @@ async function loadData(
155
182
  url: URL,
156
183
  handler: RouteHandler,
157
184
  functionName: string,
158
- params: Record<string, string>,
185
+ params: Record<string, any>,
159
186
  ) {
160
- const serverEndPoint: ServerEndPoint | undefined = (await handler.endPoint()).default;
161
- if (serverEndPoint && serverEndPoint[functionName]) {
162
- const serverParams = buildServerParams(ev, url, params);
163
-
164
- if (handler.serverHook) {
165
- const serverHook: ServerHook | undefined = (await handler.serverHook()).default;
166
- if (serverHook?.handle) {
167
- await serverHook.handle(serverParams);
168
- }
187
+ // The endpoint module is cached on the handler, so once warm this is a
188
+ // plain property read
189
+ const mod = handler.resolvedModule ?? (await resolveModule(handler));
190
+ const serverEndPoint: ServerEndPoint | undefined = mod?.default;
191
+ // `functionName` is computed from the request method, so the handlers are
192
+ // accessed through a loose record
193
+ const handlerFn = (
194
+ serverEndPoint as unknown as Record<string, ServerRequest | undefined> | undefined
195
+ )?.[functionName];
196
+ if (!serverEndPoint || !handlerFn) {
197
+ return notFound();
198
+ }
199
+
200
+ // Route params come from the URL, so a failed validation means the
201
+ // resource doesn't exist. Only endpoints declaring a params schema pay
202
+ // for validation
203
+ const paramsSchema = endpointSchema(serverEndPoint, "params");
204
+ if (paramsSchema) {
205
+ try {
206
+ params = (await validate(paramsSchema, params)) as Record<string, any>;
207
+ } catch (error) {
208
+ if (error instanceof ValidationError) return notFound();
209
+ throw error;
169
210
  }
211
+ }
170
212
 
171
- const result = await serverEndPoint[functionName](serverParams);
213
+ try {
214
+ // If the endpoint declares a schema for this handler, validate its
215
+ // input up front and reject with 422 if it fails. get/head/load
216
+ // schemas validate the query string; other schemas validate the json
217
+ // body. Endpoints without a schema skip straight to the handler
218
+ const schema = endpointSchema(serverEndPoint, functionName);
219
+ const values = schema
220
+ ? functionName === "get" || functionName === "head" || functionName === "load"
221
+ ? { query: await validate(schema, searchParamsToRecord(url.searchParams)) }
222
+ : { json: await validate(schema, await ev.json()) }
223
+ : {};
224
+
225
+ const serverParams = buildServerParams(ev, url, params, values);
226
+
227
+ // Hooks are resolved once per handler; routes without hooks don't
228
+ // await anything here
229
+ const hooks = handler.serverHooks ? await loadServerHooks(handler) : [];
230
+
231
+ let entered = 0;
232
+ let enterResponse: Response | undefined = undefined;
233
+ try {
234
+ // Hooks run from the root down; a hook's enter can return a
235
+ // Response to short-circuit the request
236
+ for (; entered < hooks.length && !enterResponse; entered++) {
237
+ const hookResult = await hooks[entered].enter?.(serverParams);
238
+ if (hookResult) {
239
+ enterResponse = hookResult;
240
+ }
241
+ }
172
242
 
173
- // If there was no response returned from load (such as errors or a
174
- // redirect), send an ok response
175
- return result || ok();
243
+ // If there was no response returned from load (such as errors or a
244
+ // redirect), send an ok response
245
+ return (await handlerFn(serverParams)) || ok();
246
+ } finally {
247
+ // Run exit hooks in reverse order for hooks that were entered
248
+ while (entered > 0) {
249
+ entered--;
250
+ await hooks[entered].exit?.(serverParams);
251
+ }
252
+ }
253
+ } catch (error) {
254
+ const response = validationErrorResponse(error);
255
+ if (response) return response;
256
+ throw error;
176
257
  }
177
-
178
- return notFound();
179
258
  }
180
259
 
181
260
  async function loadView(
182
261
  ev: ServerEvent,
183
262
  url: URL,
184
263
  handler: RouteHandler,
185
- params: Record<string, string>,
186
- template: string,
264
+ params: Record<string, any>,
265
+ template: string | undefined,
187
266
  formStatus?: number,
188
267
  form?: Record<string, string | number>,
268
+ skipHook?: boolean,
189
269
  ) {
190
270
  // There must be a client endpoint with a component
191
- const clientEndPoint: PageEndPoint | undefined = (await handler.endPoint()).default;
271
+ const clientEndPoint: PageEndPoint | undefined = (await resolveModule(handler)).default;
192
272
  if (!clientEndPoint?.component) {
193
273
  return notFound();
194
274
  }
195
275
 
276
+ // The template comes from site.html, which endpoints-only sites don't have
277
+ if (!template) {
278
+ throw new Error(
279
+ "Page routes require a src/site.html file, which was not found; add it, or remove page/layout routes to serve endpoints only",
280
+ );
281
+ }
282
+
196
283
  // There may be a server endpoint
197
284
  const serverEndPoint: PageServerEndPoint | undefined =
198
285
  handler.serverEndPoint && (await handler.serverEndPoint())?.default;
199
286
 
200
- // Maybe hit the server hook
201
- const serverParams = buildServerParams(ev, url, params);
202
- if (handler.serverHook) {
203
- const serverHook: ServerHook | undefined = (await handler.serverHook()).default;
204
- if (serverHook?.handle) {
205
- await serverHook.handle(serverParams);
287
+ // Validate the route params and the load query against the server
288
+ // endpoint's schemas, if it declares any. Params come from the URL, so a
289
+ // failed validation means the resource doesn't exist; a failed query goes
290
+ // to the error page like any other load failure
291
+ let query: unknown;
292
+ if (serverEndPoint) {
293
+ try {
294
+ params = await validateEndpointParams(serverEndPoint, params);
295
+ } catch (error) {
296
+ if (error instanceof ValidationError) return notFound();
297
+ throw error;
298
+ }
299
+ const loadSchema = endpointSchema(serverEndPoint, "load");
300
+ if (loadSchema) {
301
+ try {
302
+ query = await validate(loadSchema, searchParamsToRecord(url.searchParams));
303
+ } catch (error) {
304
+ const response = validationErrorResponse(error);
305
+ if (response) return response;
306
+ throw error;
307
+ }
206
308
  }
207
309
  }
208
310
 
209
- // Pass the data into $props
210
- // TODO: Promise.all
211
- // NOTE: We're loading data from top to bottom, overriding as we go, and I'm
212
- // not sure if this is the best way to go
213
- let data = {};
214
- if (handler.layouts) {
215
- for (let layout of handler.layouts) {
216
- const layoutEndPoint: PageEndPoint | undefined = (await layout.endPoint())?.default;
217
- const layoutServerEndPoint: PageServerEndPoint | undefined =
218
- layout.serverEndPoint && (await layout.serverEndPoint())?.default;
219
- const layoutResponse = await loadClientAndServerData(
220
- url,
221
- params,
222
- serverParams,
223
- data,
224
- layoutEndPoint,
225
- layoutServerEndPoint,
226
- );
227
- if (layoutResponse?.ok === false) {
228
- return layoutResponse;
311
+ // Maybe hit the server hooks -- unless they already ran for this request,
312
+ // which is the case when the view is re-rendered after a form action
313
+ const serverParams = buildServerParams(ev, url, params, query ? { query } : {});
314
+ const serverHooks = skipHook ? [] : await loadServerHooks(handler);
315
+ let entered = 0;
316
+ let enterResponse: Response | undefined = undefined;
317
+ try {
318
+ // Hooks run from the root down; a hook's enter can return a Response
319
+ // to short-circuit the request
320
+ for (; entered < serverHooks.length && !enterResponse; entered++) {
321
+ const hookResult = await serverHooks[entered].enter?.(serverParams);
322
+ if (hookResult) {
323
+ enterResponse = hookResult;
229
324
  }
230
325
  }
231
- }
232
- let endPointResponse = await loadClientAndServerData(
233
- url,
234
- params,
235
- serverParams,
236
- data,
237
- clientEndPoint,
238
- serverEndPoint,
239
- );
240
- if (endPointResponse?.ok === false) {
241
- return endPointResponse;
242
- }
243
- let $props: Record<string, any> = { data, form };
244
-
245
- let styles = "";
246
-
247
- // If there are layouts, work our way upwards, pushing each component into
248
- // the default slot of its parent
249
- // TODO: Also handle layout server data
250
- // TODO: There's probably a nicer way to do this with reducers or something
251
- let component = clientEndPoint.component as ServerComponent;
252
- let slots: Record<string, ServerSlotRender> | undefined = undefined;
253
- if (handler.layouts) {
254
- let slotFunctions: ServerSlotRender[] = [];
255
- slotFunctions[handler.layouts.length] = (_, $context) => {
256
- let { body, head } = (clientEndPoint.component as ServerComponent)($props, $context);
257
- styles += head;
258
- return body;
326
+ if (enterResponse) {
327
+ return enterResponse;
328
+ }
329
+
330
+ // Pass the data into $props
331
+ // TODO: Promise.all
332
+ // NOTE: We're loading data from top to bottom, overriding as we go, and I'm
333
+ // not sure if this is the best way to go
334
+ let data = {};
335
+ if (handler.layouts) {
336
+ for (let layout of handler.layouts) {
337
+ const layoutEndPoint: PageEndPoint | undefined = (await layout.endPoint())?.default;
338
+ const layoutServerEndPoint: PageServerEndPoint | undefined =
339
+ layout.serverEndPoint && (await layout.serverEndPoint())?.default;
340
+ const layoutResponse = await loadClientAndServerData(
341
+ url,
342
+ params,
343
+ serverParams,
344
+ data,
345
+ layoutEndPoint,
346
+ layoutServerEndPoint,
347
+ );
348
+ if (layoutResponse?.ok === false) {
349
+ return layoutResponse;
350
+ }
351
+ }
352
+ }
353
+ let endPointResponse = await loadClientAndServerData(
354
+ url,
355
+ params,
356
+ serverParams,
357
+ data,
358
+ clientEndPoint,
359
+ serverEndPoint,
360
+ );
361
+ if (endPointResponse?.ok === false) {
362
+ return endPointResponse;
363
+ }
364
+ let $props: Record<string, any> = { data, form };
365
+
366
+ let styles = "";
367
+
368
+ // If there are layouts, work our way upwards, pushing each component into
369
+ // the default slot of its parent
370
+ // TODO: Also handle layout server data
371
+ // TODO: There's probably a nicer way to do this with reducers or something
372
+ let component = clientEndPoint.component as ServerComponent;
373
+ let slots: Record<string, ServerSlotRender> | undefined = undefined;
374
+ // Which slot levels actually ran during the render below, so a layout
375
+ // that never rendered its <slot /> (silently dropping the page
376
+ // content) can be reported (see findMissingSlotLayout)
377
+ const invokedLevels = new Set<number>();
378
+ const trackSlot = (level: number, slot: ServerSlotRender): ServerSlotRender => {
379
+ return ($slot, $context) => {
380
+ invokedLevels.add(level);
381
+ return slot($slot, $context);
382
+ };
259
383
  };
260
- for (let i = handler.layouts.length - 1; i >= 0; i--) {
261
- const layoutEndPoint: PageEndPoint | undefined = (await handler.layouts[i].endPoint())
262
- ?.default;
263
- if (layoutEndPoint?.component) {
264
- if (i === 0) {
265
- component = layoutEndPoint.component as ServerComponent;
266
- slots = { _: slotFunctions[i + 1] };
267
- } else {
268
- slotFunctions[i] = (_, $context) => {
269
- let { body, head } = (layoutEndPoint.component as ServerComponent)($props, $context, {
270
- _: slotFunctions[i + 1],
384
+ if (handler.layouts) {
385
+ let slotFunctions: ServerSlotRender[] = [];
386
+ // The last slot function will render the client component
387
+ slotFunctions[handler.layouts.length] = trackSlot(handler.layouts.length, (_, $context) => {
388
+ let { body, head } = (clientEndPoint.component as ServerComponent)($props, $context);
389
+ styles += head;
390
+ return body;
391
+ });
392
+ for (let i = handler.layouts.length - 1; i >= 0; i--) {
393
+ const layoutEndPoint: PageEndPoint | undefined = (await handler.layouts[i].endPoint())
394
+ ?.default;
395
+ if (layoutEndPoint?.component) {
396
+ if (i === 0) {
397
+ component = layoutEndPoint.component as ServerComponent;
398
+ slots = { _: slotFunctions[i + 1] };
399
+ } else {
400
+ slotFunctions[i] = trackSlot(i, (_, $context) => {
401
+ let { body, head } = (layoutEndPoint.component as ServerComponent)($props, $context, {
402
+ _: slotFunctions[i + 1],
403
+ });
404
+ styles += head;
405
+ return body;
271
406
  });
272
- styles += head;
273
- return body;
274
- };
407
+ }
275
408
  }
276
409
  }
277
410
  }
278
- }
279
411
 
280
- let html;
281
- try {
282
- let { body, head } = component($props, undefined, slots);
412
+ let html;
413
+ try {
414
+ let { body, head } = component($props, undefined, slots);
283
415
 
284
- // Put the form info in a hidden input so that it can be accessed on the client
285
- if (form) {
286
- body += `\n<input type="hidden" id="t-form-data" value='${JSON.stringify(form).replaceAll("'", "\\'")}' />`;
416
+ // Put the form info in a hidden input so that it can be accessed on the client
417
+ if (form) {
418
+ body += `\n<input type="hidden" id="t-form-data" value='${JSON.stringify(form).replaceAll("'", "\\'")}' />`;
419
+ }
420
+
421
+ styles += head;
422
+ html = template.replace("%COMPONENT_BODY%", body).replace("%COMPONENT_HEAD%", styles);
423
+ } catch (error) {
424
+ // TODO: Show a proper Error component
425
+ html = '<span style="color: red">Script syntax error</span><p>' + error + "</p>";
426
+ console.log(error);
287
427
  }
288
428
 
289
- styles += head;
290
- html = template.replace("%COMPONENT_BODY%", body).replace("%COMPONENT_HEAD%", styles);
291
- } catch (error) {
292
- // TODO: Show a proper Error component
293
- html = '<span style="color: red">Script syntax error</span><p>' + error + "</p>";
294
- console.log(error);
295
- }
429
+ // A layout that never rendered <slot /> silently drops the whole page
430
+ // content from the output; find and report the outermost one
431
+ if (handler.layouts) {
432
+ const missingLayout = findMissingSlotLayout(handler.layouts, invokedLevels);
433
+ if (missingLayout !== undefined) {
434
+ warnMissingSlotContent(missingLayout);
435
+ }
436
+ }
296
437
 
297
- return new Response(html, {
298
- status: formStatus ?? 200,
299
- headers: {
300
- "Content-Type": "text/html",
301
- },
302
- });
438
+ return new Response(html, {
439
+ status: formStatus ?? 200,
440
+ headers: {
441
+ "Content-Type": "text/html",
442
+ },
443
+ });
444
+ } finally {
445
+ // Run exit hooks in reverse order for hooks that were entered
446
+ while (entered > 0) {
447
+ entered--;
448
+ await serverHooks[entered].exit?.(serverParams);
449
+ }
450
+ }
303
451
  }
304
452
 
305
453
  async function runAction(
@@ -307,26 +455,72 @@ async function runAction(
307
455
  url: URL,
308
456
  handler: RouteHandler,
309
457
  serverEndPoint: PageServerEndPoint | undefined,
310
- params: Record<string, string>,
458
+ params: Record<string, any>,
311
459
  query: URLSearchParams,
312
- template: string,
460
+ template: string | undefined,
313
461
  ) {
314
462
  const actionName = (Array.from(query.keys())[0] || "default").replace(/^\//, "");
315
463
  if (serverEndPoint?.actions) {
316
464
  const action = serverEndPoint.actions[actionName];
317
465
  if (action) {
318
- const serverParams = buildServerParams(ev, url, params);
466
+ if (actionName === "load" || actionName === "params") {
467
+ // These names are reserved in the endpoint's schema map: `load`
468
+ // validates the query string and `params` the route params, so
469
+ // an action with one of these names would get the wrong schema
470
+ // applied to its form data
471
+ throw new Error(
472
+ `The action name "${actionName}" is reserved. ` +
473
+ `Rename the action (and its schema key, if any), e.g. to "save".`,
474
+ );
475
+ }
476
+ // Route params come from the URL, so a failed validation means the
477
+ // resource doesn't exist
478
+ try {
479
+ params = await validateEndpointParams(serverEndPoint, params);
480
+ } catch (error) {
481
+ if (error instanceof ValidationError) return notFound();
482
+ throw error;
483
+ }
319
484
 
320
- if (handler.serverHook) {
321
- const serverHook: ServerHook | undefined = (await handler.serverHook()).default;
322
- if (serverHook?.handle) {
323
- await serverHook.handle(serverParams);
485
+ let result: Response | undefined | void;
486
+ try {
487
+ // If the endpoint declares a schema for this action, validate the
488
+ // submitted form data up front and reject with 422 if it fails
489
+ const schema = endpointSchema(serverEndPoint, actionName);
490
+ const form = schema
491
+ ? await validate(schema, await formDataToRecord(ev.request))
492
+ : undefined;
493
+
494
+ const serverParams = buildServerParams(ev, url, params, form ? { form } : {});
495
+
496
+ const serverHooks = await loadServerHooks(handler);
497
+ let entered = 0;
498
+ let enterResponse: Response | undefined = undefined;
499
+ try {
500
+ // Hooks run from the root down; a hook's enter can return a
501
+ // Response to short-circuit the action
502
+ for (; entered < serverHooks.length && !enterResponse; entered++) {
503
+ const hookResult = await serverHooks[entered].enter?.(serverParams);
504
+ if (hookResult) {
505
+ enterResponse = hookResult;
506
+ }
507
+ }
508
+
509
+ result = enterResponse || (await action(serverParams));
510
+ } finally {
511
+ // Run exit hooks in reverse order for hooks that were entered
512
+ while (entered > 0) {
513
+ entered--;
514
+ await serverHooks[entered].exit?.(serverParams);
515
+ }
324
516
  }
517
+ } catch (error) {
518
+ const response = validationErrorResponse(error);
519
+ if (response) return response;
520
+ throw error;
325
521
  }
326
522
 
327
- let result = await action(serverParams);
328
-
329
- if (ev.request.headers.has("x-torpor-form-submit")) {
523
+ if (ev.request.headers.has("X-Torpor-Form-Submit")) {
330
524
  // If the form was submitted from javascript, just return the
331
525
  // response and it will be handled on the client
332
526
  // TODO: Should we make the user return a response??
@@ -334,10 +528,10 @@ async function runAction(
334
528
 
335
529
  // If it's a redirect, we need to stop the browser handling it
336
530
  if (result.status >= 300 && result.status <= 399) {
337
- const location = result.headers.get("location") ?? "";
531
+ const location = result.headers.get("Location") ?? "";
338
532
  result = ok();
339
- result.headers.set("x-torpor-form-redirect", "");
340
- result.headers.set("location", location);
533
+ result.headers.set("X-Torpor-Form-Redirect", "");
534
+ result.headers.set("Location", location);
341
535
  }
342
536
 
343
537
  return result;
@@ -348,9 +542,11 @@ async function runAction(
348
542
  ) {
349
543
  // If the form was submitted without javascript, and there was
350
544
  // no result, an ok result, or a 4xx error, re-render the view
351
- // with the form result
352
- // TODO: do not re-run hooks?
353
- const newUrl = new URL(url.pathname, url);
545
+ // with the form result. The server hook already ran around the
546
+ // action, so it is not run again here
547
+ // Keep the query string, so that loads validating the query see
548
+ // the same values as the original GET
549
+ const newUrl = new URL(url.pathname + url.search, url);
354
550
  const formStatus = result?.status;
355
551
  let formMessage = await result?.text();
356
552
  if (formMessage && result?.headers.get("Content-Type")?.includes("application/json")) {
@@ -366,7 +562,7 @@ async function runAction(
366
562
  // object with whatever info you need
367
563
  $page.form!.status ??= formStatus ?? 0;
368
564
  $page.form!.message ??= formMessage ?? "";
369
- return await loadView(ev, newUrl, handler, params, template, formStatus, $page.form);
565
+ return await loadView(ev, newUrl, handler, params, template, formStatus, $page.form, true);
370
566
  } else {
371
567
  // If the form was submitted without javascript and there was a
372
568
  // redirect or server error, just return the result to handle it
@@ -381,7 +577,7 @@ async function runAction(
381
577
 
382
578
  async function loadClientAndServerData(
383
579
  url: URL,
384
- params: Record<string, string>,
580
+ params: Record<string, any>,
385
581
  serverParams: ServerLoadEvent,
386
582
  data: Record<string, any>,
387
583
  clientEndPoint?: PageEndPoint,
@@ -414,7 +610,7 @@ async function loadClientAndServerData(
414
610
  }
415
611
  }
416
612
 
417
- function buildClientParams(url: URL, params: Record<string, string>, data: Record<string, any>) {
613
+ function buildClientParams(url: URL, params: Record<string, any>, data: Record<string, any>) {
418
614
  return {
419
615
  url,
420
616
  params,
@@ -425,13 +621,26 @@ function buildClientParams(url: URL, params: Record<string, string>, data: Recor
425
621
  function buildServerParams(
426
622
  ev: ServerEvent,
427
623
  url: URL,
428
- params: Record<string, string>,
624
+ params: Record<string, any>,
625
+ values: { json?: unknown; form?: unknown; query?: unknown } = {},
429
626
  ): ServerLoadEvent {
430
627
  return {
431
628
  url,
432
629
  params,
433
630
  appData: {},
434
631
  request: ev.request,
632
+ // The inputs may have been validated already, in which case the request
633
+ // body has been consumed and the parsed values are returned instead.
634
+ // Their types come from the endpoint's schemas, so they are loosely
635
+ // typed here
636
+ json: (): Promise<any> =>
637
+ values.json !== undefined ? Promise.resolve(values.json) : ev.json(),
638
+ form: (): Promise<any> =>
639
+ values.form !== undefined ? Promise.resolve(values.form) : formDataToRecord(ev.request),
640
+ query: (): Promise<any> =>
641
+ values.query !== undefined
642
+ ? Promise.resolve(values.query)
643
+ : Promise.resolve(searchParamsToRecord(url.searchParams)),
435
644
  cookies: ev.cookies,
436
645
  headers: ev.headers,
437
646
  adapter: ev.adapter,
@@ -6,7 +6,8 @@ const PAGE_SYMBOL: unique symbol = Symbol.for("torp.Page");
6
6
  const $page: PageState =
7
7
  // @ts-ignore
8
8
  (globalThis[PAGE_SYMBOL] ??=
9
- // TODO: Should use the SSR $watch if entering via serverEntry
9
+ // The server request path uses `state/$serverPage`, which stores the
10
+ // same state (same `globalThis` slot) without a reactive proxy
10
11
  $watch(
11
12
  {
12
13
  status: 404,
@@ -0,0 +1,22 @@
1
+ import { $watch } from "@torpor/view/ssr";
2
+ import type PageState from "../types/PageState";
3
+
4
+ const PAGE_SYMBOL: unique symbol = Symbol.for("torp.Page");
5
+
6
+ // The server request path has no reactive subscribers, so the state is
7
+ // stored plainly ($watch from `@torpor/view/ssr` is a no-op that returns
8
+ // the object itself) instead of through a reactive proxy. Shares the same
9
+ // `globalThis` slot as the client `$page`, so both variants always see the
10
+ // same state object whichever loads first.
11
+ const $page: PageState =
12
+ // @ts-ignore
13
+ (globalThis[PAGE_SYMBOL] ??= $watch({
14
+ status: 404,
15
+ url: new URL("http://localhost"),
16
+ form: undefined,
17
+ error: {
18
+ message: "",
19
+ },
20
+ }));
21
+
22
+ export default $page;