@durable-streams/server 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/dist/index.d.ts +494 -0
- package/dist/index.js +1592 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
- package/src/cursor.ts +156 -0
- package/src/file-manager.ts +89 -0
- package/src/file-store.ts +863 -0
- package/src/index.ts +27 -0
- package/src/path-encoding.ts +61 -0
- package/src/registry-hook.ts +118 -0
- package/src/server.ts +946 -0
- package/src/store.ts +433 -0
- package/src/types.ts +183 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory test server for durable-stream e2e testing.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DurableStreamTestServer } from "./server"
|
|
8
|
+
export { StreamStore } from "./store"
|
|
9
|
+
export { FileBackedStreamStore } from "./file-store"
|
|
10
|
+
export { encodeStreamPath, decodeStreamPath } from "./path-encoding"
|
|
11
|
+
export { createRegistryHooks } from "./registry-hook"
|
|
12
|
+
export {
|
|
13
|
+
calculateCursor,
|
|
14
|
+
handleCursorCollision,
|
|
15
|
+
generateResponseCursor,
|
|
16
|
+
DEFAULT_CURSOR_EPOCH,
|
|
17
|
+
DEFAULT_CURSOR_INTERVAL_SECONDS,
|
|
18
|
+
type CursorOptions,
|
|
19
|
+
} from "./cursor"
|
|
20
|
+
export type {
|
|
21
|
+
Stream,
|
|
22
|
+
StreamMessage,
|
|
23
|
+
TestServerOptions,
|
|
24
|
+
PendingLongPoll,
|
|
25
|
+
StreamLifecycleEvent,
|
|
26
|
+
StreamLifecycleHook,
|
|
27
|
+
} from "./types"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path encoding utilities for converting stream paths to filesystem-safe directory names.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto"
|
|
6
|
+
|
|
7
|
+
const MAX_ENCODED_LENGTH = 200
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encode a stream path to a filesystem-safe directory name using base64url encoding.
|
|
11
|
+
* Long paths (>200 chars) are hashed to keep directory names manageable.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* encodeStreamPath("/stream/users:created") → "L3N0cmVhbS91c2VyczpjcmVhdGVk"
|
|
15
|
+
*/
|
|
16
|
+
export function encodeStreamPath(path: string): string {
|
|
17
|
+
// Base64url encoding (RFC 4648 §5)
|
|
18
|
+
// Replace + with - and / with _, remove padding =
|
|
19
|
+
const base64 = Buffer.from(path, `utf-8`)
|
|
20
|
+
.toString(`base64`)
|
|
21
|
+
.replace(/\+/g, `-`)
|
|
22
|
+
.replace(/\//g, `_`)
|
|
23
|
+
.replace(/=/g, ``)
|
|
24
|
+
|
|
25
|
+
// Hash long paths to keep directory names manageable
|
|
26
|
+
if (base64.length > MAX_ENCODED_LENGTH) {
|
|
27
|
+
const hash = createHash(`sha256`).update(path).digest(`hex`).slice(0, 16)
|
|
28
|
+
// Use ~ as separator since it cannot appear in base64url output
|
|
29
|
+
return `${base64.slice(0, 180)}~${hash}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return base64
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decode a filesystem-safe directory name back to the original stream path.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* decodeStreamPath("L3N0cmVhbS91c2VyczpjcmVhdGVk") → "/stream/users:created"
|
|
40
|
+
*/
|
|
41
|
+
export function decodeStreamPath(encoded: string): string {
|
|
42
|
+
// Remove hash suffix if present (hash is always 16 chars after ~ separator)
|
|
43
|
+
// Use ~ as separator since it cannot appear in base64url output
|
|
44
|
+
let base = encoded
|
|
45
|
+
const tildeIndex = encoded.lastIndexOf(`~`)
|
|
46
|
+
if (tildeIndex !== -1) {
|
|
47
|
+
const possibleHash = encoded.slice(tildeIndex + 1)
|
|
48
|
+
// Verify it's a 16-char hex hash before removing it
|
|
49
|
+
if (possibleHash.length === 16 && /^[0-9a-f]+$/.test(possibleHash)) {
|
|
50
|
+
base = encoded.slice(0, tildeIndex)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Restore base64 from base64url
|
|
55
|
+
const normalized = base.replace(/-/g, `+`).replace(/_/g, `/`)
|
|
56
|
+
|
|
57
|
+
// Add padding back
|
|
58
|
+
const padded = normalized + `=`.repeat((4 - (normalized.length % 4)) % 4)
|
|
59
|
+
|
|
60
|
+
return Buffer.from(padded, `base64`).toString(`utf-8`)
|
|
61
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper to create lifecycle hooks that maintain a __registry__ stream.
|
|
3
|
+
* This stream records all create/delete events for observability.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DurableStream } from "@durable-streams/client"
|
|
7
|
+
import { createStateSchema } from "@durable-streams/state"
|
|
8
|
+
import type { StreamLifecycleHook } from "./types"
|
|
9
|
+
import type { StreamStore } from "./store"
|
|
10
|
+
import type { FileBackedStreamStore } from "./file-store"
|
|
11
|
+
|
|
12
|
+
const REGISTRY_PATH = `/v1/stream/__registry__`
|
|
13
|
+
|
|
14
|
+
// Registry schema for the server
|
|
15
|
+
interface StreamMetadata {
|
|
16
|
+
path: string
|
|
17
|
+
contentType: string
|
|
18
|
+
createdAt: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const streamMetadataSchema = {
|
|
22
|
+
"~standard": {
|
|
23
|
+
version: 1 as const,
|
|
24
|
+
vendor: `durable-streams`,
|
|
25
|
+
validate: (value: unknown) => {
|
|
26
|
+
if (typeof value !== `object` || value === null) {
|
|
27
|
+
return { issues: [{ message: `value must be an object` }] }
|
|
28
|
+
}
|
|
29
|
+
const data = value as any
|
|
30
|
+
if (typeof data.path !== `string` || data.path.length === 0) {
|
|
31
|
+
return { issues: [{ message: `path must be a non-empty string` }] }
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
typeof data.contentType !== `string` ||
|
|
35
|
+
data.contentType.length === 0
|
|
36
|
+
) {
|
|
37
|
+
return {
|
|
38
|
+
issues: [{ message: `contentType must be a non-empty string` }],
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (typeof data.createdAt !== `number`) {
|
|
42
|
+
return { issues: [{ message: `createdAt must be a number` }] }
|
|
43
|
+
}
|
|
44
|
+
return { value: data as StreamMetadata }
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const registryStateSchema = createStateSchema({
|
|
50
|
+
streams: {
|
|
51
|
+
schema: streamMetadataSchema,
|
|
52
|
+
type: `stream`,
|
|
53
|
+
primaryKey: `path`,
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Creates lifecycle hooks that write to a __registry__ stream.
|
|
59
|
+
* Any client can read this stream to discover all streams and their lifecycle events.
|
|
60
|
+
*/
|
|
61
|
+
export function createRegistryHooks(
|
|
62
|
+
store: StreamStore | FileBackedStreamStore,
|
|
63
|
+
serverUrl: string
|
|
64
|
+
): {
|
|
65
|
+
onStreamCreated: StreamLifecycleHook
|
|
66
|
+
onStreamDeleted: StreamLifecycleHook
|
|
67
|
+
} {
|
|
68
|
+
const registryStream = new DurableStream({
|
|
69
|
+
url: `${serverUrl}${REGISTRY_PATH}`,
|
|
70
|
+
contentType: `application/json`,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const ensureRegistryExists = async () => {
|
|
74
|
+
if (!store.has(REGISTRY_PATH)) {
|
|
75
|
+
await DurableStream.create({
|
|
76
|
+
url: `${serverUrl}${REGISTRY_PATH}`,
|
|
77
|
+
contentType: `application/json`,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Helper to extract stream name from full path
|
|
83
|
+
const extractStreamName = (fullPath: string): string => {
|
|
84
|
+
// Remove /v1/stream/ prefix if present
|
|
85
|
+
return fullPath.replace(/^\/v1\/stream\//, ``)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
onStreamCreated: async (event) => {
|
|
90
|
+
await ensureRegistryExists()
|
|
91
|
+
|
|
92
|
+
const streamName = extractStreamName(event.path)
|
|
93
|
+
|
|
94
|
+
const changeEvent = registryStateSchema.streams.insert({
|
|
95
|
+
key: streamName,
|
|
96
|
+
value: {
|
|
97
|
+
path: streamName,
|
|
98
|
+
contentType: event.contentType || `application/octet-stream`,
|
|
99
|
+
createdAt: event.timestamp,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await registryStream.append(changeEvent)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
onStreamDeleted: async (event) => {
|
|
107
|
+
await ensureRegistryExists()
|
|
108
|
+
|
|
109
|
+
const streamName = extractStreamName(event.path)
|
|
110
|
+
|
|
111
|
+
const changeEvent = registryStateSchema.streams.delete({
|
|
112
|
+
key: streamName,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await registryStream.append(changeEvent)
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|