@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/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
+ }