@pablozaiden/webapp 0.6.4 → 0.6.6

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/docs/server.md CHANGED
@@ -55,11 +55,18 @@ timestamps, and other server-managed fields under application control.
55
55
  `parseJson(req, schema)` parses and validates the body at runtime. Malformed JSON
56
56
  returns a 400 `invalid_json` response, while a JSON value that does not satisfy
57
57
  the schema returns a 400 `invalid_request_body` response with field details.
58
- Use `parseOptionalJson(req, schema)` only for endpoints that deliberately allow
59
- an empty body. Only a zero-byte body is considered absent; whitespace-only
60
- content is non-empty malformed JSON and is rejected just like any other
61
- malformed body. `parseUnknownJson` returns `unknown` and is intentionally
62
- unvalidated, so application handlers should prefer a schema-backed parser.
58
+ Pass `{ maxBytes, requireContentType: true }` when an endpoint needs a
59
+ bounded JSON body. The parser rejects a non-JSON content type with a 400
60
+ `invalid_request_content_type` response, rejects an invalid `Content-Length`
61
+ with a 400 `invalid_request_content_length` response, rejects declared and
62
+ streamed bodies over `maxBytes` with a 413 `request_body_too_large` response,
63
+ and cancels a streamed body as soon as the limit is exceeded. JSON media types
64
+ with a `+json` suffix are accepted. Use `parseOptionalJson(req, schema)` only
65
+ for endpoints that deliberately allow an empty body. Only a zero-byte body is
66
+ considered absent; whitespace-only content is non-empty malformed JSON and is
67
+ rejected just like any other malformed body. `parseUnknownJson` returns
68
+ `unknown` and is intentionally unvalidated, so application handlers should
69
+ prefer a schema-backed parser.
63
70
 
64
71
  The Notes TODO webhook uses an absent body (or an object without `title`) to
65
72
  apply its source-based fallback title. When an owner does not exist, that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,6 +3,7 @@ import tailwindPlugin from "bun-plugin-tailwind";
3
3
  import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, extname, resolve } from "node:path";
5
5
  import { findPackageRoot, resolveReactDomClient } from "../package-resolution";
6
+ import { compileWebAppPublicAsset, type WebAppPublicAssetOptions } from "../server/public-assets";
6
7
 
7
8
  export const BUN_COMPILE_TARGETS = [
8
9
  "bun-linux-x64",
@@ -27,6 +28,7 @@ export interface BuildWebAppBinaryOptions {
27
28
  define?: Record<string, string>;
28
29
  web?: {
29
30
  entry?: string;
31
+ publicAssets?: WebAppPublicAssetOptions[];
30
32
  build?: {
31
33
  plugins?: BunPlugin[];
32
34
  define?: Record<string, string>;
@@ -35,6 +37,11 @@ export interface BuildWebAppBinaryOptions {
35
37
  };
36
38
  }
37
39
 
40
+ interface CompiledPublicAsset {
41
+ path: string;
42
+ body: string;
43
+ }
44
+
38
45
  function formatTargetValue(value: unknown): string {
39
46
  if (value === undefined) return "<missing>";
40
47
  if (typeof value === "string") return JSON.stringify(value);
@@ -125,6 +132,17 @@ configureWebAppRenderer(createRoot);
125
132
  }
126
133
  throw new Error("Browser build failed");
127
134
  }
135
+ const publicAssets: CompiledPublicAsset[] = [];
136
+ for (const publicAsset of options.web?.publicAssets ?? []) {
137
+ const body = await compileWebAppPublicAsset({
138
+ ...publicAsset,
139
+ define: { ...options.define, ...publicAsset.define },
140
+ });
141
+ publicAssets.push({
142
+ path: publicAsset.path,
143
+ body: Buffer.from(body).toString("base64"),
144
+ });
145
+ }
128
146
  const assets = browserBuild.outputs
129
147
  .filter((output) => extname(output.path).toLowerCase() !== ".map")
130
148
  .map((output) => {
@@ -142,6 +160,7 @@ configureWebAppRenderer(createRoot);
142
160
  });
143
161
  const compiledAssetsModule = resolve(cacheDir, "compiled-webapp-assets.ts");
144
162
  writeFileSync(compiledAssetsModule, `globalThis[Symbol.for("webapp.compiledClient")] = ${JSON.stringify({ packageRoot, assets })};
163
+ globalThis[Symbol.for("webapp.compiledPublicAssets")] = ${JSON.stringify({ assets: publicAssets })};
145
164
  `);
146
165
  const compiledEntrypoint = resolve(cacheDir, "entrypoint.ts");
147
166
  writeFileSync(compiledEntrypoint, `import "./compiled-webapp-assets";
@@ -2,6 +2,7 @@ export * from "./runtime-config";
2
2
  export * from "./routes";
3
3
  export * from "./route-catalog";
4
4
  export * from "./responses";
5
+ export * from "./public-assets";
5
6
  export * from "./create-web-app-server";
6
7
  export { getRequestBaseUrl, getRequestOriginInfo, type RequestOriginInfo } from "./auth/request-origin";
7
8
  export * from "./auth/store";
@@ -0,0 +1,115 @@
1
+ import type { BunPlugin } from "bun";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { PublicRouteDefinition } from "./server-types";
7
+
8
+ const COMPILED_PUBLIC_ASSETS_SYMBOL = Symbol.for("webapp.compiledPublicAssets");
9
+
10
+ export interface WebAppPublicAssetOptions {
11
+ path: string;
12
+ entrypoint: string | URL;
13
+ contentType: string;
14
+ headers?: HeadersInit;
15
+ format?: "iife" | "esm";
16
+ define?: Record<string, string>;
17
+ plugins?: BunPlugin[];
18
+ }
19
+
20
+ interface CompiledPublicAsset {
21
+ path: string;
22
+ body: string;
23
+ }
24
+
25
+ interface CompiledPublicAssets {
26
+ assets: CompiledPublicAsset[];
27
+ }
28
+
29
+ function normalizePublicAssetPath(path: string): string {
30
+ const trimmed = path.trim();
31
+ if (!trimmed.startsWith("/") || trimmed.includes("?") || trimmed.includes("#")) {
32
+ throw new Error(`Public asset path must be an absolute URL path without a query or fragment: ${path}`);
33
+ }
34
+ return trimmed;
35
+ }
36
+
37
+ function resolveEntrypoint(entrypoint: string | URL): string {
38
+ if (entrypoint instanceof URL) {
39
+ if (entrypoint.protocol !== "file:") {
40
+ throw new Error(`Public asset entrypoint must be a local file path or file URL; received ${entrypoint.protocol} URL`);
41
+ }
42
+ return fileURLToPath(entrypoint);
43
+ }
44
+ if (isAbsolute(entrypoint)) {
45
+ return entrypoint;
46
+ }
47
+ return resolve(dirname(Bun.main || process.argv[1] || "."), entrypoint);
48
+ }
49
+
50
+ function responseHeaders(options: WebAppPublicAssetOptions): Record<string, string> {
51
+ const headers = new Headers(options.headers);
52
+ headers.set("content-type", options.contentType);
53
+ return Object.fromEntries(headers.entries());
54
+ }
55
+
56
+ function compiledPublicAsset(path: string): Uint8Array | undefined {
57
+ const value = (globalThis as { [key: symbol]: unknown })[COMPILED_PUBLIC_ASSETS_SYMBOL];
58
+ if (!value || typeof value !== "object" || !("assets" in value) || !Array.isArray(value.assets)) {
59
+ return undefined;
60
+ }
61
+ const asset = (value as CompiledPublicAssets).assets.find((candidate) => candidate.path === path);
62
+ if (!asset || typeof asset.body !== "string") {
63
+ return undefined;
64
+ }
65
+ return new Uint8Array(Buffer.from(asset.body, "base64"));
66
+ }
67
+
68
+ export async function compileWebAppPublicAsset(options: WebAppPublicAssetOptions): Promise<Uint8Array> {
69
+ const entrypoint = resolveEntrypoint(options.entrypoint);
70
+ const outputDirectory = mkdtempSync(join(tmpdir(), "webapp-public-asset-"));
71
+ try {
72
+ const result = await Bun.build({
73
+ entrypoints: [entrypoint],
74
+ outdir: outputDirectory,
75
+ target: "browser",
76
+ format: options.format ?? "iife",
77
+ splitting: false,
78
+ minify: true,
79
+ sourcemap: "none",
80
+ define: options.define,
81
+ plugins: options.plugins,
82
+ });
83
+ if (!result.success) {
84
+ for (const log of result.logs) {
85
+ console.error(log);
86
+ }
87
+ throw new Error(`Public asset build failed for ${entrypoint}`);
88
+ }
89
+ const output = result.outputs[0];
90
+ if (!output) {
91
+ throw new Error(`Public asset build produced no output for ${entrypoint}`);
92
+ }
93
+ return new Uint8Array(await output.arrayBuffer());
94
+ } finally {
95
+ rmSync(outputDirectory, { recursive: true, force: true });
96
+ }
97
+ }
98
+
99
+ export function createWebAppPublicAsset(options: WebAppPublicAssetOptions): PublicRouteDefinition {
100
+ const path = normalizePublicAssetPath(options.path);
101
+ const headers = responseHeaders(options);
102
+ let assetPromise: Promise<Uint8Array> | undefined;
103
+
104
+ return {
105
+ headers,
106
+ GET: async () => {
107
+ const embedded = compiledPublicAsset(path);
108
+ if (embedded) {
109
+ return embedded;
110
+ }
111
+ assetPromise ??= compileWebAppPublicAsset(options);
112
+ return await assetPromise;
113
+ },
114
+ };
115
+ }
@@ -38,6 +38,36 @@ export class InvalidJsonError extends Error {
38
38
  }
39
39
  }
40
40
 
41
+ export class InvalidRequestContentTypeError extends Error {
42
+ readonly code = "invalid_request_content_type";
43
+ readonly status = 400;
44
+
45
+ constructor() {
46
+ super("Request content type must be application/json");
47
+ this.name = "InvalidRequestContentTypeError";
48
+ }
49
+ }
50
+
51
+ export class InvalidRequestContentLengthError extends Error {
52
+ readonly code = "invalid_request_content_length";
53
+ readonly status = 400;
54
+
55
+ constructor() {
56
+ super("Request content length must be a non-negative integer");
57
+ this.name = "InvalidRequestContentLengthError";
58
+ }
59
+ }
60
+
61
+ export class RequestBodyTooLargeError extends Error {
62
+ readonly code = "request_body_too_large";
63
+ readonly status = 413;
64
+
65
+ constructor() {
66
+ super("Request body is too large");
67
+ this.name = "RequestBodyTooLargeError";
68
+ }
69
+ }
70
+
41
71
  export interface RequestBodyValidationIssue {
42
72
  path: Array<string | number>;
43
73
  code: string;
@@ -61,7 +91,13 @@ export class InvalidRequestBodyError extends Error {
61
91
  }
62
92
 
63
93
  export function requestBodyErrorResponse(error: unknown): Response | undefined {
64
- if (error instanceof InvalidJsonError || error instanceof InvalidRequestBodyError) {
94
+ if (
95
+ error instanceof InvalidJsonError
96
+ || error instanceof InvalidRequestContentTypeError
97
+ || error instanceof InvalidRequestContentLengthError
98
+ || error instanceof InvalidRequestBodyError
99
+ || error instanceof RequestBodyTooLargeError
100
+ ) {
65
101
  return errorResponse(error.status, error.code, error.message, "details" in error ? error.details : undefined);
66
102
  }
67
103
  return undefined;
@@ -75,16 +111,114 @@ function validateJson<TSchema extends z.ZodTypeAny>(value: unknown, schema: TSch
75
111
  return result.data;
76
112
  }
77
113
 
78
- export async function parseUnknownJson(req: Request): Promise<unknown> {
114
+ export interface ParseJsonOptions {
115
+ maxBytes?: number;
116
+ requireContentType?: boolean;
117
+ }
118
+
119
+ function validateParseJsonOptions(options: ParseJsonOptions): void {
120
+ if (options.maxBytes !== undefined && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)) {
121
+ throw new RangeError("parseJson maxBytes must be a non-negative safe integer");
122
+ }
123
+ }
124
+
125
+ function hasJsonContentType(req: Request): boolean {
126
+ const contentType = req.headers.get("content-type");
127
+ if (!contentType) {
128
+ return false;
129
+ }
130
+ const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase();
131
+ return mediaType === "application/json" || mediaType?.endsWith("+json") === true;
132
+ }
133
+
134
+ function declaredContentLength(req: Request, maxBytes: number | undefined): void {
135
+ const value = req.headers.get("content-length");
136
+ if (value === null) {
137
+ return;
138
+ }
139
+ const normalized = value.trim();
140
+ if (!/^\d+$/.test(normalized)) {
141
+ throw new InvalidRequestContentLengthError();
142
+ }
143
+ const length = BigInt(normalized);
144
+ if (maxBytes !== undefined && length > BigInt(maxBytes)) {
145
+ throw new RequestBodyTooLargeError();
146
+ }
147
+ }
148
+
149
+ async function cancelOversizedBody(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
150
+ await reader.cancel("Request body is too large");
151
+ }
152
+
153
+ async function readLimitedRequestBody(req: Request, maxBytes: number): Promise<string> {
154
+ declaredContentLength(req, maxBytes);
155
+ if (!req.body) {
156
+ return "";
157
+ }
158
+
159
+ const reader = req.body.getReader();
160
+ const chunks: Uint8Array[] = [];
161
+ let byteLength = 0;
162
+
163
+ while (true) {
164
+ const { done, value } = await reader.read();
165
+ if (done) {
166
+ break;
167
+ }
168
+ if (!value) {
169
+ continue;
170
+ }
171
+ byteLength += value.byteLength;
172
+ if (byteLength > maxBytes) {
173
+ await cancelOversizedBody(reader);
174
+ throw new RequestBodyTooLargeError();
175
+ }
176
+ chunks.push(value);
177
+ }
178
+
179
+ const body = new Uint8Array(byteLength);
180
+ let offset = 0;
181
+ for (const chunk of chunks) {
182
+ body.set(chunk, offset);
183
+ offset += chunk.byteLength;
184
+ }
79
185
  try {
80
- return await req.json();
186
+ return new TextDecoder("utf-8", { fatal: true }).decode(body);
81
187
  } catch {
82
188
  throw new InvalidJsonError();
83
189
  }
84
190
  }
85
191
 
86
- export async function parseJson<TSchema extends z.ZodTypeAny>(req: Request, schema: TSchema): Promise<z.infer<TSchema>> {
87
- return validateJson(await parseUnknownJson(req), schema);
192
+ async function readRequestBody(req: Request, maxBytes: number | undefined): Promise<string> {
193
+ if (maxBytes === undefined) {
194
+ declaredContentLength(req, undefined);
195
+ return await req.text();
196
+ }
197
+ return await readLimitedRequestBody(req, maxBytes);
198
+ }
199
+
200
+ function parseJsonText(text: string): unknown {
201
+ try {
202
+ return JSON.parse(text);
203
+ } catch {
204
+ throw new InvalidJsonError();
205
+ }
206
+ }
207
+
208
+ export async function parseUnknownJson(req: Request, options: ParseJsonOptions = {}): Promise<unknown> {
209
+ validateParseJsonOptions(options);
210
+ if (options.requireContentType && !hasJsonContentType(req)) {
211
+ throw new InvalidRequestContentTypeError();
212
+ }
213
+ return parseJsonText(await readRequestBody(req, options.maxBytes));
214
+ }
215
+
216
+ export async function parseJson<TSchema extends z.ZodTypeAny>(
217
+ req: Request,
218
+ schema: TSchema,
219
+ options: ParseJsonOptions = {},
220
+ ): Promise<z.infer<TSchema>> {
221
+ return validateJson(await parseUnknownJson(req, options), schema);
88
222
  }
89
223
 
90
224
  export async function parseOptionalJson<TSchema extends z.ZodTypeAny>(req: Request, schema: TSchema): Promise<z.infer<TSchema> | undefined> {