bosia 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +5 -6
  2. package/package.json +2 -1
  3. package/src/core/backend.ts +87 -3
  4. package/src/core/basePath.ts +30 -0
  5. package/src/core/cache.ts +378 -30
  6. package/src/core/client/App.svelte +19 -11
  7. package/src/core/client/hydrate.ts +17 -9
  8. package/src/core/client/navigation.ts +1 -1
  9. package/src/core/client/prefetch.ts +66 -41
  10. package/src/core/client/router.svelte.ts +41 -22
  11. package/src/core/cors.ts +17 -1
  12. package/src/core/csrf.ts +32 -19
  13. package/src/core/dedup.ts +51 -7
  14. package/src/core/dev.ts +11 -1
  15. package/src/core/devWsProxy.ts +144 -0
  16. package/src/core/env.ts +7 -0
  17. package/src/core/errors.ts +23 -6
  18. package/src/core/hooks.ts +1 -1
  19. package/src/core/html.ts +13 -1
  20. package/src/core/plugins/access-log.ts +128 -0
  21. package/src/core/prerender.ts +143 -6
  22. package/src/core/proxy.ts +48 -0
  23. package/src/core/renderer.ts +70 -29
  24. package/src/core/routeTypes.ts +16 -1
  25. package/src/core/server.bun.ts +6 -1
  26. package/src/core/server.ts +198 -77
  27. package/src/core/server.workers.ts +3 -2
  28. package/src/core/types/plugin.ts +2 -1
  29. package/src/core/websocket.ts +232 -0
  30. package/src/core/workersGuard.ts +18 -3
  31. package/src/lib/index.ts +1 -0
  32. package/templates/default/.env.example +20 -5
  33. package/templates/default/tsconfig.json +1 -1
  34. package/templates/demo/.env.example +20 -5
  35. package/templates/demo/src/hooks.server.ts +3 -12
  36. package/templates/demo/tsconfig.json +1 -1
  37. package/templates/shop/src/app.d.ts +0 -1
  38. package/templates/shop/src/hooks.server.ts +3 -11
  39. package/templates/shop/src/routes/+layout.server.ts +0 -1
  40. package/templates/shop/tsconfig.json +1 -1
  41. package/templates/store/src/app.d.ts +0 -1
  42. package/templates/store/src/hooks.server.ts +3 -11
  43. package/templates/store/src/routes/+layout.server.ts +0 -1
  44. package/templates/store/tsconfig.json +1 -1
package/README.md CHANGED
@@ -144,16 +144,15 @@ const authHandle: Handle = async ({ event, resolve }) => {
144
144
  return resolve(event);
145
145
  };
146
146
 
147
- const loggingHandle: Handle = async ({ event, resolve }) => {
148
- const res = await resolve(event);
149
- console.log(`${event.request.method} ${event.url.pathname} ${res.status}`);
150
- return res;
147
+ const langHandle: Handle = async ({ event, resolve }) => {
148
+ event.locals.lang = event.request.headers.get("accept-language")?.split(",")[0] ?? "en";
149
+ return resolve(event);
151
150
  };
152
151
 
153
- export const handle = sequence(authHandle, loggingHandle);
152
+ export const handle = sequence(authHandle, langHandle);
154
153
  ```
155
154
 
156
- `locals` set here are available in every loader and API handler.
155
+ `locals` set here are available in every loader and API handler. For an access log, use your reverse proxy's or add `accessLog()` from `bosia/plugins/access-log` to `bosia.config.ts` — not a `console.log` per request.
157
156
 
158
157
  ## Public API
159
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
@@ -37,6 +37,7 @@
37
37
  "./client": "./src/lib/client.ts",
38
38
  "./server": "./src/lib/server.ts",
39
39
  "./plugins/server-timing": "./src/core/plugins/server-timing.ts",
40
+ "./plugins/access-log": "./src/core/plugins/access-log.ts",
40
41
  "./plugins/inspector": "./src/core/plugins/inspector/index.ts"
41
42
  },
42
43
  "bin": {
@@ -1,8 +1,19 @@
1
1
  // ─── Bosia Backend App ────────────────────────────────────
2
2
  // A tiny Elysia-shaped HTTP app: chainable routes + onRequest / onAfterHandle /
3
- // onError hooks, one `fetch(request)` entry for Bun.serve and Workers alike.
3
+ // onError / onResponse hooks, one `fetch(request)` entry for Bun.serve and
4
+ // Workers alike.
4
5
  // Only the subset the framework and plugins use — not a general router.
5
6
 
7
+ import type { Server } from "bun";
8
+
9
+ import {
10
+ isUpgradeRequest,
11
+ isUpgraded,
12
+ runUpgrade,
13
+ websocketHandlers,
14
+ type WebSocketOptions,
15
+ } from "./websocket.ts";
16
+
6
17
  /** Outbound response bag — merged into whatever the handler returns. */
7
18
  export interface ResponseSet {
8
19
  status?: number;
@@ -34,10 +45,28 @@ export type AfterHandleHook = (ctx: {
34
45
 
35
46
  export type ErrorHook = (ctx: { request: Request; error: unknown; set: ResponseSet }) => unknown;
36
47
 
48
+ /**
49
+ * Sees the final Response — after onRequest short-circuits, after-handle
50
+ * replacements, error hooks and the HEAD body drop — exactly once per request.
51
+ * `start`: `performance.now()` when the request entered `fetch`. Observe only:
52
+ * the return value is ignored and a throw or rejection is swallowed. `response`
53
+ * is the one the client gets — don't read its body or change its headers.
54
+ * `status` is the status the client got: 101 for a WebSocket upgrade, whose
55
+ * `response` is only a placeholder (a 101 Response can't be built).
56
+ */
57
+ export type ResponseHook = (ctx: {
58
+ request: Request;
59
+ response: Response;
60
+ status: number;
61
+ upgraded: boolean;
62
+ start: number;
63
+ }) => void | Promise<unknown>;
64
+
37
65
  export interface ServeOptions {
38
66
  maxRequestBodySize?: number;
39
67
  idleTimeout?: number;
40
68
  reusePort?: boolean;
69
+ websocket?: WebSocketOptions;
41
70
  }
42
71
 
43
72
  type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "*";
@@ -55,6 +84,7 @@ export class BosiaApp {
55
84
  private requestHooks: RequestHook[] = [];
56
85
  private afterHooks: AfterHandleHook[] = [];
57
86
  private errorHooks: ErrorHook[] = [];
87
+ private responseHooks: ResponseHook[] = [];
58
88
  private server: ReturnType<typeof Bun.serve> | null = null;
59
89
 
60
90
  constructor(private config: { serve?: ServeOptions } = {}) {}
@@ -100,6 +130,12 @@ export class BosiaApp {
100
130
  return this;
101
131
  }
102
132
 
133
+ /** Observes the final response of every request (see ResponseHook). */
134
+ onResponse(hook: ResponseHook): this {
135
+ this.responseHooks.push(hook);
136
+ return this;
137
+ }
138
+
103
139
  // Paths match exactly, or "*" matches everything. Enough for plugin endpoints
104
140
  // like /__bosia/locate; add `:param` matching here when a plugin needs it.
105
141
  private route(method: Method, path: string, handler: Handler): this {
@@ -129,7 +165,52 @@ export class BosiaApp {
129
165
  return [...methods];
130
166
  }
131
167
 
132
- fetch = async (request: Request): Promise<Response> => {
168
+ /** `undefined` after a WebSocket upgrade — Bun has already answered with 101.
169
+ * Only Bun passes `server`, so Workers never upgrades. */
170
+ fetch = (request: Request, server?: Server<any>): Promise<Response | undefined> =>
171
+ server && isUpgradeRequest(request)
172
+ ? runUpgrade(request, server, () => this.handle(request))
173
+ : this.handle(request);
174
+
175
+ private handle = async (request: Request): Promise<Response | undefined> => {
176
+ if (this.responseHooks.length === 0) {
177
+ const response = await this.respond(request);
178
+ return isUpgraded() ? undefined : response;
179
+ }
180
+ const start = performance.now();
181
+ let response: Response;
182
+ try {
183
+ response = await this.respond(request);
184
+ } catch (error) {
185
+ // An error hook threw: the runtime answers 500, so observers see that.
186
+ this.observe(request, new Response(null, { status: 500 }), start);
187
+ throw error;
188
+ }
189
+ this.observe(request, response, start);
190
+ return isUpgraded() ? undefined : response;
191
+ };
192
+
193
+ private observe(request: Request, response: Response, start: number): void {
194
+ const upgraded = isUpgraded();
195
+ const status = upgraded ? 101 : response.status;
196
+ for (const hook of this.responseHooks) {
197
+ try {
198
+ const out = hook({ request, response, status, upgraded, start });
199
+ // An async observer's rejection would be unhandled — fatal in prod.
200
+ if (out instanceof Promise) out.catch(() => {});
201
+ } catch {
202
+ // Observers must not change what the client gets.
203
+ }
204
+ }
205
+ }
206
+
207
+ /** Answers without running onResponse observers — for the Workers warm-up
208
+ * render, which no client made and no access log should count. */
209
+ fetchUnobserved(request: Request): Promise<Response> {
210
+ return this.respond(request);
211
+ }
212
+
213
+ private async respond(request: Request): Promise<Response> {
133
214
  const set: ResponseSet = { headers: {} };
134
215
  const isHead = request.method === "HEAD";
135
216
  const method = isHead ? "GET" : request.method.toUpperCase();
@@ -143,7 +224,7 @@ export class BosiaApp {
143
224
  }
144
225
  return new Response("Internal Server Error", { status: 500 });
145
226
  }
146
- };
227
+ }
147
228
 
148
229
  private async dispatch(request: Request, method: string, set: ResponseSet): Promise<Response> {
149
230
  for (const hook of this.requestHooks) {
@@ -173,6 +254,8 @@ export class BosiaApp {
173
254
  response = "Method Not Allowed";
174
255
  }
175
256
 
257
+ // Bun already sent the 101: nothing after this can change the answer.
258
+ if (isUpgraded()) return response as Response;
176
259
  for (const hook of this.afterHooks) {
177
260
  const out = await hook({ request, response, set });
178
261
  if (out !== undefined) response = out;
@@ -189,6 +272,7 @@ export class BosiaApp {
189
272
  maxRequestBodySize: serve.maxRequestBodySize,
190
273
  idleTimeout: serve.idleTimeout,
191
274
  reusePort: serve.reusePort ?? false,
275
+ websocket: { ...websocketHandlers, ...serve.websocket, sendPings: true },
192
276
  });
193
277
  callback?.();
194
278
  return this;
@@ -30,6 +30,36 @@ export function withBase(base: string, path: string): string {
30
30
  return base + path;
31
31
  }
32
32
 
33
+ /** Never a navigation target. Test a parsed `URL.protocol`, not the raw string: the
34
+ * parser drops tabs, newlines and leading control chars, so `java\tscript:` is one too. */
35
+ export const DANGEROUS_PROTOCOL = /^(javascript|data|vbscript):$/;
36
+
37
+ /**
38
+ * What the client router matches: the path the server would see. Every URL goes
39
+ * through the URL parser against `pageHref`, like a link: `?page=2`, `b`, `../x`
40
+ * resolve, `/a b` and `/café` get encoded (params match the server's), `/\host`
41
+ * is another origin. Another origin comes back as its full URL so the router leaves the app.
42
+ */
43
+ export function toRouterPath(url: string, pageHref: string): string {
44
+ const u = new URL(url, pageHref);
45
+ if (DANGEROUS_PROTOCOL.test(u.protocol)) {
46
+ throw new Error(`navigation: dangerous scheme in URL "${url}". Use a path or an http(s) URL.`);
47
+ }
48
+ // Scheme + host, not `origin`: a `blob:` URL shares the page's origin, and the URL
49
+ // parser gives app shells (`capacitor://localhost`) origin "null" for every host.
50
+ // A path starting `//` (from `/.//evil.com`) would read as another host once used as
51
+ // a URL, so it stays a full URL: the router leaves for this origin's `//evil.com` page.
52
+ const page = new URL(pageHref);
53
+ // No host (`file:`) is never ours: every file on disk would count as one app.
54
+ const ours =
55
+ u.host !== "" &&
56
+ u.protocol === page.protocol &&
57
+ u.host === page.host &&
58
+ !u.pathname.startsWith("//");
59
+ // `capacitor://localhost` has an empty path: that's the root.
60
+ return ours ? (u.pathname || "/") + u.search + u.hash : u.href;
61
+ }
62
+
33
63
  /**
34
64
  * The in-app pathname for an incoming request, or `null` when the request is
35
65
  * not ours to answer. `/sso` and `/sso/` both resolve to `/` so the root route