@pablozaiden/webapp 0.6.4 → 0.6.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.
- package/docs/server.md +12 -5
- package/package.json +1 -1
- package/src/build/build-binary.ts +19 -0
- package/src/server/index.ts +1 -0
- package/src/server/public-assets.ts +115 -0
- package/src/server/responses.ts +139 -5
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
|
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";
|
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> {
|