@dxos/web-context 0.0.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/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ MIT License
2
+ Copyright (c) 2025 DXOS
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+
6
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @dxos/web-context
2
+
3
+ Framework-agnostic definitions for the Web Component Context Protocol.
4
+
5
+ ## Overview
6
+
7
+ This package provides the core types and event definitions for the [Web Component Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md). It is intended to be used by framework-specific implementations (providers and consumers) to ensure interoperability.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @dxos/web-context
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Context Definitions
18
+
19
+ ```typescript
20
+ import { createContext } from '@dxos/web-context';
21
+
22
+ export const ThemeContext = createContext<{ color: string }>('theme');
23
+ ```
24
+
25
+ ### Requesting Context
26
+
27
+ ```typescript
28
+ import { ContextRequestEvent } from '@dxos/web-context';
29
+
30
+ const event = new ContextRequestEvent(ThemeContext, (value, unsubscribe) => {
31
+ console.log('Context value:', value);
32
+ // Optional: unsubscribe()
33
+ }, { subscribe: true });
34
+
35
+ element.dispatchEvent(event);
36
+ ```
37
+
38
+ ### Providing Context
39
+
40
+ ```typescript
41
+ import { ContextProviderEvent } from '@dxos/web-context';
42
+
43
+ element.addEventListener('context-request', (event) => {
44
+ if (event.context === ThemeContext) {
45
+ event.stopPropagation();
46
+ event.callback({ color: 'blue' });
47
+ }
48
+ });
49
+ ```
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@dxos/web-context",
3
+ "version": "0.0.0",
4
+ "description": "Web Component Context Protocol definitions",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "license": "MIT",
8
+ "author": "DXOS.org",
9
+ "sideEffects": false,
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "source": "./src/index.ts",
14
+ "types": "./dist/types/src/index.d.ts",
15
+ "browser": "./dist/lib/browser/index.mjs",
16
+ "node": "./dist/lib/node-esm/index.mjs"
17
+ }
18
+ },
19
+ "types": "dist/types/src/index.d.ts",
20
+ "typesVersions": {
21
+ "*": {}
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "devDependencies": {},
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './protocol';
@@ -0,0 +1,312 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, expect, test, vi } from 'vitest';
6
+
7
+ import {
8
+ CONTEXT_REQUEST_EVENT,
9
+ type ContextCallback,
10
+ ContextRequestEvent,
11
+ type ContextType,
12
+ type UnknownContext,
13
+ createContext,
14
+ } from './protocol';
15
+
16
+ describe('protocol', () => {
17
+ describe('createContext', () => {
18
+ test('creates a context with string key', () => {
19
+ const ctx = createContext<number>('my-context');
20
+ expect(ctx).toBe('my-context');
21
+ });
22
+
23
+ test('creates a context with symbol key', () => {
24
+ const key = Symbol('my-context');
25
+ const ctx = createContext<string>(key);
26
+ expect(ctx).toBe(key);
27
+ });
28
+
29
+ test('creates a context with object key', () => {
30
+ const key = { name: 'my-context' };
31
+ const ctx = createContext<boolean>(key);
32
+ expect(ctx).toBe(key);
33
+ });
34
+
35
+ test('contexts with same string key are strictly equal', () => {
36
+ const ctx1 = createContext<number>('shared-key');
37
+ const ctx2 = createContext<number>('shared-key');
38
+ expect(ctx1 === ctx2).toBe(true);
39
+ });
40
+
41
+ test('contexts with unique symbols are not equal', () => {
42
+ const ctx1 = createContext<number>(Symbol('unique'));
43
+ const ctx2 = createContext<number>(Symbol('unique'));
44
+ expect(ctx1 === ctx2).toBe(false);
45
+ });
46
+
47
+ test('contexts with Symbol.for are equal', () => {
48
+ const ctx1 = createContext<number>(Symbol.for('shared'));
49
+ const ctx2 = createContext<number>(Symbol.for('shared'));
50
+ expect(ctx1 === ctx2).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe('ContextRequestEvent', () => {
55
+ test('creates event with correct type', () => {
56
+ const ctx = createContext<string>('test');
57
+ const target = document.createElement('div');
58
+ const callback = vi.fn();
59
+ const event = new ContextRequestEvent(ctx, callback, { target });
60
+
61
+ expect(event.type).toBe('context-request');
62
+ });
63
+
64
+ test('event bubbles', () => {
65
+ const ctx = createContext<string>('test');
66
+ const target = document.createElement('div');
67
+ const callback = vi.fn();
68
+ const event = new ContextRequestEvent(ctx, callback, { target });
69
+
70
+ expect(event.bubbles).toBe(true);
71
+ });
72
+
73
+ test('event is composed (crosses shadow DOM boundaries)', () => {
74
+ const ctx = createContext<string>('test');
75
+ const target = document.createElement('div');
76
+ const callback = vi.fn();
77
+ const event = new ContextRequestEvent(ctx, callback, { target });
78
+
79
+ expect(event.composed).toBe(true);
80
+ });
81
+
82
+ test('carries context key', () => {
83
+ const ctx = createContext<string>('my-key');
84
+ const target = document.createElement('div');
85
+ const callback = vi.fn();
86
+ const event = new ContextRequestEvent(ctx, callback, { target });
87
+
88
+ expect(event.context).toBe(ctx);
89
+ });
90
+
91
+ test('carries contextTarget', () => {
92
+ const ctx = createContext<string>('test');
93
+ const target = document.createElement('div');
94
+ const callback = vi.fn();
95
+ const event = new ContextRequestEvent(ctx, callback, { target });
96
+
97
+ expect(event.contextTarget).toBe(target);
98
+ });
99
+
100
+ test('carries callback', () => {
101
+ const ctx = createContext<string>('test');
102
+ const target = document.createElement('div');
103
+ const callback = vi.fn();
104
+ const event = new ContextRequestEvent(ctx, callback, { target });
105
+
106
+ expect(event.callback).toBe(callback);
107
+ });
108
+
109
+ test('subscribe defaults to undefined', () => {
110
+ const ctx = createContext<string>('test');
111
+ const target = document.createElement('div');
112
+ const callback = vi.fn();
113
+ const event = new ContextRequestEvent(ctx, callback, { target });
114
+
115
+ expect(event.subscribe).toBeUndefined();
116
+ });
117
+
118
+ test('subscribe can be set to true', () => {
119
+ const ctx = createContext<string>('test');
120
+ const target = document.createElement('div');
121
+ const callback = vi.fn();
122
+ const event = new ContextRequestEvent(ctx, callback, { subscribe: true, target });
123
+
124
+ expect(event.subscribe).toBe(true);
125
+ });
126
+
127
+ test('subscribe can be set to false', () => {
128
+ const ctx = createContext<string>('test');
129
+ const target = document.createElement('div');
130
+ const callback = vi.fn();
131
+ const event = new ContextRequestEvent(ctx, callback, { subscribe: false, target });
132
+
133
+ expect(event.subscribe).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe('ContextRequestEvent integration', () => {
138
+ test('event bubbles through DOM', () => {
139
+ const ctx = createContext<string>('test');
140
+ const callback = vi.fn();
141
+
142
+ const parent = document.createElement('div');
143
+ const child = document.createElement('div');
144
+ parent.appendChild(child);
145
+ document.body.appendChild(parent);
146
+
147
+ const handler = vi.fn((e: Event) => {
148
+ const event = e as ContextRequestEvent<typeof ctx>;
149
+ if (event.context === ctx) {
150
+ event.stopImmediatePropagation();
151
+ event.callback('provided-value');
152
+ }
153
+ });
154
+
155
+ parent.addEventListener(CONTEXT_REQUEST_EVENT, handler);
156
+
157
+ const event = new ContextRequestEvent(ctx, callback, { target: child });
158
+ child.dispatchEvent(event);
159
+
160
+ expect(handler).toHaveBeenCalled();
161
+ expect(callback).toHaveBeenCalledWith('provided-value');
162
+
163
+ // Cleanup
164
+ parent.removeEventListener(CONTEXT_REQUEST_EVENT, handler);
165
+ document.body.removeChild(parent);
166
+ });
167
+
168
+ test('stopImmediatePropagation prevents other handlers', () => {
169
+ const ctx = createContext<string>('test');
170
+ const callback = vi.fn();
171
+
172
+ const grandparent = document.createElement('div');
173
+ const parent = document.createElement('div');
174
+ const child = document.createElement('div');
175
+ grandparent.appendChild(parent);
176
+ parent.appendChild(child);
177
+ document.body.appendChild(grandparent);
178
+
179
+ const parentHandler = vi.fn((e: Event) => {
180
+ const event = e as ContextRequestEvent<typeof ctx>;
181
+ if (event.context === ctx) {
182
+ event.stopImmediatePropagation();
183
+ event.callback('parent-value');
184
+ }
185
+ });
186
+
187
+ const grandparentHandler = vi.fn((e: Event) => {
188
+ const event = e as ContextRequestEvent<typeof ctx>;
189
+ if (event.context === ctx) {
190
+ event.callback('grandparent-value');
191
+ }
192
+ });
193
+
194
+ parent.addEventListener(CONTEXT_REQUEST_EVENT, parentHandler);
195
+ grandparent.addEventListener(CONTEXT_REQUEST_EVENT, grandparentHandler);
196
+
197
+ const event = new ContextRequestEvent(ctx, callback, { target: child });
198
+ child.dispatchEvent(event);
199
+
200
+ expect(parentHandler).toHaveBeenCalled();
201
+ expect(grandparentHandler).not.toHaveBeenCalled();
202
+ expect(callback).toHaveBeenCalledWith('parent-value');
203
+ expect(callback).toHaveBeenCalledTimes(1);
204
+
205
+ // Cleanup
206
+ parent.removeEventListener(CONTEXT_REQUEST_EVENT, parentHandler);
207
+ grandparent.removeEventListener(CONTEXT_REQUEST_EVENT, grandparentHandler);
208
+ document.body.removeChild(grandparent);
209
+ });
210
+
211
+ test('provider can invoke callback multiple times for subscriptions', () => {
212
+ const ctx = createContext<number>('counter');
213
+ const callback = vi.fn();
214
+ const unsubscribe = vi.fn();
215
+
216
+ const parent = document.createElement('div');
217
+ const child = document.createElement('div');
218
+ parent.appendChild(child);
219
+ document.body.appendChild(parent);
220
+
221
+ let storedCallback: ContextCallback<number> | null = null;
222
+
223
+ const handler = vi.fn((e: Event) => {
224
+ const event = e as ContextRequestEvent<typeof ctx>;
225
+ if (event.context === ctx) {
226
+ event.stopImmediatePropagation();
227
+ // Provide initial value
228
+ event.callback(0, unsubscribe);
229
+ // Store callback for future updates if subscribing
230
+ if (event.subscribe) {
231
+ storedCallback = event.callback;
232
+ }
233
+ }
234
+ });
235
+
236
+ parent.addEventListener(CONTEXT_REQUEST_EVENT, handler);
237
+
238
+ const event = new ContextRequestEvent(ctx, callback, { subscribe: true, target: child });
239
+ child.dispatchEvent(event);
240
+
241
+ expect(callback).toHaveBeenCalledWith(0, unsubscribe);
242
+
243
+ // Simulate value update
244
+ storedCallback!(1, unsubscribe);
245
+ storedCallback!(2, unsubscribe);
246
+
247
+ expect(callback).toHaveBeenCalledTimes(3);
248
+ expect(callback).toHaveBeenLastCalledWith(2, unsubscribe);
249
+
250
+ // Cleanup
251
+ parent.removeEventListener(CONTEXT_REQUEST_EVENT, handler);
252
+ document.body.removeChild(parent);
253
+ });
254
+
255
+ test('context matching uses strict equality', () => {
256
+ const ctx1 = createContext<string>('test');
257
+ const ctx2 = createContext<string>('test'); // Same key, should match
258
+ const ctx3 = createContext<string>('other');
259
+
260
+ const callback = vi.fn();
261
+ const parent = document.createElement('div');
262
+ const child = document.createElement('div');
263
+ parent.appendChild(child);
264
+ document.body.appendChild(parent);
265
+
266
+ const handler = vi.fn((e: Event) => {
267
+ const event = e as ContextRequestEvent<UnknownContext>;
268
+ // Only respond to ctx1 (or ctx2 since they're equal)
269
+ if (event.context === ctx1) {
270
+ event.stopImmediatePropagation();
271
+ event.callback('matched');
272
+ }
273
+ });
274
+
275
+ parent.addEventListener(CONTEXT_REQUEST_EVENT, handler);
276
+
277
+ // Request with ctx2 (equal to ctx1)
278
+ child.dispatchEvent(new ContextRequestEvent(ctx2, callback, { target: child }));
279
+ expect(callback).toHaveBeenCalledWith('matched');
280
+
281
+ callback.mockClear();
282
+
283
+ // Request with ctx3 (different)
284
+ child.dispatchEvent(new ContextRequestEvent(ctx3, callback, { target: child }));
285
+ expect(callback).not.toHaveBeenCalled();
286
+
287
+ // Cleanup
288
+ parent.removeEventListener(CONTEXT_REQUEST_EVENT, handler);
289
+ document.body.removeChild(parent);
290
+ });
291
+ });
292
+
293
+ describe('Type utilities', () => {
294
+ test('ContextType extracts value type', () => {
295
+ const ctx = createContext<{ name: string }>('user');
296
+
297
+ // This is a compile-time check - if it compiles, the types work
298
+ type ExtractedType = ContextType<typeof ctx>;
299
+ const value: ExtractedType = { name: 'test' };
300
+ expect(value.name).toBe('test');
301
+ });
302
+
303
+ test('Context type carries both key and value type info', () => {
304
+ const stringCtx = createContext<number>('key');
305
+ const symbolCtx = createContext<string>(Symbol('key'));
306
+
307
+ // These are compile-time checks
308
+ expect(typeof stringCtx).toBe('string');
309
+ expect(typeof symbolCtx).toBe('symbol');
310
+ });
311
+ });
312
+ });
@@ -0,0 +1,115 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Web Component Context Protocol Implementation
7
+ *
8
+ * Follows the specification at:
9
+ * https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
10
+ *
11
+ * Also implements extensions from @lit/context for better interop:
12
+ * - contextTarget property on ContextRequestEvent
13
+ * - ContextProviderEvent for late provider registration
14
+ */
15
+
16
+ /**
17
+ * A context key.
18
+ *
19
+ * A context key can be any type of object, including strings and symbols. The
20
+ * Context type brands the key type with the `__context__` property that
21
+ * carries the type of the value the context references.
22
+ */
23
+ export type Context<KeyType, ValueType> = KeyType & { __context__: ValueType };
24
+
25
+ /**
26
+ * An unknown context type
27
+ */
28
+ export type UnknownContext = Context<unknown, unknown>;
29
+
30
+ /**
31
+ * A helper type which can extract a Context value type from a Context type
32
+ */
33
+ export type ContextType<T extends UnknownContext> = T extends Context<infer _, infer V> ? V : never;
34
+
35
+ /**
36
+ * A function which creates a Context value object
37
+ */
38
+ export const createContext = <ValueType>(key: unknown) => key as Context<typeof key, ValueType>;
39
+
40
+ /**
41
+ * A callback which is provided by a context requester and is called with the
42
+ * value satisfying the request. This callback can be called multiple times by
43
+ * context providers as the requested value is changed.
44
+ */
45
+ export type ContextCallback<ValueType> = (value: ValueType, unsubscribe?: () => void) => void;
46
+
47
+ /**
48
+ * An event fired by a context requester to signal it desires a named context.
49
+ *
50
+ * A provider should inspect the `context` property of the event to determine
51
+ * if it has a value that can satisfy the request, calling the `callback` with
52
+ * the requested value if so.
53
+ *
54
+ * If the requested context event contains a truthy `subscribe` value, then a
55
+ * provider can call the callback multiple times if the value is changed, if
56
+ * this is the case the provider should pass an `unsubscribe` function to the
57
+ * callback which requesters can invoke to indicate they no longer wish to
58
+ * receive these updates.
59
+ */
60
+ export class ContextRequestEvent<T extends UnknownContext> extends Event {
61
+ /**
62
+ * @param context - The context key being requested
63
+ * @param callback - The callback to invoke with the context value
64
+ * @param options - Options for the request:
65
+ * - `subscribe`: Whether to subscribe to future updates.
66
+ * - `target`: The element that originally requested the context.
67
+ * This is preserved when events are re-dispatched for re-parenting.
68
+ */
69
+ public readonly subscribe?: boolean;
70
+ public readonly contextTarget?: Element;
71
+
72
+ public constructor(
73
+ public readonly context: T,
74
+ public readonly callback: ContextCallback<ContextType<T>>,
75
+ options?: { subscribe?: boolean; target?: Element },
76
+ ) {
77
+ super(CONTEXT_REQUEST_EVENT, { bubbles: true, composed: true });
78
+ this.subscribe = options?.subscribe;
79
+ this.contextTarget = options?.target;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The event name for context requests
85
+ */
86
+ export const CONTEXT_REQUEST_EVENT = 'context-request' as const;
87
+
88
+ /**
89
+ * An event fired by a context provider to signal it is available.
90
+ *
91
+ * This allows ContextRoot implementations to replay pending context requests
92
+ * when providers are registered after consumers, and allows parent providers
93
+ * to re-parent their subscriptions when a closer provider appears.
94
+ */
95
+ export class ContextProviderEvent<T extends UnknownContext> extends Event {
96
+ /**
97
+ * @param context - The context key this provider can provide
98
+ * @param contextTarget - The element hosting this provider
99
+ */
100
+ public constructor(
101
+ public readonly context: T,
102
+ public readonly contextTarget: Element,
103
+ ) {
104
+ super('context-provider', { bubbles: true, composed: true });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The event name for context provider announcements
110
+ */
111
+ export const CONTEXT_PROVIDER_EVENT = 'context-provider' as const;
112
+
113
+ // Note: We don't declare the global HTMLElementEventMap augmentation here
114
+ // to avoid conflicts with @lit/context which also declares it.
115
+ // Both follow the same protocol, so they're compatible.