@luckystack/core 0.1.0
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/CHANGELOG.md +14 -0
- package/CLAUDE.md +210 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/apiTypeStubs.d.ts +7 -0
- package/dist/apiTypeStubs.js +1 -0
- package/dist/apiTypeStubs.js.map +1 -0
- package/dist/client.d.ts +236 -0
- package/dist/client.js +1344 -0
- package/dist/client.js.map +1 -0
- package/dist/eslint/index.d.ts +10 -0
- package/dist/eslint/index.js +436 -0
- package/dist/eslint/index.js.map +1 -0
- package/dist/index.d.ts +1128 -0
- package/dist/index.js +2619 -0
- package/dist/index.js.map +1 -0
- package/dist/pageRouteValidation-CxZJVwZh.d.ts +665 -0
- package/docs/app-bootstrap.md +419 -0
- package/docs/config-registry.md +350 -0
- package/docs/csrf-config.md +88 -0
- package/docs/error-tracker-registry.md +174 -0
- package/docs/hooks.md +298 -0
- package/docs/rate-limit-strategy.md +170 -0
- package/docs/redis-adapter.md +111 -0
- package/docs/session-types.md +180 -0
- package/docs/socket-bootstrap.md +366 -0
- package/package.json +91 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Session Types
|
|
2
|
+
|
|
3
|
+
> Deep specs for the foundational session/auth types plus `validateRequest`, `isFalsy`, and `validateInputByType`. Source: `packages/core/src/sessionTypes.ts`, `validateRequest.ts`, `runtimeTypeValidation.ts`. Bijgewerkt: 2026-05-20.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`BaseSessionLayout` is the structural contract every project session type extends. It lives in `@luckystack/core` (not `@luckystack/login`) so `validateRequest` can consume it without creating a circular dep: login depends on core for prisma/redis, so the session shape has to live in the lower layer. `@luckystack/login` re-exports the type for ergonomics — existing `import type { BaseSessionLayout } from '@luckystack/login'` paths keep working.
|
|
8
|
+
|
|
9
|
+
`AuthProps` defines the auth gate that every API/sync handler exports (`login: true/false` + an optional `additional[]` predicate list). `validateRequest({ auth, user })` is the runtime check that turns those declarations into a `ValidationResult`.
|
|
10
|
+
|
|
11
|
+
`validateInputByType` is the runtime input validator used by the api/sync handlers in dev. It lazy-loads `@luckystack/devkit` (the heavy TS compiler resolver) only when running in non-production. In production it short-circuits to success because the prod esbuild bundle marks devkit as external and the validator should not need TS to validate request bodies at request time.
|
|
12
|
+
|
|
13
|
+
## Types
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
export interface SessionLocation {
|
|
17
|
+
pathName: string;
|
|
18
|
+
searchParams?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BaseSessionLayout {
|
|
22
|
+
id: string;
|
|
23
|
+
token: string;
|
|
24
|
+
email?: string | null;
|
|
25
|
+
name?: string | null;
|
|
26
|
+
avatar?: string | null;
|
|
27
|
+
avatarFallback?: string | null;
|
|
28
|
+
admin?: boolean | null;
|
|
29
|
+
language?: string | null;
|
|
30
|
+
location?: SessionLocation;
|
|
31
|
+
roomCodes?: string[];
|
|
32
|
+
csrfToken?: string;
|
|
33
|
+
lastLogin?: Date | string | null;
|
|
34
|
+
previousLogin?: Date | string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuthProps {
|
|
38
|
+
login: boolean;
|
|
39
|
+
additional?: {
|
|
40
|
+
key: keyof BaseSessionLayout;
|
|
41
|
+
value?: unknown;
|
|
42
|
+
type?: 'string' | 'number' | 'boolean';
|
|
43
|
+
nullish?: boolean;
|
|
44
|
+
mustBeFalsy?: boolean;
|
|
45
|
+
}[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ValidationResult {
|
|
49
|
+
status: 'success' | 'error';
|
|
50
|
+
errorCode?: string;
|
|
51
|
+
errorParams?: { key: string; value: string | number | boolean }[];
|
|
52
|
+
httpStatus?: number;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Extending `BaseSessionLayout`
|
|
57
|
+
|
|
58
|
+
Projects extend the type in their own `config.ts`:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import type { BaseSessionLayout } from '@luckystack/core';
|
|
62
|
+
|
|
63
|
+
export interface SessionLayout extends BaseSessionLayout {
|
|
64
|
+
organizationId?: string;
|
|
65
|
+
role?: 'member' | 'admin' | 'owner';
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The framework's `validateRequest` only inspects keys it knows from `BaseSessionLayout`; project-specific keys are checked via `additional[]`.
|
|
70
|
+
|
|
71
|
+
## API Reference
|
|
72
|
+
|
|
73
|
+
### `isFalsy(value: unknown): boolean`
|
|
74
|
+
|
|
75
|
+
**Signature:**
|
|
76
|
+
```typescript
|
|
77
|
+
export const isFalsy = (value: unknown): boolean
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Behavior:** Returns `true` for `false`, `0`, `0n`, `''`, `null`, `undefined`, or `NaN`. Everything else is truthy.
|
|
81
|
+
|
|
82
|
+
**Note:** This is a stricter "falsy" than JavaScript's coercion-based check because it ALSO treats `NaN` as falsy. Used inside `validateRequest` for the `mustBeFalsy` predicate.
|
|
83
|
+
|
|
84
|
+
### `validateRequest({ auth, user }): ValidationResult`
|
|
85
|
+
|
|
86
|
+
**Signature:**
|
|
87
|
+
```typescript
|
|
88
|
+
export const validateRequest = ({
|
|
89
|
+
auth,
|
|
90
|
+
user,
|
|
91
|
+
}: {
|
|
92
|
+
auth: AuthProps;
|
|
93
|
+
user: BaseSessionLayout;
|
|
94
|
+
}): ValidationResult
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Behavior (in order):**
|
|
98
|
+
1. If `auth.additional` is undefined → returns `{ status: 'success' }` immediately. (The `login` flag is enforced by the surrounding handler, not by this function.)
|
|
99
|
+
2. For each `condition` in `auth.additional`:
|
|
100
|
+
- If `condition.key` is not in `user` → returns `{ status: 'error', errorCode: 'auth.invalidCondition', errorParams: [{ key: 'key', value: condition.key }], httpStatus: 500 }`. This is a developer setup error, not a runtime auth failure.
|
|
101
|
+
- Reads `val = user[condition.key]`. Computes `isNullish = val === null || val === undefined`.
|
|
102
|
+
- `nullish: true` requires `isNullish`; `nullish: false` requires NOT `isNullish`. Mismatch → forbid.
|
|
103
|
+
- `type: 'string' | 'number' | 'boolean'` requires `typeof val === <type>` (skipped when `val` is nullish). Mismatch → forbid.
|
|
104
|
+
- `'value' in condition` requires strict equality `val === condition.value`. The membership check distinguishes "no exact-value constraint" from `value: undefined` (the latter explicitly demands `val === undefined`).
|
|
105
|
+
- `mustBeFalsy: true` requires `isFalsy(val)`; `mustBeFalsy: false` requires `!isFalsy(val)`. Mismatch → forbid.
|
|
106
|
+
3. After all conditions pass → returns `{ status: 'success' }`.
|
|
107
|
+
|
|
108
|
+
**Forbid shape:**
|
|
109
|
+
```typescript
|
|
110
|
+
{ status: 'error', errorCode: 'auth.forbidden', errorParams: [{ key: 'key', value: <key> }], httpStatus: 403 }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Constraint AND-semantics:** Multiple fields on a single `additional` entry are AND'd (every specified field must pass). Entries across the array are also AND'd.
|
|
114
|
+
|
|
115
|
+
**Example — admin-only endpoint:**
|
|
116
|
+
```typescript
|
|
117
|
+
export const auth: AuthProps = {
|
|
118
|
+
login: true,
|
|
119
|
+
additional: [{ key: 'admin', value: true, type: 'boolean' }],
|
|
120
|
+
};
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Example — verified email required:**
|
|
124
|
+
```typescript
|
|
125
|
+
export const auth: AuthProps = {
|
|
126
|
+
login: true,
|
|
127
|
+
additional: [{ key: 'email', nullish: false, type: 'string' }],
|
|
128
|
+
};
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### `validateInputByType({ typeText, value, rootKey, filePath? }): Promise<ValidationResult>`
|
|
132
|
+
|
|
133
|
+
**Signature:**
|
|
134
|
+
```typescript
|
|
135
|
+
type ValidationResult =
|
|
136
|
+
| { status: 'success' }
|
|
137
|
+
| { status: 'error'; message: string };
|
|
138
|
+
|
|
139
|
+
export const validateInputByType = async ({
|
|
140
|
+
typeText,
|
|
141
|
+
value,
|
|
142
|
+
rootKey,
|
|
143
|
+
filePath,
|
|
144
|
+
}: {
|
|
145
|
+
typeText?: string;
|
|
146
|
+
value: unknown;
|
|
147
|
+
rootKey: string;
|
|
148
|
+
filePath?: string;
|
|
149
|
+
}): Promise<ValidationResult>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Behavior:**
|
|
153
|
+
1. When `typeText` is empty, whitespace, or literal `'any'` → returns `{ status: 'success' }`.
|
|
154
|
+
2. When `process.env.NODE_ENV === 'production'` → returns `{ status: 'success' }` (avoids loading the TS compiler in prod).
|
|
155
|
+
3. Otherwise lazy-imports `@luckystack/devkit` via an indirect module ID (`const devkitModuleId: string = '@luckystack/devkit'; await import(devkitModuleId)`) so `tsc` doesn't try to resolve devkit at build time (devkit depends on core — a literal import would be a build-time circular dep).
|
|
156
|
+
4. Calls `devkit.resolveRuntimeTypeText({ typeText, filePath })`. On `status: 'error'` returns `{ status: 'error', message: '<rootKey>: <message>' }`.
|
|
157
|
+
5. Otherwise calls the internal `validateType(resolvedType.typeText, value, rootKey)` recursive validator.
|
|
158
|
+
|
|
159
|
+
**Supported type forms (validateType):**
|
|
160
|
+
- Primitives: `string`, `number`, `boolean`, `true`, `false`, `null`, `undefined`, `Date` (accepts ISO string or `Date` instance).
|
|
161
|
+
- String/number literal types (single-quoted or double-quoted).
|
|
162
|
+
- Unions (`A | B`) — succeeds on first matching branch.
|
|
163
|
+
- Intersections (`A & B`) — requires all branches to succeed.
|
|
164
|
+
- Arrays (`T[]`) — checks every element.
|
|
165
|
+
- `Record<K, V>` — accepts any non-array object.
|
|
166
|
+
- Object literals `{ key?: T; key2: T2; [index: keyof IndexKey]: T }` — checks each named field, applies index signatures for unknown keys.
|
|
167
|
+
- `any` / `unknown` — always succeeds.
|
|
168
|
+
- Unresolved type aliases or generics return `{ status: 'error', message: '<path>: unresolved type <name>' }`.
|
|
169
|
+
|
|
170
|
+
**Edge cases:**
|
|
171
|
+
- The `__RUNTIME_UNRESOLVED__::<message>` sentinel from devkit surfaces as `{ status: 'error', message: '<path>: <message>' }` so a missing project type doesn't crash the request.
|
|
172
|
+
- Object validation rejects unknown keys when there is no matching index signature.
|
|
173
|
+
- Index-signature keys can be `string`, `number`, string-literal unions, or literal types; `number` keys require the key to match `/^-?\d+(\.\d+)?$/`.
|
|
174
|
+
|
|
175
|
+
## Related
|
|
176
|
+
|
|
177
|
+
- Function INDEX: `packages/core/CLAUDE.md`
|
|
178
|
+
- Architecture: `docs/ARCHITECTURE_SESSION.md`, `docs/ARCHITECTURE_AUTH.md`, `docs/ARCHITECTURE_API.md`
|
|
179
|
+
- README: `packages/core/README.md`
|
|
180
|
+
- Source: `packages/core/src/sessionTypes.ts`, `validateRequest.ts`, `runtimeTypeValidation.ts`
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# Socket Bootstrap
|
|
2
|
+
|
|
3
|
+
> Deep specs for socket transport bootstrap, token extraction, CORS check, offline queue, CSRF, cookies, service-route helpers and event constants. Source: `packages/core/src/`. Bijgewerkt: 2026-05-20.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This topic covers the wiring around the LuckyStack socket transport:
|
|
8
|
+
|
|
9
|
+
- **Server-side socket slot** — `setIoInstance` / `getIoInstance` so framework code can broadcast.
|
|
10
|
+
- **Client-side socket singleton** — live binding `socket`, `setSocket`, `waitForSocket`, `incrementResponseIndex`.
|
|
11
|
+
- **Token extraction** — pull the session token from a Socket.io handshake (`extractTokenFromSocket`) or a Node `IncomingMessage` (`extractTokenFromRequest`).
|
|
12
|
+
- **Origin check** — `allowedOrigin` for CORS-style allow-listing, with `corsRejected` hook fan-out.
|
|
13
|
+
- **Offline queue** — `enqueueApiRequest`, `enqueueSyncRequest`, flushers, and per-item `dropPolicy` overrides.
|
|
14
|
+
- **CSRF + httpFetch** — lazy `/auth/csrf` token cache + drop-in fetch wrapper.
|
|
15
|
+
- **Wire-protocol constants** — `socketEventNames` map + per-prefix event-name builders.
|
|
16
|
+
- **Route parsers** — `parseServiceRouteName`, `parseTransportRouteName`.
|
|
17
|
+
- **Cookie helpers** — `getCookieValue`, `hasCookie`.
|
|
18
|
+
- **HTTP helpers** — `inferHttpMethod`, `getEffectiveHttpMethod`, `isMethodAllowed`.
|
|
19
|
+
|
|
20
|
+
Token extraction respects `projectConfig.session.basedToken`: when `true` the framework prefers `socket.handshake.auth.token` / `Authorization: Bearer ...`, otherwise the configured session cookie wins (with the other transport as fallback).
|
|
21
|
+
|
|
22
|
+
## API Reference — IO Instance
|
|
23
|
+
|
|
24
|
+
### `setIoInstance(io: SocketIOServer | null): void`
|
|
25
|
+
|
|
26
|
+
**Signature:**
|
|
27
|
+
```typescript
|
|
28
|
+
export const setIoInstance = (io: SocketIOServer | null): void
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Behavior:** Stores the running Socket.io server in a module-level slot. Framework code (`@luckystack/sync`, presence package, etc.) reads via `getIoInstance()` for broadcast paths.
|
|
32
|
+
|
|
33
|
+
### `getIoInstance(): SocketIOServer | null`
|
|
34
|
+
|
|
35
|
+
Returns the registered instance or `null` when no server has been set. Callers must null-check.
|
|
36
|
+
|
|
37
|
+
## API Reference — Client Socket Singleton
|
|
38
|
+
|
|
39
|
+
### `socket: Socket | null`
|
|
40
|
+
|
|
41
|
+
A live `export let` binding — importers always see the current value after `setSocket(...)` replaces it.
|
|
42
|
+
|
|
43
|
+
### `setSocket(next: Socket | null): void`
|
|
44
|
+
|
|
45
|
+
Stores the project's socket.io-client instance. Called from `src/_sockets/socketInitializer.ts`.
|
|
46
|
+
|
|
47
|
+
### `incrementResponseIndex(): number`
|
|
48
|
+
|
|
49
|
+
**Returns:** The next monotonic response index (used to build per-request reply event names like `apiResponse-42`).
|
|
50
|
+
|
|
51
|
+
**Behavior:**
|
|
52
|
+
- Module-scoped counter, increments before returning so the first call returns `1`.
|
|
53
|
+
- Used by `apiRequest` / `syncRequest` clients to disambiguate concurrent requests on the same socket.
|
|
54
|
+
|
|
55
|
+
### `waitForSocket(): Promise<Socket | null>`
|
|
56
|
+
|
|
57
|
+
**Behavior:**
|
|
58
|
+
- Polls every 10 ms until `socket !== null` or 500 iterations elapse (~5 seconds).
|
|
59
|
+
- Returns the socket on success, `null` on timeout.
|
|
60
|
+
|
|
61
|
+
**Example:**
|
|
62
|
+
```typescript
|
|
63
|
+
import { waitForSocket } from '@luckystack/core/client';
|
|
64
|
+
|
|
65
|
+
const s = await waitForSocket();
|
|
66
|
+
if (!s) throw new Error('socket never connected');
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## API Reference — Custom Socket Middlewares
|
|
70
|
+
|
|
71
|
+
The framework lets consumers wedge their own Socket.io `io.use(...)` middlewares into the bootstrap without forking `loadSocket.ts`. Registered middlewares run in registration order, before any `connect` handler — same contract as a direct `io.use(...)` call.
|
|
72
|
+
|
|
73
|
+
### `registerSocketMiddleware(mw: SocketMiddleware): void`
|
|
74
|
+
|
|
75
|
+
**Signature:**
|
|
76
|
+
```typescript
|
|
77
|
+
export type SocketMiddleware = (socket: Socket, next: (err?: Error) => void) => void;
|
|
78
|
+
export const registerSocketMiddleware = (mw: SocketMiddleware): void
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Behavior:** Appends the middleware to a module-level list. `@luckystack/server`'s `loadSocket` calls `applySocketMiddlewares(io)` after constructing the `SocketIOServer` and before attaching the `connect` handler, so every registered middleware runs on the handshake of every incoming socket.
|
|
82
|
+
|
|
83
|
+
**Example — license-key gate:**
|
|
84
|
+
```typescript
|
|
85
|
+
import { registerSocketMiddleware } from '@luckystack/core';
|
|
86
|
+
|
|
87
|
+
registerSocketMiddleware((socket, next) => {
|
|
88
|
+
const licenseKey = socket.handshake.auth.licenseKey;
|
|
89
|
+
if (typeof licenseKey !== 'string' || !isValidLicense(licenseKey)) {
|
|
90
|
+
return next(new Error('invalid license'));
|
|
91
|
+
}
|
|
92
|
+
next();
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Example — observability tag (Datadog APM):**
|
|
97
|
+
```typescript
|
|
98
|
+
import { registerSocketMiddleware } from '@luckystack/core';
|
|
99
|
+
import tracer from 'dd-trace';
|
|
100
|
+
|
|
101
|
+
registerSocketMiddleware((socket, next) => {
|
|
102
|
+
const span = tracer.scope().active();
|
|
103
|
+
if (span) span.setTag('socket.id', socket.id);
|
|
104
|
+
next();
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `getSocketMiddlewares(): readonly SocketMiddleware[]`
|
|
109
|
+
|
|
110
|
+
Read the registered list (frozen view). Useful for diagnostics.
|
|
111
|
+
|
|
112
|
+
### `clearSocketMiddlewares(): void`
|
|
113
|
+
|
|
114
|
+
Drops every registered middleware. Test/hot-reload helper.
|
|
115
|
+
|
|
116
|
+
### `applySocketMiddlewares(io: SocketIOServer): void`
|
|
117
|
+
|
|
118
|
+
Framework-internal — wires every registered middleware via `io.use(...)`. Called by `@luckystack/server`'s `loadSocket`; consumers should not call this directly.
|
|
119
|
+
|
|
120
|
+
## API Reference — Token Extraction
|
|
121
|
+
|
|
122
|
+
### `extractTokenFromSocket(socket: Socket): string | null`
|
|
123
|
+
|
|
124
|
+
**Signature:**
|
|
125
|
+
```typescript
|
|
126
|
+
export const extractTokenFromSocket = (socket: Socket): string | null
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Behavior:**
|
|
130
|
+
- Reads `socket.handshake.auth.token` (when it is a string) and the configured cookie (`projectConfig.http.sessionCookieName`) from `socket.handshake.headers.cookie`.
|
|
131
|
+
- When `session.basedToken === true`: returns the auth-token first, falls back to the cookie.
|
|
132
|
+
- When `session.basedToken === false`: returns the cookie first, falls back to the auth-token.
|
|
133
|
+
|
|
134
|
+
**Returns:** The token string or `null`.
|
|
135
|
+
|
|
136
|
+
### `extractTokenFromRequest(req: IncomingMessage): string | null`
|
|
137
|
+
|
|
138
|
+
**Signature:**
|
|
139
|
+
```typescript
|
|
140
|
+
export const extractTokenFromRequest = (req: IncomingMessage): string | null
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Behavior:**
|
|
144
|
+
- Reads `req.headers.authorization` and extracts the bearer (`'Bearer <token>'`).
|
|
145
|
+
- Reads the configured cookie from `req.headers.cookie`.
|
|
146
|
+
- Picks cookie-first vs bearer-first based on `session.basedToken`.
|
|
147
|
+
|
|
148
|
+
## API Reference — Origin Check
|
|
149
|
+
|
|
150
|
+
### `allowedOrigin(origin: string): boolean`
|
|
151
|
+
|
|
152
|
+
**Signature:**
|
|
153
|
+
```typescript
|
|
154
|
+
const allowedOrigin = (origin: string): boolean
|
|
155
|
+
export default allowedOrigin
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Behavior (in execution order):**
|
|
159
|
+
1. Normalizes the origin to `scheme://host[:port]`, strips default `:80` / `:443`, lowercases.
|
|
160
|
+
2. Computes the same-origin bind location from `getBindAddress()` and the `SECURE` env var.
|
|
161
|
+
3. When `cors.allowLocalhost` and the origin matches `/^https?:\/\/localhost(:\d+)?$/i` → returns `true`.
|
|
162
|
+
4. When `cors.allowedOrigins` is a function → calls it; if it returns true (or the origin equals the bind location) → returns `true`.
|
|
163
|
+
5. When `cors.allowedOrigins` is a string array → builds a normalized allowed set (bind location + configured list); returns `true` on match.
|
|
164
|
+
6. Otherwise: dispatches the `corsRejected` hook (with origin + normalized origin + allowedOrigins + allowLocalhost) and returns `false`. Also logs via `getLogger().warn` when `logging.devLogs` is on.
|
|
165
|
+
|
|
166
|
+
**Edge cases:**
|
|
167
|
+
- An empty `origin` is treated as not-allowed.
|
|
168
|
+
- The resolver-function form is sync only — async predicates are not supported because Socket.io's CORS check is sync.
|
|
169
|
+
|
|
170
|
+
## API Reference — Offline Queue
|
|
171
|
+
|
|
172
|
+
Per-queue items:
|
|
173
|
+
```typescript
|
|
174
|
+
interface QueueItem {
|
|
175
|
+
id: string;
|
|
176
|
+
key: string;
|
|
177
|
+
run: (socketInstance: Socket) => void;
|
|
178
|
+
createdAt: number;
|
|
179
|
+
dropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `isOnline(): boolean`
|
|
184
|
+
Returns `true` in non-browser environments. In browsers, reads `navigator.onLine`.
|
|
185
|
+
|
|
186
|
+
### `enqueueApiRequest(item): boolean` / `enqueueSyncRequest(item): boolean`
|
|
187
|
+
|
|
188
|
+
**Behavior:**
|
|
189
|
+
1. Evicts items older than `offlineQueue.maxAgeMs` from the relevant queue.
|
|
190
|
+
2. If `queue.length < offlineQueue.maxSize`, pushes the new item and returns `true`.
|
|
191
|
+
3. Otherwise applies the effective drop policy (per-item `dropPolicy` overrides the global default):
|
|
192
|
+
- `'drop-oldest'` — shift + push, returns `true` (logs debug).
|
|
193
|
+
- `'drop-newest'` — keep existing queue, returns `false` (logs debug).
|
|
194
|
+
- `'reject'` — returns `false` (logs warn).
|
|
195
|
+
|
|
196
|
+
### `removeApiQueueItem(id)` / `removeSyncQueueItem(id)`
|
|
197
|
+
Removes a single item by `id`. No-op if not found.
|
|
198
|
+
|
|
199
|
+
### `removeApiQueueItemsByKey(key)`
|
|
200
|
+
Removes every API queue item matching `item.key`. Used to coalesce duplicate requests.
|
|
201
|
+
|
|
202
|
+
### `flushApiQueue(canRun, socketInstance)` / `flushSyncQueue(canRun, socketInstance)`
|
|
203
|
+
|
|
204
|
+
**Behavior:**
|
|
205
|
+
- Re-entrancy guard prevents nested flushes for the same queue.
|
|
206
|
+
- Evicts expired items.
|
|
207
|
+
- Drains FIFO while `canRun()` returns `true`, calling each item's `run(socketInstance)`.
|
|
208
|
+
|
|
209
|
+
### `getApiQueueSize(): number` / `getSyncQueueSize(): number`
|
|
210
|
+
|
|
211
|
+
Read-only sizes (after eviction triggers from enqueue/flush; reads alone do not evict).
|
|
212
|
+
|
|
213
|
+
**Example:**
|
|
214
|
+
```typescript
|
|
215
|
+
import { enqueueApiRequest } from '@luckystack/core/client';
|
|
216
|
+
|
|
217
|
+
enqueueApiRequest({
|
|
218
|
+
id: requestId,
|
|
219
|
+
key: `${routeName}:${userId}`,
|
|
220
|
+
createdAt: Date.now(),
|
|
221
|
+
dropPolicy: 'drop-newest',
|
|
222
|
+
run: (s) => s.emit('apiRequest', message),
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## API Reference — CSRF + httpFetch
|
|
227
|
+
|
|
228
|
+
### `getCsrfToken(): Promise<string | null>`
|
|
229
|
+
|
|
230
|
+
**Behavior:**
|
|
231
|
+
- Returns `null` immediately in token-mode sessions (`session.basedToken === true`) — CSRF is unnecessary because cross-origin requests don't auto-attach sessionStorage.
|
|
232
|
+
- Returns the cached token if previously fetched.
|
|
233
|
+
- Otherwise fetches `/auth/csrf` with `credentials: 'include'`, caches and returns `body.csrfToken`.
|
|
234
|
+
- Inflight de-duplication: concurrent callers share the same promise.
|
|
235
|
+
|
|
236
|
+
### `clearCsrfToken(): void`
|
|
237
|
+
|
|
238
|
+
Drops the cached token + any inflight fetch. Call on logout or when a 403 `auth.csrfMismatch` is observed.
|
|
239
|
+
|
|
240
|
+
### `httpFetch(input, init?): Promise<Response>`
|
|
241
|
+
|
|
242
|
+
Drop-in `fetch` replacement that:
|
|
243
|
+
- Forces `credentials: 'include'` unless overridden.
|
|
244
|
+
- Adds `Content-Type: application/json` when a string body is present and no content-type was set.
|
|
245
|
+
- For state-changing methods (`POST`, `PUT`, `PATCH`, `DELETE`) in cookie mode: lazily fetches the CSRF token, attaches as `x-csrf-token`. On a 403 `auth.csrfMismatch` response, clears the cache and retries once with a refreshed token.
|
|
246
|
+
|
|
247
|
+
**Example:**
|
|
248
|
+
```typescript
|
|
249
|
+
import { httpFetch } from '@luckystack/core/client';
|
|
250
|
+
|
|
251
|
+
const res = await httpFetch('/api/system/myRoute/v1', {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
body: JSON.stringify({ foo: 1 }),
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## API Reference — Wire Protocol Constants
|
|
258
|
+
|
|
259
|
+
### `socketEventNames`
|
|
260
|
+
|
|
261
|
+
A frozen record of event names:
|
|
262
|
+
|
|
263
|
+
| Key | Value |
|
|
264
|
+
|---|---|
|
|
265
|
+
| `apiRequest` | `'apiRequest'` |
|
|
266
|
+
| `sync` | `'sync'` |
|
|
267
|
+
| `joinRoom` / `leaveRoom` / `getJoinedRooms` | (same literal) |
|
|
268
|
+
| `updateLocation` / `updateSession` / `sessionReplaced` / `logout` | (same literal) |
|
|
269
|
+
| `intentionalDisconnect` / `intentionalReconnect` / `userAfk` / `userBack` | (same literal) |
|
|
270
|
+
| `connect` / `disconnect` / `reconnectAttempt` / `connectError` | underlying socket.io events |
|
|
271
|
+
| `apiResponsePrefix` | `'apiResponse-'` |
|
|
272
|
+
| `apiStreamPrefix` | `'apiStream-'` |
|
|
273
|
+
| `syncResponsePrefix` | `'sync-'` |
|
|
274
|
+
| `syncProgressPrefix` | `'sync-progress-'` |
|
|
275
|
+
| `joinRoomResponsePrefix` / `leaveRoomResponsePrefix` / `getJoinedRoomsResponsePrefix` | (same prefix) |
|
|
276
|
+
|
|
277
|
+
### Event name builders
|
|
278
|
+
|
|
279
|
+
| Function | Behavior |
|
|
280
|
+
|---|---|
|
|
281
|
+
| `buildApiResponseEventName(idx)` | `'apiResponse-<idx>'` |
|
|
282
|
+
| `buildApiStreamEventName(idx)` | `'apiStream-<idx>'` |
|
|
283
|
+
| `buildSyncResponseEventName(idx)` | `'sync-<idx>'` |
|
|
284
|
+
| `buildSyncProgressEventName(idx)` | `'sync-progress-<idx>'` |
|
|
285
|
+
| `buildJoinRoomResponseEventName(idx)` | `'joinRoom-<idx>'` |
|
|
286
|
+
| `buildLeaveRoomResponseEventName(idx)` | `'leaveRoom-<idx>'` |
|
|
287
|
+
| `buildGetJoinedRoomsResponseEventName(idx)` | `'getJoinedRooms-<idx>'` |
|
|
288
|
+
|
|
289
|
+
## API Reference — Service Route Parsers
|
|
290
|
+
|
|
291
|
+
### `parseServiceRouteName(value: string): ServiceRouteParseResult`
|
|
292
|
+
|
|
293
|
+
Validates `<service>/<routeName...>` strings.
|
|
294
|
+
|
|
295
|
+
**Returns:**
|
|
296
|
+
- `{ status: 'success', normalizedRouteName, service, routeName }` — when at least two non-empty segments are present.
|
|
297
|
+
- `{ status: 'error', reason }` — for empty input, empty segments, or fewer than two segments.
|
|
298
|
+
|
|
299
|
+
Normalization rules: trim, replace `\` with `/`, strip leading/trailing slashes.
|
|
300
|
+
|
|
301
|
+
### `parseTransportRouteName({ value, prefix })`
|
|
302
|
+
|
|
303
|
+
Validates a full transport route like `'api/system/foo/v1'`.
|
|
304
|
+
|
|
305
|
+
**Parameters:**
|
|
306
|
+
| Name | Type | Required | Description |
|
|
307
|
+
|---|---|---|---|
|
|
308
|
+
| `value` | `string` | yes | Raw route string. |
|
|
309
|
+
| `prefix` | `'api' \| 'sync'` | yes | Determines whether the leading `'api'`/`'sync'` segment is stripped. |
|
|
310
|
+
|
|
311
|
+
**Returns:**
|
|
312
|
+
- `{ status: 'success', normalizedFullName, version, serviceRoute }` — when the trailing segment matches `/^v\d+$/` and the leading service/route part parses cleanly.
|
|
313
|
+
- `{ status: 'error', reason }` — on length / version / segment violations.
|
|
314
|
+
|
|
315
|
+
## API Reference — Cookies
|
|
316
|
+
|
|
317
|
+
### `getCookieValue(cookieHeader: string | undefined, cookieName: string): string | null`
|
|
318
|
+
|
|
319
|
+
Regex-escapes `cookieName`, searches for `(?:^|;\s*)<name>=<value>` in the header, URL-decodes the captured value (falls back to raw on decode failure). Returns `null` when input is missing or no match.
|
|
320
|
+
|
|
321
|
+
### `hasCookie(cookieHeader, cookieName): boolean`
|
|
322
|
+
|
|
323
|
+
Convenience wrapper — true when `getCookieValue` returns a non-null value.
|
|
324
|
+
|
|
325
|
+
## API Reference — HTTP Method Inference
|
|
326
|
+
|
|
327
|
+
### `inferHttpMethod(apiName: string): HttpMethod`
|
|
328
|
+
|
|
329
|
+
Naming-convention heuristic (used as fallback when no `apiMethodMap` is registered):
|
|
330
|
+
- starts with `get` / `fetch` / `list` → `GET`
|
|
331
|
+
- starts with `delete` / `remove` → `DELETE`
|
|
332
|
+
- starts with `update` / `edit` / `patch` → `PUT`
|
|
333
|
+
- otherwise → `POST`
|
|
334
|
+
|
|
335
|
+
Strips a trailing `v\d+` segment before applying the rule, so `api/examples/getUser/v1` resolves to `'GET'`.
|
|
336
|
+
|
|
337
|
+
### `getEffectiveHttpMethod(apiConfig, apiName)`
|
|
338
|
+
|
|
339
|
+
Prefers an explicit `apiConfig.httpMethod`; otherwise calls `inferHttpMethod(apiName)`.
|
|
340
|
+
|
|
341
|
+
### `isMethodAllowed(requestMethod, allowedMethod): boolean`
|
|
342
|
+
|
|
343
|
+
Returns `true` when the request method matches the allowed method or is `'OPTIONS'` (CORS preflight passthrough).
|
|
344
|
+
|
|
345
|
+
## Hooks dispatched
|
|
346
|
+
|
|
347
|
+
| Hook name | Payload type | When |
|
|
348
|
+
|---|---|---|
|
|
349
|
+
| `corsRejected` | `CorsRejectedPayload` | `allowedOrigin` is about to return `false`. |
|
|
350
|
+
|
|
351
|
+
## Config keys consumed
|
|
352
|
+
|
|
353
|
+
| Key | Where |
|
|
354
|
+
|---|---|
|
|
355
|
+
| `session.basedToken` | token extractors, `httpFetch`, `getCsrfToken` |
|
|
356
|
+
| `http.sessionCookieName` | token extractors |
|
|
357
|
+
| `http.cors.allowedOrigins`, `http.cors.allowLocalhost` | `allowedOrigin` |
|
|
358
|
+
| `offlineQueue.maxSize`, `maxAgeMs`, `dropPolicy` | offline queue |
|
|
359
|
+
| `logging.devLogs` | `allowedOrigin` warn-logs |
|
|
360
|
+
|
|
361
|
+
## Related
|
|
362
|
+
|
|
363
|
+
- Function INDEX: `packages/core/CLAUDE.md`
|
|
364
|
+
- Architecture: `docs/ARCHITECTURE_SOCKET.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`, `docs/ARCHITECTURE_AUTH.md`
|
|
365
|
+
- README: `packages/core/README.md`
|
|
366
|
+
- Source: `packages/core/src/socketTypes.ts`, `socketState.ts`, `socketEvents.ts`, `extractToken.ts`, `extractTokenFromRequest.ts`, `checkOrigin.ts`, `offlineQueue.ts`, `csrf.ts`, `cookies.ts`, `httpApiUtils.ts`, `serviceRoute.ts`
|
package/package.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luckystack/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"description": "LuckyStack core: socket-first transport contracts, Redis adapter, project-config registry, hooks registry, response normalization, rate limiting, and shared session types. Foundation for every other @luckystack/* package.",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Mathijs van Melick <mathijsvanmelick3@gmail.com>",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/ItsLucky23/LuckyStack-v2.git",
|
|
15
|
+
"directory": "packages/core"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/ItsLucky23/LuckyStack-v2/tree/master/packages/core#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/ItsLucky23/LuckyStack-v2/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"luckystack",
|
|
23
|
+
"socket.io",
|
|
24
|
+
"realtime",
|
|
25
|
+
"framework",
|
|
26
|
+
"fullstack",
|
|
27
|
+
"prisma",
|
|
28
|
+
"typescript",
|
|
29
|
+
"redis",
|
|
30
|
+
"react",
|
|
31
|
+
"nodejs"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.0.0"
|
|
35
|
+
},
|
|
36
|
+
"main": "./dist/index.js",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"import": "./dist/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./client": {
|
|
44
|
+
"types": "./dist/client.d.ts",
|
|
45
|
+
"import": "./dist/client.js"
|
|
46
|
+
},
|
|
47
|
+
"./eslint": {
|
|
48
|
+
"types": "./dist/eslint/index.d.ts",
|
|
49
|
+
"import": "./dist/eslint/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./typemap": {
|
|
52
|
+
"types": "./dist/apiTypeStubs.d.ts"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"dist",
|
|
57
|
+
"README.md",
|
|
58
|
+
"CLAUDE.md",
|
|
59
|
+
"docs",
|
|
60
|
+
"LICENSE",
|
|
61
|
+
"CHANGELOG.md"
|
|
62
|
+
],
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsup",
|
|
65
|
+
"build:watch": "tsup --watch",
|
|
66
|
+
"test": "vitest run"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@socket.io/redis-adapter": "^8.3.0",
|
|
70
|
+
"dotenv": "^16.6.1"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"@prisma/client": "^6.19.0",
|
|
74
|
+
"eslint": "^9.0.0",
|
|
75
|
+
"ioredis": "^5.10.0",
|
|
76
|
+
"react": "^19.2.0",
|
|
77
|
+
"react-dom": "^19.2.0",
|
|
78
|
+
"react-router-dom": "^7.0.0",
|
|
79
|
+
"socket.io": "^4.8.0",
|
|
80
|
+
"socket.io-client": "^4.8.0",
|
|
81
|
+
"sonner": "^2.0.0",
|
|
82
|
+
"zod": "^4.0.0"
|
|
83
|
+
},
|
|
84
|
+
"peerDependenciesMeta": {
|
|
85
|
+
"eslint": { "optional": true },
|
|
86
|
+
"react": { "optional": true },
|
|
87
|
+
"react-dom": { "optional": true },
|
|
88
|
+
"react-router-dom": { "optional": true },
|
|
89
|
+
"sonner": { "optional": true }
|
|
90
|
+
}
|
|
91
|
+
}
|