@orochibraru/svelte-smol 1.3.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.
- package/README.md +100 -0
- package/index.ts +238 -0
- package/internal.d.ts +38 -0
- package/package.json +55 -0
- package/templates/env.ts +38 -0
- package/templates/handler.ts +215 -0
- package/templates/index.ts +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Svelte Smol Adapter
|
|
2
|
+
|
|
3
|
+
A [SvelteKit](https://svelte.dev/docs/kit) adapter that compiles your app into a
|
|
4
|
+
**single standalone executable** with `bun build --compile`. No `node_modules`,
|
|
5
|
+
no JS files to ship — just one binary plus its static assets.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add -d @orochibraru/svelte-adapter-bun
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// svelte.config.js
|
|
17
|
+
import adapter from "@orochibraru/svelte-adapter-bun";
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
kit: {
|
|
21
|
+
adapter: adapter(),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Because the adapter touches `Bun.*` at config-load time, run the build with the
|
|
27
|
+
Bun runtime:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun --bun vite build
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Output
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
build/
|
|
37
|
+
├── server # the compiled executable
|
|
38
|
+
├── client/ # static assets, served by the executable
|
|
39
|
+
└── prerendered/ # prerendered pages, served by the executable
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Deploy the whole `build/` directory (or just `server` if a proxy/CDN serves the
|
|
43
|
+
assets — see `serveAssets`). The executable locates `client/` and `prerendered/`
|
|
44
|
+
relative to its own path, so it can be run from any working directory:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
./build/server
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
adapter({
|
|
54
|
+
out: "build", // output directory
|
|
55
|
+
name: "server", // executable filename within `out`
|
|
56
|
+
target: undefined, // cross-compile target, e.g. "bun-linux-x64"
|
|
57
|
+
bytecode: false, // embed a V8 bytecode cache (faster cold start, bigger binary)
|
|
58
|
+
minify: false, // minify the bundled server code
|
|
59
|
+
sourcemap: false, // embed a source map for server stack traces
|
|
60
|
+
precompress: false, // emit + serve .gz / .br sibling files
|
|
61
|
+
envPrefix: "", // prefix for the runtime env vars below
|
|
62
|
+
serveAssets: true, // serve client/ and prerendered/ from the binary
|
|
63
|
+
serveOptions: {}, // extra Bun.serve() options (tls, reusePort, …)
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Cross-compilation
|
|
68
|
+
|
|
69
|
+
`target` accepts any Bun compile target, e.g. `"bun-linux-x64"`,
|
|
70
|
+
`"bun-linux-arm64-musl"` (Alpine), `"bun-darwin-arm64"`, `"bun-windows-x64"`,
|
|
71
|
+
with optional `-modern` / `-baseline` SIMD suffixes. Bun downloads the matching
|
|
72
|
+
runtime the first time you use a target.
|
|
73
|
+
|
|
74
|
+
## Runtime environment variables
|
|
75
|
+
|
|
76
|
+
| Variable | Default | Purpose |
|
|
77
|
+
| ------------------ | --------- | ----------------------------------------------------------------------------------------------- |
|
|
78
|
+
| `HOST` | `0.0.0.0` | Listen address |
|
|
79
|
+
| `PORT` | `3000` | Listen port |
|
|
80
|
+
| `SOCKET_PATH` | — | Listen on a Unix socket instead of `HOST`/`PORT` |
|
|
81
|
+
| `ASSETS_DIR` | — | Override where `client/` and `prerendered/` are looked up (absolute, or relative to the binary) |
|
|
82
|
+
| `ORIGIN` | — | Absolute origin used for request URL resolution |
|
|
83
|
+
| `PROTOCOL_HEADER` | — | Header carrying the forwarded protocol (e.g. `x-forwarded-proto`) |
|
|
84
|
+
| `HOST_HEADER` | — | Header carrying the forwarded host |
|
|
85
|
+
| `PORT_HEADER` | — | Header carrying the forwarded port |
|
|
86
|
+
| `ADDRESS_HEADER` | — | Header carrying the client address (e.g. `x-forwarded-for`) |
|
|
87
|
+
| `XFF_DEPTH` | `1` | Trusted-proxy depth when `ADDRESS_HEADER=x-forwarded-for` |
|
|
88
|
+
| `BODY_SIZE_LIMIT` | `512K` | Max request body size (`K`/`M`/`G` suffixes allowed) |
|
|
89
|
+
| `IDLE_TIMEOUT` | `10` | Bun socket idle timeout in seconds (SSE responses opt out) |
|
|
90
|
+
| `SHUTDOWN_TIMEOUT` | `30` | Seconds to wait for in-flight requests on `SIGINT`/`SIGTERM` |
|
|
91
|
+
|
|
92
|
+
Set `envPrefix` to namespace these (`envPrefix: "MY_APP_"` → `MY_APP_PORT`).
|
|
93
|
+
|
|
94
|
+
## Notes
|
|
95
|
+
|
|
96
|
+
- The SvelteKit server code is JavaScript emitted by Vite; `--compile` embeds it
|
|
97
|
+
in the binary, so nothing but the executable ships. Native (`.node`) modules in
|
|
98
|
+
your dependencies are the one thing that can't be bundled this way.
|
|
99
|
+
- WebSockets, `read()` from `$app/server`, prerendering, and server
|
|
100
|
+
instrumentation are all supported.
|
package/index.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import type { Adapter, Builder } from "@sveltejs/kit";
|
|
3
|
+
|
|
4
|
+
export interface AdapterOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Output directory. Holds the compiled executable plus the `client/` and
|
|
7
|
+
* `prerendered/` folders it serves.
|
|
8
|
+
* @default "build"
|
|
9
|
+
*/
|
|
10
|
+
out?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Filename of the compiled executable, written into `out`.
|
|
13
|
+
* @default "server"
|
|
14
|
+
*/
|
|
15
|
+
name?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Cross-compilation target for `bun build --compile`, e.g.
|
|
18
|
+
* `"bun-linux-x64"`, `"bun-linux-arm64-musl"`, `"bun-darwin-arm64"`,
|
|
19
|
+
* `"bun-windows-x64"` (optional `-modern` / `-baseline` SIMD suffix, and
|
|
20
|
+
* `-musl` for Alpine). Bun downloads the matching runtime on first use.
|
|
21
|
+
* @default the host platform
|
|
22
|
+
*/
|
|
23
|
+
target?: Bun.Build.CompileTarget;
|
|
24
|
+
/**
|
|
25
|
+
* Emit a V8 bytecode cache into the executable for faster cold starts, at
|
|
26
|
+
* the cost of a larger binary.
|
|
27
|
+
* @default false
|
|
28
|
+
*/
|
|
29
|
+
bytecode?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Minify the bundled server code before embedding it.
|
|
32
|
+
* @default false
|
|
33
|
+
*/
|
|
34
|
+
minify?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Embed a source map so server stack traces point at original code.
|
|
37
|
+
* @default false
|
|
38
|
+
*/
|
|
39
|
+
sourcemap?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Pre-compress client assets and prerendered pages to `.gz` / `.br`
|
|
42
|
+
* siblings. The handler serves them with the matching `Content-Encoding`
|
|
43
|
+
* when the request's `Accept-Encoding` allows.
|
|
44
|
+
* @default false
|
|
45
|
+
*/
|
|
46
|
+
precompress?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Prefix for this adapter's own runtime env vars (`PORT`, `HOST`,
|
|
49
|
+
* `ORIGIN`, ...).
|
|
50
|
+
* @default ""
|
|
51
|
+
*/
|
|
52
|
+
envPrefix?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Serve the app's static assets and prerendered pages from the
|
|
55
|
+
* executable. The `client/` and `prerendered/` folders must sit next to
|
|
56
|
+
* the binary (or point `ASSETS_DIR` at their parent). Turn off when a
|
|
57
|
+
* reverse proxy or CDN serves them instead.
|
|
58
|
+
* @default true
|
|
59
|
+
*/
|
|
60
|
+
serveAssets?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Extra options merged into the `Bun.serve()` call (`tls`, `reusePort`,
|
|
63
|
+
* `maxConnections`, a custom `error` handler, ...). Applied *before* this
|
|
64
|
+
* adapter's own required fields (`idleTimeout`, `maxRequestBodySize`,
|
|
65
|
+
* `fetch`, `hostname`/`port`/`unix`, `websocket`), so it can't be used to
|
|
66
|
+
* override request handling, only to add to it. Use the existing env vars
|
|
67
|
+
* (`IDLE_TIMEOUT`, `BODY_SIZE_LIMIT`, `HOST`/`PORT`, `SOCKET_PATH`,
|
|
68
|
+
* `SHUTDOWN_TIMEOUT`) to change those instead.
|
|
69
|
+
* @default {}
|
|
70
|
+
*/
|
|
71
|
+
serveOptions?: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `node:url`, not `Bun.fileURLToPath`: this module is loaded at config-parse
|
|
75
|
+
// time by Node-based tooling too (svelte-check, the Svelte language server,
|
|
76
|
+
// `svelte-kit sync`), where `Bun` is undefined. Everything that actually needs
|
|
77
|
+
// the Bun runtime lives inside `adapt()`.
|
|
78
|
+
const templates = fileURLToPath(new URL("./templates", import.meta.url));
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* SvelteKit adapter that compiles the app into a single standalone executable
|
|
82
|
+
* with `bun build --compile`.
|
|
83
|
+
*
|
|
84
|
+
* `adapt()` writes the SvelteKit server and this package's entrypoint templates
|
|
85
|
+
* into a temp directory, then compiles them — `@sveltejs/kit` and every other
|
|
86
|
+
* pure-JS dependency bundled in — into one binary. The build must run under the
|
|
87
|
+
* Bun runtime (`bun --bun vite build`, or a `bunfig.toml` with `[run] bun =
|
|
88
|
+
* true`); loading the config alone works under Node too.
|
|
89
|
+
*
|
|
90
|
+
* Output, all written to {@link AdapterOptions.out | `out`}:
|
|
91
|
+
*
|
|
92
|
+
* ```text
|
|
93
|
+
* build/
|
|
94
|
+
* ├── server the executable (rename with `name`)
|
|
95
|
+
* ├── client/ static assets, served by the executable
|
|
96
|
+
* └── prerendered/ prerendered pages, served by the executable
|
|
97
|
+
* ```
|
|
98
|
+
*
|
|
99
|
+
* The executable resolves `client/` and `prerendered/` from its own location
|
|
100
|
+
* (`dirname(process.execPath)`, overridable with the `ASSETS_DIR` env var), so
|
|
101
|
+
* it can run from any working directory. Deploy the whole `out` directory, or
|
|
102
|
+
* just the binary when {@link AdapterOptions.serveAssets | `serveAssets`} is
|
|
103
|
+
* off and a proxy/CDN serves the assets.
|
|
104
|
+
*
|
|
105
|
+
* Runtime configuration (`HOST`, `PORT`, `ORIGIN`, `BODY_SIZE_LIMIT`, …) is
|
|
106
|
+
* read from environment variables, optionally namespaced by
|
|
107
|
+
* {@link AdapterOptions.envPrefix | `envPrefix`}.
|
|
108
|
+
*
|
|
109
|
+
* @param options - see {@link AdapterOptions}
|
|
110
|
+
* @returns the configured SvelteKit {@link Adapter}
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```js
|
|
114
|
+
* // svelte.config.js
|
|
115
|
+
* import adapter from "@orochibraru/svelte-adapter-bun";
|
|
116
|
+
*
|
|
117
|
+
* export default {
|
|
118
|
+
* kit: {
|
|
119
|
+
* adapter: adapter({
|
|
120
|
+
* // cross-compile for an Alpine container from any host
|
|
121
|
+
* target: "bun-linux-x64-musl",
|
|
122
|
+
* bytecode: true,
|
|
123
|
+
* }),
|
|
124
|
+
* },
|
|
125
|
+
* };
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* @see {@link https://bun.com/docs/bundler/executables | Bun — Single-file executables}
|
|
129
|
+
*/
|
|
130
|
+
export default function adapter(options: AdapterOptions = {}): Adapter {
|
|
131
|
+
const {
|
|
132
|
+
out = "build",
|
|
133
|
+
name = "server",
|
|
134
|
+
target,
|
|
135
|
+
bytecode = false,
|
|
136
|
+
minify = false,
|
|
137
|
+
sourcemap = false,
|
|
138
|
+
precompress = false,
|
|
139
|
+
envPrefix = "",
|
|
140
|
+
serveAssets = true,
|
|
141
|
+
serveOptions = {},
|
|
142
|
+
} = options;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name: "homerun-svelte-adapter-bun",
|
|
146
|
+
supports: {
|
|
147
|
+
instrumentation: () => true,
|
|
148
|
+
read: () => true,
|
|
149
|
+
},
|
|
150
|
+
async adapt(builder: Builder) {
|
|
151
|
+
const { base } = builder.config.kit.paths;
|
|
152
|
+
const tmp = builder.getBuildDirectory("adapter-bun");
|
|
153
|
+
|
|
154
|
+
builder.rimraf(out);
|
|
155
|
+
builder.rimraf(tmp);
|
|
156
|
+
builder.mkdirp(`${out}/`);
|
|
157
|
+
builder.mkdirp(tmp);
|
|
158
|
+
|
|
159
|
+
builder.log.minor("Copying assets");
|
|
160
|
+
builder.writeClient(`${out}/client${base}`);
|
|
161
|
+
builder.writePrerendered(`${out}/prerendered${base}`);
|
|
162
|
+
|
|
163
|
+
if (precompress) {
|
|
164
|
+
builder.log.minor("Compressing assets");
|
|
165
|
+
await Promise.all([
|
|
166
|
+
builder.compress(`${out}/client`),
|
|
167
|
+
builder.compress(`${out}/prerendered`),
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
builder.log.minor("Building server");
|
|
172
|
+
builder.writeServer(`${tmp}/server`);
|
|
173
|
+
await Bun.write(
|
|
174
|
+
`${tmp}/server/manifest.js`,
|
|
175
|
+
[
|
|
176
|
+
`export const manifest = ${builder.generateManifest({ relativePath: "./" })};`,
|
|
177
|
+
`export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});`,
|
|
178
|
+
`export const base = ${JSON.stringify(base)};`,
|
|
179
|
+
].join("\n\n"),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Entry templates ship as raw `.ts` — `bun build --compile` runs them
|
|
183
|
+
// straight through. The virtual specifiers (`ENV`, `MANIFEST`,
|
|
184
|
+
// `SERVER`, `HANDLER`) and the `ENV_PREFIX` / `BUILD_OPTIONS` /
|
|
185
|
+
// `SERVE_OPTIONS` tokens are resolved by this raw word-boundary token
|
|
186
|
+
// swap over the copied source.
|
|
187
|
+
builder.log.minor("Copying entrypoint");
|
|
188
|
+
builder.copy(templates, tmp, {
|
|
189
|
+
replace: {
|
|
190
|
+
BUILD_OPTIONS: JSON.stringify({ serveAssets, precompress }),
|
|
191
|
+
ENV: "./env.ts",
|
|
192
|
+
ENV_PREFIX: JSON.stringify(envPrefix),
|
|
193
|
+
HANDLER: "./handler.ts",
|
|
194
|
+
MANIFEST: "./server/manifest.js",
|
|
195
|
+
SERVE_OPTIONS: JSON.stringify(serveOptions),
|
|
196
|
+
SERVER: "./server/index.js",
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const entry = `${tmp}/index.ts`;
|
|
201
|
+
if (builder.hasServerInstrumentationFile?.()) {
|
|
202
|
+
// Instrumentation (OpenTelemetry &c.) has to load before anything
|
|
203
|
+
// else in the bundle. A compiled binary has no post-build
|
|
204
|
+
// entrypoint to rewrite, so prepend the import to the compile
|
|
205
|
+
// entrypoint instead.
|
|
206
|
+
await Bun.write(
|
|
207
|
+
entry,
|
|
208
|
+
`import "./server/instrumentation.server.js";\n${await Bun.file(entry).text()}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
builder.log.minor(
|
|
213
|
+
target ? `Compiling executable (${target})` : "Compiling executable",
|
|
214
|
+
);
|
|
215
|
+
const outfile = `${out}/${name}`;
|
|
216
|
+
const result = await Bun.build({
|
|
217
|
+
entrypoints: [entry],
|
|
218
|
+
target: "bun",
|
|
219
|
+
minify,
|
|
220
|
+
bytecode,
|
|
221
|
+
sourcemap: sourcemap ? "linked" : "none",
|
|
222
|
+
compile: {
|
|
223
|
+
outfile,
|
|
224
|
+
...(target ? { target } : {}),
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (!result.success) {
|
|
229
|
+
for (const message of result.logs) {
|
|
230
|
+
builder.log.error(String(message));
|
|
231
|
+
}
|
|
232
|
+
throw new Error("`bun build --compile` failed");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
builder.log.success(`Compiled ${outfile}`);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
package/internal.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Ambient declarations for the virtual specifiers/globals `templates/*.ts`
|
|
2
|
+
// reference. None of these are real modules or runtime globals at
|
|
3
|
+
// type-check time, `index.ts`'s adapt() resolves them via a raw text
|
|
4
|
+
// replacement over the compiled output (see its `builder.copy(..., {
|
|
5
|
+
// replace })` call), so this file exists purely so `templates/*.ts`
|
|
6
|
+
// typechecks against the shape that replacement actually produces.
|
|
7
|
+
|
|
8
|
+
declare module "ENV" {
|
|
9
|
+
export function env(name: string, fallback: string): string;
|
|
10
|
+
export function env(name: string, fallback: false): string | false;
|
|
11
|
+
export function env(name: string, fallback?: undefined): string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare module "HANDLER" {
|
|
15
|
+
export function getHandler(): {
|
|
16
|
+
fetch: (
|
|
17
|
+
request: Request,
|
|
18
|
+
server: Bun.Server<undefined>,
|
|
19
|
+
) => Response | Promise<Response>;
|
|
20
|
+
websocket: Bun.WebSocketHandler<undefined> | undefined;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
declare module "MANIFEST" {
|
|
25
|
+
import type { SSRManifest } from "@sveltejs/kit";
|
|
26
|
+
|
|
27
|
+
export const base: string;
|
|
28
|
+
export const manifest: SSRManifest;
|
|
29
|
+
export const prerendered: Set<string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare module "SERVER" {
|
|
33
|
+
export { Server } from "@sveltejs/kit";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
declare const ENV_PREFIX: string;
|
|
37
|
+
declare const BUILD_OPTIONS: { serveAssets: boolean; precompress: boolean };
|
|
38
|
+
declare const SERVE_OPTIONS: Record<string, unknown>;
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orochibraru/svelte-smol",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./index.ts",
|
|
6
|
+
"module": "./index.ts",
|
|
7
|
+
"types": "./index.ts",
|
|
8
|
+
"author": {
|
|
9
|
+
"email": "orochibraru@gmail.com",
|
|
10
|
+
"name": "orochibraru"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.ts",
|
|
17
|
+
"internal.d.ts",
|
|
18
|
+
"templates"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.coms/orochibraru/svelte-smol.git"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
29
|
+
"test": "bun test index.test.ts",
|
|
30
|
+
"test:integration": "bun test test/integration.test.ts",
|
|
31
|
+
"lint": "biome check .",
|
|
32
|
+
"lint:fix": "biome check --write .",
|
|
33
|
+
"release": "semantic-release",
|
|
34
|
+
"release:dry": "semantic-release --dry-run --no-ci",
|
|
35
|
+
"prepare": "husky"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@biomejs/biome": "^2.5.11",
|
|
39
|
+
"@saithodev/semantic-release-gitea": "^2.1.0",
|
|
40
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
41
|
+
"@semantic-release/git": "^11.0.1",
|
|
42
|
+
"@semantic-release/npm": "^13.1.5",
|
|
43
|
+
"@sveltejs/kit": "^2.70.3",
|
|
44
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
45
|
+
"@types/bun": "^1.4.0",
|
|
46
|
+
"husky": "^9.1.7",
|
|
47
|
+
"semantic-release": "^25.0.9",
|
|
48
|
+
"svelte": "^5.56.10",
|
|
49
|
+
"typescript": "^7",
|
|
50
|
+
"vite": "^8.2.2"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@sveltejs/kit": "^2.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/templates/env.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* global ENV_PREFIX */
|
|
2
|
+
|
|
3
|
+
const expected = new Set([
|
|
4
|
+
"SOCKET_PATH",
|
|
5
|
+
"HOST",
|
|
6
|
+
"PORT",
|
|
7
|
+
"ASSETS_DIR",
|
|
8
|
+
"ORIGIN",
|
|
9
|
+
"XFF_DEPTH",
|
|
10
|
+
"ADDRESS_HEADER",
|
|
11
|
+
"PROTOCOL_HEADER",
|
|
12
|
+
"HOST_HEADER",
|
|
13
|
+
"PORT_HEADER",
|
|
14
|
+
"BODY_SIZE_LIMIT",
|
|
15
|
+
"IDLE_TIMEOUT",
|
|
16
|
+
"SHUTDOWN_TIMEOUT",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
if (ENV_PREFIX) {
|
|
20
|
+
for (const name in Bun.env) {
|
|
21
|
+
if (name.startsWith(ENV_PREFIX)) {
|
|
22
|
+
const unprefixed = name.slice(ENV_PREFIX.length);
|
|
23
|
+
if (!expected.has(unprefixed)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`You should change envPrefix (${ENV_PREFIX}) to avoid conflicts with existing environment variables — unexpectedly saw ${name}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function env(name: string, fallback: string): string;
|
|
33
|
+
export function env(name: string, fallback: false): string | false;
|
|
34
|
+
export function env(name: string, fallback?: undefined): string | undefined;
|
|
35
|
+
export function env(name: string, fallback?: string | false) {
|
|
36
|
+
const prefixed = ENV_PREFIX + name;
|
|
37
|
+
return prefixed in Bun.env ? Bun.env[prefixed] : fallback;
|
|
38
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/* global ENV_PREFIX, BUILD_OPTIONS */
|
|
2
|
+
|
|
3
|
+
import { env } from "ENV";
|
|
4
|
+
import { base, manifest, prerendered } from "MANIFEST";
|
|
5
|
+
import { Server } from "SERVER";
|
|
6
|
+
import { statSync } from "node:fs";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
import type { Server as SvelteKitServer } from "@sveltejs/kit";
|
|
9
|
+
|
|
10
|
+
const server = new Server(manifest) as SvelteKitServer & {
|
|
11
|
+
websocket?: () => Bun.WebSocketHandler<undefined> | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const { serveAssets, precompress } = BUILD_OPTIONS;
|
|
15
|
+
|
|
16
|
+
const origin = env("ORIGIN", undefined);
|
|
17
|
+
const xff_depth = Number.parseInt(env("XFF_DEPTH", "1"), 10);
|
|
18
|
+
const address_header = env("ADDRESS_HEADER", "").toLowerCase();
|
|
19
|
+
const protocol_header = env("PROTOCOL_HEADER", "").toLowerCase();
|
|
20
|
+
const host_header = env("HOST_HEADER", "").toLowerCase();
|
|
21
|
+
const port_header = env("PORT_HEADER", "").toLowerCase();
|
|
22
|
+
|
|
23
|
+
// Static assets and prerendered pages are deployed next to the executable:
|
|
24
|
+
// <dir>/<binary>
|
|
25
|
+
// <dir>/client/…
|
|
26
|
+
// <dir>/prerendered/…
|
|
27
|
+
// `ASSETS_DIR` overrides that parent directory (absolute, or relative to the
|
|
28
|
+
// binary). `import.meta.dir` is a virtual path inside a compiled binary, so
|
|
29
|
+
// the real on-disk location comes from `process.execPath`.
|
|
30
|
+
const assets_root = resolve(dirname(process.execPath), env("ASSETS_DIR", ""));
|
|
31
|
+
const client_dir = `${assets_root}/client${base}`;
|
|
32
|
+
const prerendered_dir = `${assets_root}/prerendered${base}`;
|
|
33
|
+
const immutable_prefix = `${base}/${manifest.appDir}/immutable/`;
|
|
34
|
+
|
|
35
|
+
await server.init({
|
|
36
|
+
env: Bun.env as Record<string, string>,
|
|
37
|
+
read: (file) => Bun.file(`${client_dir}/${file}`).stream(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Pre-compressed sibling files, tried in this order when `precompress` is on.
|
|
41
|
+
const encodings: Array<readonly [token: string, ext: string]> = precompress
|
|
42
|
+
? [
|
|
43
|
+
["br", ".br"],
|
|
44
|
+
["gzip", ".gz"],
|
|
45
|
+
]
|
|
46
|
+
: [];
|
|
47
|
+
|
|
48
|
+
/** `statSync` that yields `undefined` for a missing path or a non-file. */
|
|
49
|
+
function file_stat(path: string) {
|
|
50
|
+
try {
|
|
51
|
+
const stat = statSync(path);
|
|
52
|
+
return stat.isFile() ? stat : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function asset_response(
|
|
59
|
+
file: string,
|
|
60
|
+
request: Request,
|
|
61
|
+
immutable = false,
|
|
62
|
+
): Response | undefined {
|
|
63
|
+
let stat = file_stat(file);
|
|
64
|
+
if (!stat) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const headers = new Headers();
|
|
69
|
+
headers.set(
|
|
70
|
+
"cache-control",
|
|
71
|
+
immutable
|
|
72
|
+
? "public,max-age=31536000,immutable"
|
|
73
|
+
: "public,max-age=0,must-revalidate",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
let body = file;
|
|
77
|
+
const accept = request.headers.get("accept-encoding") ?? "";
|
|
78
|
+
for (const [token, ext] of encodings) {
|
|
79
|
+
const compressed = accept.includes(token) && file_stat(`${file}${ext}`);
|
|
80
|
+
if (compressed) {
|
|
81
|
+
body = `${file}${ext}`;
|
|
82
|
+
stat = compressed;
|
|
83
|
+
headers.set("content-encoding", token);
|
|
84
|
+
headers.set("vary", "Accept-Encoding");
|
|
85
|
+
// The compressed sibling has no useful media type of its own.
|
|
86
|
+
headers.set("content-type", Bun.file(file).type);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { size, mtimeMs } = stat;
|
|
92
|
+
const etag = `W/"${size.toString(16)}-${Math.round(mtimeMs).toString(16)}"`;
|
|
93
|
+
if (request.headers.get("if-none-match") === etag) {
|
|
94
|
+
return new Response(null, { headers, status: 304 });
|
|
95
|
+
}
|
|
96
|
+
headers.set("etag", etag);
|
|
97
|
+
|
|
98
|
+
return new Response(Bun.file(body), { headers });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function serve_static(url: URL, request: Request): Response | undefined {
|
|
102
|
+
if (!serveAssets) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { pathname } = url;
|
|
107
|
+
const rel = pathname.startsWith(base)
|
|
108
|
+
? pathname.slice(base.length)
|
|
109
|
+
: pathname;
|
|
110
|
+
|
|
111
|
+
if (prerendered.has(pathname)) {
|
|
112
|
+
const file =
|
|
113
|
+
rel === "/" || rel === ""
|
|
114
|
+
? "index.html"
|
|
115
|
+
: rel.endsWith("/")
|
|
116
|
+
? `${rel.slice(1)}index.html`
|
|
117
|
+
: `${rel.slice(1)}.html`;
|
|
118
|
+
return asset_response(`${prerendered_dir}/${file}`, request);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The other trailing-slash form of a prerendered path redirects to the
|
|
122
|
+
// canonical one, query string preserved (matches SvelteKit's own
|
|
123
|
+
// trailingSlash handling).
|
|
124
|
+
const toggled = pathname.endsWith("/")
|
|
125
|
+
? pathname.slice(0, -1)
|
|
126
|
+
: `${pathname}/`;
|
|
127
|
+
if (prerendered.has(toggled)) {
|
|
128
|
+
return new Response(null, {
|
|
129
|
+
headers: { location: toggled + url.search },
|
|
130
|
+
status: 308,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return asset_response(
|
|
135
|
+
`${client_dir}/${rel.slice(1)}`,
|
|
136
|
+
request,
|
|
137
|
+
pathname.startsWith(immutable_prefix),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ssr = async (request: Request, bunServer: Bun.Server<undefined>) => {
|
|
142
|
+
const url = new URL(request.url);
|
|
143
|
+
|
|
144
|
+
const asset = serve_static(url, request);
|
|
145
|
+
if (asset) {
|
|
146
|
+
return asset;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const baseOrigin = origin || get_origin(request.headers);
|
|
150
|
+
const path = request.url.slice(request.url.split("/", 3).join("/").length);
|
|
151
|
+
const newRequest = new Request(baseOrigin + path, request);
|
|
152
|
+
|
|
153
|
+
const response = await server.respond(newRequest, {
|
|
154
|
+
getClientAddress() {
|
|
155
|
+
if (address_header) {
|
|
156
|
+
if (!request.headers.has(address_header)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Address header was specified with ${`${ENV_PREFIX}ADDRESS_HEADER`}=${address_header} but is absent from request`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const value = request.headers.get(address_header) || "";
|
|
163
|
+
|
|
164
|
+
if (address_header === "x-forwarded-for") {
|
|
165
|
+
const addresses = value.split(",");
|
|
166
|
+
|
|
167
|
+
if (xff_depth < 1) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`${`${ENV_PREFIX}XFF_DEPTH`} must be a positive integer`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (xff_depth > addresses.length) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`${`${ENV_PREFIX}XFF_DEPTH`} is ${xff_depth}, but only found ${addresses.length} addresses`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return addresses[addresses.length - xff_depth]?.trim() || "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return bunServer.requestIP(request)?.address || "";
|
|
185
|
+
},
|
|
186
|
+
platform: { request, server: bunServer },
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// SvelteKit streams Server-Sent Events with no Content-Length and long
|
|
190
|
+
// gaps between writes; Bun's idleTimeout (default 10s) would sever the
|
|
191
|
+
// connection between events. Disable it for the life of this response.
|
|
192
|
+
if (response.headers.get("content-type")?.startsWith("text/event-stream")) {
|
|
193
|
+
bunServer.timeout(request, 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return response;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const getHandler = () => {
|
|
200
|
+
const websocket =
|
|
201
|
+
typeof server.websocket === "function" ? server.websocket() : undefined;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
fetch: ssr,
|
|
205
|
+
websocket,
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function get_origin(headers: Headers) {
|
|
210
|
+
const protocol = (protocol_header && headers.get(protocol_header)) || "https";
|
|
211
|
+
const host = (host_header && headers.get(host_header)) || headers.get("host");
|
|
212
|
+
const port = port_header && headers.get(port_header);
|
|
213
|
+
|
|
214
|
+
return port ? `${protocol}://${host}:${port}` : `${protocol}://${host}`;
|
|
215
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { env } from "ENV";
|
|
2
|
+
import { getHandler } from "HANDLER";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
export const path = env("SOCKET_PATH", false);
|
|
6
|
+
export const host = env("HOST", "0.0.0.0");
|
|
7
|
+
export const port = env("PORT", "3000");
|
|
8
|
+
|
|
9
|
+
const body_size_limit = parse_as_bytes(env("BODY_SIZE_LIMIT", "512K"));
|
|
10
|
+
if (Number.isNaN(body_size_limit)) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Invalid BODY_SIZE_LIMIT: '${env("BODY_SIZE_LIMIT", "512K")}'. Please provide a numeric value.`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const idle_timeout = Number.parseInt(env("IDLE_TIMEOUT", "10"), 10);
|
|
17
|
+
const { fetch: handlerFetch, websocket } = getHandler();
|
|
18
|
+
|
|
19
|
+
const options = {
|
|
20
|
+
...SERVE_OPTIONS,
|
|
21
|
+
fetch: handlerFetch,
|
|
22
|
+
idleTimeout: idle_timeout,
|
|
23
|
+
maxRequestBodySize: body_size_limit,
|
|
24
|
+
...(path ? { unix: path } : { hostname: host, port: port }),
|
|
25
|
+
...(websocket ? { websocket } : {}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const server = Bun.serve(options as Parameters<typeof Bun.serve>[0]);
|
|
29
|
+
|
|
30
|
+
console.log(`Listening on ${server.url} ${websocket ? "with WebSocket" : ""}`);
|
|
31
|
+
|
|
32
|
+
const shutdown_timeout_ms =
|
|
33
|
+
Number.parseInt(env("SHUTDOWN_TIMEOUT", "30"), 10) * 1000;
|
|
34
|
+
let shutting_down = false;
|
|
35
|
+
|
|
36
|
+
async function graceful_shutdown(reason: "SIGINT" | "SIGTERM" | "IDLE") {
|
|
37
|
+
if (shutting_down) {
|
|
38
|
+
console.info(`Received ${reason} again, forcing immediate shutdown.`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
shutting_down = true;
|
|
42
|
+
|
|
43
|
+
console.info(
|
|
44
|
+
`Stopping server (waiting up to ${shutdown_timeout_ms / 1000}s for in-flight requests to finish)...`,
|
|
45
|
+
);
|
|
46
|
+
process.emit("sveltekit:shutdown", reason);
|
|
47
|
+
|
|
48
|
+
const force_timer = setTimeout(() => {
|
|
49
|
+
console.warn(
|
|
50
|
+
`Graceful shutdown exceeded ${shutdown_timeout_ms / 1000}s, forcing.`,
|
|
51
|
+
);
|
|
52
|
+
server.stop(true).finally(() => process.exit(1));
|
|
53
|
+
}, shutdown_timeout_ms);
|
|
54
|
+
|
|
55
|
+
await server.stop();
|
|
56
|
+
clearTimeout(force_timer);
|
|
57
|
+
console.info("Stopped server");
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.on("SIGTERM", graceful_shutdown);
|
|
62
|
+
process.on("SIGINT", graceful_shutdown);
|
|
63
|
+
|
|
64
|
+
export { server };
|
|
65
|
+
|
|
66
|
+
function parse_as_bytes(value: string): number {
|
|
67
|
+
const units = value.at(-1)?.toUpperCase();
|
|
68
|
+
const multiplier =
|
|
69
|
+
{
|
|
70
|
+
B: 1,
|
|
71
|
+
G: 1024 * 1024 * 1024,
|
|
72
|
+
K: 1024,
|
|
73
|
+
M: 1024 * 1024,
|
|
74
|
+
}[units ?? "B"] ?? 1;
|
|
75
|
+
return Number(multiplier !== 1 ? value.slice(0, -1) : value) * multiplier;
|
|
76
|
+
}
|