@spikard/node 0.2.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.
- package/LICENSE +21 -0
- package/README.md +509 -0
- package/config.d.ts +279 -0
- package/index.d.ts +158 -0
- package/index.js +583 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Na'aman Hirschfeld
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# spikard-node
|
|
2
|
+
|
|
3
|
+
High-performance Node.js bindings for Spikard HTTP framework via napi-rs.
|
|
4
|
+
|
|
5
|
+
## Status & Badges
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/spikard)
|
|
8
|
+
[](https://www.npmjs.com/package/spikard)
|
|
9
|
+
[](https://crates.io/crates/spikard-node)
|
|
10
|
+
[](https://docs.rs/spikard-node)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
[](https://discord.gg/pXxagNK2zN)
|
|
13
|
+
|
|
14
|
+
## Overview
|
|
15
|
+
|
|
16
|
+
High-performance TypeScript/Node.js web framework with a Rust core. Build REST APIs with type-safe routing backed by Axum and Tower-HTTP.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
**From source (currently):**
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd packages/node
|
|
24
|
+
pnpm install
|
|
25
|
+
pnpm build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Requirements:**
|
|
29
|
+
- Node.js 20+
|
|
30
|
+
- pnpm 10+
|
|
31
|
+
- Rust toolchain (for building from source)
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { Spikard, type Request } from "spikard";
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
|
|
39
|
+
const UserSchema = z.object({
|
|
40
|
+
id: z.number(),
|
|
41
|
+
name: z.string(),
|
|
42
|
+
email: z.string().email(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
type User = z.infer<typeof UserSchema>;
|
|
46
|
+
|
|
47
|
+
const app = new Spikard();
|
|
48
|
+
|
|
49
|
+
const getUser = async (req: Request): Promise<User> => {
|
|
50
|
+
const id = Number(req.params["id"] ?? 0);
|
|
51
|
+
return { id, name: "Alice", email: "alice@example.com" };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const createUser = async (req: Request): Promise<User> => {
|
|
55
|
+
return UserSchema.parse(req.json());
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
app.addRoute(
|
|
59
|
+
{
|
|
60
|
+
method: "GET",
|
|
61
|
+
path: "/users/:id",
|
|
62
|
+
handler_name: "getUser",
|
|
63
|
+
is_async: true,
|
|
64
|
+
},
|
|
65
|
+
getUser,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
app.addRoute(
|
|
69
|
+
{
|
|
70
|
+
method: "POST",
|
|
71
|
+
path: "/users",
|
|
72
|
+
handler_name: "createUser",
|
|
73
|
+
request_schema: UserSchema,
|
|
74
|
+
response_schema: UserSchema,
|
|
75
|
+
is_async: true,
|
|
76
|
+
},
|
|
77
|
+
createUser,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (require.main === module) {
|
|
81
|
+
app.run({ port: 8000 });
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Route Registration
|
|
86
|
+
|
|
87
|
+
### Manual Registration with `addRoute`
|
|
88
|
+
|
|
89
|
+
Routes are registered manually using `app.addRoute(metadata, handler)`:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { Spikard, type Request } from "spikard";
|
|
93
|
+
|
|
94
|
+
const app = new Spikard();
|
|
95
|
+
|
|
96
|
+
async function listUsers(_req: Request): Promise<{ users: unknown[] }> {
|
|
97
|
+
return { users: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function createUser(_req: Request): Promise<{ created: boolean }> {
|
|
101
|
+
return { created: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
app.addRoute(
|
|
105
|
+
{
|
|
106
|
+
method: "GET",
|
|
107
|
+
path: "/users",
|
|
108
|
+
handler_name: "listUsers",
|
|
109
|
+
is_async: true,
|
|
110
|
+
},
|
|
111
|
+
listUsers
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
app.addRoute(
|
|
115
|
+
{
|
|
116
|
+
method: "POST",
|
|
117
|
+
path: "/users",
|
|
118
|
+
handler_name: "createUser",
|
|
119
|
+
is_async: true,
|
|
120
|
+
},
|
|
121
|
+
createUser
|
|
122
|
+
);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Supported HTTP Methods
|
|
126
|
+
|
|
127
|
+
- `GET` - Retrieve resources
|
|
128
|
+
- `POST` - Create resources
|
|
129
|
+
- `PUT` - Replace resources
|
|
130
|
+
- `PATCH` - Update resources
|
|
131
|
+
- `DELETE` - Delete resources
|
|
132
|
+
- `HEAD` - Get headers only
|
|
133
|
+
- `OPTIONS` - Get allowed methods
|
|
134
|
+
- `TRACE` - Echo the request
|
|
135
|
+
|
|
136
|
+
### With Schemas
|
|
137
|
+
|
|
138
|
+
Spikard supports **Zod schemas** and **raw JSON Schema objects**.
|
|
139
|
+
|
|
140
|
+
**With Zod (recommended - type inference):**
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { post } from "spikard";
|
|
144
|
+
import { z } from "zod";
|
|
145
|
+
|
|
146
|
+
const CreateUserSchema = z.object({
|
|
147
|
+
name: z.string().min(1),
|
|
148
|
+
email: z.string().email(),
|
|
149
|
+
age: z.number().int().min(18),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
post("/users", {
|
|
153
|
+
bodySchema: CreateUserSchema,
|
|
154
|
+
responseSchema: z.object({ id: z.number(), name: z.string() }),
|
|
155
|
+
})(async function createUser(req) {
|
|
156
|
+
const user = req.json();
|
|
157
|
+
return { id: 1, name: user.name };
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**With raw JSON Schema:**
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
const userSchema = {
|
|
165
|
+
type: "object",
|
|
166
|
+
properties: {
|
|
167
|
+
name: { type: "string" },
|
|
168
|
+
email: { type: "string", format: "email" },
|
|
169
|
+
},
|
|
170
|
+
required: ["name", "email"],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
post("/users", { bodySchema: userSchema })(async function createUser(req) {
|
|
174
|
+
const user = req.json<{ name: string; email: string }>();
|
|
175
|
+
return { id: 1, ...user };
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Request Handling
|
|
180
|
+
|
|
181
|
+
### Accessing Request Data
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
get("/search")(async function search(req) {
|
|
185
|
+
// Query parameters
|
|
186
|
+
const params = new URLSearchParams(req.queryString);
|
|
187
|
+
const q = params.get("q");
|
|
188
|
+
const limit = params.get("limit") ?? "10";
|
|
189
|
+
|
|
190
|
+
// Headers
|
|
191
|
+
const auth = req.headers["authorization"];
|
|
192
|
+
|
|
193
|
+
// Method and path
|
|
194
|
+
console.log(`${req.method} ${req.path}`);
|
|
195
|
+
|
|
196
|
+
return { query: q, limit: parseInt(limit) };
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### JSON Body
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
post("/users")(async function createUser(req) {
|
|
204
|
+
const body = req.json<{ name: string; email: string }>();
|
|
205
|
+
return { id: 1, ...body };
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Form Data
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
post("/login")(async function login(req) {
|
|
213
|
+
const form = req.form();
|
|
214
|
+
return {
|
|
215
|
+
username: form.username,
|
|
216
|
+
password: form.password,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Handler Wrappers
|
|
222
|
+
|
|
223
|
+
For automatic parameter extraction:
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { wrapHandler, wrapBodyHandler } from "spikard";
|
|
227
|
+
|
|
228
|
+
// Body-only wrapper
|
|
229
|
+
post("/users", {}, wrapBodyHandler(async (body: CreateUserRequest) => {
|
|
230
|
+
return { id: 1, name: body.name };
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
// Full context wrapper
|
|
234
|
+
get(
|
|
235
|
+
"/users/:id",
|
|
236
|
+
{},
|
|
237
|
+
wrapHandler(async (params: { id: string }, query: Record<string, unknown>) => {
|
|
238
|
+
return { id: Number(params.id), query };
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## File Uploads
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import { UploadFile } from "spikard";
|
|
247
|
+
|
|
248
|
+
interface UploadRequest {
|
|
249
|
+
file: UploadFile;
|
|
250
|
+
description: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
post("/upload")(async function upload(req) {
|
|
254
|
+
const body = req.json<UploadRequest>();
|
|
255
|
+
const content = body.file.read();
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
filename: body.file.filename,
|
|
259
|
+
size: body.file.size,
|
|
260
|
+
contentType: body.file.contentType,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Streaming Responses
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import { StreamingResponse } from "spikard";
|
|
269
|
+
|
|
270
|
+
async function* generateData() {
|
|
271
|
+
for (let i = 0; i < 10; i++) {
|
|
272
|
+
yield JSON.stringify({ count: i }) + "\n";
|
|
273
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get("/stream")(async function stream() {
|
|
278
|
+
return new StreamingResponse(generateData(), {
|
|
279
|
+
statusCode: 200,
|
|
280
|
+
headers: { "Content-Type": "application/x-ndjson" },
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Configuration
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
import { Spikard, runServer, type ServerConfig } from "spikard";
|
|
289
|
+
|
|
290
|
+
const app = new Spikard();
|
|
291
|
+
|
|
292
|
+
const config: ServerConfig = {
|
|
293
|
+
host: "0.0.0.0",
|
|
294
|
+
port: 8080,
|
|
295
|
+
workers: 4,
|
|
296
|
+
enableRequestId: true,
|
|
297
|
+
maxBodySize: 10 * 1024 * 1024, // 10 MB
|
|
298
|
+
requestTimeout: 30, // seconds
|
|
299
|
+
compression: {
|
|
300
|
+
gzip: true,
|
|
301
|
+
brotli: true,
|
|
302
|
+
quality: 9,
|
|
303
|
+
minSize: 1024,
|
|
304
|
+
},
|
|
305
|
+
rateLimit: {
|
|
306
|
+
perSecond: 100,
|
|
307
|
+
burst: 200,
|
|
308
|
+
ipBased: true,
|
|
309
|
+
},
|
|
310
|
+
jwtAuth: {
|
|
311
|
+
secret: "your-secret-key",
|
|
312
|
+
algorithm: "HS256",
|
|
313
|
+
},
|
|
314
|
+
staticFiles: [
|
|
315
|
+
{
|
|
316
|
+
directory: "./public",
|
|
317
|
+
routePrefix: "/static",
|
|
318
|
+
indexFile: true,
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
openapi: {
|
|
322
|
+
enabled: true,
|
|
323
|
+
title: "My API",
|
|
324
|
+
version: "1.0.0",
|
|
325
|
+
swaggerUiPath: "/docs",
|
|
326
|
+
redocPath: "/redoc",
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
runServer(app, config);
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Lifecycle Hooks
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
app.onRequest(async (request) => {
|
|
337
|
+
console.log(`${request.method} ${request.path}`);
|
|
338
|
+
return request;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
app.preValidation(async (request) => {
|
|
342
|
+
// Check before validation
|
|
343
|
+
if (!request.headers["authorization"]) {
|
|
344
|
+
return {
|
|
345
|
+
status: 401,
|
|
346
|
+
body: { error: "Unauthorized" },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return request;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
app.preHandler(async (request) => {
|
|
353
|
+
// After validation, before handler
|
|
354
|
+
return request;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
app.onResponse(async (response) => {
|
|
358
|
+
response.headers["X-Frame-Options"] = "DENY";
|
|
359
|
+
return response;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
app.onError(async (response) => {
|
|
363
|
+
console.error(`Error: ${response.status}`);
|
|
364
|
+
return response;
|
|
365
|
+
});
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## Background Tasks
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
import * as background from "spikard/background";
|
|
372
|
+
|
|
373
|
+
post("/process")(async function process(req) {
|
|
374
|
+
const data = req.json();
|
|
375
|
+
|
|
376
|
+
background.run(() => {
|
|
377
|
+
// Heavy processing after response sent
|
|
378
|
+
processData(data);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return { status: "processing" };
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Testing
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
import { TestClient } from "spikard";
|
|
389
|
+
import { expect } from "vitest";
|
|
390
|
+
|
|
391
|
+
const app = {
|
|
392
|
+
routes: [
|
|
393
|
+
/* ... */
|
|
394
|
+
],
|
|
395
|
+
handlers: {
|
|
396
|
+
/* ... */
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const client = new TestClient(app);
|
|
401
|
+
|
|
402
|
+
const response = await client.get("/users/123");
|
|
403
|
+
expect(response.statusCode).toBe(200);
|
|
404
|
+
expect(response.json()).toEqual({ id: "123", name: "Alice" });
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### WebSocket Testing
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
const ws = await client.websocketConnect("/ws");
|
|
411
|
+
await ws.sendJson({ message: "hello" });
|
|
412
|
+
const response = await ws.receiveJson();
|
|
413
|
+
expect(response.echo.message).toBe("hello");
|
|
414
|
+
await ws.close();
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### SSE Testing
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
const response = await client.get("/events");
|
|
421
|
+
const sse = new SseStream(response.text());
|
|
422
|
+
const events = sse.eventsAsJson();
|
|
423
|
+
expect(events.length).toBeGreaterThan(0);
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
## Type Safety
|
|
427
|
+
|
|
428
|
+
Full TypeScript support with auto-generated types:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import {
|
|
432
|
+
type Request,
|
|
433
|
+
type Response,
|
|
434
|
+
type ServerConfig,
|
|
435
|
+
type RouteOptions,
|
|
436
|
+
type HandlerFunction,
|
|
437
|
+
} from "spikard";
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Parameter Types
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
import { Query, Path, Body, QueryDefault } from "spikard";
|
|
444
|
+
|
|
445
|
+
function handler(
|
|
446
|
+
id: Path<number>,
|
|
447
|
+
limit: Query<string | undefined>,
|
|
448
|
+
body: Body<UserType>
|
|
449
|
+
) {
|
|
450
|
+
// Full type inference
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## Validation with Zod
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
import { z } from "zod";
|
|
458
|
+
|
|
459
|
+
const UserSchema = z.object({
|
|
460
|
+
name: z.string().min(1).max(100),
|
|
461
|
+
email: z.string().email(),
|
|
462
|
+
age: z.number().int().min(18).optional(),
|
|
463
|
+
tags: z.array(z.string()).default([]),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
post("/users", { bodySchema: UserSchema })(async function createUser(req) {
|
|
467
|
+
const user = req.json<z.infer<typeof UserSchema>>();
|
|
468
|
+
// user is fully typed and validated
|
|
469
|
+
return user;
|
|
470
|
+
});
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## Running the Server
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
// Simple start
|
|
477
|
+
app.run({ port: 8000 });
|
|
478
|
+
|
|
479
|
+
// With full configuration
|
|
480
|
+
import { runServer } from "spikard";
|
|
481
|
+
|
|
482
|
+
runServer(app, {
|
|
483
|
+
host: "0.0.0.0",
|
|
484
|
+
port: 8080,
|
|
485
|
+
workers: 4,
|
|
486
|
+
});
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
## Performance
|
|
490
|
+
|
|
491
|
+
Node.js bindings use:
|
|
492
|
+
- **napi-rs** for zero-copy FFI
|
|
493
|
+
- **ThreadsafeFunction** for async JavaScript callbacks
|
|
494
|
+
- Dedicated Tokio runtime (doesn't block Node event loop)
|
|
495
|
+
- Direct type conversion without JSON serialization overhead
|
|
496
|
+
|
|
497
|
+
## Examples
|
|
498
|
+
|
|
499
|
+
See `/examples/node/` for more examples.
|
|
500
|
+
|
|
501
|
+
## Documentation
|
|
502
|
+
|
|
503
|
+
- [Main Project README](../../README.md)
|
|
504
|
+
- [Contributing Guide](../../CONTRIBUTING.md)
|
|
505
|
+
- [TypeScript API Reference](./src/index.ts)
|
|
506
|
+
|
|
507
|
+
## License
|
|
508
|
+
|
|
509
|
+
MIT
|