useprint-js 0.0.1

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.
@@ -0,0 +1,111 @@
1
+ ---
2
+ description: Use Bun instead of Node.js, npm, pnpm, or vite.
3
+ globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
4
+ alwaysApply: false
5
+ ---
6
+
7
+ Default to using Bun instead of Node.js.
8
+
9
+ - Use `bun <file>` instead of `node <file>` or `ts-node <file>`
10
+ - Use `bun test` instead of `jest` or `vitest`
11
+ - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
12
+ - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
13
+ - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
14
+ - Bun automatically loads .env, so don't use dotenv.
15
+
16
+ ## APIs
17
+
18
+ - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
19
+ - `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
20
+ - `Bun.redis` for Redis. Don't use `ioredis`.
21
+ - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
22
+ - `WebSocket` is built-in. Don't use `ws`.
23
+ - Prefer `Bun.file` over `node:fs`'s readFile/writeFile
24
+ - Bun.$`ls` instead of execa.
25
+
26
+ ## Testing
27
+
28
+ Use `bun test` to run tests.
29
+
30
+ ```ts#index.test.ts
31
+ import { test, expect } from "bun:test";
32
+
33
+ test("hello world", () => {
34
+ expect(1).toBe(1);
35
+ });
36
+ ```
37
+
38
+ ## Frontend
39
+
40
+ Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
41
+
42
+ Server:
43
+
44
+ ```ts#index.ts
45
+ import index from "./index.html"
46
+
47
+ Bun.serve({
48
+ routes: {
49
+ "/": index,
50
+ "/api/users/:id": {
51
+ GET: (req) => {
52
+ return new Response(JSON.stringify({ id: req.params.id }));
53
+ },
54
+ },
55
+ },
56
+ // optional websocket support
57
+ websocket: {
58
+ open: (ws) => {
59
+ ws.send("Hello, world!");
60
+ },
61
+ message: (ws, message) => {
62
+ ws.send(message);
63
+ },
64
+ close: (ws) => {
65
+ // handle close
66
+ }
67
+ },
68
+ development: {
69
+ hmr: true,
70
+ console: true,
71
+ }
72
+ })
73
+ ```
74
+
75
+ HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
76
+
77
+ ```html#index.html
78
+ <html>
79
+ <body>
80
+ <h1>Hello, world!</h1>
81
+ <script type="module" src="./frontend.tsx"></script>
82
+ </body>
83
+ </html>
84
+ ```
85
+
86
+ With the following `frontend.tsx`:
87
+
88
+ ```tsx#frontend.tsx
89
+ import React from "react";
90
+
91
+ // import .css files directly and it works
92
+ import './index.css';
93
+
94
+ import { createRoot } from "react-dom/client";
95
+
96
+ const root = createRoot(document.body);
97
+
98
+ export default function Frontend() {
99
+ return <h1>Hello, world!</h1>;
100
+ }
101
+
102
+ root.render(<Frontend />);
103
+ ```
104
+
105
+ Then, run index.ts
106
+
107
+ ```sh
108
+ bun --hot ./index.ts
109
+ ```
110
+
111
+ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @useprint/client
2
+
3
+ To install dependencies:
4
+
5
+ ```bash
6
+ bun install
7
+ ```
8
+
9
+ To run:
10
+
11
+ ```bash
12
+ bun run index.ts
13
+ ```
14
+
15
+ This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
package/index.ts ADDED
@@ -0,0 +1,444 @@
1
+ import { render } from "@skrift/render";
2
+ import type React from "react";
3
+
4
+ /**
5
+ * Error codes that can be thrown by the UsePrint client
6
+ */
7
+ export type UsePrintErrorCode =
8
+ | "API_KEY_MISSING"
9
+ | "NETWORK_ERROR"
10
+ | "TIMEOUT"
11
+ | "RATE_LIMIT"
12
+ | "NOT_FOUND"
13
+ | "VALIDATION_ERROR"
14
+ | "AUTHENTICATION_ERROR"
15
+ | "SERVER_ERROR"
16
+ | "UNKNOWN_ERROR";
17
+
18
+ /**
19
+ * Single error class for all UsePrint API errors
20
+ */
21
+ export class UsePrintError extends Error {
22
+ code: UsePrintErrorCode;
23
+ statusCode?: number;
24
+ retryable: boolean;
25
+
26
+ constructor(
27
+ code: UsePrintErrorCode,
28
+ message: string,
29
+ options?: { statusCode?: number; retryable?: boolean },
30
+ ) {
31
+ super(message);
32
+ this.name = "UsePrintError";
33
+ this.code = code;
34
+ this.statusCode = options?.statusCode;
35
+ this.retryable = options?.retryable ?? false;
36
+ }
37
+ }
38
+
39
+ export type PdfOptions = {
40
+ margin?: {
41
+ top: number;
42
+ right: number;
43
+ bottom: number;
44
+ left: number;
45
+ };
46
+ format?: "A4" | "A3" | "A2" | "A1" | "A0";
47
+ quality?: 100 | 90 | 80 | 70 | 60 | 50 | 40 | 30 | 20 | 10;
48
+ scale?: number;
49
+ dpi?: number;
50
+ orientation?: "portrait" | "landscape";
51
+ background?: string;
52
+ font?: string;
53
+ fontSize?: number;
54
+ timeout?: number;
55
+ };
56
+
57
+ export type RetryOptions = {
58
+ maxRetries?: number;
59
+ initialDelay?: number;
60
+ maxDelay?: number;
61
+ retryableStatusCodes?: number[];
62
+ };
63
+
64
+ export type CreatePdfParams = {
65
+ pdfOptions?: PdfOptions;
66
+ retryOptions?: RetryOptions;
67
+ };
68
+
69
+ type CreatePdfResult = {
70
+ url: string;
71
+ };
72
+
73
+ type UploadUrlResult = {
74
+ url: string;
75
+ id: string;
76
+ };
77
+
78
+ type QueuePdfResult =
79
+ | {
80
+ id: string;
81
+ status: "pending" | "processing" | "failed";
82
+ createdAt: Date;
83
+ updatedAt: Date;
84
+ }
85
+ | {
86
+ id: string;
87
+ status: "completed";
88
+ url: string;
89
+ createdAt: Date;
90
+ updatedAt: Date;
91
+ };
92
+
93
+ const DEFAULT_RETRY_OPTIONS: Required<RetryOptions> = {
94
+ maxRetries: 3,
95
+ initialDelay: 1000, // 1 second minimum since PDF creation takes at least 1s
96
+ maxDelay: 8000,
97
+ retryableStatusCodes: [408, 429, 500, 502, 503, 504],
98
+ };
99
+
100
+ /**
101
+ * Creates a UsePrint API client
102
+ * @param apiKey - Your UsePrint API key
103
+ * @param retryOptions - Default retry options for all requests (can be overridden per request)
104
+ */
105
+ export const usePrint = ({
106
+ apiKey,
107
+ retryOptions,
108
+ }: {
109
+ apiKey?: string;
110
+ retryOptions?: RetryOptions;
111
+ }) => {
112
+ const BASE_URL = "https://api.useprint.dev";
113
+ const headers = new Headers();
114
+ const key = apiKey ?? process.env.USEPRINT_API_KEY;
115
+ if (!key) {
116
+ throw new UsePrintError(
117
+ "API_KEY_MISSING",
118
+ "You have to provide an API key, either via the apiKey parameter or the USEPRINT_API_KEY environment variable.",
119
+ );
120
+ }
121
+ headers.set("Authorization", `x-api-key ${key}`);
122
+ headers.set("Content-Type", "application/json");
123
+
124
+ const defaultRetryOptions: Required<RetryOptions> = {
125
+ ...DEFAULT_RETRY_OPTIONS,
126
+ ...retryOptions,
127
+ retryableStatusCodes:
128
+ retryOptions?.retryableStatusCodes ??
129
+ DEFAULT_RETRY_OPTIONS.retryableStatusCodes,
130
+ };
131
+
132
+ /**
133
+ * Creates an error from a fetch response
134
+ */
135
+ const createErrorFromResponse = async (
136
+ res: Response,
137
+ ): Promise<UsePrintError> => {
138
+ let errorMessage = `Request failed with status ${res.status}`;
139
+ try {
140
+ const errorBody = (await res.json()) as
141
+ | { message?: string; error?: string }
142
+ | unknown;
143
+ if (
144
+ typeof errorBody === "object" &&
145
+ errorBody !== null &&
146
+ ("message" in errorBody || "error" in errorBody)
147
+ ) {
148
+ errorMessage =
149
+ ("message" in errorBody && typeof errorBody.message === "string"
150
+ ? errorBody.message
151
+ : null) ||
152
+ ("error" in errorBody && typeof errorBody.error === "string"
153
+ ? errorBody.error
154
+ : null) ||
155
+ errorMessage;
156
+ }
157
+ } catch {
158
+ // If response body is not JSON, use default message
159
+ }
160
+
161
+ const statusCode = res.status;
162
+ let code: UsePrintErrorCode;
163
+ let retryable = false;
164
+
165
+ if (statusCode === 401 || statusCode === 403) {
166
+ code = "AUTHENTICATION_ERROR";
167
+ } else if (statusCode === 404) {
168
+ code = "NOT_FOUND";
169
+ } else if (statusCode === 400) {
170
+ code = "VALIDATION_ERROR";
171
+ } else if (statusCode === 429) {
172
+ code = "RATE_LIMIT";
173
+ retryable = true;
174
+ } else if (statusCode >= 500) {
175
+ code = "SERVER_ERROR";
176
+ retryable = true;
177
+ } else if (statusCode === 408) {
178
+ code = "TIMEOUT";
179
+ retryable = true;
180
+ } else {
181
+ code = "UNKNOWN_ERROR";
182
+ }
183
+
184
+ return new UsePrintError(code, errorMessage, {
185
+ statusCode,
186
+ retryable,
187
+ });
188
+ };
189
+
190
+ /**
191
+ * Retries a function with exponential backoff
192
+ */
193
+ const retryWithBackoff = async <T>(
194
+ fn: () => Promise<T>,
195
+ options: Required<RetryOptions>,
196
+ ): Promise<T> => {
197
+ let lastError: Error | UsePrintError | undefined;
198
+ let delay = options.initialDelay;
199
+
200
+ for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
201
+ try {
202
+ return await fn();
203
+ } catch (error) {
204
+ lastError = error as Error | UsePrintError;
205
+
206
+ // Don't retry if it's the last attempt
207
+ if (attempt === options.maxRetries) {
208
+ break;
209
+ }
210
+
211
+ // Don't retry if error is not retryable
212
+ if (lastError instanceof UsePrintError && !lastError.retryable) {
213
+ throw lastError;
214
+ }
215
+
216
+ // Check if it's a network error (not a UsePrintError)
217
+ if (!(lastError instanceof UsePrintError)) {
218
+ // Network errors are always retryable
219
+ await new Promise((resolve) => setTimeout(resolve, delay));
220
+ delay = Math.min(delay * 2, options.maxDelay);
221
+ continue;
222
+ }
223
+
224
+ // Check if status code is retryable
225
+ if (
226
+ lastError.statusCode &&
227
+ options.retryableStatusCodes.includes(lastError.statusCode)
228
+ ) {
229
+ await new Promise((resolve) => setTimeout(resolve, delay));
230
+ delay = Math.min(delay * 2, options.maxDelay);
231
+ continue;
232
+ }
233
+
234
+ // Error is not retryable
235
+ throw lastError;
236
+ }
237
+ }
238
+
239
+ // If we get here, all retries failed
240
+ if (!lastError) {
241
+ throw new UsePrintError(
242
+ "UNKNOWN_ERROR",
243
+ "Request failed for unknown reason",
244
+ { retryable: false },
245
+ );
246
+ }
247
+
248
+ if (lastError instanceof UsePrintError) {
249
+ throw lastError;
250
+ }
251
+
252
+ // Network error after all retries
253
+ throw new UsePrintError(
254
+ "NETWORK_ERROR",
255
+ `Network request failed after ${options.maxRetries + 1} attempts: ${lastError.message}`,
256
+ { retryable: true },
257
+ );
258
+ };
259
+
260
+ /**
261
+ * Makes a fetch request with error handling and optional retry
262
+ */
263
+ const fetchWithErrorHandling = async (
264
+ url: string,
265
+ options: RequestInit & { retryOptions?: RetryOptions } = {},
266
+ ): Promise<Response> => {
267
+ const { retryOptions: requestRetryOptions, ...fetchOptions } = options;
268
+ const effectiveRetryOptions: Required<RetryOptions> = {
269
+ ...defaultRetryOptions,
270
+ ...requestRetryOptions,
271
+ retryableStatusCodes:
272
+ requestRetryOptions?.retryableStatusCodes ??
273
+ defaultRetryOptions.retryableStatusCodes,
274
+ };
275
+
276
+ return retryWithBackoff(async () => {
277
+ try {
278
+ const res = await fetch(url, fetchOptions);
279
+
280
+ if (!res.ok) {
281
+ const error = await createErrorFromResponse(res);
282
+ throw error;
283
+ }
284
+
285
+ return res;
286
+ } catch (error) {
287
+ // Convert abort errors to timeout errors
288
+ if (
289
+ error instanceof Error &&
290
+ (error.name === "AbortError" ||
291
+ (error instanceof DOMException && error.name === "AbortError"))
292
+ ) {
293
+ throw new UsePrintError("TIMEOUT", "Request timed out", {
294
+ retryable: true,
295
+ });
296
+ }
297
+ throw error;
298
+ }
299
+ }, effectiveRetryOptions);
300
+ };
301
+
302
+ const getUploadUrl = async (retryOptions?: RetryOptions) => {
303
+ const res = await fetchWithErrorHandling(`${BASE_URL}/pdf/queue`, {
304
+ method: "POST",
305
+ headers,
306
+ retryOptions,
307
+ });
308
+ return (await res.json()) as UploadUrlResult;
309
+ };
310
+
311
+ const getPdfStatus = async ({
312
+ id,
313
+ retryOptions,
314
+ }: {
315
+ id: string;
316
+ retryOptions?: RetryOptions;
317
+ }) => {
318
+ const res = await fetchWithErrorHandling(`${BASE_URL}/pdf/status/${id}`, {
319
+ headers,
320
+ retryOptions,
321
+ });
322
+ return (await res.json()) as QueuePdfResult;
323
+ };
324
+
325
+ return {
326
+ print: async (
327
+ codeOrHtml: React.ReactNode | string,
328
+ { pdfOptions, retryOptions }: CreatePdfParams,
329
+ ) => {
330
+ const isHtml = typeof codeOrHtml === "string";
331
+ const html = isHtml ? codeOrHtml : await render(codeOrHtml);
332
+ const effectiveRetryOptions: Required<RetryOptions> = {
333
+ ...defaultRetryOptions,
334
+ ...retryOptions,
335
+ retryableStatusCodes:
336
+ retryOptions?.retryableStatusCodes ??
337
+ defaultRetryOptions.retryableStatusCodes,
338
+ };
339
+
340
+ const timeout = pdfOptions?.timeout ?? 30000; // Default 30 seconds
341
+ const sizeInBytes = new TextEncoder().encode(html).length;
342
+ const directUpload = sizeInBytes < 5 * 1024 * 1024;
343
+ let result: CreatePdfResult;
344
+
345
+ if (directUpload) {
346
+ const abortController = new AbortController();
347
+ const timeoutId = setTimeout(() => abortController.abort(), timeout);
348
+
349
+ try {
350
+ const res = await fetchWithErrorHandling(`${BASE_URL}/pdf/create`, {
351
+ method: "POST",
352
+ headers,
353
+ body: JSON.stringify({
354
+ html,
355
+ ...pdfOptions,
356
+ }),
357
+ signal: abortController.signal,
358
+ retryOptions,
359
+ });
360
+ clearTimeout(timeoutId);
361
+ result = (await res.json()) as CreatePdfResult;
362
+ } catch (error) {
363
+ clearTimeout(timeoutId);
364
+ throw error;
365
+ }
366
+ } else {
367
+ try {
368
+ const uploadUrl = await getUploadUrl(retryOptions);
369
+ let queue = await getPdfStatus({
370
+ id: uploadUrl.id,
371
+ retryOptions,
372
+ });
373
+
374
+ const pollingMaxDelay = effectiveRetryOptions.maxDelay;
375
+ let pollingDelay = effectiveRetryOptions.initialDelay;
376
+
377
+ while (queue.status !== "completed") {
378
+ if (queue.status === "failed") {
379
+ throw new UsePrintError(
380
+ "SERVER_ERROR",
381
+ "PDF generation failed in queue",
382
+ { retryable: false },
383
+ );
384
+ }
385
+
386
+ await new Promise((resolve) => setTimeout(resolve, pollingDelay));
387
+ pollingDelay = Math.min(pollingDelay * 2, pollingMaxDelay);
388
+
389
+ try {
390
+ queue = await getPdfStatus({
391
+ id: queue.id,
392
+ retryOptions,
393
+ });
394
+ } catch (error) {
395
+ // If status check fails, retry with backoff
396
+ if (error instanceof UsePrintError && error.retryable) {
397
+ await new Promise((resolve) =>
398
+ setTimeout(resolve, pollingDelay),
399
+ );
400
+ pollingDelay = Math.min(pollingDelay * 2, pollingMaxDelay);
401
+ queue = await getPdfStatus({
402
+ id: queue.id,
403
+ retryOptions,
404
+ });
405
+ } else {
406
+ throw error;
407
+ }
408
+ }
409
+ }
410
+
411
+ result = queue as UploadUrlResult;
412
+ } catch (error) {
413
+ if (error instanceof UsePrintError) {
414
+ throw error;
415
+ }
416
+ throw new UsePrintError(
417
+ "UNKNOWN_ERROR",
418
+ `Failed to create PDF: ${error instanceof Error ? error.message : "Unknown error"}`,
419
+ { retryable: false },
420
+ );
421
+ }
422
+ }
423
+
424
+ // Fetch the final PDF
425
+ const abortController = new AbortController();
426
+ const timeoutId = setTimeout(() => abortController.abort(), timeout);
427
+
428
+ try {
429
+ const res = await fetchWithErrorHandling(result.url, {
430
+ headers,
431
+ signal: abortController.signal,
432
+ retryOptions,
433
+ });
434
+ clearTimeout(timeoutId);
435
+ return await res.arrayBuffer();
436
+ } catch (error) {
437
+ clearTimeout(timeoutId);
438
+ throw error;
439
+ }
440
+ },
441
+ getUploadUrl,
442
+ getPdfStatus,
443
+ };
444
+ };
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "useprint-js",
3
+ "version": "0.0.1",
4
+ "module": "index.ts",
5
+ "type": "module",
6
+ "devDependencies": {
7
+ "@types/bun": "latest",
8
+ "@useprint/api": "workspace:*"
9
+ },
10
+ "peerDependencies": {
11
+ "typescript": "^5"
12
+ },
13
+ "dependencies": {
14
+ "@useprint/render": "^0.1.0"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ }
19
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }