@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,6 +1,6 @@
1
1
  import { clearLayoutSlot, fillLayoutSlot, hydrate } from "@torpor/view";
2
2
  import { type Component, type SlotRender } from "@torpor/view";
3
- import { mount } from "@torpor/view";
3
+ import { mount, unmount } from "@torpor/view";
4
4
  import $page from "../state/$page";
5
5
  import client from "../state/client";
6
6
  import type LayoutPath from "../types/LayoutPath";
@@ -98,6 +98,13 @@ export default async function navigate(url: URL, withHydration = false): Promise
98
98
  // TODO: There's probably a nicer way to do this with reducers or something
99
99
  let component = clientEndPoint.component as Component;
100
100
  let slots: Record<string, SlotRender> | undefined = undefined;
101
+ let reused = false;
102
+ // The index of the innermost reused layout. Its slotRegion is the region
103
+ // that must be cleared and refilled (it contains the next layout's or the
104
+ // page's content). This is usually the last entry of the stack, but not
105
+ // when an outer layout is reused while an inner one is new — e.g.
106
+ // navigating between sections that share the root layout.
107
+ let reusedIndex = -1;
101
108
  if (handler.layouts) {
102
109
  let slotFunctions: SlotRender[] = [];
103
110
  // The last slot function will render the client component
@@ -119,12 +126,12 @@ export default async function navigate(url: URL, withHydration = false): Promise
119
126
  ?.default;
120
127
  if (layoutEndPoint?.component) {
121
128
  if (layoutStack[i].reuse) {
122
- // Set the parent to add the new content to (from the old
123
- // content), clear the range under this point, and set the
124
- // component to the slot function within this layout
125
- parent = layoutStack[i].slotRegion.startNode.parentNode as HTMLElement;
126
- clearLayoutSlot(layoutStack[i].slotRegion);
127
- component = slotFunctions[i + 1];
129
+ // Reuse this layout — clear and refill its slot (done in
130
+ // the try block below so a failure doesn't leave the slot
131
+ // half-cleared)
132
+ component = slotFunctions[i + 1] as Component;
133
+ reusedIndex = i;
134
+ reused = true;
128
135
  break;
129
136
  } else if (i === 0) {
130
137
  component = layoutEndPoint.component as Component;
@@ -145,16 +152,34 @@ export default async function navigate(url: URL, withHydration = false): Promise
145
152
  }
146
153
  }
147
154
 
148
- if (withHydration) {
149
- hydrate(parent, component, $props, slots);
150
- } else {
151
- try {
155
+ try {
156
+ if (reused) {
157
+ // The layout is being reused — clear the old slot content, then
158
+ // call the slot function directly to fill it with the new page.
159
+ // We must not go through `mount` here: the slot's container still
160
+ // holds the layout's own children (e.g. a header), which `mount`
161
+ // refuses to mount into. Both the clear and the fill are inside
162
+ // the try so that a failure doesn't leave the slot half-cleared.
163
+ const slotRegion = layoutStack[reusedIndex].slotRegion;
164
+ parent = slotRegion.startNode!.parentNode as HTMLElement;
165
+ clearLayoutSlot(slotRegion);
166
+ component(parent, null);
167
+ } else if (withHydration) {
168
+ hydrate(parent, component, $props, slots);
169
+ } else {
170
+ // The layout chain changed (or there was no previous layout to
171
+ // reuse): tear down the previous UI entirely — disposing its
172
+ // region tree and clearing `#app` — so `mount` starts fresh.
173
+ // Without this, `mount` throws because `#app` still holds the
174
+ // previous render's children, and it would reuse a stale root
175
+ // region.
176
+ unmount(parent);
152
177
  mount(parent, component, $props, slots);
153
- } catch (error) {
154
- // TODO: Show a proper Error component
155
- parent.innerHTML = '<span style="color: red">Script syntax error</span><p>' + error + "</p>";
156
- console.log(error);
157
178
  }
179
+ } catch (error) {
180
+ // TODO: Show a proper Error component
181
+ parent.innerHTML = '<span style="color: red">Script syntax error</span><p>' + error + "</p>";
182
+ console.log(error);
158
183
  }
159
184
 
160
185
  // Reset prefetched data on each navigation
@@ -0,0 +1,38 @@
1
+ import type { ExactRouteParams, ParseRouteParams } from "../types/ParseRouteParams";
2
+
3
+ /**
4
+ * Builds a route path in a type-safe manner, filling in dynamic segments, e.g.
5
+ * `route("/posts/[id]", { id: 5 })` returns `/posts/5`. The params object is
6
+ * checked for exact keys: missing or unknown params error at compile time.
7
+ *
8
+ * NOTE: The rest args conditional must stay INLINE — routing it through a type
9
+ * alias defeats `Params` inference, and excess keys stop being checked
10
+ * @param path The route path
11
+ * @param params The route params, required if the path has dynamic segments
12
+ * @returns The path with the params filled in, URI-encoded
13
+ */
14
+ export default function route<
15
+ Route extends string,
16
+ Params extends ParseRouteParams<Route> = ParseRouteParams<Route>,
17
+ >(
18
+ path: Route,
19
+ ...args: string extends Route
20
+ ? [params?: Record<string, string>]
21
+ : keyof ParseRouteParams<Route> extends never
22
+ ? []
23
+ : [params: Params & ExactRouteParams<Params, ParseRouteParams<Route>>]
24
+ ): string {
25
+ const params = ((args as unknown[])[0] ?? {}) as Record<string, string>;
26
+ return path.replace(/\[(\.\.\.)?([^\]]+)\]/g, (_, splat: string | undefined, name: string) => {
27
+ const value = params[name];
28
+ if (value === undefined) {
29
+ throw new Error(`Missing param '${name}' for route '${path}'`);
30
+ }
31
+ return splat
32
+ ? value
33
+ .split("/")
34
+ .map((segment) => encodeURIComponent(segment))
35
+ .join("/")
36
+ : encodeURIComponent(String(value));
37
+ });
38
+ }
package/src/nav.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import makeApi from "./nav/api";
1
2
  import load from "./nav/load";
2
3
  import reload from "./nav/reload";
4
+ import route from "./nav/route";
3
5
 
4
- export { load, reload };
6
+ export { load, reload, route, makeApi };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A standalone HTML page loading Swagger UI from a CDN, pointed at the
3
+ * OpenAPI document endpoint.
4
+ */
5
+ export default function openApiDocsHtml(docPath: string): string {
6
+ return `<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <title>API Docs</title>
12
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
13
+ </head>
14
+ <body>
15
+ <div id="swagger-ui"></div>
16
+ <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
17
+ <script>
18
+ window.addEventListener("DOMContentLoaded", () => {
19
+ SwaggerUIBundle({ url: ${JSON.stringify(docPath)}, dom_id: "#swagger-ui" });
20
+ });
21
+ </script>
22
+ </body>
23
+ </html>
24
+ `;
25
+ }
@@ -0,0 +1,154 @@
1
+ import type { StandardSchemaV1 } from "../types/StandardSchema";
2
+ import type { OpenApiPluginOptions, OpenApiRouteEntry, JsonSchema } from "./types";
3
+
4
+ // The endpoint handler names, in the order they appear in docs
5
+ const METHODS = ["get", "post", "patch", "put", "del", "options", "head"] as const;
6
+ type Method = (typeof METHODS)[number];
7
+
8
+ // Handlers whose schema validates the query string; all others validate the
9
+ // json request body
10
+ const QUERY_METHODS: readonly string[] = ["get", "head"];
11
+ const BODY_METHODS: readonly string[] = ["post", "patch", "put", "del", "options"];
12
+
13
+ /**
14
+ * Builds an OpenAPI 3.1 document from a list of +server endpoints.
15
+ *
16
+ * Each endpoint's route path (`/api/posts/[id]`) is converted to OpenAPI
17
+ * syntax (`/api/posts/{id}`); its `params` schema becomes path parameters,
18
+ * its `get`/`head` schema becomes query parameters, and its `post`/`patch`/
19
+ * `put`/`del`/`options` schema becomes a json request body. Responses are
20
+ * stubbed: a `200` for every handler, plus a `422` when the handler declares
21
+ * an input schema (validation errors are returned automatically).
22
+ *
23
+ * Throws if any endpoint declares a schema but the options have no
24
+ * `toJsonSchema` converter.
25
+ */
26
+ export default function buildOpenApiDocument(
27
+ entries: OpenApiRouteEntry[],
28
+ options: OpenApiPluginOptions,
29
+ ): Record<string, unknown> {
30
+ if (!options.toJsonSchema) {
31
+ const withSchema = entries.find(hasSchemas);
32
+ if (withSchema) {
33
+ throw new Error(
34
+ `The endpoint at ${withSchema.path} declares schema(s), but the openApi() plugin ` +
35
+ `was not given a "toJsonSchema" converter. Pass one, e.g. ` +
36
+ `openApi({ toJsonSchema: (s) => z.toJSONSchema(s) })`,
37
+ );
38
+ }
39
+ }
40
+
41
+ const paths: Record<string, Record<string, unknown>> = {};
42
+ for (const entry of entries) {
43
+ const pathItem = buildPathItem(entry, options);
44
+ if (pathItem) {
45
+ paths[openApiPath(entry.path)] = pathItem;
46
+ }
47
+ }
48
+
49
+ return {
50
+ openapi: "3.1.0",
51
+ info: {
52
+ title: options.title ?? "API",
53
+ version: options.version ?? "1.0.0",
54
+ },
55
+ paths,
56
+ };
57
+ }
58
+
59
+ function hasSchemas(entry: OpenApiRouteEntry): boolean {
60
+ const schema = entry.endPoint?.schema;
61
+ if (!schema) return false;
62
+ return METHODS.some((method) => schema[method]) || !!schema.params;
63
+ }
64
+
65
+ function buildPathItem(
66
+ entry: OpenApiRouteEntry,
67
+ options: OpenApiPluginOptions,
68
+ ): Record<string, unknown> | undefined {
69
+ const endPoint = entry.endPoint;
70
+ const pathItem: Record<string, unknown> = {};
71
+ let count = 0;
72
+
73
+ for (const method of METHODS) {
74
+ if (typeof endPoint[method] !== "function") {
75
+ continue;
76
+ }
77
+ count++;
78
+
79
+ const schema = endPoint.schema?.[method];
80
+ const paramsSchema = endPoint.schema?.params;
81
+
82
+ const parameters: Record<string, unknown>[] = [];
83
+ if (paramsSchema) {
84
+ parameters.push(...flattenObject(convert(paramsSchema, options), "path"));
85
+ }
86
+ if (schema && QUERY_METHODS.includes(method)) {
87
+ parameters.push(...flattenObject(convert(schema, options), "query"));
88
+ }
89
+
90
+ const responses: Record<string, unknown> = { "200": { description: "OK" } };
91
+ if (schema) {
92
+ responses["422"] = { description: "Validation failed" };
93
+ }
94
+
95
+ pathItem[method === "del" ? "delete" : method] = {
96
+ operationId: operationId(method, entry.path),
97
+ ...(parameters.length ? { parameters } : {}),
98
+ ...(schema && BODY_METHODS.includes(method)
99
+ ? {
100
+ requestBody: {
101
+ required: true,
102
+ content: { "application/json": { schema: convert(schema, options) } },
103
+ },
104
+ }
105
+ : {}),
106
+ responses,
107
+ };
108
+ }
109
+
110
+ return count > 0 ? pathItem : undefined;
111
+ }
112
+
113
+ function convert(schema: StandardSchemaV1, options: OpenApiPluginOptions): JsonSchema {
114
+ return options.toJsonSchema!(schema);
115
+ }
116
+
117
+ /**
118
+ * Flattens a converted object schema into OpenAPI parameters. Path
119
+ * parameters are always required; query parameters take their `required`
120
+ * flag from the schema's `required` array.
121
+ */
122
+ function flattenObject(
123
+ jsonSchema: JsonSchema,
124
+ location: "path" | "query",
125
+ ): Record<string, unknown>[] {
126
+ const properties = jsonSchema.properties;
127
+ if (!properties || typeof properties !== "object") {
128
+ return [];
129
+ }
130
+ const required = Array.isArray(jsonSchema.required) ? jsonSchema.required : [];
131
+ return Object.entries(properties as Record<string, JsonSchema>).map(([name, propSchema]) => ({
132
+ name,
133
+ in: location,
134
+ required: location === "path" ? true : required.includes(name),
135
+ schema: propSchema,
136
+ }));
137
+ }
138
+
139
+ /**
140
+ * Converts a route path to OpenAPI syntax: `/api/posts/[id]` becomes
141
+ * `/api/posts/{id}`.
142
+ */
143
+ function openApiPath(routePath: string): string {
144
+ return routePath.replace(/\[\.\.\.([^\]]+)\]/g, "{$1}").replace(/\[([^\]]+)\]/g, "{$1}");
145
+ }
146
+
147
+ function operationId(method: Method, routePath: string): string {
148
+ const segments = openApiPath(routePath)
149
+ .split(/[^a-zA-Z0-9]+/)
150
+ .filter((s) => s.length > 0)
151
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1));
152
+ const verb = method === "del" ? "delete" : method;
153
+ return verb + (segments.join("") || "Root");
154
+ }
@@ -0,0 +1,78 @@
1
+ import type SitePlugin from "../types/SitePlugin";
2
+ import openApiDocsHtml from "./docsHtml";
3
+ import type { OpenApiPluginOptions, ResolvedOpenApiOptions } from "./types";
4
+
5
+ /**
6
+ * The `site.pluginState` key the openApi plugin stores its resolved options
7
+ * under. A registry symbol (`Symbol.for`), so that the user's config, the
8
+ * manifest plugin and the generated runtime code all resolve to the same
9
+ * key even when they are loaded as separate module instances.
10
+ */
11
+ export const OPEN_API_STATE_KEY: symbol = Symbol.for("@torpor/build/openapi");
12
+
13
+ /**
14
+ * Serves an OpenAPI 3.1 document for the site's `+server` endpoints, plus an
15
+ * optional interactive docs page.
16
+ *
17
+ * ```ts
18
+ * import { Site } from "@torpor/build";
19
+ * import { openApi } from "@torpor/build/openapi";
20
+ * import { z } from "zod";
21
+ *
22
+ * const site = new Site();
23
+ * site.addRouteFolder("src/routes");
24
+ * site.plugins = [
25
+ * openApi({
26
+ * title: "My API",
27
+ * toJsonSchema: (s) => z.toJSONSchema(s as z.ZodType, { io: "input" }),
28
+ * }),
29
+ * ];
30
+ * export default site;
31
+ * ```
32
+ *
33
+ * The document is served at `/openapi.json` (configurable with `path`) and
34
+ * the docs page at `/docs` (set `docs: false` to disable). Endpoints are
35
+ * picked up from every `+server.ts` route: its `params` schema becomes path
36
+ * parameters, `get`/`head` schemas become query parameters, and
37
+ * `post`/`patch`/`put`/`del`/`options` schemas become request bodies.
38
+ *
39
+ * The resolved options are stored in `site.pluginState` under
40
+ * `OPEN_API_STATE_KEY`, which the manifest plugin reads at build time to
41
+ * generate the document endpoint.
42
+ */
43
+ export function openApi(options: OpenApiPluginOptions = {}): SitePlugin {
44
+ return (site) => {
45
+ const docPath = withLeadingSlash(options.path ?? "/openapi.json");
46
+ if (site.routes.some((route) => route.path === docPath)) {
47
+ throw new Error(
48
+ `There is already a route registered at ${docPath}, so the OpenAPI document can't be ` +
49
+ `served there. Pass a different "path" to the openApi() plugin`,
50
+ );
51
+ }
52
+
53
+ const resolved: ResolvedOpenApiOptions = {
54
+ path: docPath,
55
+ docs: options.docs === false ? undefined : withLeadingSlash(options.docs ?? "/docs"),
56
+ title: options.title ?? "API",
57
+ version: options.version ?? "1.0.0",
58
+ toJsonSchema: options.toJsonSchema,
59
+ };
60
+ site.pluginState.set(OPEN_API_STATE_KEY, resolved);
61
+
62
+ if (resolved.docs) {
63
+ const html = openApiDocsHtml(docPath);
64
+ site.addRoute(resolved.docs, {
65
+ server: {
66
+ get: () =>
67
+ new Response(html, {
68
+ headers: { "Content-Type": "text/html; charset=utf-8" },
69
+ }),
70
+ },
71
+ });
72
+ }
73
+ };
74
+ }
75
+
76
+ function withLeadingSlash(pathName: string): string {
77
+ return pathName.startsWith("/") ? pathName : "/" + pathName;
78
+ }
@@ -0,0 +1,82 @@
1
+ import type { StandardSchemaV1 } from "../types/StandardSchema";
2
+
3
+ /**
4
+ * A JSON Schema object, as produced by a schema library's converter (e.g.
5
+ * `z.toJSONSchema`, valibot's `toJsonSchema`, arktype's `toJsonSchema`).
6
+ */
7
+ export type JsonSchema = Record<string, unknown>;
8
+
9
+ /**
10
+ * Converts a Standard Schema (zod, valibot, arktype, ...) into a JSON Schema
11
+ * object, for inclusion in an OpenAPI document. Supplied to the openApi
12
+ * plugin, since each schema library has its own conversion function:
13
+ *
14
+ * ```ts
15
+ * // zod
16
+ * openApi({ toJsonSchema: (s) => z.toJSONSchema(s as z.ZodType, { io: "input" }) })
17
+ * // arktype
18
+ * openApi({ toJsonSchema: (s) => (s as Type).toJsonSchema() })
19
+ * ```
20
+ */
21
+ export type ToJsonSchema = (schema: StandardSchemaV1) => JsonSchema;
22
+
23
+ /**
24
+ * Options for the openApi plugin.
25
+ */
26
+ export type OpenApiPluginOptions = {
27
+ /**
28
+ * The path to serve the OpenAPI document at. Defaults to `/openapi.json`.
29
+ */
30
+ path?: string;
31
+ /**
32
+ * The path to serve interactive API docs at, or `false` to disable the
33
+ * docs page. Defaults to `/docs`.
34
+ */
35
+ docs?: string | false;
36
+ /**
37
+ * The API title for the document's `info` section. Defaults to `"API"`.
38
+ */
39
+ title?: string;
40
+ /**
41
+ * The API version for the document's `info` section. Defaults to
42
+ * `"1.0.0"`.
43
+ */
44
+ version?: string;
45
+ /**
46
+ * Converts an endpoint's schemas into JSON Schema. Required for endpoints
47
+ * that declare schemas; endpoints without schemas are documented with
48
+ * their paths and methods only.
49
+ */
50
+ toJsonSchema?: ToJsonSchema;
51
+ };
52
+
53
+ /**
54
+ * OpenApi options with defaults applied. Created by the openApi plugin and
55
+ * consumed by the document builder.
56
+ */
57
+ export type ResolvedOpenApiOptions = {
58
+ path: string;
59
+ /** Undefined when the docs page is disabled */
60
+ docs: string | undefined;
61
+ title: string;
62
+ version: string;
63
+ toJsonSchema: ToJsonSchema | undefined;
64
+ };
65
+
66
+ /**
67
+ * A ServerEndPoint shape, loosely typed so that endpoint modules loaded from
68
+ * anywhere can be passed to the document builder.
69
+ */
70
+ export type OpenApiEndPoint = {
71
+ schema?: Record<string, StandardSchemaV1 | undefined> | undefined;
72
+ [method: string]: unknown;
73
+ };
74
+
75
+ /**
76
+ * A single API endpoint to include in an OpenAPI document: its route path
77
+ * (with `[param]` segments) and its default-exported endpoint object.
78
+ */
79
+ export type OpenApiRouteEntry = {
80
+ path: string;
81
+ endPoint: OpenApiEndPoint;
82
+ };
package/src/openapi.ts ADDED
@@ -0,0 +1,14 @@
1
+ import buildOpenApiDocument from "./openapi/document";
2
+ import openApiDocsHtml from "./openapi/docsHtml";
3
+ import { OPEN_API_STATE_KEY, openApi } from "./openapi/plugin";
4
+
5
+ export { buildOpenApiDocument, openApi, openApiDocsHtml, OPEN_API_STATE_KEY };
6
+
7
+ export type {
8
+ JsonSchema,
9
+ OpenApiEndPoint,
10
+ OpenApiPluginOptions,
11
+ OpenApiRouteEntry,
12
+ ResolvedOpenApiOptions,
13
+ ToJsonSchema,
14
+ } from "./openapi/types";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A Response whose JSON body type is known, as returned by e.g. `ok({ ... })`.
3
+ * Purely a compile-time marker: `__body` is never set at runtime.
4
+ */
5
+ export default interface TypedResponse<T> extends Response {
6
+ /**
7
+ * Phantom property carrying the body type. Required so that plain
8
+ * `Response` values do not match `TypedResponse<infer T>`.
9
+ */
10
+ readonly __body: T;
11
+ }
12
+
13
+ /**
14
+ * A Response that does not carry typed JSON data (e.g. redirects, errors,
15
+ * plain-text bodies). Loads may return these alongside typed data responses.
16
+ */
17
+ export type UntypedResponse = Response & { readonly __body?: never };
@@ -1,4 +1,10 @@
1
1
  import response from "./response";
2
+ import type { Jsonify } from "../types/Jsonify";
3
+ import type TypedResponse from "./TypedResponse";
4
+
5
+ type BadRequestResponse<T extends object | string | undefined> = T extends object
6
+ ? TypedResponse<Jsonify<T>>
7
+ : Response;
2
8
 
3
9
  /**
4
10
  * 400 Bad Request
@@ -12,8 +18,14 @@ import response from "./response";
12
18
  * Clients that receive a 400 response should expect that repeating the request
13
19
  * without modification will fail with the same error.
14
20
  *
21
+ * An object body is typed: it becomes the page's `$props.form` (with its JSON
22
+ * form) after a form submit, so validation errors can be surfaced with their
23
+ * field types via `PageForm`.
24
+ *
15
25
  * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/400
16
26
  */
17
- export default function badRequest(body?: object | string): Response {
18
- return response(400, body ?? "Bad request");
27
+ export default function badRequest<T extends object | string | undefined>(
28
+ body?: T,
29
+ ): BadRequestResponse<T> {
30
+ return response(400, body ?? "Bad request") as BadRequestResponse<T>;
19
31
  }
@@ -1,5 +1,11 @@
1
+ import type { Jsonify } from "../types/Jsonify";
2
+ import type TypedResponse from "./TypedResponse";
1
3
  import response from "./response";
2
4
 
5
+ type CreatedResponse<T extends object | string | undefined> = T extends object
6
+ ? TypedResponse<Jsonify<T>>
7
+ : Response;
8
+
3
9
  /**
4
10
  * 201 Created
5
11
  *
@@ -13,8 +19,13 @@ import response from "./response";
13
19
  * initiating request or by the URL in the value of the Location header provided
14
20
  * with the response.
15
21
  *
22
+ * An object body is typed: the client sees its JSON form through
23
+ * `makeApi`-created callers.
24
+ *
16
25
  * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/201
17
26
  */
18
- export default function created(body?: object | string): Response {
19
- return response(201, body);
27
+ export default function created<T extends object | string | undefined>(
28
+ body?: T,
29
+ ): CreatedResponse<T> {
30
+ return response(201, body) as CreatedResponse<T>;
20
31
  }
@@ -25,6 +25,6 @@ import transfer from "./transfer";
25
25
  *
26
26
  * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302
27
27
  */
28
- export default function permRedirect(location: string): Response {
29
- return transfer(308, location);
28
+ export default function found(location: string): Response {
29
+ return transfer(302, location);
30
30
  }
@@ -20,6 +20,6 @@ import transfer from "./transfer";
20
20
  *
21
21
  * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/301
22
22
  */
23
- export default function permRedirect(location: string): Response {
24
- return transfer(308, location);
23
+ export default function movedPermanently(location: string): Response {
24
+ return transfer(301, location);
25
25
  }
@@ -29,6 +29,6 @@ import transfer from "./transfer";
29
29
  *
30
30
  * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/304
31
31
  */
32
- export default function notModified(location: string): Response {
33
- return transfer(304, location);
32
+ export default function notModified(): Response {
33
+ return transfer(304, "");
34
34
  }
@@ -1,5 +1,11 @@
1
+ import type { Jsonify } from "../types/Jsonify";
2
+ import type TypedResponse from "./TypedResponse";
1
3
  import response from "./response";
2
4
 
5
+ type OkResponse<T extends object | string | undefined> = T extends object
6
+ ? TypedResponse<Jsonify<T>>
7
+ : Response;
8
+
3
9
  /**
4
10
  * 200 OK
5
11
  *
@@ -17,13 +23,16 @@ import response from "./response";
17
23
  * - TRACE: The response has a message body containing the request as received
18
24
  * by the server.
19
25
  *
20
- * Although possible, successful PUT or DELETE requests often do not result in a
21
- * 200 OK response. It is more common to see 201 Created if the resource is
26
+ * Although possible, successful PUT or DELETE requests often do not result in
27
+ * a 200 OK response. It is more common to see 201 Created if the resource is
22
28
  * uploaded or created for the first time, or 204 No Content upon successful
23
29
  * deletion of a resource.
24
30
  *
31
+ * An object body is typed: the client sees its JSON form through
32
+ * `makeApi`-created callers.
33
+ *
25
34
  * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/200
26
35
  */
27
- export default function ok(body?: object | string): Response {
28
- return response(200, body);
36
+ export default function ok<T extends object | string | undefined>(body?: T): OkResponse<T> {
37
+ return response(200, body) as OkResponse<T>;
29
38
  }
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * Creates a response with the supplied status code and optional body.
3
3
  *
4
- * If the body is an object, it will be converted to JSON and the content-type
4
+ * If the body is an object, it will be converted to JSON and the Content-Type
5
5
  * header set to "application/json". Otherwise, if the body is a string, the
6
- * content-type header will be set to "text/plain".
6
+ * Content-Type header will be set to "text/plain".
7
7
  */
8
8
  export default function response(status: number, body?: object | string): Response {
9
9
  let headers: Record<string, string> | undefined = undefined;
10
10
  if (body) {
11
11
  if (typeof body === "object") {
12
- headers = { "content-type": "application/json" };
12
+ headers = { "Content-Type": "application/json" };
13
13
  body = JSON.stringify(body);
14
14
  } else {
15
- headers = { "content-type": "text/plain" };
15
+ headers = { "Content-Type": "text/plain" };
16
16
  }
17
17
  }
18
18
  return new Response(body, {