@novadxhq/sveltekit-inngest 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.
package/README.md ADDED
@@ -0,0 +1,226 @@
1
+ # @novadxhq/sveltekit-inngest
2
+
3
+ Svelte 5 helpers for building Inngest realtime subscriptions in SvelteKit.
4
+
5
+ ## Features
6
+
7
+ - `RealtimeManager` wrapper component for app-level realtime context.
8
+ - `getRealtimeState()` for Svelte 5 `.current` connection state access.
9
+ - `getRealtimeTopicState()` for typed topic `.current` full-envelope payload access.
10
+ - Compatibility APIs: `getRealtime()` and `getRealtimeTopicJson()`.
11
+ - `createRealtimeEndpoint()` server helper for a single `POST` SSE endpoint using `sveltekit-sse`.
12
+ - Full SvelteKit `RequestEvent` access inside `authorize` and `channelArgs`.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ npm install @novadxhq/sveltekit-inngest
18
+ ```
19
+
20
+ Peer dependencies:
21
+
22
+ - `svelte` (v5)
23
+ - `@sveltejs/kit`
24
+ - `sveltekit-sse`
25
+ - `@inngest/realtime`
26
+ - `inngest`
27
+
28
+ ## Client usage (Svelte 5)
29
+
30
+ `RealtimeBody` below is only an example. `RealtimeManager` can wrap any children and render them internally via `{@render children?.()}`.
31
+
32
+ ```svelte
33
+ <!-- +page.svelte -->
34
+ <script lang="ts">
35
+ import { RealtimeManager } from "@novadxhq/sveltekit-inngest";
36
+ import { myChannel } from "$lib/channels";
37
+ import RealtimeBody from "./RealtimeBody.svelte";
38
+ </script>
39
+
40
+ <RealtimeManager endpoint="/api/events" channel={myChannel}>
41
+ <RealtimeBody />
42
+ </RealtimeManager>
43
+ ```
44
+
45
+ ```svelte
46
+ <!-- RealtimeBody.svelte -->
47
+ <script lang="ts">
48
+ import { getRealtimeState, getRealtimeTopicState } from "@novadxhq/sveltekit-inngest";
49
+ import { myChannel } from "$lib/channels";
50
+
51
+ const { health } = getRealtimeState();
52
+ const orderUpdates = getRealtimeTopicState<typeof myChannel, "orders.updated">(
53
+ "orders.updated"
54
+ );
55
+ </script>
56
+
57
+ <p>
58
+ {#if health.current}
59
+ {health.current.ok ? "Connected" : "Degraded"} ({health.current.status})
60
+ {:else}
61
+ Waiting for connection...
62
+ {/if}
63
+ </p>
64
+
65
+ <pre>{JSON.stringify(orderUpdates.current, null, 2)}</pre>
66
+ ```
67
+
68
+ By default, topic helpers return the full Inngest envelope, not only `data`, so
69
+ metadata like `runId`, `createdAt`, `envId`, `kind`, and `fnId` are available.
70
+ `createdAt` is a string on the client because SSE payloads are JSON-parsed.
71
+
72
+ ```svelte
73
+ <!-- AppRealtime.svelte -->
74
+ <script lang="ts">
75
+ import type { Snippet } from "svelte";
76
+ import { RealtimeManager } from "@novadxhq/sveltekit-inngest";
77
+ import { myChannel } from "$lib/channels";
78
+
79
+ let { children }: { children?: Snippet } = $props();
80
+ </script>
81
+
82
+ <RealtimeManager endpoint="/api/events" channel={myChannel}>
83
+ {@render children?.()}
84
+ </RealtimeManager>
85
+ ```
86
+
87
+ ## Server usage (`+server.ts`)
88
+
89
+ ```ts
90
+ import {createRealtimeEndpoint} from "@novadxhq/sveltekit-inngest/server";
91
+ import {inngest} from "$lib/server/inngest";
92
+ import {myChannel} from "$lib/channels";
93
+
94
+ type LocalsWithPermissions = {
95
+ permissions?: {
96
+ has(permission: string): boolean;
97
+ };
98
+ };
99
+
100
+ export const POST = createRealtimeEndpoint({
101
+ inngest,
102
+ channel: myChannel,
103
+ healthCheck: {
104
+ // Configure periodic health ticks (for example, every 1000ms)
105
+ intervalMs: 1_000,
106
+ },
107
+ // Optional: derive channel builder args from the full RequestEvent
108
+ channelArgs: (event) => [event.params.workspaceId],
109
+ authorize: ({event, locals, topics, params}) => {
110
+ // You can use all standard +server.ts POST event fields here:
111
+ // event.url, event.params, event.cookies, event.fetch, event.getClientAddress, event.setHeaders, etc.
112
+ event.setHeaders({"x-realtime-route": "events"});
113
+
114
+ const canUse =
115
+ (locals as LocalsWithPermissions).permissions?.has("can_use") ?? false;
116
+
117
+ if (!canUse) return false;
118
+
119
+ // Optional: filter to a subset of requested topics.
120
+ if (params?.scope === "limited") {
121
+ return {
122
+ allowedTopics: topics.filter((topic) => topic === "orders.updated"),
123
+ };
124
+ }
125
+
126
+ return true;
127
+ },
128
+ });
129
+ ```
130
+
131
+ ## Endpoint contract
132
+
133
+ - Route method: `POST`
134
+ - Request body:
135
+
136
+ ```json
137
+ {
138
+ "channel": "your-channel-id",
139
+ "topics": ["topic.name"],
140
+ "params": {
141
+ "scope": "limited"
142
+ }
143
+ }
144
+ ```
145
+
146
+ - SSE event names:
147
+ - `message`: JSON realtime messages
148
+ - `health`: `{ ok, status, ts, detail? }`
149
+ - Unauthorized requests return `403` JSON and do not start SSE.
150
+ - Set `healthCheck.intervalMs` in `createRealtimeEndpoint(...)` to configure how often health ticks are emitted.
151
+
152
+ ## Health model
153
+
154
+ `HealthPayload`:
155
+
156
+ ```ts
157
+ type HealthStatus = "connecting" | "connected" | "degraded";
158
+ type HealthPayload = {
159
+ ok: boolean;
160
+ status: HealthStatus;
161
+ ts: number;
162
+ detail?: string;
163
+ };
164
+ ```
165
+
166
+ ## Compatibility APIs
167
+
168
+ If you prefer store-returning helpers, these are still available:
169
+
170
+ - `getRealtime()` (returns `health` as a `Readable` store)
171
+ - `getRealtimeTopicJson()` (returns full topic envelope as a `Readable` store)
172
+
173
+ If you want the previous data-only behavior, project it with `map`:
174
+
175
+ ```ts
176
+ const payloadOnly = getRealtimeTopicState<typeof myChannel, "orders.updated", { id: string }>(
177
+ "orders.updated",
178
+ {
179
+ map: (message) => message.data,
180
+ }
181
+ );
182
+ ```
183
+
184
+ ## Publishing
185
+
186
+ ### 1. Auth setup
187
+
188
+ - npmjs token: create an npm automation token with publish rights for the `@novadxhq` scope.
189
+ - GitHub Packages token: create a GitHub token with `write:packages` and `read:packages` for the `novadxhq` org.
190
+
191
+ Use local user-level config (do not commit tokens):
192
+
193
+ ```sh
194
+ npm config set //registry.npmjs.org/:_authToken <NPM_TOKEN>
195
+ npm config set //npm.pkg.github.com/:_authToken <GITHUB_TOKEN>
196
+ ```
197
+
198
+ ### 2. Verify package output locally
199
+
200
+ ```sh
201
+ npm run release:verify
202
+ ```
203
+
204
+ ### 3. Publish
205
+
206
+ ```sh
207
+ # npmjs
208
+ npm run publish:npm
209
+
210
+ # GitHub Packages
211
+ npm run publish:gpr
212
+
213
+ # or both (sequential)
214
+ npm run publish:both
215
+ ```
216
+
217
+ ### 4. Dry-run checks
218
+
219
+ ```sh
220
+ npm publish --dry-run --registry https://registry.npmjs.org/ --cache /tmp/novadx-npm-cache
221
+ npm publish --dry-run --registry https://npm.pkg.github.com/ --cache /tmp/novadx-npm-cache
222
+ ```
223
+
224
+ ## Breaking rename
225
+
226
+ This package scope changed from `@novadx/sveltekit-inngest` to `@novadxhq/sveltekit-inngest`.
@@ -0,0 +1,123 @@
1
+ <script lang="ts" generics="TChannel extends ChannelInput">
2
+ import { onDestroy } from "svelte";
3
+ import { fromStore, writable } from "svelte/store";
4
+ import { source, type Event as SseEvent } from "sveltekit-sse";
5
+ import { setRealtimeContext } from "./context.js";
6
+ import type {
7
+ ChannelInput,
8
+ HealthPayload,
9
+ RealtimeManagerProps,
10
+ ResolvedChannel,
11
+ TopicKey,
12
+ } from "./types.js";
13
+
14
+ let {
15
+ endpoint = "/api/events",
16
+ channel,
17
+ channelArgs = [],
18
+ topics,
19
+ params,
20
+ children,
21
+ }: RealtimeManagerProps<TChannel> = $props();
22
+
23
+ const resolveChannel = <TInput extends ChannelInput>(
24
+ input: TInput,
25
+ args: unknown[]
26
+ ): ResolvedChannel<TInput> => {
27
+ if (typeof input === "function") {
28
+ const channelFactory = input as unknown as (
29
+ ...callArgs: unknown[]
30
+ ) => ResolvedChannel<TInput>;
31
+ return channelFactory(...args);
32
+ }
33
+
34
+ return input as ResolvedChannel<TInput>;
35
+ };
36
+
37
+ const getEndpoint = () => endpoint ?? "/api/events";
38
+ const getResolvedChannel = () => resolveChannel(channel, channelArgs ?? []);
39
+ const getResolvedTopics = () =>
40
+ topics?.length
41
+ ? topics
42
+ : (Object.keys(getResolvedChannel().topics) as TopicKey<TChannel>[]);
43
+ const getRequestBody = () =>
44
+ JSON.stringify({
45
+ channel: getResolvedChannel().name,
46
+ topics: getResolvedTopics(),
47
+ params,
48
+ });
49
+
50
+ const resolvedChannel = getResolvedChannel();
51
+ const resolvedTopics = getResolvedTopics();
52
+
53
+ const health = writable<HealthPayload | null>({
54
+ ok: true,
55
+ status: "connecting",
56
+ ts: Date.now(),
57
+ });
58
+ const healthState = fromStore(health);
59
+
60
+ const getDetail = (event: SseEvent): string | undefined => {
61
+ if (event.error instanceof Error) return event.error.message;
62
+ if (event.status >= 400) return `${event.status} ${event.statusText}`;
63
+ return undefined;
64
+ };
65
+
66
+ const events = source(getEndpoint(), {
67
+ cache: false,
68
+ options: {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ },
73
+ body: getRequestBody(),
74
+ },
75
+ open: () => {
76
+ health.set({
77
+ ok: true,
78
+ status: "connected",
79
+ ts: Date.now(),
80
+ });
81
+ },
82
+ close: (event: SseEvent) => {
83
+ const detail = getDetail(event);
84
+ if (!detail && event.status < 400) return;
85
+ health.set({
86
+ ok: false,
87
+ status: "degraded",
88
+ ts: Date.now(),
89
+ ...(detail ? { detail } : {}),
90
+ });
91
+ },
92
+ error: (event: SseEvent) => {
93
+ health.set({
94
+ ok: false,
95
+ status: "degraded",
96
+ ts: Date.now(),
97
+ detail: getDetail(event) ?? "Realtime connection error",
98
+ });
99
+ },
100
+ });
101
+
102
+ const streamHealth = events
103
+ .select("health")
104
+ .json<HealthPayload>(({ previous }: { previous: HealthPayload | null }) => previous ?? null);
105
+ const streamHealthUnsubscribe = streamHealth.subscribe((value: HealthPayload | null) => {
106
+ if (value) health.set(value);
107
+ });
108
+
109
+ setRealtimeContext({
110
+ channelId: resolvedChannel.name,
111
+ topics: resolvedTopics as string[],
112
+ select: events.select,
113
+ health,
114
+ healthState,
115
+ });
116
+
117
+ onDestroy(() => {
118
+ streamHealthUnsubscribe();
119
+ events.close();
120
+ });
121
+ </script>
122
+
123
+ {@render children?.()}
@@ -0,0 +1,25 @@
1
+ import type { ChannelInput, RealtimeManagerProps } from "./types.js";
2
+ declare function $$render<TChannel extends ChannelInput>(): {
3
+ props: RealtimeManagerProps<TChannel>;
4
+ exports: {};
5
+ bindings: "";
6
+ slots: {};
7
+ events: {};
8
+ };
9
+ declare class __sveltets_Render<TChannel extends ChannelInput> {
10
+ props(): ReturnType<typeof $$render<TChannel>>['props'];
11
+ events(): ReturnType<typeof $$render<TChannel>>['events'];
12
+ slots(): ReturnType<typeof $$render<TChannel>>['slots'];
13
+ bindings(): "";
14
+ exports(): {};
15
+ }
16
+ interface $$IsomorphicComponent {
17
+ new <TChannel extends ChannelInput>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TChannel>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TChannel>['props']>, ReturnType<__sveltets_Render<TChannel>['events']>, ReturnType<__sveltets_Render<TChannel>['slots']>> & {
18
+ $$bindings?: ReturnType<__sveltets_Render<TChannel>['bindings']>;
19
+ } & ReturnType<__sveltets_Render<TChannel>['exports']>;
20
+ <TChannel extends ChannelInput>(internal: unknown, props: ReturnType<__sveltets_Render<TChannel>['props']> & {}): ReturnType<__sveltets_Render<TChannel>['exports']>;
21
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
+ }
23
+ declare const RealtimeManager: $$IsomorphicComponent;
24
+ type RealtimeManager<TChannel extends ChannelInput> = InstanceType<typeof RealtimeManager<TChannel>>;
25
+ export default RealtimeManager;
@@ -0,0 +1,12 @@
1
+ import type { SourceSelect } from "sveltekit-sse";
2
+ import type { Readable } from "svelte/store";
3
+ import type { HealthPayload, RealtimeHealthState } from "./types.js";
4
+ export type RealtimeContextValue = {
5
+ channelId: string;
6
+ topics: string[];
7
+ select: SourceSelect;
8
+ health: Readable<HealthPayload | null>;
9
+ healthState?: RealtimeHealthState;
10
+ };
11
+ export declare function setRealtimeContext(value: RealtimeContextValue): RealtimeContextValue;
12
+ export declare function getRealtimeContext(): RealtimeContextValue | undefined;
@@ -0,0 +1,9 @@
1
+ import { getContext, setContext } from "svelte";
2
+ const REALTIME_CONTEXT_KEY = Symbol("novadx-realtime-context");
3
+ export function setRealtimeContext(value) {
4
+ setContext(REALTIME_CONTEXT_KEY, value);
5
+ return value;
6
+ }
7
+ export function getRealtimeContext() {
8
+ return getContext(REALTIME_CONTEXT_KEY);
9
+ }
@@ -0,0 +1,18 @@
1
+ import { type Readable } from "svelte/store";
2
+ import type { ChannelInput, RealtimeHealthState, RealtimeTopicEnvelope, RealtimeTopicMessage, RealtimeTopicState, TopicKey } from "./types.js";
3
+ type JsonPredicatePayload<T> = {
4
+ error: Error;
5
+ raw: string;
6
+ previous: T | null;
7
+ };
8
+ type TopicJsonOptions<TChannel extends ChannelInput, TTopic extends TopicKey<TChannel>, TOutput> = {
9
+ map?: (message: RealtimeTopicMessage<TChannel, TTopic>) => TOutput;
10
+ or?: (payload: JsonPredicatePayload<RealtimeTopicMessage<TChannel, TTopic>>) => RealtimeTopicMessage<TChannel, TTopic> | null;
11
+ };
12
+ export declare function getRealtime(): import("./context.js").RealtimeContextValue;
13
+ export declare function getRealtimeState(): Omit<ReturnType<typeof getRealtime>, "health"> & {
14
+ health: RealtimeHealthState;
15
+ };
16
+ export declare function getRealtimeTopicJson<TChannel extends ChannelInput, TTopic extends TopicKey<TChannel>, TOutput = RealtimeTopicEnvelope<TChannel, TTopic>>(topic: TTopic, options?: TopicJsonOptions<TChannel, TTopic, TOutput>): Readable<TOutput | null>;
17
+ export declare function getRealtimeTopicState<TChannel extends ChannelInput, TTopic extends TopicKey<TChannel>, TOutput = RealtimeTopicEnvelope<TChannel, TTopic>>(topic: TTopic, options?: TopicJsonOptions<TChannel, TTopic, TOutput>): RealtimeTopicState<TOutput>;
18
+ export {};
@@ -0,0 +1,36 @@
1
+ import { derived, fromStore } from "svelte/store";
2
+ import { getRealtimeContext } from "./context.js";
3
+ export function getRealtime() {
4
+ const context = getRealtimeContext();
5
+ if (!context) {
6
+ throw new Error("getRealtime() requires <RealtimeManager> in the component tree.");
7
+ }
8
+ return context;
9
+ }
10
+ export function getRealtimeState() {
11
+ const context = getRealtime();
12
+ return {
13
+ channelId: context.channelId,
14
+ topics: context.topics,
15
+ select: context.select,
16
+ health: context.healthState ?? fromStore(context.health),
17
+ };
18
+ }
19
+ export function getRealtimeTopicJson(topic, options = {}) {
20
+ const { select } = getRealtime();
21
+ const parsedMessages = select("message").json(options.or ??
22
+ (({ previous }) => previous ?? null));
23
+ const mapMessage = options.map ??
24
+ ((message) => message);
25
+ let previous = null;
26
+ return derived(parsedMessages, ($message) => {
27
+ if (!$message || $message.topic !== topic) {
28
+ return previous;
29
+ }
30
+ previous = mapMessage($message);
31
+ return previous;
32
+ });
33
+ }
34
+ export function getRealtimeTopicState(topic, options = {}) {
35
+ return fromStore(getRealtimeTopicJson(topic, options));
36
+ }
@@ -0,0 +1,49 @@
1
+ import type { Realtime } from "@inngest/realtime";
2
+ import type { Snippet } from "svelte";
3
+ export type ChannelInput = Realtime.Channel | Realtime.Channel.Definition;
4
+ export type ResolvedChannel<T extends ChannelInput> = T extends Realtime.Channel.Definition ? Realtime.Channel.Definition.AsChannel<T> : T extends Realtime.Channel ? T : never;
5
+ type ChannelTopics<T extends ChannelInput> = Realtime.Channel.InferTopics<ResolvedChannel<T>>;
6
+ export type TopicKey<T extends ChannelInput> = keyof ChannelTopics<T> & string;
7
+ export type TopicData<T extends ChannelInput, K extends TopicKey<T>> = Realtime.Topic.InferSubscribe<ChannelTopics<T>[K]>;
8
+ export type HealthStatus = "connecting" | "connected" | "degraded";
9
+ export type HealthPayload = {
10
+ ok: boolean;
11
+ status: HealthStatus;
12
+ ts: number;
13
+ detail?: string;
14
+ };
15
+ /**
16
+ * Svelte 5-compatible state wrapper from `fromStore(...)`.
17
+ * Consumers can read values with `.current` instead of `$store` syntax.
18
+ */
19
+ export type ReactiveCurrent<T> = {
20
+ readonly current: T;
21
+ };
22
+ export type RealtimeHealthState = ReactiveCurrent<HealthPayload | null>;
23
+ export type RealtimeTopicState<T> = ReactiveCurrent<T | null>;
24
+ export type RealtimeRequestParams = Record<string, string | number | boolean | null>;
25
+ export type RealtimeManagerProps<TChannel extends ChannelInput> = {
26
+ endpoint?: string;
27
+ channel: TChannel;
28
+ channelArgs?: unknown[];
29
+ topics?: TopicKey<TChannel>[];
30
+ params?: RealtimeRequestParams;
31
+ children?: Snippet;
32
+ };
33
+ export type RealtimeRequestPayload = {
34
+ channel: string;
35
+ topics?: string[];
36
+ params?: RealtimeRequestParams;
37
+ };
38
+ export type RealtimeTopicEnvelope<TChannel extends ChannelInput, TTopic extends TopicKey<TChannel>> = {
39
+ channel?: string;
40
+ topic: TTopic;
41
+ data: TopicData<TChannel, TTopic>;
42
+ runId?: string;
43
+ createdAt?: string;
44
+ envId?: string;
45
+ kind?: string;
46
+ fnId?: string;
47
+ } & Record<string, unknown>;
48
+ export type RealtimeTopicMessage<TChannel extends ChannelInput, TTopic extends TopicKey<TChannel>> = RealtimeTopicEnvelope<TChannel, TTopic>;
49
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export { default as RealtimeManager } from "./client/RealtimeManager.svelte";
2
+ export { getRealtime, getRealtimeState, getRealtimeTopicJson, getRealtimeTopicState, } from "./client/hooks.js";
3
+ export type { ChannelInput, HealthPayload, HealthStatus, ReactiveCurrent, RealtimeManagerProps, RealtimeRequestParams, RealtimeHealthState, RealtimeTopicEnvelope, RealtimeTopicMessage, RealtimeTopicState, ResolvedChannel, TopicData, TopicKey, } from "./client/types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { default as RealtimeManager } from "./client/RealtimeManager.svelte";
2
+ export { getRealtime, getRealtimeState, getRealtimeTopicJson, getRealtimeTopicState, } from "./client/hooks.js";
@@ -0,0 +1,75 @@
1
+ import type { RequestEvent, RequestHandler } from "@sveltejs/kit";
2
+ import type { Inngest } from "inngest";
3
+ import type { ChannelInput, RealtimeRequestParams, TopicKey } from "../client/types.js";
4
+ /**
5
+ * Result of authorization:
6
+ * - `true`: allow all requested topics
7
+ * - `false`: deny request (403)
8
+ * - `{ allowedTopics }`: allow only a subset of requested topics
9
+ */
10
+ type AuthorizationResult<TTopic extends string> = boolean | {
11
+ allowedTopics?: TTopic[];
12
+ };
13
+ /**
14
+ * Optional resolver for channel builder args.
15
+ * This receives the full SvelteKit RequestEvent so callers can derive args from
16
+ * route params, cookies, headers, etc.
17
+ */
18
+ type ChannelArgsResolver = (event: RequestEvent) => unknown[] | Promise<unknown[]>;
19
+ /**
20
+ * Context passed to `authorize` so userland code can make auth decisions with
21
+ * full request visibility and typed topic names.
22
+ */
23
+ export type RealtimeAuthorizeContext<TLocals, TTopic extends string> = {
24
+ event: RequestEvent;
25
+ locals: TLocals;
26
+ request: Request;
27
+ channelId: string;
28
+ topics: TTopic[];
29
+ params?: RealtimeRequestParams;
30
+ };
31
+ /**
32
+ * Configuration for creating a SvelteKit `POST` handler that proxies Inngest
33
+ * realtime data over SSE.
34
+ */
35
+ export type RealtimeEndpointOptions<TChannelInput extends ChannelInput, TLocals = App.Locals> = {
36
+ /** Inngest client used for token generation + realtime subscription. */
37
+ inngest: Inngest.Like;
38
+ /** Channel definition/object to subscribe to. */
39
+ channel: TChannelInput;
40
+ /** Static args or event-driven args for channel builders. */
41
+ channelArgs?: unknown[] | ChannelArgsResolver;
42
+ /**
43
+ * @deprecated Use `healthCheck.intervalMs` instead.
44
+ */
45
+ heartbeatMs?: number;
46
+ /** Health tick behavior for `health` SSE events. */
47
+ healthCheck?: RealtimeHealthCheckOptions;
48
+ /** Optional permission hook that can deny or filter topics. */
49
+ authorize?: (context: RealtimeAuthorizeContext<TLocals, TopicKey<TChannelInput>>) => AuthorizationResult<TopicKey<TChannelInput>> | Promise<AuthorizationResult<TopicKey<TChannelInput>>>;
50
+ };
51
+ export type RealtimeHealthCheckOptions = {
52
+ /**
53
+ * Emit periodic health updates at this interval (in milliseconds).
54
+ * Set to 0 or a negative number to disable interval-based health ticks.
55
+ *
56
+ * @default 5000
57
+ */
58
+ intervalMs?: number;
59
+ /**
60
+ * Enable or disable interval-based health ticks.
61
+ *
62
+ * @default true
63
+ */
64
+ enabled?: boolean;
65
+ };
66
+ /**
67
+ * Creates a SvelteKit `POST` RequestHandler that:
68
+ * 1) validates subscription input,
69
+ * 2) optionally authorizes/filter topics,
70
+ * 3) opens an Inngest realtime subscription,
71
+ * 4) forwards data as SSE `message` events,
72
+ * 5) emits lifecycle `health` SSE events.
73
+ */
74
+ export declare function createRealtimeEndpoint<TChannelInput extends ChannelInput, TLocals = App.Locals>({ inngest, channel, channelArgs, heartbeatMs, healthCheck, authorize, }: RealtimeEndpointOptions<TChannelInput, TLocals>): RequestHandler;
75
+ export {};
@@ -0,0 +1,233 @@
1
+ import { getSubscriptionToken, subscribe } from "@inngest/realtime";
2
+ import { produce } from "sveltekit-sse";
3
+ /**
4
+ * Small helper for JSON responses used by validation/auth failure paths.
5
+ */
6
+ const json = (body, status = 200) => new Response(JSON.stringify(body), {
7
+ status,
8
+ headers: { "Content-Type": "application/json" },
9
+ });
10
+ /**
11
+ * Normalize unknown error values into a human-readable message.
12
+ */
13
+ const formatError = (error) => {
14
+ if (error instanceof Error)
15
+ return error.message;
16
+ return "Unknown realtime error";
17
+ };
18
+ /**
19
+ * Keep only primitives/null from `params` so we never pass arbitrary objects
20
+ * into authorization logic.
21
+ */
22
+ const normalizeParams = (input) => {
23
+ if (!input || typeof input !== "object" || Array.isArray(input))
24
+ return undefined;
25
+ const entries = Object.entries(input).filter(([, value]) => typeof value === "string" ||
26
+ typeof value === "number" ||
27
+ typeof value === "boolean" ||
28
+ value === null);
29
+ if (entries.length === 0)
30
+ return undefined;
31
+ return Object.fromEntries(entries);
32
+ };
33
+ /**
34
+ * Parse and lightly validate the expected POST payload shape.
35
+ */
36
+ const parseRequestPayload = async (request) => {
37
+ try {
38
+ const body = (await request.json());
39
+ if (!body || typeof body !== "object" || Array.isArray(body))
40
+ return null;
41
+ const record = body;
42
+ return {
43
+ channel: typeof record.channel === "string" ? record.channel : "",
44
+ topics: Array.isArray(record.topics)
45
+ ? record.topics.filter((topic) => typeof topic === "string")
46
+ : undefined,
47
+ params: normalizeParams(record.params),
48
+ };
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ };
54
+ /**
55
+ * Resolve a channel input into a concrete channel object.
56
+ * Supports both channel objects and channel builder functions.
57
+ */
58
+ const resolveChannel = (input, args) => {
59
+ if (typeof input === "function") {
60
+ const channelFactory = input;
61
+ return channelFactory(...args);
62
+ }
63
+ return input;
64
+ };
65
+ /**
66
+ * Resolve channel args from either static config or a RequestEvent callback.
67
+ */
68
+ const resolveChannelArgs = async (channelArgs, event) => {
69
+ if (Array.isArray(channelArgs))
70
+ return channelArgs;
71
+ if (typeof channelArgs === "function") {
72
+ const args = await channelArgs(event);
73
+ return Array.isArray(args) ? args : [];
74
+ }
75
+ return [];
76
+ };
77
+ /**
78
+ * Intersect requested topics with an optional `allowedTopics` list.
79
+ */
80
+ const filterAllowedTopics = (requested, allowed) => {
81
+ if (!allowed)
82
+ return requested;
83
+ const allowedSet = new Set(allowed);
84
+ return requested.filter((topic) => allowedSet.has(topic));
85
+ };
86
+ /**
87
+ * Creates a SvelteKit `POST` RequestHandler that:
88
+ * 1) validates subscription input,
89
+ * 2) optionally authorizes/filter topics,
90
+ * 3) opens an Inngest realtime subscription,
91
+ * 4) forwards data as SSE `message` events,
92
+ * 5) emits lifecycle `health` SSE events.
93
+ */
94
+ export function createRealtimeEndpoint({ inngest, channel, channelArgs, heartbeatMs = 5_000, healthCheck, authorize, }) {
95
+ const healthCheckEnabled = healthCheck?.enabled ?? true;
96
+ const healthCheckIntervalMs = healthCheck?.intervalMs ?? heartbeatMs;
97
+ return async (event) => {
98
+ const { request, locals } = event;
99
+ // Resolve channel + known topic list for this specific request.
100
+ const resolvedChannel = resolveChannel(channel, await resolveChannelArgs(channelArgs, event));
101
+ const configuredChannelId = resolvedChannel.name;
102
+ const availableTopics = Object.keys(resolvedChannel.topics);
103
+ const availableTopicSet = new Set(availableTopics);
104
+ // Parse body and validate requested channel.
105
+ const payload = await parseRequestPayload(request);
106
+ if (!payload)
107
+ return json({ error: "Invalid request body" }, 400);
108
+ if (!payload.channel)
109
+ return json({ error: "Missing channel" }, 400);
110
+ if (payload.channel !== configuredChannelId) {
111
+ return json({ error: "Requested channel is not available" }, 400);
112
+ }
113
+ // Default to all channel topics when none are explicitly requested.
114
+ const requestedTopics = (payload.topics && payload.topics.length > 0
115
+ ? payload.topics
116
+ : availableTopics);
117
+ // Reject unknown topics early.
118
+ const invalidTopics = requestedTopics.filter((topic) => !availableTopicSet.has(topic));
119
+ if (invalidTopics.length > 0) {
120
+ return json({
121
+ error: "Requested topics are not available",
122
+ invalidTopics,
123
+ }, 400);
124
+ }
125
+ let authorizedTopics = requestedTopics;
126
+ // Run optional auth hook so callers can deny or scope topic access.
127
+ if (authorize) {
128
+ let authorizationResult;
129
+ try {
130
+ authorizationResult = await authorize({
131
+ event,
132
+ locals: locals,
133
+ request,
134
+ channelId: configuredChannelId,
135
+ topics: requestedTopics,
136
+ params: payload.params,
137
+ });
138
+ }
139
+ catch (error) {
140
+ return json({ error: formatError(error) }, 403);
141
+ }
142
+ if (authorizationResult === false) {
143
+ return json({ error: "Forbidden" }, 403);
144
+ }
145
+ // If auth returns allowed topics, intersect with requested topics.
146
+ if (typeof authorizationResult === "object") {
147
+ authorizedTopics = filterAllowedTopics(requestedTopics, authorizationResult.allowedTopics);
148
+ }
149
+ }
150
+ if (authorizedTopics.length === 0) {
151
+ return json({ error: "Forbidden" }, 403);
152
+ }
153
+ // Start SSE stream and bridge Inngest realtime messages to the client.
154
+ return produce(({ emit, lock }) => {
155
+ let closed = false;
156
+ let heartbeat = null;
157
+ let reader = null;
158
+ // Shared shutdown path for client disconnects / errors / completion.
159
+ const stop = () => {
160
+ if (closed)
161
+ return;
162
+ closed = true;
163
+ if (heartbeat)
164
+ clearInterval(heartbeat);
165
+ reader?.cancel().catch(() => { });
166
+ lock.set(false);
167
+ };
168
+ // Emit structured health status as JSON under SSE event name `health`.
169
+ const emitHealth = (payload) => {
170
+ const { error } = emit("health", JSON.stringify(payload));
171
+ if (error) {
172
+ stop();
173
+ return false;
174
+ }
175
+ return true;
176
+ };
177
+ // Convenience helper for degraded health transitions.
178
+ const emitDegraded = (detail) => {
179
+ emitHealth({
180
+ ok: false,
181
+ status: "degraded",
182
+ ts: Date.now(),
183
+ detail,
184
+ });
185
+ };
186
+ void (async () => {
187
+ // Tell client the stream lifecycle has started.
188
+ if (!emitHealth({ ok: true, status: "connecting", ts: Date.now() }))
189
+ return;
190
+ try {
191
+ // Create token and open a realtime stream for authorized topics.
192
+ const token = await getSubscriptionToken(inngest, {
193
+ channel: configuredChannelId,
194
+ topics: authorizedTopics,
195
+ });
196
+ const stream = await subscribe({ ...token, app: inngest });
197
+ reader = stream.getReader();
198
+ // Signal healthy active connection.
199
+ if (!emitHealth({ ok: true, status: "connected", ts: Date.now() }))
200
+ return;
201
+ // Optional periodic health ticks while stream is active.
202
+ if (healthCheckEnabled && healthCheckIntervalMs > 0) {
203
+ heartbeat = setInterval(() => {
204
+ emitHealth({ ok: true, status: "connected", ts: Date.now() });
205
+ }, healthCheckIntervalMs);
206
+ }
207
+ // Forward each realtime chunk as SSE `message` without reshaping.
208
+ // Only JSON serialization is applied so clients receive full
209
+ // Inngest envelope fields (for example: runId, createdAt, kind, envId).
210
+ while (!closed) {
211
+ const { value, done } = await reader.read();
212
+ if (done || closed)
213
+ break;
214
+ if (value == null)
215
+ continue;
216
+ console.log("Received value:", value);
217
+ const { error } = emit("message", JSON.stringify(value));
218
+ if (error)
219
+ break;
220
+ }
221
+ }
222
+ catch (error) {
223
+ // Surface failures to the client before closing.
224
+ emitDegraded(formatError(error));
225
+ }
226
+ finally {
227
+ stop();
228
+ }
229
+ })();
230
+ return () => stop();
231
+ });
232
+ };
233
+ }
@@ -0,0 +1,2 @@
1
+ export { createRealtimeEndpoint } from "./createRealtimeEndpoint.js";
2
+ export type { RealtimeAuthorizeContext, RealtimeEndpointOptions, RealtimeHealthCheckOptions, } from "./createRealtimeEndpoint.js";
@@ -0,0 +1 @@
1
+ export { createRealtimeEndpoint } from "./createRealtimeEndpoint.js";
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@novadxhq/sveltekit-inngest",
3
+ "version": "0.0.1",
4
+ "scripts": {
5
+ "dev": "vite dev",
6
+ "build": "vite build && npm run prepack",
7
+ "preview": "vite preview",
8
+ "prepare": "svelte-kit sync || echo ''",
9
+ "prepack": "svelte-kit sync && svelte-package && publint",
10
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
11
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
12
+ "release:verify": "npm run check && npm run build && npm pack --dry-run --cache /tmp/novadx-npm-cache",
13
+ "publish:npm": "npm publish --access public --registry https://registry.npmjs.org/ --cache /tmp/novadx-npm-cache",
14
+ "publish:gpr": "npm publish --registry https://npm.pkg.github.com/ --cache /tmp/novadx-npm-cache",
15
+ "publish:both": "npm run release:verify && npm run publish:npm && npm run publish:gpr"
16
+ },
17
+ "files": [
18
+ "dist/index.js",
19
+ "dist/index.d.ts",
20
+ "dist/client/**/*",
21
+ "dist/server/index.js",
22
+ "dist/server/index.d.ts",
23
+ "dist/server/createRealtimeEndpoint.js",
24
+ "dist/server/createRealtimeEndpoint.d.ts",
25
+ "README.md",
26
+ "LICENSE*"
27
+ ],
28
+ "sideEffects": [
29
+ "**/*.css"
30
+ ],
31
+ "svelte": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "type": "module",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "svelte": "./dist/index.js",
38
+ "default": "./dist/index.js"
39
+ },
40
+ "./server": {
41
+ "types": "./dist/server/index.d.ts",
42
+ "default": "./dist/server/index.js"
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "peerDependencies": {
47
+ "@inngest/realtime": "^0.4.5",
48
+ "@sveltejs/kit": "^2.50.0",
49
+ "inngest": "^3.51.0",
50
+ "svelte": "^5.0.0",
51
+ "sveltekit-sse": "^0.14.3"
52
+ },
53
+ "devDependencies": {
54
+ "@inngest/realtime": "^0.4.5",
55
+ "@lucide/svelte": "^0.561.0",
56
+ "@sveltejs/adapter-auto": "^7.0.0",
57
+ "@sveltejs/kit": "^2.50.1",
58
+ "@sveltejs/package": "^2.5.7",
59
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
60
+ "@tailwindcss/vite": "^4.1.18",
61
+ "clsx": "^2.1.1",
62
+ "mode-watcher": "^1.1.0",
63
+ "inngest": "^3.51.0",
64
+ "publint": "^0.3.17",
65
+ "svelte": "^5.48.2",
66
+ "svelte-check": "^4.3.5",
67
+ "sveltekit-sse": "^0.14.3",
68
+ "svelte-sonner": "^1.0.7",
69
+ "tailwind-merge": "^3.4.0",
70
+ "tailwind-variants": "^3.2.2",
71
+ "tailwindcss": "^4.1.18",
72
+ "tw-animate-css": "^1.4.0",
73
+ "typescript": "^5.9.3",
74
+ "vite": "^7.3.1"
75
+ },
76
+ "keywords": [
77
+ "svelte"
78
+ ],
79
+ "dependencies": {
80
+ "zod": "^4.3.6"
81
+ }
82
+ }