@orochibraru/svelte-smol 1.8.0 → 1.8.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/README.md +50 -25
- package/dist/index.d.ts +10 -140
- package/dist/index.js +27 -163
- package/dist/kit2/index.d.ts +149 -0
- package/dist/kit2/index.js +181 -0
- package/dist/kit3/index.d.ts +80 -0
- package/dist/kit3/index.js +353 -0
- package/dist/kit3/templates/env.ts +133 -0
- package/dist/kit3/templates/handler.ts +135 -0
- package/dist/kit3/templates/healthcheck.ts +31 -0
- package/dist/kit3/templates/index.ts +114 -0
- package/dist/kit3/templates/routes-util.ts +207 -0
- package/package.json +5 -11
- /package/dist/{templates → kit2/templates}/env.ts +0 -0
- /package/dist/{templates → kit2/templates}/handler.ts +0 -0
- /package/dist/{templates → kit2/templates}/healthcheck.ts +0 -0
- /package/dist/{templates → kit2/templates}/index.ts +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { env_prefix } from "MANIFEST";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
const expected = new Set([
|
|
5
|
+
"SOCKET_PATH",
|
|
6
|
+
"HOST",
|
|
7
|
+
"PORT",
|
|
8
|
+
"REUSE_PORT",
|
|
9
|
+
"IPV6_ONLY",
|
|
10
|
+
"CONNECTION_IDLE_TIMEOUT",
|
|
11
|
+
"BODY_SIZE_LIMIT",
|
|
12
|
+
"SHUTDOWN_TIMEOUT",
|
|
13
|
+
"DEVELOPMENT",
|
|
14
|
+
"XFF_DEPTH",
|
|
15
|
+
"ADDRESS_HEADER",
|
|
16
|
+
"PROTOCOL_HEADER",
|
|
17
|
+
"HOST_HEADER",
|
|
18
|
+
"PORT_HEADER",
|
|
19
|
+
"HEALTHCHECK_PATH",
|
|
20
|
+
"HEALTHCHECK_TIMEOUT",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
if (env_prefix) {
|
|
24
|
+
for (const name in process.env) {
|
|
25
|
+
if (
|
|
26
|
+
name.startsWith(env_prefix) &&
|
|
27
|
+
!expected.has(name.slice(env_prefix.length))
|
|
28
|
+
) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`You should change envPrefix (${env_prefix}) to avoid conflicts with existing environment variables — unexpectedly saw ${name}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parsing_error(name: string, value: string, expected: string): never {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Invalid value for environment variable ${env_prefix + name}: ${JSON.stringify(value)} (expected ${expected})`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function env<T extends string | undefined = undefined>(
|
|
43
|
+
name: string,
|
|
44
|
+
fallback?: T,
|
|
45
|
+
): string | T {
|
|
46
|
+
const prefixed = env_prefix + name;
|
|
47
|
+
return prefixed in process.env
|
|
48
|
+
? (process.env[prefixed] as string)
|
|
49
|
+
: (fallback as T);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const BOOLEANS: Record<string, boolean> = {
|
|
53
|
+
1: true,
|
|
54
|
+
true: true,
|
|
55
|
+
yes: true,
|
|
56
|
+
on: true,
|
|
57
|
+
0: false,
|
|
58
|
+
false: false,
|
|
59
|
+
no: false,
|
|
60
|
+
off: false,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function boolean_env<T extends boolean | undefined = undefined>(
|
|
64
|
+
name: string,
|
|
65
|
+
fallback?: T,
|
|
66
|
+
): boolean | T {
|
|
67
|
+
const value = env(name);
|
|
68
|
+
if (value === undefined) return fallback as T;
|
|
69
|
+
return (
|
|
70
|
+
BOOLEANS[value.toLowerCase()] ?? parsing_error(name, value, "a boolean")
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function number_env<T extends number | undefined = undefined>(
|
|
75
|
+
name: string,
|
|
76
|
+
fallback?: T,
|
|
77
|
+
limits: { min?: number; max?: number } = {},
|
|
78
|
+
): number | T {
|
|
79
|
+
const value = env(name);
|
|
80
|
+
if (value === undefined) return fallback as T;
|
|
81
|
+
if (!/^\d+$/.test(value)) {
|
|
82
|
+
parsing_error(name, value, "a non-negative integer");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const number = Number(value);
|
|
86
|
+
if (
|
|
87
|
+
!Number.isSafeInteger(number) ||
|
|
88
|
+
number < (limits.min ?? 0) ||
|
|
89
|
+
number > (limits.max ?? Number.POSITIVE_INFINITY)
|
|
90
|
+
) {
|
|
91
|
+
const range =
|
|
92
|
+
limits.max === undefined
|
|
93
|
+
? `at least ${limits.min ?? 0}`
|
|
94
|
+
: `between ${limits.min ?? 0} and ${limits.max}`;
|
|
95
|
+
parsing_error(name, value, `an integer ${range}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function bytes_env<T extends number | undefined = undefined>(
|
|
102
|
+
name: string,
|
|
103
|
+
fallback?: T,
|
|
104
|
+
): number | T {
|
|
105
|
+
const value = env(name);
|
|
106
|
+
if (value === undefined) return fallback as T;
|
|
107
|
+
// adapter-node documents Infinity as the value that disables the limit
|
|
108
|
+
if (value === "Infinity") return Number.POSITIVE_INFINITY;
|
|
109
|
+
if (!/^(?:\d+(?:\.\d*)?|\.\d+)(?:[KMG])?$/i.test(value)) {
|
|
110
|
+
parsing_error(
|
|
111
|
+
name,
|
|
112
|
+
value,
|
|
113
|
+
"a non-negative number with an optional K, M, or G suffix, or Infinity",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const suffix = value.at(-1)?.toUpperCase() as string;
|
|
118
|
+
const multiplier =
|
|
119
|
+
(
|
|
120
|
+
{ K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 } as Record<
|
|
121
|
+
string,
|
|
122
|
+
number
|
|
123
|
+
>
|
|
124
|
+
)[suffix] ?? 1;
|
|
125
|
+
const number =
|
|
126
|
+
Number(multiplier === 1 ? value : value.slice(0, -1)) * multiplier;
|
|
127
|
+
|
|
128
|
+
if (!Number.isSafeInteger(number)) {
|
|
129
|
+
parsing_error(name, value, "a non-negative number of whole bytes");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return number;
|
|
133
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { app_dir, base, env_prefix, origin } from "MANIFEST";
|
|
2
|
+
import { server_assets } from "ROUTES";
|
|
3
|
+
import { server } from "SERVER";
|
|
4
|
+
import type { Server as BunServer } from "bun";
|
|
5
|
+
import { env, number_env } from "./env.ts";
|
|
6
|
+
|
|
7
|
+
const address_header = env("ADDRESS_HEADER", "").toLowerCase();
|
|
8
|
+
const protocol_header = env("PROTOCOL_HEADER", "").toLowerCase();
|
|
9
|
+
const host_header = env("HOST_HEADER", "").toLowerCase();
|
|
10
|
+
const port_header = env("PORT_HEADER", "").toLowerCase();
|
|
11
|
+
const xff_depth = number_env("XFF_DEPTH", 1, { min: 1 });
|
|
12
|
+
|
|
13
|
+
await server.init({
|
|
14
|
+
env: Bun.env as Record<string, string>,
|
|
15
|
+
read: (file) => server_assets.get(file)?.stream() ?? null,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** The Bun-native SvelteKit request handler used by the generated server. */
|
|
19
|
+
export async function handler(
|
|
20
|
+
request: Request,
|
|
21
|
+
bun_server: BunServer<undefined>,
|
|
22
|
+
): Promise<Response> {
|
|
23
|
+
const normalized_request = normalize_request(request);
|
|
24
|
+
if (normalized_request instanceof Response) return normalized_request;
|
|
25
|
+
|
|
26
|
+
const response = await server.respond(normalized_request, {
|
|
27
|
+
platform: { server: bun_server },
|
|
28
|
+
getClientAddress: () => get_client_address(request, bun_server),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (response.headers.get("content-type")?.startsWith("text/event-stream")) {
|
|
32
|
+
bun_server.timeout(request, 0);
|
|
33
|
+
response.headers.set("x-accel-buffering", "no");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Rewrites the request onto the public origin the user actually requested. */
|
|
40
|
+
function normalize_request(request: Request): Request | Response {
|
|
41
|
+
try {
|
|
42
|
+
// an empty Host header makes request.url relative, so parsing belongs in the try
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
const request_origin =
|
|
45
|
+
same_host_remote_origin(request, url) ||
|
|
46
|
+
origin ||
|
|
47
|
+
get_origin(request, url);
|
|
48
|
+
return request_origin === url.origin
|
|
49
|
+
? request
|
|
50
|
+
: new Request(request_origin + url.pathname + url.search, request);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(
|
|
53
|
+
`Could not determine request origin: ${error instanceof Error ? error.message : String(error)}`,
|
|
54
|
+
);
|
|
55
|
+
return new Response("Bad Request", { status: 400 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function get_origin(request: Request, url: URL): string {
|
|
60
|
+
// assume TLS terminates upstream, like adapter-node; an http origin would fail CSRF checks
|
|
61
|
+
const protocol = decodeURIComponent(
|
|
62
|
+
(protocol_header && request.headers.get(protocol_header)) || "https",
|
|
63
|
+
);
|
|
64
|
+
if (!/^https?$/i.test(protocol)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`The ${protocol_header} header specified ${protocol} which is an invalid protocol scheme. It should only contain the protocol scheme (e.g. \`https\`)`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const host =
|
|
71
|
+
(host_header && request.headers.get(host_header)) ||
|
|
72
|
+
(request.headers.get("host") ?? url.host);
|
|
73
|
+
if (!host) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Could not determine host from the ${host_header ? `${host_header} or ` : ""}host header`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const port = port_header ? request.headers.get(port_header) : null;
|
|
80
|
+
if (port && Number.isNaN(+port)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`The ${port_header} header specified ${port} which is an invalid port because it is not a number. The value should only contain the port number (e.g. 443)`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// canonicalized so the caller's comparison with url.origin matches (case, default ports)
|
|
87
|
+
return new URL(`${protocol}://${host}${port ? `:${port}` : ""}`).origin;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function get_client_address(
|
|
91
|
+
request: Request,
|
|
92
|
+
bun_server: BunServer<undefined>,
|
|
93
|
+
): string {
|
|
94
|
+
if (!address_header) {
|
|
95
|
+
// requestIP() is null over unix sockets; undefined matches adapter-node
|
|
96
|
+
return bun_server.requestIP(request)?.address as string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const value = request.headers.get(address_header);
|
|
100
|
+
if (value === null) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Address header was specified with ${env_prefix}ADDRESS_HEADER=${address_header} but is absent from request`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (address_header !== "x-forwarded-for") return value;
|
|
106
|
+
|
|
107
|
+
const addresses = value.split(",");
|
|
108
|
+
if (xff_depth > addresses.length) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`${env_prefix}XFF_DEPTH is ${xff_depth}, but only found ${addresses.length} addresses`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return (addresses[addresses.length - xff_depth] as string).trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Remote function calls are CSRF-checked against the request origin. When the
|
|
117
|
+
// browser's Origin header matches the Host, use it instead of `paths.origin`.
|
|
118
|
+
function same_host_remote_origin(request: Request, url: URL) {
|
|
119
|
+
const request_origin = request.headers.get("origin");
|
|
120
|
+
if (
|
|
121
|
+
!request_origin ||
|
|
122
|
+
!url.pathname.startsWith(`${base}/${app_dir}/remote/`)
|
|
123
|
+
) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const parsed = new URL(request_origin);
|
|
128
|
+
const host =
|
|
129
|
+
(host_header && request.headers.get(host_header)) ||
|
|
130
|
+
request.headers.get("host");
|
|
131
|
+
return parsed.host === host ? parsed.origin : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { healthcheck_path } from "MANIFEST";
|
|
2
|
+
import server_options from "SERVER_OPTIONS";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { env, number_env } from "./env.ts";
|
|
5
|
+
|
|
6
|
+
const unix = env("SOCKET_PATH", server_options.unix);
|
|
7
|
+
const timeout = number_env("HEALTHCHECK_TIMEOUT", 2000);
|
|
8
|
+
const path = env("HEALTHCHECK_PATH", healthcheck_path || "/_health");
|
|
9
|
+
|
|
10
|
+
// a server bound to a wildcard address is reached over loopback
|
|
11
|
+
const raw_host = env("HOST", server_options.hostname) ?? "";
|
|
12
|
+
const host = ["", "0.0.0.0", "::"].includes(raw_host) ? "127.0.0.1" : raw_host;
|
|
13
|
+
const port = env("PORT", server_options.port?.toString()) ?? 3000;
|
|
14
|
+
|
|
15
|
+
const url = unix
|
|
16
|
+
? `http://localhost${path}`
|
|
17
|
+
: `http://${host.includes(":") ? `[${host}]` : host}:${port}${path}`;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(url, {
|
|
21
|
+
signal: AbortSignal.timeout(timeout),
|
|
22
|
+
...(unix ? { unix } : {}),
|
|
23
|
+
});
|
|
24
|
+
if (!response.ok) throw new Error(`status ${response.status}`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(
|
|
28
|
+
`unhealthy: ${url} -> ${error instanceof Error ? error.message : String(error)}`,
|
|
29
|
+
);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { healthcheck_path } from "MANIFEST";
|
|
2
|
+
import { routes } from "ROUTES";
|
|
3
|
+
import server_options from "SERVER_OPTIONS";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import type { Serve } from "bun";
|
|
7
|
+
import { boolean_env, bytes_env, env, number_env } from "./env.ts";
|
|
8
|
+
import { handler } from "./handler.ts";
|
|
9
|
+
|
|
10
|
+
// nothing enforces `engines.bun` at install time, and the build may have run on a newer Bun.
|
|
11
|
+
// order() rather than satisfies(): a range would reject canary builds such as 1.5.0-canary.1
|
|
12
|
+
if (Bun.semver.order(Bun.version, "1.4.0") < 0) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`@orochibraru/svelte-smol requires Bun 1.4 or newer, but this is Bun ${Bun.version}`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const options = { ...server_options } as Serve.Options<undefined, string>;
|
|
19
|
+
|
|
20
|
+
const unix = env("SOCKET_PATH", options.unix);
|
|
21
|
+
|
|
22
|
+
if (unix) {
|
|
23
|
+
options.unix = unix;
|
|
24
|
+
delete options.hostname;
|
|
25
|
+
delete options.port;
|
|
26
|
+
delete options.reusePort;
|
|
27
|
+
delete options.ipv6Only;
|
|
28
|
+
|
|
29
|
+
// an unclean shutdown leaves the socket file behind and the next listen would
|
|
30
|
+
// fail with EADDRINUSE; the zero-size check (same heuristic as adapter-node)
|
|
31
|
+
// avoids deleting a regular file that happens to sit at this path
|
|
32
|
+
try {
|
|
33
|
+
if (fs.statSync(unix).size === 0) fs.rmSync(unix);
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
delete options.unix;
|
|
39
|
+
options.hostname = env("HOST", options.hostname);
|
|
40
|
+
// always set an explicit port: left undefined, Bun.serve reads the unprefixed
|
|
41
|
+
// BUN_PORT/PORT/NODE_PORT itself, bypassing envPrefix isolation
|
|
42
|
+
options.port = env("PORT", options.port?.toString()) ?? 3000;
|
|
43
|
+
options.reusePort = boolean_env("REUSE_PORT", options.reusePort);
|
|
44
|
+
options.ipv6Only = boolean_env("IPV6_ONLY", options.ipv6Only);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// not IDLE_TIMEOUT: that name means idle-shutdown seconds on adapter-node, and a
|
|
48
|
+
// carried-over value would crash on the 255 cap or silently kill slow requests
|
|
49
|
+
options.idleTimeout = number_env(
|
|
50
|
+
"CONNECTION_IDLE_TIMEOUT",
|
|
51
|
+
options.idleTimeout,
|
|
52
|
+
{ max: 255 },
|
|
53
|
+
);
|
|
54
|
+
options.development =
|
|
55
|
+
boolean_env("DEVELOPMENT") ?? options.development ?? false;
|
|
56
|
+
options.maxRequestBodySize = bytes_env(
|
|
57
|
+
"BODY_SIZE_LIMIT",
|
|
58
|
+
options.maxRequestBodySize ?? 512 * 1024,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const shutdown_timeout = number_env("SHUTDOWN_TIMEOUT", 30);
|
|
62
|
+
|
|
63
|
+
options.fetch = handler;
|
|
64
|
+
options.routes = healthcheck_path
|
|
65
|
+
? {
|
|
66
|
+
...routes,
|
|
67
|
+
[healthcheck_path]: {
|
|
68
|
+
GET: () =>
|
|
69
|
+
Response.json(
|
|
70
|
+
{ status: "ok", uptime: Math.round(process.uptime()) },
|
|
71
|
+
{ headers: { "cache-control": "no-store" } },
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
: routes;
|
|
76
|
+
|
|
77
|
+
const server = Bun.serve(options);
|
|
78
|
+
|
|
79
|
+
console.log(unix ? `Listening on ${unix}` : `Listening on ${server.url}`);
|
|
80
|
+
|
|
81
|
+
let shutting_down = false;
|
|
82
|
+
|
|
83
|
+
async function graceful_shutdown(reason: "SIGINT" | "SIGTERM") {
|
|
84
|
+
if (shutting_down) return process.exit(1);
|
|
85
|
+
shutting_down = true;
|
|
86
|
+
|
|
87
|
+
if (server.pendingRequests !== 0) {
|
|
88
|
+
console.log(
|
|
89
|
+
`Waiting for ${server.pendingRequests} requests to finish before shutting down...\n` +
|
|
90
|
+
"Press Ctrl+C again to force shutdown.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// stop() waits for in-flight requests, and an open event stream is one forever,
|
|
95
|
+
// so race the drain against the deadline. The timer must stay referenced: a
|
|
96
|
+
// draining server no longer holds the event loop open on its own
|
|
97
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
98
|
+
const drained = await Promise.race([
|
|
99
|
+
server.stop().then(() => true),
|
|
100
|
+
new Promise<boolean>((resolve) => {
|
|
101
|
+
deadline = setTimeout(() => resolve(false), shutdown_timeout * 1000);
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
clearTimeout(deadline);
|
|
105
|
+
|
|
106
|
+
// force-close aborts the in-flight handlers, so shutdown listeners do not tear
|
|
107
|
+
// down resources those handlers still hold
|
|
108
|
+
if (!drained) await server.stop(true);
|
|
109
|
+
|
|
110
|
+
process.emit("sveltekit:shutdown" as "exit", reason as never);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
process.on("SIGTERM", () => graceful_shutdown("SIGTERM"));
|
|
114
|
+
process.on("SIGINT", () => graceful_shutdown("SIGINT"));
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { app_dir, base, embed } from "MANIFEST";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { BunRequest, Serve } from "bun";
|
|
5
|
+
|
|
6
|
+
type RouteHandler = Serve.Routes<undefined, string>[string];
|
|
7
|
+
type AssetMeta = { hash: string; mtime: number; br?: boolean; gz?: boolean };
|
|
8
|
+
|
|
9
|
+
// not Bun.main: when the built server is imported from a wrapper script rather than
|
|
10
|
+
// run directly, Bun.main is the wrapper and every asset path resolves wrong
|
|
11
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
/** Embedded assets are imported by identity path; on-disk assets live next to this module. */
|
|
14
|
+
function resolve_file(subdir: string, filename: string) {
|
|
15
|
+
return embed ? filename : path.resolve(dir, subdir, filename);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CONTENT_ENCODING = { br: "br", gz: "gzip" };
|
|
19
|
+
|
|
20
|
+
// the URL Standard's path percent-encode set, plus `%` and `\` so they stay literal
|
|
21
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are part of the encode set
|
|
22
|
+
const ESCAPED_PATH_CHAR = /[\u0000-\u001f\u007f-\u{10ffff} "#<>?`{}%\\]/gu;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* WHATWG path serialization: the exact bytes user agents put on the wire, because
|
|
26
|
+
* Bun matches route keys against the raw request pathname.
|
|
27
|
+
*/
|
|
28
|
+
function encode_pathname(pathname: string) {
|
|
29
|
+
return pathname.replace(ESCAPED_PATH_CHAR, (char) =>
|
|
30
|
+
encodeURIComponent(char),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Registers a path both as user agents send it and fully percent-encoded. */
|
|
35
|
+
function route_paths(pathname: string) {
|
|
36
|
+
const minimal = encode_pathname(pathname);
|
|
37
|
+
const full = pathname.split("/").map(encodeURIComponent).join("/");
|
|
38
|
+
return minimal === full ? [minimal] : [minimal, full];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function to_paths(url: string) {
|
|
42
|
+
return route_paths(path.posix.join(base, url));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function to_directory_paths(url: string) {
|
|
46
|
+
const directory = `${path.posix.join(base, url).replace(/\/$/, "")}/`;
|
|
47
|
+
const paths = route_paths(directory);
|
|
48
|
+
if (directory !== "/") {
|
|
49
|
+
paths.push(...route_paths(directory.slice(0, -1)));
|
|
50
|
+
}
|
|
51
|
+
return paths;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* If-None-Match takes precedence over If-Modified-Since (RFC 9110 §13.1.3);
|
|
56
|
+
* dates compare at whole-second precision because HTTP dates have none finer.
|
|
57
|
+
*/
|
|
58
|
+
function is_fresh(request: Request, etag: string, mtime: number) {
|
|
59
|
+
const header = request.headers.get("if-none-match");
|
|
60
|
+
if (header !== null) {
|
|
61
|
+
return header
|
|
62
|
+
.split(",")
|
|
63
|
+
.some((value) => ["*", etag].includes(value.trim().replace(/^W\//, "")));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const since = Date.parse(request.headers.get("if-modified-since") ?? "");
|
|
67
|
+
return (
|
|
68
|
+
Number.isFinite(since) &&
|
|
69
|
+
Math.trunc(mtime / 1000) <= Math.trunc(since / 1000)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function negotiate(accept: string | null, meta: AssetMeta): "br" | "gz" | null {
|
|
74
|
+
if (accept === null || (!meta.br && !meta.gz)) return null;
|
|
75
|
+
|
|
76
|
+
const accepted = new Set<string>();
|
|
77
|
+
for (const part of accept.split(",")) {
|
|
78
|
+
const [name = "", ...params] = part.trim().toLowerCase().split(";");
|
|
79
|
+
if (params.some((param) => /^q=0(\.0*)?$/.test(param.trim()))) continue;
|
|
80
|
+
accepted.add(name.trim());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (meta.br && (accepted.has("br") || accepted.has("*"))) return "br";
|
|
84
|
+
if (meta.gz && (accepted.has("gzip") || accepted.has("*"))) return "gz";
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function route_entries(
|
|
89
|
+
paths: string[],
|
|
90
|
+
route: RouteHandler,
|
|
91
|
+
): Array<[string, RouteHandler]> {
|
|
92
|
+
return paths.map((route_path) => [route_path, route]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Serves one file with its build-time validator and precompressed variants. */
|
|
96
|
+
function file_route(
|
|
97
|
+
file: string,
|
|
98
|
+
meta: AssetMeta,
|
|
99
|
+
extra_headers: Record<string, string> = {},
|
|
100
|
+
): RouteHandler {
|
|
101
|
+
const content_type = Bun.file(file).type;
|
|
102
|
+
const last_modified = new Date(meta.mtime).toUTCString();
|
|
103
|
+
|
|
104
|
+
const handler = (request: BunRequest) => {
|
|
105
|
+
// Bun serializes Range itself for file bodies; ranges apply to the identity representation
|
|
106
|
+
const encoding =
|
|
107
|
+
request.headers.get("range") === null
|
|
108
|
+
? negotiate(request.headers.get("accept-encoding"), meta)
|
|
109
|
+
: null;
|
|
110
|
+
const etag =
|
|
111
|
+
encoding === null ? `"${meta.hash}"` : `"${meta.hash}-${encoding}"`;
|
|
112
|
+
|
|
113
|
+
const response_headers: Record<string, string> = {
|
|
114
|
+
"content-type": content_type,
|
|
115
|
+
...extra_headers,
|
|
116
|
+
etag,
|
|
117
|
+
"last-modified": last_modified,
|
|
118
|
+
};
|
|
119
|
+
if (meta.br || meta.gz) response_headers.vary = "accept-encoding";
|
|
120
|
+
|
|
121
|
+
if (is_fresh(request, etag, meta.mtime)) {
|
|
122
|
+
return new Response(null, { status: 304, headers: response_headers });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let body_file = file;
|
|
126
|
+
if (encoding !== null) {
|
|
127
|
+
response_headers["content-encoding"] = CONTENT_ENCODING[encoding];
|
|
128
|
+
body_file = `${file}.${encoding}`;
|
|
129
|
+
}
|
|
130
|
+
return new Response(Bun.file(body_file), { headers: response_headers });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return { GET: handler };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function client_asset(
|
|
137
|
+
url: string,
|
|
138
|
+
filename: string | undefined,
|
|
139
|
+
meta: AssetMeta,
|
|
140
|
+
) {
|
|
141
|
+
const immutable = url.startsWith(`${app_dir}/immutable/`);
|
|
142
|
+
const route = file_route(
|
|
143
|
+
resolve_file("client", filename ?? url),
|
|
144
|
+
meta,
|
|
145
|
+
immutable ? { "cache-control": "public,max-age=31536000,immutable" } : {},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const paths = to_paths(url);
|
|
149
|
+
if (url.endsWith("/index.html") || url === "index.html") {
|
|
150
|
+
paths.push(...to_directory_paths(url.slice(0, -"index.html".length)));
|
|
151
|
+
} else if (url.endsWith(".html")) {
|
|
152
|
+
// sirv also serves `page.html` at `/page`
|
|
153
|
+
paths.push(...to_paths(url.slice(0, -".html".length)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return route_entries(paths, route);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function server_asset(url: string, filename = url) {
|
|
160
|
+
return Bun.file(resolve_file("client", filename));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function prerendered_asset(
|
|
164
|
+
url: string,
|
|
165
|
+
filename: string | undefined,
|
|
166
|
+
meta: AssetMeta,
|
|
167
|
+
) {
|
|
168
|
+
const route = file_route(resolve_file("prerendered", filename ?? url), meta);
|
|
169
|
+
return route_entries(to_paths(url), route);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function prerendered_page(
|
|
173
|
+
url: string,
|
|
174
|
+
filename: string,
|
|
175
|
+
meta: AssetMeta,
|
|
176
|
+
) {
|
|
177
|
+
const route = file_route(resolve_file("prerendered", filename), meta);
|
|
178
|
+
// path already contains base, no need to add it here
|
|
179
|
+
const entries = route_entries(route_paths(url), route);
|
|
180
|
+
|
|
181
|
+
const inverted = url.endsWith("/") ? url.slice(0, -1) : `${url}/`;
|
|
182
|
+
if (inverted) {
|
|
183
|
+
const canonical = encode_pathname(url);
|
|
184
|
+
entries.push(
|
|
185
|
+
...route_entries(route_paths(inverted), {
|
|
186
|
+
GET: (req: BunRequest) => {
|
|
187
|
+
const location = canonical + new URL(req.url).search;
|
|
188
|
+
return new Response(null, { status: 308, headers: { location } });
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return entries;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function prerendered_redirect(
|
|
198
|
+
url: string,
|
|
199
|
+
status: number,
|
|
200
|
+
location: string,
|
|
201
|
+
) {
|
|
202
|
+
const route = {
|
|
203
|
+
GET: new Response(null, { status, headers: { location } }),
|
|
204
|
+
};
|
|
205
|
+
// path already contains base, no need to add it here
|
|
206
|
+
return route_entries(route_paths(url), route);
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orochibraru/svelte-smol",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"keywords": [
|
|
@@ -33,32 +33,26 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R templates dist/templates",
|
|
36
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R kit2/templates dist/kit2/templates && cp -R kit3/templates dist/kit3/templates",
|
|
37
37
|
"prepack": "bun run build",
|
|
38
|
-
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
38
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p kit2 && tsc --noEmit -p kit3",
|
|
39
39
|
"test": "bun test index.test.ts",
|
|
40
|
-
"test:integration": "bun test
|
|
40
|
+
"test:integration": "bun test integration",
|
|
41
41
|
"lint": "biome check .",
|
|
42
42
|
"lint:fix": "biome check --write .",
|
|
43
|
-
"release": "semantic-release",
|
|
44
|
-
"release:dry": "semantic-release --dry-run --no-ci",
|
|
45
43
|
"prepare": "husky"
|
|
46
44
|
},
|
|
47
45
|
"devDependencies": {
|
|
48
46
|
"@biomejs/biome": "^2.5.11",
|
|
49
|
-
"@semantic-release/github": "^12.0.9",
|
|
50
|
-
"@semantic-release/npm": "^13.1.5",
|
|
51
47
|
"@sveltejs/kit": "^2.70.3",
|
|
52
48
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
53
49
|
"@types/bun": "^1.4.0",
|
|
54
|
-
"conventional-changelog-conventionalcommits": "^8",
|
|
55
50
|
"husky": "^9.1.7",
|
|
56
|
-
"semantic-release": "^25.0.9",
|
|
57
51
|
"svelte": "^5.56.10",
|
|
58
52
|
"typescript": "^7",
|
|
59
53
|
"vite": "^8.2.2"
|
|
60
54
|
},
|
|
61
55
|
"peerDependencies": {
|
|
62
|
-
"@sveltejs/kit": "^2.0.0"
|
|
56
|
+
"@sveltejs/kit": "^2.0.0 || ^3.0.0-next.0"
|
|
63
57
|
}
|
|
64
58
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|