@pablozaiden/webapp 0.6.3 → 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 +37 -5
- package/package.json +1 -1
- package/src/build/build-binary.ts +19 -0
- package/src/server/create-web-app-server.ts +4 -1
- package/src/server/index.ts +1 -0
- package/src/server/public-assets.ts +115 -0
- package/src/server/responses.ts +139 -5
- package/src/server/server-types.ts +1 -0
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
@@ -106,6 +113,31 @@ Route context is user-aware:
|
|
|
106
113
|
|
|
107
114
|
Use `ctx.filterOwned(records, getUserId)` and `ctx.requireOwned(record, getUserId)` when app records use a different ownership field. Return 404 for other-user resources so route responses do not reveal whether another user's id exists.
|
|
108
115
|
|
|
116
|
+
## Supplying a validated runtime config
|
|
117
|
+
|
|
118
|
+
`createWebAppServer` reads and validates `{PREFIX}_*` environment variables by
|
|
119
|
+
default. An application that has already resolved a `RuntimeConfig` can pass it
|
|
120
|
+
through `runtimeConfig` instead:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { createWebAppServer, readRuntimeConfig } from "@pablozaiden/webapp/server";
|
|
124
|
+
|
|
125
|
+
const runtimeConfig = readRuntimeConfig({ appName: "My App", envPrefix: "MY_APP" });
|
|
126
|
+
const app = createWebAppServer({
|
|
127
|
+
appName: "My App",
|
|
128
|
+
envPrefix: "MY_APP",
|
|
129
|
+
runtimeConfig,
|
|
130
|
+
routes,
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The supplied config is used during every framework initialization step,
|
|
135
|
+
including store selection, logging, authentication, document generation,
|
|
136
|
+
request handling, and lifecycle setup. Its `appName` and `envPrefix` must match
|
|
137
|
+
the constructor inputs. Do not mutate `app.config` after construction to
|
|
138
|
+
replace constructor options; omit `runtimeConfig` when the framework should read
|
|
139
|
+
the environment itself.
|
|
140
|
+
|
|
109
141
|
Built-in endpoints include:
|
|
110
142
|
|
|
111
143
|
| Endpoint | Purpose |
|
package/package.json
CHANGED
|
@@ -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";
|
|
@@ -41,7 +41,10 @@ function canUseSpaFallback(req: Request): boolean {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<TEvent>): WebAppServer<TEvent> {
|
|
44
|
-
const config = readRuntimeConfig({ appName: input.appName, envPrefix: input.envPrefix });
|
|
44
|
+
const config = input.runtimeConfig ?? readRuntimeConfig({ appName: input.appName, envPrefix: input.envPrefix });
|
|
45
|
+
if (config.appName !== input.appName || config.envPrefix !== input.envPrefix) {
|
|
46
|
+
throw new Error("runtimeConfig appName and envPrefix must match the createWebAppServer inputs");
|
|
47
|
+
}
|
|
45
48
|
const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
|
|
46
49
|
store.initialize();
|
|
47
50
|
const savedLogLevel = store.getLogLevelPreference();
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/responses.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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> {
|