@atcute/xrpc-server 0.1.2 → 0.1.4

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.
Files changed (72) hide show
  1. package/README.md +206 -28
  2. package/dist/auth/jwt-creator.d.ts.map +1 -1
  3. package/dist/auth/jwt-creator.js.map +1 -1
  4. package/dist/auth/jwt-verifier.d.ts.map +1 -1
  5. package/dist/auth/jwt-verifier.js.map +1 -1
  6. package/dist/auth/jwt.d.ts +10 -15
  7. package/dist/auth/jwt.d.ts.map +1 -1
  8. package/dist/auth/jwt.js.map +1 -1
  9. package/dist/main/index.d.ts +2 -0
  10. package/dist/main/index.d.ts.map +1 -1
  11. package/dist/main/index.js +2 -0
  12. package/dist/main/index.js.map +1 -1
  13. package/dist/main/response.js.map +1 -1
  14. package/dist/main/router.d.ts +15 -5
  15. package/dist/main/router.d.ts.map +1 -1
  16. package/dist/main/router.js +103 -16
  17. package/dist/main/router.js.map +1 -1
  18. package/dist/main/types/operation.d.ts +16 -1
  19. package/dist/main/types/operation.d.ts.map +1 -1
  20. package/dist/main/types/websocket.d.ts +10 -0
  21. package/dist/main/types/websocket.d.ts.map +1 -0
  22. package/dist/main/types/websocket.js +2 -0
  23. package/dist/main/types/websocket.js.map +1 -0
  24. package/dist/main/utils/event-emitter.d.ts +37 -0
  25. package/dist/main/utils/event-emitter.d.ts.map +1 -0
  26. package/dist/main/utils/event-emitter.js +96 -0
  27. package/dist/main/utils/event-emitter.js.map +1 -0
  28. package/dist/main/utils/frames.d.ts +5 -0
  29. package/dist/main/utils/frames.d.ts.map +1 -0
  30. package/dist/main/utils/frames.js +45 -0
  31. package/dist/main/utils/frames.js.map +1 -0
  32. package/dist/main/utils/middlewares.d.ts.map +1 -1
  33. package/dist/main/utils/middlewares.js.map +1 -1
  34. package/dist/main/utils/namespaced.d.ts +5 -0
  35. package/dist/main/utils/namespaced.d.ts.map +1 -0
  36. package/dist/main/utils/namespaced.js +4 -0
  37. package/dist/main/utils/namespaced.js.map +1 -0
  38. package/dist/main/utils/request-input.d.ts +1 -1
  39. package/dist/main/utils/request-input.d.ts.map +1 -1
  40. package/dist/main/utils/request-input.js.map +1 -1
  41. package/dist/main/utils/request-params.d.ts +1 -1
  42. package/dist/main/utils/request-params.d.ts.map +1 -1
  43. package/dist/main/utils/request-params.js.map +1 -1
  44. package/dist/main/utils/response.d.ts +1 -1
  45. package/dist/main/utils/response.d.ts.map +1 -1
  46. package/dist/main/utils/response.js.map +1 -1
  47. package/dist/main/utils/websocket-mock.d.ts +24 -0
  48. package/dist/main/utils/websocket-mock.d.ts.map +1 -0
  49. package/dist/main/utils/websocket-mock.js +71 -0
  50. package/dist/main/utils/websocket-mock.js.map +1 -0
  51. package/dist/main/xrpc-error.d.ts +12 -1
  52. package/dist/main/xrpc-error.d.ts.map +1 -1
  53. package/dist/main/xrpc-error.js +11 -0
  54. package/dist/main/xrpc-error.js.map +1 -1
  55. package/dist/main/xrpc-handler.d.ts +23 -0
  56. package/dist/main/xrpc-handler.d.ts.map +1 -0
  57. package/dist/main/xrpc-handler.js +19 -0
  58. package/dist/main/xrpc-handler.js.map +1 -0
  59. package/dist/middlewares/cors.d.ts.map +1 -1
  60. package/dist/middlewares/cors.js.map +1 -1
  61. package/lib/auth/jwt.ts +16 -5
  62. package/lib/main/index.ts +3 -0
  63. package/lib/main/router.ts +158 -23
  64. package/lib/main/types/operation.ts +33 -0
  65. package/lib/main/types/websocket.ts +14 -0
  66. package/lib/main/utils/event-emitter.ts +116 -0
  67. package/lib/main/utils/frames.ts +71 -0
  68. package/lib/main/utils/namespaced.ts +5 -0
  69. package/lib/main/utils/websocket-mock.ts +111 -0
  70. package/lib/main/xrpc-error.ts +20 -0
  71. package/lib/main/xrpc-handler.ts +54 -0
  72. package/package.json +17 -12
@@ -0,0 +1,116 @@
1
+ /** Converts a tuple into a listener function */
2
+ export type ListenerFor<T extends any[]> = (...args: T) => void;
3
+
4
+ /** Generic record of the event name and its argument tuple */
5
+ export type EventMap = {
6
+ [key: string | symbol]: any[];
7
+ };
8
+
9
+ type MaybeArray<T> = T | T[];
10
+
11
+ /** Event emitter */
12
+ export class EventEmitter<Events extends EventMap> {
13
+ #events?: { [E in keyof Events]?: MaybeArray<ListenerFor<Events[E]>> };
14
+
15
+ /**
16
+ * Appends a listener for the specified event name
17
+ * @param name Name of the event
18
+ * @param listener Callback that should be invoked when an event is dispatched
19
+ * @returns Cleanup function that can be called to remove it
20
+ */
21
+ on<E extends keyof Events>(name: E, listener: ListenerFor<Events[E]>): () => void {
22
+ let events = this.#events;
23
+ let existing: MaybeArray<ListenerFor<Events[E]>> | undefined;
24
+
25
+ if (events === undefined) {
26
+ events = this.#events = Object.create(null);
27
+ } else {
28
+ existing = events[name];
29
+ }
30
+
31
+ if (existing === undefined) {
32
+ events![name] = listener;
33
+ } else if (typeof existing === 'function') {
34
+ events![name] = [existing, listener];
35
+ } else {
36
+ events![name] = existing.concat(listener);
37
+ }
38
+
39
+ // @ts-expect-error: complains about `listener`
40
+ return this.off.bind(this, name, listener);
41
+ }
42
+
43
+ /**
44
+ * Remove listener from the specified event name
45
+ * @param name Name of the event
46
+ * @param listener Callback to remove
47
+ */
48
+ off<E extends keyof Events>(name: E, listener: ListenerFor<Events[E]>): void {
49
+ const events = this.#events;
50
+
51
+ if (events === undefined) {
52
+ return;
53
+ }
54
+
55
+ const list = events[name];
56
+
57
+ if (list == undefined) {
58
+ return;
59
+ }
60
+
61
+ if (list === listener) {
62
+ delete events[name];
63
+ } else if (typeof list !== 'function') {
64
+ const index = list.indexOf(listener);
65
+
66
+ if (index !== -1) {
67
+ if (list.length === 2) {
68
+ // ^ flips the bit, it's either 0 or 1 here.
69
+ events[name] = list[index ^ 1];
70
+ } else {
71
+ events[name] = list.toSpliced(index, 1);
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Emit an event with the specified name and its payload
79
+ * @param name Name of the event
80
+ * @param args Payload for the event
81
+ * @returns Whether a listener has been called
82
+ */
83
+ emit<E extends keyof Events>(name: E, ...args: Events[E]): boolean {
84
+ const events = this.#events;
85
+
86
+ if (events === undefined) {
87
+ return false;
88
+ }
89
+
90
+ const handler = events[name];
91
+
92
+ if (handler === undefined) {
93
+ return false;
94
+ }
95
+
96
+ if (typeof handler === 'function') {
97
+ handler.apply(this, args);
98
+ } else {
99
+ for (let idx = 0, len = handler.length; idx < len; idx++) {
100
+ handler[idx].apply(this, args);
101
+ }
102
+ }
103
+
104
+ return true;
105
+ }
106
+
107
+ /**
108
+ * Determines if there is a listener on a specified event name
109
+ * @param name Name of the event
110
+ * @returns Whether there is a listener registered
111
+ */
112
+ has(name: keyof Events): boolean {
113
+ const events = this.#events;
114
+ return events !== undefined && name in events;
115
+ }
116
+ }
@@ -0,0 +1,71 @@
1
+ import { encode } from '@atcute/cbor';
2
+ import { concat } from '@atcute/uint8array';
3
+
4
+ interface MessageFrameHeader {
5
+ op: 1;
6
+ t?: string; // Type discriminator for union messages (relative to NSID)
7
+ }
8
+
9
+ interface ErrorFrameHeader {
10
+ op: -1;
11
+ }
12
+
13
+ interface ErrorFrameBody {
14
+ error: string;
15
+ message?: string;
16
+ }
17
+
18
+ export const encodeMessageFrame = (body: unknown, type?: string): Uint8Array => {
19
+ const header: MessageFrameHeader = {
20
+ op: 1,
21
+ t: type,
22
+ };
23
+
24
+ return concat([encode(header), encode(body)]);
25
+ };
26
+
27
+ export const encodeErrorFrame = (error: string, message?: string): Uint8Array => {
28
+ const header: ErrorFrameHeader = {
29
+ op: -1,
30
+ };
31
+
32
+ const body: ErrorFrameBody = {
33
+ error: error,
34
+ message: message,
35
+ };
36
+
37
+ return concat([encode(header), encode(body)]);
38
+ };
39
+
40
+ export const extractMessageType = (message: unknown, nsid: string): string | undefined => {
41
+ if (typeof message !== 'object' || message === null) {
42
+ return undefined;
43
+ }
44
+
45
+ const obj = message as Record<string, unknown>;
46
+ const type = obj.$type;
47
+
48
+ if (typeof type !== 'string') {
49
+ return undefined;
50
+ }
51
+
52
+ // If type starts with the subscription NSID, make it relative
53
+ // e.g., "com.atproto.sync.subscribeRepos#commit" → "#commit"
54
+ if (type.startsWith(nsid + '#')) {
55
+ return type.slice(nsid.length);
56
+ }
57
+
58
+ // Otherwise return the full type
59
+ return type;
60
+ };
61
+
62
+ export const omitMessageType = (message: unknown): unknown => {
63
+ if (typeof message !== 'object' || message === null) {
64
+ return message;
65
+ }
66
+
67
+ const obj = message as Record<string, unknown>;
68
+ const { $type: _type, ...rest } = obj;
69
+
70
+ return rest;
71
+ };
@@ -0,0 +1,5 @@
1
+ export type Namespaced<T> = { mainSchema: T };
2
+
3
+ export const unwrapLxm = <T extends object>(schema: T | Namespaced<T>): T => {
4
+ return 'mainSchema' in schema ? (schema as Namespaced<T>).mainSchema : schema;
5
+ };
@@ -0,0 +1,111 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ import type { Promisable } from '../../types/misc.js';
4
+ import type { XRPCRouter } from '../router.js';
5
+ import type { WebSocketAdapter, WebSocketConnection } from '../types/websocket.js';
6
+ import { EventEmitter } from './event-emitter.js';
7
+
8
+ interface WebSocketHandlerContext {
9
+ handler: ((ws: WebSocketConnection) => Promisable<void>) | null;
10
+ }
11
+
12
+ export interface SubscriptionClient extends Disposable {
13
+ events: EventEmitter<{
14
+ message: [data: Uint8Array];
15
+ close: [event: { code: number; reason: string; wasClean: boolean }];
16
+ }>;
17
+ dispose(): void;
18
+ }
19
+
20
+ export interface SubscriptionMock {
21
+ subscribe(url: string): Promise<SubscriptionClient>;
22
+ }
23
+
24
+ export class MockWebSocketAdapter implements WebSocketAdapter {
25
+ #context = new AsyncLocalStorage<WebSocketHandlerContext>();
26
+
27
+ upgrade(
28
+ _request: Request,
29
+ handler: (ws: WebSocketConnection) => Promisable<void>,
30
+ ): Promisable<Response | undefined> {
31
+ const ctx = this.#context.getStore();
32
+ if (!ctx) {
33
+ return undefined;
34
+ }
35
+
36
+ ctx.handler = handler;
37
+
38
+ return new Response(null);
39
+ }
40
+
41
+ attach(router: XRPCRouter): SubscriptionMock {
42
+ return {
43
+ subscribe: async (url) => {
44
+ const ctx: WebSocketHandlerContext = {
45
+ handler: null,
46
+ };
47
+
48
+ await this.#context.run(ctx, async () => {
49
+ const urlp = new URL(url, 'http://localhost');
50
+ const request = new Request(urlp, {
51
+ headers: {
52
+ upgrade: 'websocket',
53
+ connection: 'upgrade',
54
+ },
55
+ });
56
+
57
+ const response = await router.fetch(request);
58
+
59
+ return response;
60
+ });
61
+
62
+ if (!ctx.handler) {
63
+ throw new Error(`WebSocket upgrade succeeded but no handler was set`);
64
+ }
65
+
66
+ const events = new EventEmitter<{
67
+ message: [data: Uint8Array];
68
+ close: [event: { code: number; reason: string; wasClean: boolean }];
69
+ }>();
70
+
71
+ const controller = new AbortController();
72
+ const signal = controller.signal;
73
+
74
+ const connection: WebSocketConnection = {
75
+ signal: signal,
76
+ send(data) {
77
+ events.emit('message', data);
78
+ },
79
+ close(code = 1000, reason = '') {
80
+ if (!signal.aborted) {
81
+ events.emit('close', { code, reason, wasClean: true });
82
+ controller.abort();
83
+ }
84
+ },
85
+ };
86
+
87
+ {
88
+ const handler = ctx.handler;
89
+ setTimeout(() => {
90
+ handler(connection);
91
+ }, 1);
92
+ }
93
+
94
+ const client: SubscriptionClient = {
95
+ events,
96
+ dispose() {
97
+ if (!signal.aborted) {
98
+ events.emit('close', { code: 1000, reason: '', wasClean: true });
99
+ controller.abort();
100
+ }
101
+ },
102
+ [Symbol.dispose]() {
103
+ this.dispose();
104
+ },
105
+ };
106
+
107
+ return client;
108
+ },
109
+ };
110
+ }
111
+ }
@@ -78,3 +78,23 @@ export class UpstreamTimeoutError extends XRPCError {
78
78
  super({ status, error, description });
79
79
  }
80
80
  }
81
+
82
+ export interface XRPCSubscriptionErrorOptions {
83
+ closeCode?: number;
84
+ error: string;
85
+ description?: string;
86
+ }
87
+
88
+ export class XRPCSubscriptionError extends Error {
89
+ readonly closeCode: number;
90
+ readonly error: string;
91
+ readonly description?: string;
92
+
93
+ constructor({ closeCode = 1008, error, description }: XRPCSubscriptionErrorOptions) {
94
+ super(`Subscription error: ${error}${description ? ` - ${description}` : ''}`);
95
+
96
+ this.closeCode = closeCode;
97
+ this.error = error;
98
+ this.description = description;
99
+ }
100
+ }
@@ -0,0 +1,54 @@
1
+ import type { XRPCProcedureMetadata, XRPCQueryMetadata } from '@atcute/lexicons/validations';
2
+
3
+ import { XRPCRouter, type XRPCRouterOptions } from './router.js';
4
+ import type { ProcedureConfig, QueryConfig } from './types/operation.js';
5
+ import { unwrapLxm, type Namespaced } from './utils/namespaced.js';
6
+
7
+ type XrpcHandlerRouterOptions = Pick<XRPCRouterOptions, 'middlewares' | 'handleNotFound' | 'handleException'>;
8
+
9
+ export type XrpcQueryHandlerOptions<TQuery extends XRPCQueryMetadata> = {
10
+ lxm: TQuery | Namespaced<TQuery>;
11
+ routerOptions?: XrpcHandlerRouterOptions;
12
+ } & QueryConfig<TQuery>;
13
+
14
+ export type XrpcProcedureHandlerOptions<TProcedure extends XRPCProcedureMetadata> =
15
+ {
16
+ lxm: TProcedure | Namespaced<TProcedure>;
17
+ routerOptions?: XrpcHandlerRouterOptions;
18
+ } & ProcedureConfig<TProcedure>;
19
+
20
+ export type XrpcHandlerOptions =
21
+ | XrpcQueryHandlerOptions<XRPCQueryMetadata>
22
+ | XrpcProcedureHandlerOptions<XRPCProcedureMetadata>;
23
+
24
+ /**
25
+ * create a fetch handler for a single xrpc query or procedure.
26
+ * requests are expected at `/xrpc/<nsid>`.
27
+ * subscriptions are not supported.
28
+ */
29
+ export function createXrpcHandler<TQuery extends XRPCQueryMetadata>(
30
+ options: XrpcQueryHandlerOptions<TQuery>,
31
+ ): (request: Request) => Promise<Response>;
32
+ export function createXrpcHandler<TProcedure extends XRPCProcedureMetadata>(
33
+ options: XrpcProcedureHandlerOptions<TProcedure>,
34
+ ): (request: Request) => Promise<Response>;
35
+ export function createXrpcHandler(options: XrpcHandlerOptions): (request: Request) => Promise<Response> {
36
+ const { lxm, handler, routerOptions } = options;
37
+
38
+ const router = new XRPCRouter(routerOptions);
39
+
40
+ const schema = unwrapLxm(lxm);
41
+
42
+ switch (schema.type) {
43
+ case 'xrpc_query': {
44
+ router.addQuery(schema, { handler: handler as QueryConfig<XRPCQueryMetadata>['handler'] });
45
+ break;
46
+ }
47
+ case 'xrpc_procedure': {
48
+ router.addProcedure(schema, { handler: handler as ProcedureConfig<XRPCProcedureMetadata>['handler'] });
49
+ break;
50
+ }
51
+ }
52
+
53
+ return router.fetch;
54
+ }
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@atcute/xrpc-server",
4
- "version": "0.1.2",
4
+ "version": "0.1.4",
5
5
  "description": "a small web framework for handling XRPC operations",
6
6
  "license": "0BSD",
7
7
  "repository": {
8
8
  "url": "https://github.com/mary-ext/atcute",
9
9
  "directory": "packages/servers/xrpc-server"
10
10
  },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
11
14
  "files": [
12
15
  "dist/",
13
16
  "lib/",
@@ -21,23 +24,25 @@
21
24
  },
22
25
  "dependencies": {
23
26
  "@badrap/valita": "^0.4.6",
24
- "nanoid": "^5.1.5",
25
- "@atcute/crypto": "^2.2.5",
26
- "@atcute/identity": "^1.1.1",
27
- "@atcute/lexicons": "^1.2.2",
28
- "@atcute/identity-resolver": "^1.1.4",
27
+ "nanoid": "^5.1.6",
28
+ "@atcute/cbor": "^2.2.8",
29
+ "@atcute/crypto": "^2.3.0",
30
+ "@atcute/identity": "^1.1.3",
31
+ "@atcute/identity-resolver": "^1.2.1",
29
32
  "@atcute/multibase": "^1.1.6",
30
- "@atcute/uint8array": "^1.0.5"
33
+ "@atcute/lexicons": "^1.2.5",
34
+ "@atcute/uint8array": "^1.0.6"
31
35
  },
32
36
  "devDependencies": {
33
37
  "@atcute/xrpc-server": "file:",
34
- "@vitest/coverage-v8": "^3.2.4",
35
- "vitest": "^3.2.4",
36
- "@atcute/atproto": "^3.1.6",
37
- "@atcute/bluesky": "^3.2.5"
38
+ "@types/node": "^22.19.3",
39
+ "@vitest/coverage-v8": "^4.0.16",
40
+ "vitest": "^4.0.16",
41
+ "@atcute/atproto": "^3.1.9",
42
+ "@atcute/bluesky": "^3.2.14"
38
43
  },
39
44
  "scripts": {
40
- "build": "tsc --project tsconfig.build.json",
45
+ "build": "tsgo --project tsconfig.build.json",
41
46
  "test": "vitest --coverage",
42
47
  "prepublish": "rm -rf dist; pnpm run build"
43
48
  }