@forgebook/sveltekit 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -129,8 +129,15 @@ function `use:enhance` takes runs as in the app. A POST form without `use:enhanc
129
129
  | `$app/env/private`, `$env/*/private`, `$app/server`, `$service-worker` | Refused: the story fails with why, and the value never reaches the page |
130
130
  | The other SvelteKit's environment | Refused too: SvelteKit 3 has no `$env/*`, so no variable reaches its page by its prefix alone; SvelteKit 2 has no `$app/env/public` |
131
131
 
132
- Generated server tests take `kitRenderer` from `@forgebook/sveltekit/renderer`: a page's `load` runs first, its relative
133
- `fetch` read from the story's page. They answer no fetch, so a story whose load fetches is tagged `!test` there.
132
+ Generated server tests take `kitRenderer` from `@forgebook/sveltekit/renderer`: a page's `load` runs first, and a server
133
+ render never reaches the network for the page's own origin. A load's `fetch` of a path, or of an address on the story's
134
+ page's origin (`http://localhost` unless `page.url` names another), is answered by the story's `parameters.msw`
135
+ handlers, the ones the frame uses, run in Node by MSW's `getResponse` (MSW 2.11 or later, installed by the project): no
136
+ request is intercepted, so tests running at once keep their own mocks. A mocked redirect is followed through the
137
+ handlers and `HttpResponse.error()` rejects the fetch, as in the frame; `{ once: true }` handlers answer again in each
138
+ render. When no handler answers, or one answers `passthrough()`, the story fails with the address and what to give
139
+ instead: a handler, or the page's data (`serverData`, `page.data`); it fails even when the load catches the error. An
140
+ address on another origin, `127.0.0.1` beside a `localhost` page among them, is fetched as it is.
134
141
 
135
142
  ## Not drawn
136
143
 
@@ -16,6 +16,7 @@
16
16
  import { svelteRenderer } from "@forgebook/svelte/renderer";
17
17
  import { hrefCallbacksOf } from "./kitParameters.js";
18
18
  import { createKitStore, errorOfThrown, setCurrentStore } from "./kitStore.js";
19
+ import { loadFetch, mockHandlersOf } from "./loadFetch.js";
19
20
  /** Whether a value is a plain object. */
20
21
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
21
22
  /** An error as the frame takes it: SvelteKit's `error(404, "…")` written with its status. */
@@ -26,45 +27,66 @@ const toError = (error) => {
26
27
  return new Error(`${read.status ?? 500}: ${read.message}`);
27
28
  };
28
29
  /**
29
- * The address a load fetches: in a page, as the frame's `fetch` reads it, so the catalogue's mocks answer a path; on a
30
- * server, which has no page to read a path from, from the story's page, as SvelteKit's `fetch` does.
30
+ * Runs a story's loads in order, the layouts' first, each `parent()` the data above it.
31
31
  *
32
- * @param input - what the load fetches.
33
- * @param pageUrl - the story's page.
32
+ * @param kit - the story's settings.
33
+ * @param store - the story's page, which records what the loads call.
34
+ * @param at - the page they load.
35
+ * @param fetch - the loads' `fetch`.
36
+ * @param afterLoad - runs after each load, before its data is taken: it throws to stop them.
37
+ * @returns the data, every load's merged.
34
38
  */
35
- const pageRequest = (input, pageUrl) => typeof location === "undefined" ? new URL(input, pageUrl).href : input;
39
+ const runLoads = async (kit, store, at, fetch, afterLoad) => {
40
+ let merged = kit.page.data;
41
+ for (const [index, load] of kit.loads.entries()) {
42
+ const above = merged;
43
+ const isPage = index === kit.loads.length - 1;
44
+ const result = await load({
45
+ params: at.params,
46
+ url: at.url,
47
+ route: at.route,
48
+ fetch,
49
+ // the page's server data is the page's load's; a layout's server load is given by `page.data`
50
+ data: isPage ? kit.serverData : null,
51
+ parent: async () => above,
52
+ depends: (...dependencies) => store.record("depends", dependencies),
53
+ setHeaders: () => undefined,
54
+ untrack: (read) => read()
55
+ });
56
+ afterLoad();
57
+ merged = { ...merged, ...(isRecord(result) ? result : {}) };
58
+ }
59
+ return merged;
60
+ };
36
61
  /**
37
- * The loader of a story's page data: its loads in order, the layouts' first, each `parent()` the data above it.
62
+ * The loader of a story's page data: its loads, or its server data without them.
38
63
  *
39
64
  * @param kit - the story's settings.
40
65
  * @param store - the story's page, which records what the loads call.
66
+ * @param handlers - the story's mock handlers, which answer the loads' fetches on a server.
41
67
  * @returns the loader, or none for a story that gives no load and no server data.
42
68
  */
43
- const loaderOf = (kit, store) => {
69
+ const loaderOf = (kit, store, handlers) => {
44
70
  if (kit.loads.length === 0 && kit.serverData === null)
45
71
  return undefined;
46
72
  return async (at) => {
47
- let merged = kit.page.data;
48
73
  if (kit.loads.length === 0)
49
- return { ...merged, ...kit.serverData };
50
- for (const [index, load] of kit.loads.entries()) {
51
- const above = merged;
52
- const isPage = index === kit.loads.length - 1;
53
- const result = await load({
54
- params: at.params,
55
- url: at.url,
56
- route: at.route,
57
- fetch: (input, init) => globalThis.fetch(typeof input === "string" ? pageRequest(input, at.url) : input, init),
58
- // the page's server data is the page's load's; a layout's server load is given by `page.data`
59
- data: isPage ? kit.serverData : null,
60
- parent: async () => above,
61
- depends: (...dependencies) => store.record("depends", dependencies),
62
- setHeaders: () => undefined,
63
- untrack: (read) => read()
74
+ return { ...kit.page.data, ...kit.serverData };
75
+ // a fetch no mock answered on a server fails the page, though its load caught it or threw an error of its own:
76
+ // what the load drew instead is not the page
77
+ let unanswered;
78
+ const fetch = loadFetch(at.url, handlers, (error) => {
79
+ unanswered ??= error;
80
+ });
81
+ try {
82
+ return await runLoads(kit, store, at, fetch, () => {
83
+ if (unanswered !== undefined)
84
+ throw unanswered;
64
85
  });
65
- merged = { ...merged, ...(isRecord(result) ? result : {}) };
66
86
  }
67
- return merged;
87
+ catch (thrown) {
88
+ throw unanswered ?? thrown;
89
+ }
68
90
  };
69
91
  };
70
92
  /**
@@ -178,7 +200,7 @@ const storeOf = (drawing) => {
178
200
  ? shared
179
201
  : { store: createKitStore(key, drawing.onEvent), mounts: 0 };
180
202
  if (entry !== shared) {
181
- entry.store.setLoader(loaderOf(entry.store.kit, entry.store));
203
+ entry.store.setLoader(loaderOf(entry.store.kit, entry.store, mockHandlersOf(key)));
182
204
  SHARED.set(key, entry);
183
205
  }
184
206
  entry.mounts += 1;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The `fetch` a story's `load` gets: in a page, the frame's own, which the catalogue's mocks answer; on a server, the
3
+ * story's mock handlers run in place of the network for an address on the story's page's origin.
4
+ *
5
+ * @remarks
6
+ * SvelteKit's `fetch` on a server reads a path from the page's address, so a server render (the generated server
7
+ * tests) would ask `http://localhost` for it: whatever answers on that machine, or nothing, would decide what the page
8
+ * draws. A server render never reaches the network for the page's own origin: the story's handlers
9
+ * (`parameters.msw.handlers`, the ones the frame's worker serves) answer it through MSW's `getResponse`, a plain call
10
+ * with no interception, so concurrent renders never see each other's handlers. A request no handler answers, or one
11
+ * a handler sends on to the network (`passthrough()`), fails the render with what to give instead. An address on
12
+ * another origin is fetched as it is. A handler's `HttpResponse.error()` rejects the fetch and its redirect is followed,
13
+ * as in the frame.
14
+ *
15
+ * @module
16
+ */
17
+ /** A load's `fetch`. */
18
+ export type LoadFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
19
+ /**
20
+ * The fetch a load gets.
21
+ *
22
+ * @param pageUrl - the address of the page being loaded, which a path is read from.
23
+ * @param handlers - the story's mock handlers.
24
+ * @param onUnanswered - told of a request on the page's origin that no handler answered on a server, so the render
25
+ * fails even when the load catches the error and draws something else.
26
+ */
27
+ export declare const loadFetch: (pageUrl: URL, handlers: readonly unknown[], onUnanswered: (error: Error) => void) => LoadFetch;
28
+ /**
29
+ * The handlers a story's parameters declare, flat: a list, or a record of lists as MSW's Storybook addon writes them.
30
+ *
31
+ * @param parameters - the story's parameters.
32
+ */
33
+ export declare const mockHandlersOf: (parameters: Readonly<Record<string, unknown>>) => readonly unknown[];
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The `fetch` a story's `load` gets: in a page, the frame's own, which the catalogue's mocks answer; on a server, the
3
+ * story's mock handlers run in place of the network for an address on the story's page's origin.
4
+ *
5
+ * @remarks
6
+ * SvelteKit's `fetch` on a server reads a path from the page's address, so a server render (the generated server
7
+ * tests) would ask `http://localhost` for it: whatever answers on that machine, or nothing, would decide what the page
8
+ * draws. A server render never reaches the network for the page's own origin: the story's handlers
9
+ * (`parameters.msw.handlers`, the ones the frame's worker serves) answer it through MSW's `getResponse`, a plain call
10
+ * with no interception, so concurrent renders never see each other's handlers. A request no handler answers, or one
11
+ * a handler sends on to the network (`passthrough()`), fails the render with what to give instead. An address on
12
+ * another origin is fetched as it is. A handler's `HttpResponse.error()` rejects the fetch and its redirect is followed,
13
+ * as in the frame.
14
+ *
15
+ * @module
16
+ */
17
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
18
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
19
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
20
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
21
+ });
22
+ }
23
+ return path;
24
+ };
25
+ /** The module MSW's request handling is imported from, by the project that installs it. */
26
+ const MSW_MODULE = "msw";
27
+ /**
28
+ * The fetch a load gets.
29
+ *
30
+ * @param pageUrl - the address of the page being loaded, which a path is read from.
31
+ * @param handlers - the story's mock handlers.
32
+ * @param onUnanswered - told of a request on the page's origin that no handler answered on a server, so the render
33
+ * fails even when the load catches the error and draws something else.
34
+ */
35
+ export const loadFetch = (pageUrl, handlers, onUnanswered) => {
36
+ // in a page, a path is read from the frame's address, where the catalogue's mocks answer it
37
+ if (typeof location !== "undefined")
38
+ return (input, init) => globalThis.fetch(input, init);
39
+ restoreHandlers(handlers);
40
+ const send = async (request, redirects) => {
41
+ if (new URL(request.url).origin !== pageUrl.origin)
42
+ return globalThis.fetch(request);
43
+ // kept for a redirect that sends the request again, body included, after a handler read it
44
+ const kept = request.clone();
45
+ const answer = await mockAnswer(handlers, request, pageUrl.origin);
46
+ if (answer === undefined) {
47
+ const error = unansweredError(request);
48
+ onUnanswered(error);
49
+ throw error;
50
+ }
51
+ // MSW's mocked network error: `fetch` rejects, as it does in the frame
52
+ if (answer.type === "error")
53
+ throw new TypeError("Failed to fetch", { cause: answer });
54
+ const next = redirectOf(answer, kept);
55
+ if (next === undefined)
56
+ return answer;
57
+ if (redirects >= MOST_REDIRECTS)
58
+ throw new TypeError(`Too many redirects, the last to ${next.url}`);
59
+ return send(next, redirects + 1);
60
+ };
61
+ return (input, init) => send(new Request(typeof input === "string" ? new URL(input, pageUrl) : input, init), 0);
62
+ };
63
+ /** How many redirects a fetch follows, as browsers and Node's `fetch` do. */
64
+ const MOST_REDIRECTS = 20;
65
+ /**
66
+ * The request a mocked redirect leads to, as `fetch` follows it: the same method and body for a 307 or a 308, a GET
67
+ * for a 303 and for a POST's 301 or 302; none when the answer is no redirect or the request does not follow one.
68
+ *
69
+ * @param answer - a handler's answer.
70
+ * @param request - the request it answered, its body unread.
71
+ * @throws a `TypeError` for a redirect a request with `redirect: "error"` gets, as `fetch` does.
72
+ */
73
+ const redirectOf = (answer, request) => {
74
+ const location = answer.headers.get("location");
75
+ if (![301, 302, 303, 307, 308].includes(answer.status) || location === null)
76
+ return undefined;
77
+ if (request.redirect === "manual")
78
+ return undefined;
79
+ if (request.redirect === "error")
80
+ throw new TypeError(`Redirected to ${location}, which the request refuses`);
81
+ const target = new URL(location, request.url);
82
+ const isGet = answer.status === 303 || (request.method === "POST" && answer.status !== 307 && answer.status !== 308);
83
+ if (!isGet)
84
+ return new Request(target, request);
85
+ return new Request(target, {
86
+ method: request.method === "HEAD" ? "HEAD" : "GET",
87
+ headers: request.headers
88
+ });
89
+ };
90
+ /**
91
+ * The handlers a story's parameters declare, flat: a list, or a record of lists as MSW's Storybook addon writes them.
92
+ *
93
+ * @param parameters - the story's parameters.
94
+ */
95
+ export const mockHandlersOf = (parameters) => {
96
+ const msw = parameters["msw"];
97
+ const handlers = isRecord(msw) ? msw["handlers"] : undefined;
98
+ if (Array.isArray(handlers))
99
+ return handlers;
100
+ return isRecord(handlers) ? Object.values(handlers).flat() : [];
101
+ };
102
+ /**
103
+ * Makes a story's `{ once: true }` handlers answer again, as MSW's `restoreHandlers` does: each page load on a server is
104
+ * a page of its own, as each frame is, while the handler objects outlive it in the test process.
105
+ *
106
+ * @param handlers - the story's handlers.
107
+ */
108
+ const restoreHandlers = (handlers) => {
109
+ for (const handler of handlers)
110
+ if (isRecord(handler) && typeof handler["isUsed"] === "boolean")
111
+ Reflect.set(handler, "isUsed", false);
112
+ };
113
+ /** Whether a value is an object with named fields. */
114
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
115
+ /**
116
+ * What the story's handlers answer: none when they do not, or when the one that does sends the request on to the
117
+ * network, which a server render never reaches.
118
+ *
119
+ * @param handlers - the story's handlers.
120
+ * @param request - the request.
121
+ * @param origin - the page's origin, which a handler's path (`/api/users`) is read from, as the frame reads it.
122
+ */
123
+ const mockAnswer = async (handlers, request, origin) => {
124
+ if (handlers.length === 0)
125
+ return undefined;
126
+ const { getResponse } = await mswCore();
127
+ const answer = await getResponse([...handlers], request, { baseUrl: origin });
128
+ return answer === undefined || isPassthrough(answer) ? undefined : answer;
129
+ };
130
+ /**
131
+ * Whether an answer is MSW's `passthrough()`: MSW marks it with this status and header, which its own
132
+ * `isPassthroughResponse` reads (exported from MSW 3 only).
133
+ *
134
+ * @param answer - a handler's answer.
135
+ */
136
+ const isPassthrough = (answer) => answer.status === 302 && answer.headers.get("x-msw-intention") === "passthrough";
137
+ /**
138
+ * Whether a module is MSW's, as much as a server render uses.
139
+ *
140
+ * @param module - the module, as `import()` gave it.
141
+ */
142
+ const isMswCore = (module) => typeof module === "object" &&
143
+ module !== null &&
144
+ "getResponse" in module &&
145
+ typeof module.getResponse === "function";
146
+ /**
147
+ * Loads MSW, which only a story with handlers needs on a server.
148
+ *
149
+ * @throws when MSW is not installed, or is older than its `getResponse`.
150
+ */
151
+ const mswCore = async () => {
152
+ let module;
153
+ try {
154
+ // named by a variable, so a frame's build never resolves MSW: the frame fetches through the page
155
+ module = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ MSW_MODULE));
156
+ }
157
+ catch (error) {
158
+ throw new Error("Answering a load's fetch with the story's mocks on a server needs MSW: npm install --save-dev msw", { cause: error });
159
+ }
160
+ if (!isMswCore(module))
161
+ throw new Error("The installed MSW has no getResponse: MSW 2.11 or later is needed");
162
+ return module;
163
+ };
164
+ /**
165
+ * The error of a request on the page's origin that no handler answered: what was asked, and what to give.
166
+ *
167
+ * @param request - the request.
168
+ */
169
+ const unansweredError = (request) => new Error(`A load fetched ${request.method} ${request.url} on a server, and no mock of the story answers it: a server render ` +
170
+ "never reaches the network. Answer it with a handler in parameters.msw.handlers, or give the page's data " +
171
+ "with parameters.sveltekit.serverData or parameters.sveltekit.page.data.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgebook/sveltekit",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Forgebook for SvelteKit: a SvelteKit project's components drawn outside the app, with $app/state, $app/navigation, $app/forms, $app/paths, the environment and a page's load, on Vite.",
5
5
  "keywords": [
6
6
  "sveltekit",
@@ -81,28 +81,35 @@
81
81
  }
82
82
  },
83
83
  "dependencies": {
84
- "@forgebook/story-format": "0.2.0",
85
- "@forgebook/svelte": "0.2.0"
84
+ "@forgebook/story-format": "0.2.1",
85
+ "@forgebook/svelte": "0.2.1"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "svelte": "^5.46.4",
89
89
  "vite": "^8.0.0",
90
- "forgebook": "^0.2.0"
90
+ "msw": "^2.11.0 || ^3.0.0",
91
+ "forgebook": "^0.3.0"
92
+ },
93
+ "peerDependenciesMeta": {
94
+ "msw": {
95
+ "optional": true
96
+ }
91
97
  },
92
98
  "devDependencies": {
93
99
  "@sveltejs/kit": "3.1.1",
94
100
  "@sveltejs/vite-plugin-svelte": "7.3.1",
95
101
  "@types/node": "24.19.2",
96
102
  "happy-dom": "21.0.4",
103
+ "msw": "3.0.3",
97
104
  "svelte": "5.57.3",
98
105
  "svelte-check": "4.7.8",
99
106
  "typescript": "npm:typescript@6.0.3",
100
107
  "vite": "8.3.5",
101
108
  "vitest": "5.0.5",
102
- "@forgebook/testing": "0.2.0",
103
109
  "@forgebook/typescript-config": "0.0.0",
104
- "forgebook": "0.2.0",
105
- "@forgebook/tools": "0.0.0"
110
+ "@forgebook/tools": "0.0.0",
111
+ "@forgebook/testing": "0.2.1",
112
+ "forgebook": "0.3.0"
106
113
  },
107
114
  "publishConfig": {
108
115
  "access": "public"