@kasperrt/smolter 0.3.1 → 0.3.2
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/.generated/client/deployment-check.ts +70 -0
- package/.generated/client/deployment.ts +21 -58
- package/.generated/client/index.ts +8 -2
- package/.generated/core/responses.ts +8 -3
- package/.generated/core/router.ts +32 -0
- package/.generated/core/types.ts +2 -0
- package/.generated/preact/client.tsx +48 -6
- package/.generated/preact/document.tsx +4 -2
- package/.generated/preact/lazy-route.ts +2 -0
- package/.generated/preact/root-context.ts +2 -0
- package/.generated/preact/route-types.ts +1 -0
- package/.generated/preact/router.tsx +9 -3
- package/.generated/server/application.tsx +83 -19
- package/.generated/server/handler.ts +8 -2
- package/.generated/server/http.ts +24 -2
- package/.generated/server/index.ts +17 -5
- package/.generated/server/preact.ts +1 -0
- package/.generated/server/private-response.ts +11 -0
- package/.generated/server/stream-document.ts +121 -0
- package/.generated/vite/application/lazy-files.ts +8 -2
- package/.generated/vite/application/vite.ts +9 -3
- package/.generated/vite/index.ts +4 -2
- package/.generated/vite/transport.ts +4 -1
- package/CHANGELOG.md +10 -0
- package/README.md +29 -11
- package/dist/client/deployment-check.d.ts +6 -0
- package/dist/client/deployment-check.js +51 -0
- package/dist/client/deployment.d.ts +2 -0
- package/dist/client/deployment.js +11 -39
- package/dist/client/index.d.ts +3 -1
- package/dist/client/index.js +3 -3
- package/dist/core/responses.d.ts +1 -1
- package/dist/core/responses.js +3 -3
- package/dist/core/router.js +16 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/preact/client.d.ts +6 -1
- package/dist/preact/client.js +27 -8
- package/dist/preact/document.js +4 -2
- package/dist/preact/lazy-route.d.ts +1 -0
- package/dist/preact/lazy-route.js +2 -1
- package/dist/preact/root-context.d.ts +2 -0
- package/dist/preact/route-types.d.ts +1 -0
- package/dist/preact/router.d.ts +1 -1
- package/dist/preact/router.js +3 -3
- package/dist/server/application.d.ts +4 -1
- package/dist/server/application.js +47 -13
- package/dist/server/handler.d.ts +3 -1
- package/dist/server/handler.js +5 -3
- package/dist/server/http.d.ts +5 -1
- package/dist/server/http.js +10 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +12 -2
- package/dist/server/preact.d.ts +1 -1
- package/dist/server/private-response.d.ts +2 -0
- package/dist/server/private-response.js +13 -0
- package/dist/server/stream-document.d.ts +9 -0
- package/dist/server/stream-document.js +78 -0
- package/dist/vite/application/lazy-files.d.ts +1 -1
- package/dist/vite/application/lazy-files.js +5 -2
- package/dist/vite/application/vite.d.ts +2 -1
- package/dist/vite/application/vite.js +7 -4
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +6 -3
- package/dist/vite/transport.d.ts +1 -1
- package/dist/vite/transport.js +3 -2
- package/jsr.json +1 -1
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { send, webRequest } from "./http.ts";
|
|
3
|
+
import { privateResponse } from "./private-response.ts";
|
|
3
4
|
|
|
4
5
|
/** Server module produced by an application's SSR entrypoint. */
|
|
5
6
|
export interface ServerBuild {
|
|
@@ -11,6 +12,8 @@ export interface RequestHandlerOptions {
|
|
|
11
12
|
build: ServerBuild;
|
|
12
13
|
/** Browser entry URL resolved from Vite's production manifest. */
|
|
13
14
|
entry: string;
|
|
15
|
+
/** Must match the browser and Vite transport endpoint. */
|
|
16
|
+
dataEndpoint?: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export interface ServerIncomingMessage extends IncomingMessage {
|
|
@@ -25,6 +28,7 @@ export type NextHandler = (error?: unknown) => void;
|
|
|
25
28
|
export function createRequestHandler({
|
|
26
29
|
build,
|
|
27
30
|
entry,
|
|
31
|
+
dataEndpoint = "/_smolter",
|
|
28
32
|
}: RequestHandlerOptions): (
|
|
29
33
|
incoming: ServerIncomingMessage,
|
|
30
34
|
outgoing: ServerResponse,
|
|
@@ -40,7 +44,9 @@ export function createRequestHandler({
|
|
|
40
44
|
incoming.originalUrl ?? incoming.url ?? "/",
|
|
41
45
|
"http://local",
|
|
42
46
|
);
|
|
43
|
-
const dataRequest = url.pathname ===
|
|
47
|
+
const dataRequest = url.pathname === dataEndpoint;
|
|
48
|
+
if (dataRequest) outgoing.setHeader("Cache-Control", "private, no-store");
|
|
49
|
+
|
|
44
50
|
const target = dataRequest ? url.searchParams.get("url") : undefined;
|
|
45
51
|
|
|
46
52
|
if (
|
|
@@ -61,7 +67,7 @@ export function createRequestHandler({
|
|
|
61
67
|
? await build.handleDataRequest(request)
|
|
62
68
|
: await build.default(request, entry);
|
|
63
69
|
|
|
64
|
-
await send(response, outgoing);
|
|
70
|
+
await send(dataRequest ? privateResponse(response) : response, outgoing);
|
|
65
71
|
};
|
|
66
72
|
|
|
67
73
|
return handle().catch(next);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ServerResponse } from "node:http";
|
|
2
|
+
import { Readable, Transform } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
2
4
|
import type { ServerIncomingMessage } from "./handler.ts";
|
|
3
5
|
|
|
4
6
|
export async function webRequest(
|
|
@@ -34,7 +36,11 @@ export async function webRequest(
|
|
|
34
36
|
);
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
interface StreamingResponse extends ServerResponse {
|
|
40
|
+
flush?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function send(response: Response, outgoing: StreamingResponse) {
|
|
38
44
|
outgoing.statusCode = response.status;
|
|
39
45
|
response.headers.forEach((value, name) => {
|
|
40
46
|
if (name !== "set-cookie") outgoing.setHeader(name, value);
|
|
@@ -51,5 +57,21 @@ export async function send(response: Response, outgoing: ServerResponse) {
|
|
|
51
57
|
),
|
|
52
58
|
);
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
if (!response.body) {
|
|
61
|
+
outgoing.end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await pipeline(
|
|
66
|
+
Readable.fromWeb(
|
|
67
|
+
response.body as import("node:stream/web").ReadableStream<Uint8Array>,
|
|
68
|
+
),
|
|
69
|
+
new Transform({
|
|
70
|
+
transform(chunk, _encoding, done) {
|
|
71
|
+
done(null, chunk);
|
|
72
|
+
outgoing.flush?.();
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
outgoing,
|
|
76
|
+
);
|
|
55
77
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { privateResponse } from "./private-response.ts";
|
|
1
2
|
/**
|
|
2
3
|
* Request-scoped server rendering and HTTP data dispatch.
|
|
3
4
|
* @module
|
|
@@ -63,7 +64,7 @@ export async function loadRouter(
|
|
|
63
64
|
export { serialize } from "../core/index.ts";
|
|
64
65
|
|
|
65
66
|
/** Dispatch an HTTP data request to a registered server loader or action. */
|
|
66
|
-
|
|
67
|
+
async function dispatchRequest(
|
|
67
68
|
routes: readonly Route[],
|
|
68
69
|
request: Request,
|
|
69
70
|
): Promise<Response> {
|
|
@@ -93,10 +94,13 @@ export async function handleRequest(
|
|
|
93
94
|
if (result.data instanceof Response) {
|
|
94
95
|
const location = result.data.headers.get("Location");
|
|
95
96
|
|
|
96
|
-
if (location)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
if (location) {
|
|
98
|
+
const headers = new Headers(result.data.headers);
|
|
99
|
+
headers.delete("Location");
|
|
100
|
+
headers.set("X-Smolter-Redirect", location);
|
|
101
|
+
|
|
102
|
+
return new Response(null, { headers });
|
|
103
|
+
}
|
|
100
104
|
|
|
101
105
|
return result.data;
|
|
102
106
|
}
|
|
@@ -105,3 +109,11 @@ export async function handleRequest(
|
|
|
105
109
|
headers: { "Cache-Control": "no-store" },
|
|
106
110
|
});
|
|
107
111
|
}
|
|
112
|
+
|
|
113
|
+
/** Dispatch a server data request with an explicit private cache policy. */
|
|
114
|
+
export async function handleRequest(
|
|
115
|
+
routes: readonly Route[],
|
|
116
|
+
request: Request,
|
|
117
|
+
): Promise<Response> {
|
|
118
|
+
return privateResponse(await dispatchRequest(routes, request));
|
|
119
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Transport data must not be cached by shared intermediaries or the browser. */
|
|
2
|
+
export function privateResponse(response: Response): Response {
|
|
3
|
+
const headers = new Headers(response.headers);
|
|
4
|
+
headers.set("Cache-Control", "private, no-store");
|
|
5
|
+
|
|
6
|
+
return new Response(response.body, {
|
|
7
|
+
status: response.status,
|
|
8
|
+
statusText: response.statusText,
|
|
9
|
+
headers,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { serializeData } from "../core/serialize.ts";
|
|
2
|
+
|
|
3
|
+
// Deferred failures must remain rejected promises after hydration so Await's
|
|
4
|
+
// errorElement has the same behavior on the server and in the browser.
|
|
5
|
+
export async function resolveHydration(value: unknown): Promise<unknown> {
|
|
6
|
+
if (value instanceof Promise)
|
|
7
|
+
return value.then(resolveHydration, (error: unknown) => ({
|
|
8
|
+
__smolterRejected: {
|
|
9
|
+
message: error instanceof Error ? error.message : String(error),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
if (Array.isArray(value)) return Promise.all(value.map(resolveHydration));
|
|
14
|
+
|
|
15
|
+
if (
|
|
16
|
+
value &&
|
|
17
|
+
typeof value === "object" &&
|
|
18
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
19
|
+
) {
|
|
20
|
+
const entries = await Promise.all(
|
|
21
|
+
Object.entries(value).map(async ([key, field]) => [
|
|
22
|
+
key,
|
|
23
|
+
await resolveHydration(field),
|
|
24
|
+
]),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return Object.fromEntries(entries);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StreamDocumentOptions {
|
|
34
|
+
hydration: Promise<unknown>;
|
|
35
|
+
nonce?: string;
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
dispose: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stream the shell immediately, then finalize the JSON snapshot before module scripts execute. */
|
|
41
|
+
export function streamDocument(
|
|
42
|
+
source: ReadableStream<Uint8Array>,
|
|
43
|
+
options: StreamDocumentOptions,
|
|
44
|
+
): ReadableStream<Uint8Array> {
|
|
45
|
+
const reader = source.getReader();
|
|
46
|
+
const encoder = new TextEncoder();
|
|
47
|
+
const decoder = new TextDecoder();
|
|
48
|
+
let suffix = "";
|
|
49
|
+
let first = true;
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
let finish!: () => void;
|
|
52
|
+
const interrupted = new Promise<void>((resolve) => {
|
|
53
|
+
finish = resolve;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const abort = () => {
|
|
57
|
+
finish();
|
|
58
|
+
void reader.cancel().catch(() => {});
|
|
59
|
+
};
|
|
60
|
+
options.signal.addEventListener("abort", abort, { once: true });
|
|
61
|
+
if (options.signal.aborted) abort();
|
|
62
|
+
|
|
63
|
+
function dispose() {
|
|
64
|
+
options.signal.removeEventListener("abort", abort);
|
|
65
|
+
options.dispose();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return new ReadableStream({
|
|
69
|
+
async pull(controller) {
|
|
70
|
+
const result = await reader.read().catch((error: unknown) => ({ error }));
|
|
71
|
+
|
|
72
|
+
if ("error" in result) {
|
|
73
|
+
dispose();
|
|
74
|
+
controller.error(result.error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (result.done) {
|
|
79
|
+
if (!options.signal.aborted) {
|
|
80
|
+
const value = await Promise.race([options.hydration, interrupted]);
|
|
81
|
+
if (cancelled) return;
|
|
82
|
+
if (options.signal.aborted) {
|
|
83
|
+
dispose();
|
|
84
|
+
controller.close();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const hydration = serializeData(value);
|
|
88
|
+
const nonce = options.nonce
|
|
89
|
+
? ` nonce="${options.nonce.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<")}"`
|
|
90
|
+
: "";
|
|
91
|
+
controller.enqueue(
|
|
92
|
+
encoder.encode(
|
|
93
|
+
`<script type="application/json"${nonce}>${hydration}</script><script${nonce}>(function(){var script=document.currentScript,payload=script.previousElementSibling,data=document.getElementById("smolter-data");if(data)data.textContent=payload.textContent;payload.remove();script.remove()})()</script>${suffix}`,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dispose();
|
|
99
|
+
controller.close();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let chunk = decoder.decode(result.value, { stream: true });
|
|
104
|
+
if (first && !/^<!doctype/i.test(chunk))
|
|
105
|
+
chunk = `<!doctype html>${chunk}`;
|
|
106
|
+
first = false;
|
|
107
|
+
const closing = chunk.lastIndexOf("</body>");
|
|
108
|
+
if (closing !== -1) {
|
|
109
|
+
suffix = chunk.slice(closing);
|
|
110
|
+
chunk = chunk.slice(0, closing);
|
|
111
|
+
}
|
|
112
|
+
controller.enqueue(encoder.encode(chunk));
|
|
113
|
+
},
|
|
114
|
+
async cancel() {
|
|
115
|
+
cancelled = true;
|
|
116
|
+
finish();
|
|
117
|
+
dispose();
|
|
118
|
+
await reader.cancel();
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -5,7 +5,10 @@ import { type Plugin, transformWithOxc } from "vite";
|
|
|
5
5
|
|
|
6
6
|
// Reuse Smolter's file discovery and server-export stripping. Only the browser
|
|
7
7
|
// route manifest changes: matched routes load before their data is committed.
|
|
8
|
-
export function lazyFiles(
|
|
8
|
+
export function lazyFiles(
|
|
9
|
+
directory: string,
|
|
10
|
+
dataEndpoint = "/_smolter",
|
|
11
|
+
): Plugin {
|
|
9
12
|
let root: string;
|
|
10
13
|
const modules = new Map<string, string>();
|
|
11
14
|
|
|
@@ -74,7 +77,10 @@ export function lazyFiles(directory: string): Plugin {
|
|
|
74
77
|
const source = await readFile(route.file, "utf8");
|
|
75
78
|
const transformed = await transformWithOxc(source, route.file);
|
|
76
79
|
const ast = this.parse(transformed.code);
|
|
77
|
-
const fields: Record<string, string | boolean> = {
|
|
80
|
+
const fields: Record<string, string | boolean> = {
|
|
81
|
+
path: route.path,
|
|
82
|
+
dataEndpoint,
|
|
83
|
+
};
|
|
78
84
|
|
|
79
85
|
for (const statement of ast.body) {
|
|
80
86
|
if (statement.type === "ExportDefaultDeclaration") {
|
|
@@ -6,6 +6,7 @@ import { lazyFiles } from "./lazy-files.ts";
|
|
|
6
6
|
|
|
7
7
|
export interface ApplicationOptions {
|
|
8
8
|
directory?: string;
|
|
9
|
+
dataEndpoint?: string;
|
|
9
10
|
ssr?: boolean;
|
|
10
11
|
splitRoutes?: boolean;
|
|
11
12
|
resourceRequest?: (url: string) => boolean;
|
|
@@ -42,6 +43,7 @@ export const preactConfig: UserConfig = {
|
|
|
42
43
|
|
|
43
44
|
export function applicationPlugins({
|
|
44
45
|
directory = "src/routes",
|
|
46
|
+
dataEndpoint = "/_smolter",
|
|
45
47
|
ssr = true,
|
|
46
48
|
splitRoutes = true,
|
|
47
49
|
resourceRequest,
|
|
@@ -72,7 +74,9 @@ export function applicationPlugins({
|
|
|
72
74
|
configureServer(server) {
|
|
73
75
|
// Own the HTTP adapter so redirects retain authentication cookies.
|
|
74
76
|
server.middlewares.use((incoming, outgoing, next) => {
|
|
75
|
-
if (!incoming.url?.startsWith(
|
|
77
|
+
if (!incoming.url?.startsWith(`${dataEndpoint}?`)) return next();
|
|
78
|
+
|
|
79
|
+
outgoing.setHeader("Cache-Control", "private, no-store");
|
|
76
80
|
|
|
77
81
|
const run = async () => {
|
|
78
82
|
const target = new URL(
|
|
@@ -106,6 +110,8 @@ export function applicationPlugins({
|
|
|
106
110
|
)
|
|
107
111
|
return next();
|
|
108
112
|
|
|
113
|
+
outgoing.setHeader("Cache-Control", "private, no-store");
|
|
114
|
+
|
|
109
115
|
const run = async () => {
|
|
110
116
|
const request = await webRequest(incoming, outgoing);
|
|
111
117
|
const entry = await server.ssrLoadModule("/src/entry.server.tsx");
|
|
@@ -138,7 +144,7 @@ export function applicationPlugins({
|
|
|
138
144
|
return [
|
|
139
145
|
application,
|
|
140
146
|
browserBoundary(),
|
|
141
|
-
...(splitRoutes ? [lazyFiles(directory)] : []),
|
|
142
|
-
routerPlugin({ directory, framework: "preact" }),
|
|
147
|
+
...(splitRoutes ? [lazyFiles(directory, dataEndpoint)] : []),
|
|
148
|
+
routerPlugin({ directory, framework: "preact", dataEndpoint }),
|
|
143
149
|
];
|
|
144
150
|
}
|
package/.generated/vite/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { configureTransport } from "./transport.ts";
|
|
|
6
6
|
|
|
7
7
|
export interface FileRouterOptions {
|
|
8
8
|
directory?: string;
|
|
9
|
+
dataEndpoint?: string;
|
|
9
10
|
clientRoutes?: string;
|
|
10
11
|
serverRoutes?: string;
|
|
11
12
|
framework?: "react" | "preact";
|
|
@@ -14,6 +15,7 @@ export interface FileRouterOptions {
|
|
|
14
15
|
/** Build-time flat route discovery and the optional server data transport. */
|
|
15
16
|
export function routerPlugin({
|
|
16
17
|
directory = "src/routes",
|
|
18
|
+
dataEndpoint = "/_smolter",
|
|
17
19
|
clientRoutes,
|
|
18
20
|
serverRoutes,
|
|
19
21
|
framework = "react",
|
|
@@ -166,7 +168,7 @@ export function routerPlugin({
|
|
|
166
168
|
definitions.push(
|
|
167
169
|
options?.ssr || !Object.keys(handlers).length
|
|
168
170
|
? `{ ...route${index}, path: ${JSON.stringify(route.path)} }`
|
|
169
|
-
: `serverRoute(${JSON.stringify(route.path)}, route${index}, ${JSON.stringify(handlers)})`,
|
|
171
|
+
: `serverRoute(${JSON.stringify(route.path)}, route${index}, ${JSON.stringify({ ...handlers, dataEndpoint })})`,
|
|
170
172
|
);
|
|
171
173
|
}
|
|
172
174
|
|
|
@@ -210,7 +212,7 @@ export function routerPlugin({
|
|
|
210
212
|
server.watcher.off("unlink", refresh);
|
|
211
213
|
});
|
|
212
214
|
|
|
213
|
-
configureTransport(server, publicId, framework);
|
|
215
|
+
configureTransport(server, publicId, framework, dataEndpoint);
|
|
214
216
|
},
|
|
215
217
|
};
|
|
216
218
|
}
|
|
@@ -4,9 +4,12 @@ export function configureTransport(
|
|
|
4
4
|
server: ViteDevServer,
|
|
5
5
|
publicId: string,
|
|
6
6
|
framework: "react" | "preact" = "react",
|
|
7
|
+
dataEndpoint = "/_smolter",
|
|
7
8
|
): void {
|
|
8
9
|
server.middlewares.use((incoming, outgoing, next) => {
|
|
9
|
-
if (!incoming.url?.startsWith(
|
|
10
|
+
if (!incoming.url?.startsWith(`${dataEndpoint}?`)) return next();
|
|
11
|
+
|
|
12
|
+
outgoing.setHeader("Cache-Control", "private, no-store");
|
|
10
13
|
|
|
11
14
|
const dispatch = async () => {
|
|
12
15
|
const origin = `http://${incoming.headers.host}`;
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2
|
|
4
|
+
|
|
5
|
+
- Configure transport endpoints consistently across browser handlers, file routes, Express and development middleware for multiple apps sharing one origin.
|
|
6
|
+
- Refresh root data after action redirects, preserve redirect cookies and synchronize hash-history redirects.
|
|
7
|
+
- Recover missing initial route chunks when deployment metadata is passed to mountApplication.
|
|
8
|
+
- Stream Preact SSR with concurrent root/route loading, deferred data, root error boundaries and disconnect cleanup. Hydration begins after the document stream completes; requires preact-render-to-string >=6.8.1.
|
|
9
|
+
- Enforce private, no-store on route-data responses, including errors and mutations.
|
|
10
|
+
- Expose loader execution reason for explicit navigation/revalidation handling.
|
|
11
|
+
- Add regression coverage, production streaming browser tests and updated API documentation.
|
|
12
|
+
|
|
3
13
|
## 0.3.1
|
|
4
14
|
|
|
5
15
|
- Refresh browser and SSR file-route discovery on additions, renames and removals; preserve normal Vite HMR for colocated helpers and components.
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# smolter _
|
|
2
2
|
|
|
3
3
|
<!-- bundlejs:start -->
|
|
4
|
-
[](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.3.2%2Freact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22react%22%2C%22react%2F*%22%5D%7D%7D&bundle)
|
|
5
|
+
[](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.3.2%2Fpreact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22preact%22%2C%22preact%2F*%22%5D%7D%7D&bundle)
|
|
6
6
|
<!-- bundlejs:end -->
|
|
7
7
|
|
|
8
8
|
A typed data router for React and Preact. **Zero runtime dependencies.**
|
|
@@ -534,7 +534,7 @@ consume request bodies with Express body parsers before this handler.
|
|
|
534
534
|
The adapter has no Express dependency and forwards failures to `next(error)`.
|
|
535
535
|
|
|
536
536
|
See the [Express guide](https://kasperrt.github.io/smolter/react/docs/express)
|
|
537
|
-
for a complete server example and
|
|
537
|
+
for a complete server example and streaming and hydration behavior.
|
|
538
538
|
|
|
539
539
|
## Resource subscriptions
|
|
540
540
|
|
|
@@ -603,15 +603,15 @@ a claim of identical features.
|
|
|
603
603
|
| `React Router: data routing subset` | 29780 |
|
|
604
604
|
| `React: routing-only named exports` | 1245 |
|
|
605
605
|
| `Preact: routing-only named exports` | 1405 |
|
|
606
|
-
| `@kasperrt/smolter/react` |
|
|
607
|
-
| `@kasperrt/smolter/preact` |
|
|
608
|
-
| `@kasperrt/smolter/react + @kasperrt/smolter/react/client` |
|
|
609
|
-
| `@kasperrt/smolter/preact + server transport` |
|
|
610
|
-
| `@kasperrt/smolter/preact + client + document (application without lazy routes)` |
|
|
611
|
-
| `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` |
|
|
606
|
+
| `@kasperrt/smolter/react` | 2948 |
|
|
607
|
+
| `@kasperrt/smolter/preact` | 4842 |
|
|
608
|
+
| `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 4046 |
|
|
609
|
+
| `@kasperrt/smolter/preact + server transport` | 5886 |
|
|
610
|
+
| `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 8418 |
|
|
611
|
+
| `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 8704 |
|
|
612
612
|
| `@kasperrt/smolter/react/data (optional resource API)` | 2962 |
|
|
613
|
-
| `React: common routing/data exports` |
|
|
614
|
-
| `Preact: common routing/data exports` |
|
|
613
|
+
| `React: common routing/data exports` | 2761 |
|
|
614
|
+
| `Preact: common routing/data exports` | 3046 |
|
|
615
615
|
| `wouter: routing subset (Router, Route, Switch, Link, useParams, useLocation)` | 2854 |
|
|
616
616
|
| `wouter-preact: same routing subset` | 2211 |
|
|
617
617
|
| `wouter@3.13.0` | 3091 |
|
|
@@ -733,3 +733,21 @@ entrypoints and never replays form submissions.
|
|
|
733
733
|
|
|
734
734
|
See [deployment updates](https://kasperrt.github.io/smolter/react/docs/deployments/)
|
|
735
735
|
for Express, CDN paths, TypeScript declarations and unsaved-state handling.
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
### Shared origins and streaming SSR
|
|
739
|
+
|
|
740
|
+
Set the same `dataEndpoint` in `applicationPlugins` (or `fileRouter`), browser `serverRoute` handlers and `createRequestHandler`. For example, Admin can use `/admin/_smolter` while Platform uses `/_smolter`. Transport responses, including errors and redirects, use `Cache-Control: private, no-store`.
|
|
741
|
+
|
|
742
|
+
Pass deployment metadata before mounting so recovery covers the initial route import:
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
import deployment from "virtual:smolter/deployment";
|
|
746
|
+
import { mountApplication } from "@kasperrt/smolter/preact/client";
|
|
747
|
+
|
|
748
|
+
await mountApplication(routes, root, { deployment });
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Preact SSR starts root and route loaders concurrently. Return nested promises and render them with `Await` inside `Suspense` to stream a shell before optional data resolves. The Node/Express adapter streams the response; hydration starts after the document stream finishes. Root-loader failures render the root error boundary. Vite development HTML transformation still buffers the document.
|
|
752
|
+
|
|
753
|
+
Successful action redirects refresh registered root data without replaying the mutation. Loader arguments expose `reason` to distinguish initial loads, navigation, revalidation and fetcher reads without comparing browser URLs.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region .generated/client/deployment-check.ts
|
|
2
|
+
function deploymentCheck(options) {
|
|
3
|
+
const { version, manifestURL, timeout = 2e3 } = options;
|
|
4
|
+
const manifest = new URL(manifestURL, location.href);
|
|
5
|
+
const key = `smolter:deployment:${manifest.href}:${version}`;
|
|
6
|
+
let stopped = false;
|
|
7
|
+
let pending;
|
|
8
|
+
let controller;
|
|
9
|
+
function changed() {
|
|
10
|
+
if (pending) return pending;
|
|
11
|
+
controller = new AbortController();
|
|
12
|
+
const timer = setTimeout(() => controller?.abort(), timeout);
|
|
13
|
+
pending = fetch(manifest, {
|
|
14
|
+
cache: "no-store",
|
|
15
|
+
signal: controller.signal
|
|
16
|
+
}).then(async (response) => {
|
|
17
|
+
if (!response.ok) return false;
|
|
18
|
+
const data = await response.json();
|
|
19
|
+
return typeof data === "object" && data !== null && "version" in data && typeof data.version === "string" && data.version.length > 0 && data.version !== version;
|
|
20
|
+
}).catch(() => false).finally(() => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
pending = void 0;
|
|
23
|
+
});
|
|
24
|
+
return pending;
|
|
25
|
+
}
|
|
26
|
+
async function reload(target) {
|
|
27
|
+
const allowed = await Promise.resolve().then(() => {
|
|
28
|
+
if (sessionStorage.getItem(key)) return false;
|
|
29
|
+
sessionStorage.setItem(key, "1");
|
|
30
|
+
return true;
|
|
31
|
+
}).catch(() => false);
|
|
32
|
+
if (allowed && !stopped) {
|
|
33
|
+
if (options?.reload) options.reload(target);
|
|
34
|
+
if (!options?.reload) {
|
|
35
|
+
if (target.href === location.href) location.reload();
|
|
36
|
+
if (target.href !== location.href) location.assign(target.href);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return allowed && !stopped;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
changed,
|
|
43
|
+
reload,
|
|
44
|
+
dispose() {
|
|
45
|
+
stopped = true;
|
|
46
|
+
controller?.abort();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { deploymentCheck };
|
|
@@ -10,3 +10,5 @@ export interface DeploymentOptions {
|
|
|
10
10
|
}
|
|
11
11
|
/** Check for deployment changes before navigation. Returns a cleanup function. */
|
|
12
12
|
export declare function watchDeployment(router: NavigationRouter, options: DeploymentOptions | null): () => void;
|
|
13
|
+
/** Recover an initial lazy-import failure before a router has mounted. Returns whether a reload was requested. */
|
|
14
|
+
export declare function recoverDeployment(options: DeploymentOptions | null, destination?: URL): Promise<boolean>;
|
|
@@ -1,50 +1,16 @@
|
|
|
1
|
+
import { deploymentCheck } from "./deployment-check.js";
|
|
1
2
|
//#region .generated/client/deployment.ts
|
|
2
3
|
/** Check for deployment changes before navigation. Returns a cleanup function. */
|
|
3
4
|
function watchDeployment(router, options) {
|
|
4
5
|
if (!options || typeof window === "undefined") return () => {};
|
|
5
|
-
const
|
|
6
|
+
const check = deploymentCheck(options);
|
|
7
|
+
const { changed, reload } = check;
|
|
6
8
|
const navigate = router.navigate;
|
|
7
9
|
const dispose = router.dispose;
|
|
8
|
-
const manifest = new URL(manifestURL, location.href);
|
|
9
|
-
const key = `smolter:deployment:${manifest.href}:${version}`;
|
|
10
10
|
let sequence = 0;
|
|
11
11
|
let stopped = false;
|
|
12
12
|
let replay = false;
|
|
13
13
|
let destination = new URL(location.href);
|
|
14
|
-
let pending;
|
|
15
|
-
let controller;
|
|
16
|
-
function changed() {
|
|
17
|
-
if (pending) return pending;
|
|
18
|
-
controller = new AbortController();
|
|
19
|
-
const timer = setTimeout(() => controller?.abort(), timeout);
|
|
20
|
-
pending = fetch(manifest, {
|
|
21
|
-
cache: "no-store",
|
|
22
|
-
signal: controller.signal
|
|
23
|
-
}).then(async (response) => {
|
|
24
|
-
if (!response.ok) return false;
|
|
25
|
-
const data = await response.json();
|
|
26
|
-
return typeof data === "object" && data !== null && "version" in data && typeof data.version === "string" && data.version.length > 0 && data.version !== version;
|
|
27
|
-
}).catch(() => false).finally(() => {
|
|
28
|
-
clearTimeout(timer);
|
|
29
|
-
pending = void 0;
|
|
30
|
-
});
|
|
31
|
-
return pending;
|
|
32
|
-
}
|
|
33
|
-
async function reload(target) {
|
|
34
|
-
const allowed = await Promise.resolve().then(() => {
|
|
35
|
-
if (sessionStorage.getItem(key)) return false;
|
|
36
|
-
sessionStorage.setItem(key, "1");
|
|
37
|
-
return true;
|
|
38
|
-
}).catch(() => false);
|
|
39
|
-
if (allowed && !stopped) {
|
|
40
|
-
if (options?.reload) options.reload(target);
|
|
41
|
-
if (!options?.reload) {
|
|
42
|
-
if (target.href === location.href) location.reload();
|
|
43
|
-
if (target.href !== location.href) location.assign(target.href);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return allowed && !stopped;
|
|
47
|
-
}
|
|
48
14
|
const guarded = async (href, operation) => {
|
|
49
15
|
const target = new URL(href, router.getSnapshot().url);
|
|
50
16
|
const currentURL = new URL(router.getSnapshot().url);
|
|
@@ -82,7 +48,7 @@ function watchDeployment(router, options) {
|
|
|
82
48
|
function cleanup() {
|
|
83
49
|
stopped = true;
|
|
84
50
|
sequence++;
|
|
85
|
-
|
|
51
|
+
check.dispose();
|
|
86
52
|
window.removeEventListener("popstate", pop, true);
|
|
87
53
|
window.removeEventListener("vite:preloadError", preloadError);
|
|
88
54
|
if (router.navigate === guarded) router.navigate = navigate;
|
|
@@ -98,5 +64,11 @@ function watchDeployment(router, options) {
|
|
|
98
64
|
window.addEventListener("vite:preloadError", preloadError);
|
|
99
65
|
return cleanup;
|
|
100
66
|
}
|
|
67
|
+
/** Recover an initial lazy-import failure before a router has mounted. Returns whether a reload was requested. */
|
|
68
|
+
async function recoverDeployment(options, destination) {
|
|
69
|
+
if (!options || typeof window === "undefined") return false;
|
|
70
|
+
const check = deploymentCheck(options);
|
|
71
|
+
return check.changed().then((changed) => changed ? check.reload(destination ?? new URL(location.href)) : false).finally(check.dispose);
|
|
72
|
+
}
|
|
101
73
|
//#endregion
|
|
102
|
-
export { watchDeployment };
|
|
74
|
+
export { recoverDeployment, watchDeployment };
|
package/dist/client/index.d.ts
CHANGED
|
@@ -19,7 +19,9 @@ export interface ClientRouteModule {
|
|
|
19
19
|
export interface ServerHandlers {
|
|
20
20
|
loader?: boolean;
|
|
21
21
|
action?: boolean;
|
|
22
|
+
/** Application-scoped HTTP transport path. Defaults to /_smolter. */
|
|
23
|
+
dataEndpoint?: string;
|
|
22
24
|
}
|
|
23
25
|
/** Browser transport for server routes. File-router builds generate this call. */
|
|
24
26
|
export declare function serverRoute<M extends RouteModule = RouteModule>(path: string, module: ClientRouteModule, handlers?: ServerHandlers): Route<M>;
|
|
25
|
-
export { type DeploymentOptions, watchDeployment } from "./deployment.ts";
|
|
27
|
+
export { type DeploymentOptions, recoverDeployment, watchDeployment, } from "./deployment.ts";
|
package/dist/client/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { deserialize } from "./deserialize.js";
|
|
2
|
-
import { watchDeployment } from "./deployment.js";
|
|
2
|
+
import { recoverDeployment, watchDeployment } from "./deployment.js";
|
|
3
3
|
//#region .generated/client/index.ts
|
|
4
4
|
/** Browser transport for server routes. File-router builds generate this call. */
|
|
5
5
|
function serverRoute(path, module, handlers = {
|
|
@@ -8,7 +8,7 @@ function serverRoute(path, module, handlers = {
|
|
|
8
8
|
}) {
|
|
9
9
|
const remote = async ({ request, signal }) => {
|
|
10
10
|
const url = new URL(request.url);
|
|
11
|
-
const response = await fetch(
|
|
11
|
+
const response = await fetch(`${handlers.dataEndpoint ?? "/_smolter"}?url=${encodeURIComponent(url.pathname + url.search)}`, {
|
|
12
12
|
method: request.method,
|
|
13
13
|
signal,
|
|
14
14
|
...request.method !== "GET" && { body: await request.formData() }
|
|
@@ -44,4 +44,4 @@ function serverRoute(path, module, handlers = {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
//#endregion
|
|
47
|
-
export { deserialize, serverRoute, watchDeployment };
|
|
47
|
+
export { deserialize, recoverDeployment, serverRoute, watchDeployment };
|
package/dist/core/responses.d.ts
CHANGED
|
@@ -11,5 +11,5 @@ export interface DataResponse<T> {
|
|
|
11
11
|
export declare function data<T>(value: T, init?: ResponseInit): DataResponse<T>;
|
|
12
12
|
export declare function redirect(location: string, init?: number | ResponseInit): Response;
|
|
13
13
|
export declare function isRouteErrorResponse(value: unknown): value is RouteError;
|
|
14
|
-
export declare function normalizeResult(value: unknown): Promise<unknown>;
|
|
14
|
+
export declare function normalizeResult(value: unknown, resolveDeferred?: boolean): Promise<unknown>;
|
|
15
15
|
export declare function resolveData(value: unknown): Promise<unknown>;
|