@shenora/react 0.11.0 → 0.13.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/bridge.d.ts +28 -47
- package/dist/bridge.js +37 -70
- package/dist/clipboard.d.ts +5 -10
- package/dist/clipboard.js +11 -18
- package/dist/devInterceptor.d.ts +8 -12
- package/dist/devInterceptor.js +10 -15
- package/dist/errors.d.ts +4 -5
- package/dist/errors.js +4 -5
- package/dist/eventBus.d.ts +13 -28
- package/dist/eventBus.js +19 -40
- package/dist/fileDialogs.d.ts +8 -11
- package/dist/fileDialogs.js +9 -13
- package/dist/hooks.d.ts +15 -24
- package/dist/hooks.js +19 -31
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -14
- package/dist/internal.d.ts +2 -8
- package/dist/internal.js +2 -8
- package/dist/media.d.ts +14 -22
- package/dist/media.js +14 -22
- package/dist/mediaPlayer.d.ts +39 -22
- package/dist/mediaPlayer.js +54 -44
- package/dist/moduleService.d.ts +11 -22
- package/dist/moduleService.js +11 -22
- package/dist/requests.d.ts +34 -65
- package/dist/requests.js +21 -54
- package/dist/segmentBinder.d.ts +13 -26
- package/dist/segmentBinder.js +65 -42
- package/dist/segmentStream.d.ts +43 -54
- package/dist/segmentStream.js +102 -70
- package/dist/store.d.ts +15 -26
- package/dist/store.js +63 -59
- package/dist/transport.d.ts +9 -18
- package/dist/transport.js +9 -18
- package/dist/types.d.ts +23 -57
- package/dist/types.js +19 -40
- package/dist/useDropZone.d.ts +13 -25
- package/dist/useDropZone.js +24 -41
- package/dist/windowCommands.d.ts +14 -18
- package/dist/windowCommands.js +16 -23
- package/package.json +1 -1
package/dist/requests.d.ts
CHANGED
|
@@ -3,11 +3,8 @@ import type { ShenoraEventBus } from './eventBus.js';
|
|
|
3
3
|
import { type ShenoraStore } from './store.js';
|
|
4
4
|
import type { IpcError } from './types.js';
|
|
5
5
|
/**
|
|
6
|
-
* Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2)
|
|
7
|
-
*
|
|
8
|
-
* needed on either side. Pinned against the host by
|
|
9
|
-
* `WireMirrorTests.Every_request_state_exists_on_both_sides` — a status added on one side and
|
|
10
|
-
* not the other fails that test by name, not by a green suite that never looked.
|
|
6
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2), pinned against the host by
|
|
7
|
+
* `WireMirrorTests.Every_request_state_exists_on_both_sides`.
|
|
11
8
|
*/
|
|
12
9
|
export declare const IpcRequestStates: {
|
|
13
10
|
readonly Running: "running";
|
|
@@ -18,26 +15,21 @@ export declare const IpcRequestStates: {
|
|
|
18
15
|
/** One of {@link IpcRequestStates}. */
|
|
19
16
|
export type IpcRequestState = (typeof IpcRequestStates)[keyof typeof IpcRequestStates];
|
|
20
17
|
/**
|
|
21
|
-
* Mirrors `Shenora.Core.Ipc.IpcRequestEvents
|
|
22
|
-
* `WireMirrorTests.Request_event_names_match_the_host
|
|
23
|
-
* these were bare string literals with nothing comparing them to the host's own constants, so a host
|
|
24
|
-
* rename left the suite green and the client permanently deaf to the renamed event.
|
|
18
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestEvents`, pinned against the host by
|
|
19
|
+
* `WireMirrorTests.Request_event_names_match_the_host`.
|
|
25
20
|
*/
|
|
26
21
|
export declare const IpcRequestEventTypes: {
|
|
27
22
|
readonly Updated: "REQUEST_UPDATED";
|
|
28
23
|
/**
|
|
29
24
|
* One or more request ids left the host with no corresponding `Updated` snapshot — history eviction
|
|
30
25
|
* and `CLEAR_FINISHED`. Payload is `{ requestIds: string[] }`; {@link createRequestsStore} folds it
|
|
31
|
-
* by deleting those ids.
|
|
32
|
-
* removed.
|
|
26
|
+
* by deleting those ids.
|
|
33
27
|
*/
|
|
34
28
|
readonly Removed: "REQUEST_REMOVED";
|
|
35
29
|
};
|
|
36
30
|
/**
|
|
37
|
-
* Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on
|
|
38
|
-
* `
|
|
39
|
-
* `WireMirrorTests.Request_route_names_match_the_hosts_module`, same rationale as
|
|
40
|
-
* {@link IpcRequestEventTypes}.
|
|
31
|
+
* Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on, pinned by
|
|
32
|
+
* `WireMirrorTests.Request_route_names_match_the_hosts_module`.
|
|
41
33
|
*/
|
|
42
34
|
export declare const IpcRequestRoutes: {
|
|
43
35
|
readonly List: "LIST";
|
|
@@ -51,7 +43,7 @@ export declare const IpcRequestRoutes: {
|
|
|
51
43
|
export declare const IpcRequestsModuleName = "SHENORA.REQUESTS";
|
|
52
44
|
/**
|
|
53
45
|
* Mirrors `Shenora.Core.Ipc.IpcLabel` — human-facing text the HOST never renders itself: an
|
|
54
|
-
* untranslated fallback plus an app i18n key and interpolation parameters
|
|
46
|
+
* untranslated fallback plus an app i18n key and interpolation parameters.
|
|
55
47
|
*/
|
|
56
48
|
export interface IpcLabel {
|
|
57
49
|
text?: string;
|
|
@@ -60,13 +52,11 @@ export interface IpcLabel {
|
|
|
60
52
|
}
|
|
61
53
|
/**
|
|
62
54
|
* Mirrors `Shenora.Core.Ipc.IpcProgress` — how far a tracked operation has gotten, in the APP's own
|
|
63
|
-
* unit
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* a ratio only when `total` is set, e.g. `total ? (value / total) * 100 : undefined` — that division
|
|
69
|
-
* is the consumer's own policy (see the README example).
|
|
55
|
+
* unit rather than a kit-assumed percent. `unit` is app-defined (`'bytes'`, `'files'`, `'percent'`)
|
|
56
|
+
* and the kit never interprets it.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ `total` undefined means there is NO known total — an absolute count with nothing to divide by,
|
|
59
|
+
* never zero. Render a ratio only when it is set; the README has the example.
|
|
70
60
|
*/
|
|
71
61
|
export interface IpcProgress {
|
|
72
62
|
value: number;
|
|
@@ -91,19 +81,11 @@ export interface IpcRequestStatus {
|
|
|
91
81
|
finishedAt?: string;
|
|
92
82
|
}
|
|
93
83
|
/**
|
|
94
|
-
* State behind {@link useShenoraRequests}.
|
|
95
|
-
*
|
|
96
|
-
* | Band | Getter |
|
|
97
|
-
* |---|---|
|
|
98
|
-
* | In flight | {@link running} |
|
|
99
|
-
* | Finished | {@link finished} — prunable via `clearFinished` |
|
|
100
|
-
*
|
|
101
|
-
* Every getter is DERIVED from `byId` on each read — never a second copy a fold has to remember to
|
|
102
|
-
* keep in sync. `byId` is the only thing any reducer here writes.
|
|
84
|
+
* State behind {@link useShenoraRequests}. `byId` is the only thing a reducer writes; the two bands
|
|
85
|
+
* are derived from it on every read.
|
|
103
86
|
*
|
|
104
87
|
* ⚠ Most requests never appear at all: one that finishes inside the host's grace period is never
|
|
105
|
-
* announced, so this
|
|
106
|
-
* every call the page made.
|
|
88
|
+
* announced, so this lists work that is actually TAKING A WHILE, not every call the page made.
|
|
107
89
|
*/
|
|
108
90
|
export interface RequestsState {
|
|
109
91
|
byId: Record<string, IpcRequestStatus>;
|
|
@@ -118,11 +100,9 @@ export interface RequestsActions {
|
|
|
118
100
|
cancel: (requestId: string) => string;
|
|
119
101
|
/**
|
|
120
102
|
* `CLEAR_FINISHED { scope? }` — drop retained finished history, forwarding this store's own
|
|
121
|
-
* configured scope
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* {@link IpcRequestEventTypes.Removed}. It used to carry an optimistic local prune of every TERMINAL
|
|
125
|
-
* entry, added because removals had no wire event at all; that guess is retired now that one exists.
|
|
103
|
+
* configured scope so a scoped store's "clear completed" cannot wipe another scope's history
|
|
104
|
+
* host-side. Nothing is mutated locally: the host's {@link IpcRequestEventTypes.Removed} is the only
|
|
105
|
+
* thing that removes a row.
|
|
126
106
|
*/
|
|
127
107
|
clearFinished: () => string;
|
|
128
108
|
}
|
|
@@ -130,19 +110,17 @@ export interface RequestsActions {
|
|
|
130
110
|
export interface RequestsStoreOptions {
|
|
131
111
|
/**
|
|
132
112
|
* The request/event module this store talks to. Must match the host's
|
|
133
|
-
* `IpcRequestTrackerOptions.ModuleName` — default `'SHENORA.REQUESTS'` on both sides —
|
|
134
|
-
* renamed it to avoid
|
|
135
|
-
* `IpcRequestsModule`'s own docs describe). A store bound to the default name cannot reach a
|
|
136
|
-
* renamed host at all, which is exactly the gap this field closes.
|
|
113
|
+
* `IpcRequestTrackerOptions.ModuleName` — default `'SHENORA.REQUESTS'` on both sides — for an app
|
|
114
|
+
* that renamed it to avoid colliding with one of its own modules.
|
|
137
115
|
*/
|
|
138
116
|
module?: string;
|
|
139
117
|
/**
|
|
140
|
-
* Optional app-defined scope, applied to THREE places
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* into only the first two
|
|
145
|
-
* out-of-scope rows, since no delta for them ever arrives
|
|
118
|
+
* Optional app-defined scope, applied to THREE places: the bus subscription, the actions' request
|
|
119
|
+
* envelope, and the initial `LIST` snapshot's PAYLOAD (`IpcRequestsModule` reads its scope filter
|
|
120
|
+
* from the payload, not the envelope).
|
|
121
|
+
*
|
|
122
|
+
* ⚠ All three or none. Threading it into only the first two loads every scope on first subscribe and
|
|
123
|
+
* then never sheds the out-of-scope rows, since no delta for them ever arrives.
|
|
146
124
|
*/
|
|
147
125
|
scope?: string;
|
|
148
126
|
/** Test/multi-transport seams. Default: the shared bridge and event bus. */
|
|
@@ -150,27 +128,18 @@ export interface RequestsStoreOptions {
|
|
|
150
128
|
bus?: ShenoraEventBus;
|
|
151
129
|
}
|
|
152
130
|
/**
|
|
153
|
-
* Build a store instance over the requests module — the factory {@link useShenoraRequests}
|
|
154
|
-
*
|
|
155
|
-
* `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
|
|
156
|
-
* (`requests.test.ts`), an app that renamed the host's `IpcRequestTrackerOptions.ModuleName`
|
|
157
|
-
* needs a store bound to that name instead of the unreachable default, and an app running a
|
|
158
|
-
* secondary window or auxiliary session needs its own scope-filtered instance instead of being
|
|
159
|
-
* stuck with the shared, unscoped default.
|
|
131
|
+
* Build a store instance over the requests module — the factory {@link useShenoraRequests} is built
|
|
132
|
+
* from. Use it directly for a renamed host module, a scope-filtered instance, or a fake bridge/bus.
|
|
160
133
|
*/
|
|
161
134
|
export declare function createRequestsStore(options?: RequestsStoreOptions): ShenoraStore<RequestsState, RequestsActions>;
|
|
162
135
|
/**
|
|
163
136
|
* The client side of the operations primitive (design §4.6, `IpcRequestsModule` +
|
|
164
137
|
* `IIpcRequestTracker`): snapshots via `LIST` on first subscribe, then folds `REQUEST_UPDATED` by
|
|
165
|
-
* id — one subscription however many components read it, and a late mounter renders CURRENT state
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* directly: `useShenoraRequests((s) => s.running)`. There is no `waiting` band and no `waitReason` —
|
|
169
|
-
* a request is IN FLIGHT or DONE, the same two states `XMLHttpRequest` has, since D66. Bound to the
|
|
170
|
-
* default module/no scope — use {@link createRequestsStore} directly for a renamed module or a
|
|
171
|
-
* scope-filtered instance.
|
|
138
|
+
* id — one subscription however many components read it, and a late mounter renders CURRENT state.
|
|
139
|
+
* `running`/`finished` are selectors an activity panel reads directly:
|
|
140
|
+
* `useShenoraRequests((s) => s.running)`.
|
|
172
141
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
142
|
+
* Bound to the default module and no scope — use {@link createRequestsStore} for a renamed module or
|
|
143
|
+
* a scope-filtered instance.
|
|
175
144
|
*/
|
|
176
145
|
export declare const useShenoraRequests: ShenoraStore<RequestsState, RequestsActions>;
|
package/dist/requests.js
CHANGED
|
@@ -1,48 +1,35 @@
|
|
|
1
1
|
import { createShenoraStore } from './store.js';
|
|
2
2
|
/**
|
|
3
|
-
* Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2)
|
|
4
|
-
*
|
|
5
|
-
* needed on either side. Pinned against the host by
|
|
6
|
-
* `WireMirrorTests.Every_request_state_exists_on_both_sides` — a status added on one side and
|
|
7
|
-
* not the other fails that test by name, not by a green suite that never looked.
|
|
3
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2), pinned against the host by
|
|
4
|
+
* `WireMirrorTests.Every_request_state_exists_on_both_sides`.
|
|
8
5
|
*/
|
|
9
6
|
export const IpcRequestStates = {
|
|
10
7
|
Running: 'running',
|
|
11
8
|
Completed: 'completed',
|
|
12
9
|
Failed: 'failed',
|
|
13
10
|
Cancelled: 'cancelled',
|
|
14
|
-
// NO 'waiting'. A request is IN FLIGHT or DONE — the XHR model this mirrors has no parked state,
|
|
15
|
-
// and neither does the host since D66. Work that parks awaiting a human is host-initiated work
|
|
16
|
-
// (a queued mission), which reports on its own event stream rather than as a request.
|
|
17
11
|
};
|
|
18
12
|
/**
|
|
19
|
-
* Mirrors `Shenora.Core.Ipc.IpcRequestEvents
|
|
20
|
-
* `WireMirrorTests.Request_event_names_match_the_host
|
|
21
|
-
* these were bare string literals with nothing comparing them to the host's own constants, so a host
|
|
22
|
-
* rename left the suite green and the client permanently deaf to the renamed event.
|
|
13
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestEvents`, pinned against the host by
|
|
14
|
+
* `WireMirrorTests.Request_event_names_match_the_host`.
|
|
23
15
|
*/
|
|
24
16
|
export const IpcRequestEventTypes = {
|
|
25
17
|
Updated: 'REQUEST_UPDATED',
|
|
26
18
|
/**
|
|
27
19
|
* One or more request ids left the host with no corresponding `Updated` snapshot — history eviction
|
|
28
20
|
* and `CLEAR_FINISHED`. Payload is `{ requestIds: string[] }`; {@link createRequestsStore} folds it
|
|
29
|
-
* by deleting those ids.
|
|
30
|
-
* removed.
|
|
21
|
+
* by deleting those ids.
|
|
31
22
|
*/
|
|
32
23
|
Removed: 'REQUEST_REMOVED',
|
|
33
24
|
};
|
|
34
25
|
/**
|
|
35
|
-
* Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on
|
|
36
|
-
* `
|
|
37
|
-
* `WireMirrorTests.Request_route_names_match_the_hosts_module`, same rationale as
|
|
38
|
-
* {@link IpcRequestEventTypes}.
|
|
26
|
+
* Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on, pinned by
|
|
27
|
+
* `WireMirrorTests.Request_route_names_match_the_hosts_module`.
|
|
39
28
|
*/
|
|
40
29
|
export const IpcRequestRoutes = {
|
|
41
30
|
List: 'LIST',
|
|
42
31
|
Cancel: 'CANCEL',
|
|
43
32
|
ClearFinished: 'CLEAR_FINISHED',
|
|
44
|
-
// THREE routes — the same three `XMLHttpRequest` offers. RESUME/WAIT/DISMISS went with the waiting
|
|
45
|
-
// band (D66), and the wire-mirror test pins this object's SIZE so a retired name cannot creep back.
|
|
46
33
|
};
|
|
47
34
|
/**
|
|
48
35
|
* Default `Shenora.Core.Ipc.IpcRequestTrackerOptions.ModuleName` — pinned by
|
|
@@ -61,9 +48,7 @@ function index(list) {
|
|
|
61
48
|
byId[operation.id] = operation;
|
|
62
49
|
return byId;
|
|
63
50
|
}
|
|
64
|
-
/**
|
|
65
|
-
* The one place `running`/`finished` are computed — wrap `byId` here, nowhere else.
|
|
66
|
-
*/
|
|
51
|
+
/** The one place `running`/`finished` are computed — wrap `byId` here, nowhere else. */
|
|
67
52
|
function makeState(byId) {
|
|
68
53
|
return {
|
|
69
54
|
byId,
|
|
@@ -76,23 +61,16 @@ function makeState(byId) {
|
|
|
76
61
|
};
|
|
77
62
|
}
|
|
78
63
|
/**
|
|
79
|
-
* Build a store instance over the requests module — the factory {@link useShenoraRequests}
|
|
80
|
-
*
|
|
81
|
-
* `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
|
|
82
|
-
* (`requests.test.ts`), an app that renamed the host's `IpcRequestTrackerOptions.ModuleName`
|
|
83
|
-
* needs a store bound to that name instead of the unreachable default, and an app running a
|
|
84
|
-
* secondary window or auxiliary session needs its own scope-filtered instance instead of being
|
|
85
|
-
* stuck with the shared, unscoped default.
|
|
64
|
+
* Build a store instance over the requests module — the factory {@link useShenoraRequests} is built
|
|
65
|
+
* from. Use it directly for a renamed host module, a scope-filtered instance, or a fake bridge/bus.
|
|
86
66
|
*/
|
|
87
67
|
export function createRequestsStore(options = {}) {
|
|
88
68
|
const module = options.module ?? IpcRequestsModuleName;
|
|
89
69
|
return createShenoraStore(module, {
|
|
90
70
|
initial: makeState({}),
|
|
91
|
-
// LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// (below, and via createShenoraStore's own `scope` option) — both halves must agree, or a
|
|
95
|
-
// scoped store loads every scope once and then never sheds the out-of-scope rows.
|
|
71
|
+
// LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component that
|
|
72
|
+
// mounts while work is already running gets it from here before folding any deltas. The payload
|
|
73
|
+
// carries `scope` so the initial load is filtered the same way the deltas are.
|
|
96
74
|
snapshot: {
|
|
97
75
|
type: IpcRequestRoutes.List,
|
|
98
76
|
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
@@ -102,9 +80,8 @@ export function createRequestsStore(options = {}) {
|
|
|
102
80
|
// ONE event type for every transition (design §4.3) — last-write-wins by id, so folding needs
|
|
103
81
|
// no ordering logic and no cross-type races.
|
|
104
82
|
[IpcRequestEventTypes.Updated]: (state, payload) => makeState({ ...state.byId, [payload.id]: payload }),
|
|
105
|
-
// The ONE removal delta
|
|
106
|
-
//
|
|
107
|
-
// exactly the ids the host named, regardless of status; an id this store never had is a no-op.
|
|
83
|
+
// The ONE removal delta: deletes exactly the ids the host named, regardless of status. An id
|
|
84
|
+
// this store never had is a no-op.
|
|
108
85
|
[IpcRequestEventTypes.Removed]: (state, payload) => {
|
|
109
86
|
const byId = { ...state.byId };
|
|
110
87
|
for (const id of payload.requestIds)
|
|
@@ -114,13 +91,7 @@ export function createRequestsStore(options = {}) {
|
|
|
114
91
|
},
|
|
115
92
|
actions: ({ post }) => ({
|
|
116
93
|
cancel: (requestId) => post(IpcRequestRoutes.Cancel, { payload: { requestId } }),
|
|
117
|
-
clearFinished: () =>
|
|
118
|
-
// Forward THIS store's own configured scope (Finding 1, generic-library audit) — the same
|
|
119
|
-
// key the LIST snapshot payload already carries above. No local mutation here any more
|
|
120
|
-
// (Finding 4): the host's REQUEST_REMOVED is the ONLY thing that removes a row now, which
|
|
121
|
-
// is also what makes the scope threading safe to add — nothing here can diverge from what
|
|
122
|
-
// the host actually cleared.
|
|
123
|
-
post(IpcRequestRoutes.ClearFinished, {
|
|
94
|
+
clearFinished: () => post(IpcRequestRoutes.ClearFinished, {
|
|
124
95
|
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
125
96
|
}),
|
|
126
97
|
}),
|
|
@@ -132,15 +103,11 @@ export function createRequestsStore(options = {}) {
|
|
|
132
103
|
/**
|
|
133
104
|
* The client side of the operations primitive (design §4.6, `IpcRequestsModule` +
|
|
134
105
|
* `IIpcRequestTracker`): snapshots via `LIST` on first subscribe, then folds `REQUEST_UPDATED` by
|
|
135
|
-
* id — one subscription however many components read it, and a late mounter renders CURRENT state
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* directly: `useShenoraRequests((s) => s.running)`. There is no `waiting` band and no `waitReason` —
|
|
139
|
-
* a request is IN FLIGHT or DONE, the same two states `XMLHttpRequest` has, since D66. Bound to the
|
|
140
|
-
* default module/no scope — use {@link createRequestsStore} directly for a renamed module or a
|
|
141
|
-
* scope-filtered instance.
|
|
106
|
+
* id — one subscription however many components read it, and a late mounter renders CURRENT state.
|
|
107
|
+
* `running`/`finished` are selectors an activity panel reads directly:
|
|
108
|
+
* `useShenoraRequests((s) => s.running)`.
|
|
142
109
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
110
|
+
* Bound to the default module and no scope — use {@link createRequestsStore} for a renamed module or
|
|
111
|
+
* a scope-filtered instance.
|
|
145
112
|
*/
|
|
146
113
|
export const useShenoraRequests = createRequestsStore();
|
package/dist/segmentBinder.d.ts
CHANGED
|
@@ -3,24 +3,20 @@ import { type MediaSourceGlobals } from './segmentStream.js';
|
|
|
3
3
|
* The imperative half of the segment route (D71 piece 4b): open a `SourceBuffer`, feed it, and stop when
|
|
4
4
|
* the platform says stop.
|
|
5
5
|
*
|
|
6
|
-
* 🔴 **
|
|
7
|
-
*
|
|
6
|
+
* 🔴 **Three implementations disagree in ways the spec permits, so none of this is guessable** — the
|
|
7
|
+
* measurements are in `docs/design/media.md`:
|
|
8
8
|
*
|
|
9
|
-
* - **Attachment is not portable.** iOS takes `srcObject`
|
|
10
|
-
* `MediaSourceHandle`
|
|
11
|
-
*
|
|
12
|
-
* is a property of the MediaSource, not of the OS.
|
|
9
|
+
* - **Attachment is not portable.** iOS takes `srcObject` (a `ManagedMediaSource` is a valid
|
|
10
|
+
* `MediaSourceHandle`); Chromium refuses it and wants an object URL. Feature-detected, not branched on
|
|
11
|
+
* the shell: which one works is a property of the MediaSource, not of the OS.
|
|
13
12
|
* - **The codecs are read from the init segment, never assumed.** The track set is a fact about the
|
|
14
13
|
* DEVICE, not the source: the same file yields a two-track init on iOS and a video-only one on Android,
|
|
15
14
|
* which cannot decode its AC-3 soundtrack. A mismatch kills the FIRST append and plays nothing.
|
|
16
|
-
* - **The streaming gate is real on iOS and absent elsewhere.** `endstreaming`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* means "always streaming" — never "never asked".
|
|
15
|
+
* - **The streaming gate is real on iOS and absent elsewhere.** Fetching past `endstreaming` is the
|
|
16
|
+
* misuse `ManagedMediaSource` exists to detect. A plain `MediaSource` has neither the event nor a
|
|
17
|
+
* `streaming` property, and its absence means "always streaming" — never "never asked".
|
|
20
18
|
*
|
|
21
|
-
*
|
|
22
|
-
* and "cannot be verified anywhere this repo runs" was true of the whole file until the seams below
|
|
23
|
-
* existed. A fake source and a fake fetch drive every branch here.
|
|
19
|
+
* The dependencies below are injectable, so a fake source and a fake fetch drive every branch here.
|
|
24
20
|
*/
|
|
25
21
|
export interface SegmentBinderOptions {
|
|
26
22
|
/** The playlist URL. Segment URIs are resolved relative to it. */
|
|
@@ -38,11 +34,7 @@ export interface SegmentBinderOptions {
|
|
|
38
34
|
}>;
|
|
39
35
|
/** Defaults to `URL.createObjectURL`. Only used when `srcObject` is refused. */
|
|
40
36
|
createObjectURL?: (source: object) => string;
|
|
41
|
-
/**
|
|
42
|
-
* Defaults to `URL.revokeObjectURL`. The pair to {@link createObjectURL}, and injectable for the same
|
|
43
|
-
* reason: without it the failure paths that must revoke — a source that closes before opening, a
|
|
44
|
-
* codec the device refuses — cannot be asserted, only hoped for.
|
|
45
|
-
*/
|
|
37
|
+
/** Defaults to `URL.revokeObjectURL`. The pair to {@link createObjectURL}. */
|
|
46
38
|
revokeObjectURL?: (url: string) => void;
|
|
47
39
|
/** Stop fetching once this many seconds are buffered ahead. Defaults to 30. */
|
|
48
40
|
targetAheadSeconds?: number;
|
|
@@ -65,9 +57,8 @@ export interface SegmentBinding {
|
|
|
65
57
|
/**
|
|
66
58
|
* Thrown for every reason a stream cannot start, so a caller has one thing to catch.
|
|
67
59
|
*
|
|
68
|
-
* ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch
|
|
69
|
-
*
|
|
70
|
-
* need to be exhaustive.
|
|
60
|
+
* ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch and a `RangeError` from a
|
|
61
|
+
* truncated init segment propagate as themselves. Catch broadly if you need to be exhaustive.
|
|
71
62
|
*/
|
|
72
63
|
export declare class SegmentBinderError extends Error {
|
|
73
64
|
constructor(message: string);
|
|
@@ -78,10 +69,6 @@ export declare class SegmentBinderError extends Error {
|
|
|
78
69
|
* Resolves once the init segment has been appended — the point after which the element can play — and
|
|
79
70
|
* goes on fetching in the background until every segment is in or {@link SegmentBinding.dispose} is called.
|
|
80
71
|
*
|
|
81
|
-
*
|
|
82
|
-
* returns a handle, exactly as `mediaUrl` takes a path and returns a string — so a hook would add a
|
|
83
|
-
* lifecycle without adding a capability. The case that would earn one is an app wanting load/error
|
|
84
|
-
* state as component state, and the shape of that hook depends on what such an app actually asks for;
|
|
85
|
-
* inventing it first is how a seam nothing consults gets built (D63). Call this from an effect.
|
|
72
|
+
* There is no `useSegmentStream` hook: this needs no React, so call it from an effect.
|
|
86
73
|
*/
|
|
87
74
|
export declare function bindSegmentStream(options: SegmentBinderOptions): Promise<SegmentBinding>;
|
package/dist/segmentBinder.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { codecsFromInitSegment, nextSegment, parseManifest, pickMediaSource, segmentMimeType, } from './segmentStream.js';
|
|
2
2
|
/**
|
|
3
|
-
* How long to wait for the attached MediaSource to reach `open`.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* is wrong", not a budget. It exists because the alternative to a deadline here is an await that never
|
|
7
|
-
* returns: the element can refuse or tear down an attachment without ever raising an event this side
|
|
8
|
-
* can name.
|
|
3
|
+
* How long to wait for the attached MediaSource to reach `open`. Attachment is local and immediate, so
|
|
4
|
+
* this is a deadline for "something is wrong": the element can refuse or tear down an attachment
|
|
5
|
+
* without raising any event this side can name, and the await would never return.
|
|
9
6
|
*/
|
|
10
7
|
const ATTACH_TIMEOUT_MS = 10000;
|
|
11
8
|
/**
|
|
12
9
|
* Thrown for every reason a stream cannot start, so a caller has one thing to catch.
|
|
13
10
|
*
|
|
14
|
-
* ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch
|
|
15
|
-
*
|
|
16
|
-
* need to be exhaustive.
|
|
11
|
+
* ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch and a `RangeError` from a
|
|
12
|
+
* truncated init segment propagate as themselves. Catch broadly if you need to be exhaustive.
|
|
17
13
|
*/
|
|
18
14
|
export class SegmentBinderError extends Error {
|
|
19
15
|
constructor(message) {
|
|
@@ -42,27 +38,17 @@ function bufferedAhead(element) {
|
|
|
42
38
|
* Resolves once the init segment has been appended — the point after which the element can play — and
|
|
43
39
|
* goes on fetching in the background until every segment is in or {@link SegmentBinding.dispose} is called.
|
|
44
40
|
*
|
|
45
|
-
*
|
|
46
|
-
* returns a handle, exactly as `mediaUrl` takes a path and returns a string — so a hook would add a
|
|
47
|
-
* lifecycle without adding a capability. The case that would earn one is an app wanting load/error
|
|
48
|
-
* state as component state, and the shape of that hook depends on what such an app actually asks for;
|
|
49
|
-
* inventing it first is how a seam nothing consults gets built (D63). Call this from an effect.
|
|
41
|
+
* There is no `useSegmentStream` hook: this needs no React, so call it from an effect.
|
|
50
42
|
*/
|
|
51
43
|
export async function bindSegmentStream(options) {
|
|
52
44
|
const { manifest: manifestUrl, element, globals = globalThis, targetAheadSeconds = 30, onDiagnostic, } = options;
|
|
53
45
|
const doFetch = options.fetch ?? ((url) => fetch(url));
|
|
54
46
|
const say = (line) => onDiagnostic?.(line);
|
|
55
47
|
/**
|
|
56
|
-
* A FAILURE, as opposed to a trace line.
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* change — playback simply stalled, with nothing anywhere to explain it. Every other error path in this
|
|
61
|
-
* package already defaults to `console.error` (`bridge.ts`'s `onPostError`, `store.ts`'s `onError`,
|
|
62
|
-
* `eventBus.ts`); this one was the exception.
|
|
63
|
-
*
|
|
64
|
-
* ⚠ Only when the app supplied NO handler. A caller that took `onDiagnostic` owns its reporting and
|
|
65
|
-
* must not be double-logged — the same rule the two sinks above follow.
|
|
48
|
+
* A FAILURE, as opposed to a trace line: reported to `console.error` when the app supplied no
|
|
49
|
+
* `onDiagnostic`, so a segment answering 500 or an `appendBuffer` `QuotaExceededError` cannot stall
|
|
50
|
+
* playback with nothing anywhere to explain it. A caller that took `onDiagnostic` owns its reporting
|
|
51
|
+
* and is not double-logged.
|
|
66
52
|
*/
|
|
67
53
|
const fail = (line) => (onDiagnostic ? onDiagnostic(line) : console.error(`[shenora] ${line}`));
|
|
68
54
|
const kind = pickMediaSource(globals);
|
|
@@ -105,19 +91,18 @@ export async function bindSegmentStream(options) {
|
|
|
105
91
|
element.src = objectUrl;
|
|
106
92
|
}
|
|
107
93
|
// ⚠ The object URL is minted BEFORE anything below can fail, so EVERY failure between the mint and
|
|
108
|
-
// the returned binding
|
|
109
|
-
//
|
|
110
|
-
//
|
|
94
|
+
// the returned binding must revoke it itself — the caller holds no binding to dispose yet, and the
|
|
95
|
+
// document keeps the MediaSource alive for its lifetime. Three places: the open wait,
|
|
96
|
+
// addSourceBuffer, and the init append.
|
|
111
97
|
const revokeAndRethrow = (e) => {
|
|
112
98
|
if (objectUrl)
|
|
113
99
|
revoke(objectUrl);
|
|
114
100
|
throw e;
|
|
115
101
|
};
|
|
116
|
-
// 🔴 `error` IS NOT A MediaSource EVENT
|
|
117
|
-
// `sourceclose
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// URL never revoked. `sourceclose` is the real signal, and the deadline covers whatever is neither.
|
|
102
|
+
// 🔴 `error` IS NOT A MediaSource EVENT — the spec fires `sourceopen`, `sourceended` and
|
|
103
|
+
// `sourceclose`. An attachment that CLOSES rather than opening (the element detached before load, an
|
|
104
|
+
// attachment refused) leaves this await pending FOREVER on any other listener: no error, no
|
|
105
|
+
// diagnostic, no way to reach dispose(). The deadline covers whatever is neither.
|
|
121
106
|
await new Promise((res, rej) => {
|
|
122
107
|
if (source.readyState === 'open')
|
|
123
108
|
return res();
|
|
@@ -135,8 +120,7 @@ export async function bindSegmentStream(options) {
|
|
|
135
120
|
}).catch(revokeAndRethrow);
|
|
136
121
|
let buffer;
|
|
137
122
|
try {
|
|
138
|
-
// Throws synchronously for a codecs string this implementation refuses
|
|
139
|
-
// remarks on codec derivation above.
|
|
123
|
+
// Throws synchronously for a codecs string this implementation refuses.
|
|
140
124
|
buffer = source.addSourceBuffer(mime);
|
|
141
125
|
}
|
|
142
126
|
catch (e) {
|
|
@@ -152,9 +136,8 @@ export async function bindSegmentStream(options) {
|
|
|
152
136
|
* Append one buffer and settle when the source buffer says so.
|
|
153
137
|
*
|
|
154
138
|
* 🔴 **Both listeners come off on EITHER outcome.** `{ once: true }` removes only the listener that
|
|
155
|
-
* FIRES, and the success path fires `updateend` — so the `error` listener
|
|
156
|
-
* appended segment, each retaining a settled `rej` closure
|
|
157
|
-
* accumulates ~1,200 dead listeners on one `SourceBuffer`, none of which `dispose()` could shed, and a
|
|
139
|
+
* FIRES, and the success path fires `updateend` — so with it the `error` listener stays attached once
|
|
140
|
+
* per appended segment, each retaining a settled `rej` closure that `dispose()` cannot shed, and one
|
|
158
141
|
* later real `error` invokes every one of them.
|
|
159
142
|
*/
|
|
160
143
|
const append = (bytes) => new Promise((res, rej) => {
|
|
@@ -182,10 +165,37 @@ export async function bindSegmentStream(options) {
|
|
|
182
165
|
/**
|
|
183
166
|
* Fetch and append whatever {@link nextSegment} asks for, one at a time.
|
|
184
167
|
*
|
|
185
|
-
* ⚠ Re-entrancy is guarded rather than queued:
|
|
186
|
-
*
|
|
187
|
-
*
|
|
168
|
+
* ⚠ Re-entrancy is guarded rather than queued: element events fire far faster than an append
|
|
169
|
+
* completes, and two concurrent `appendBuffer` calls on one SourceBuffer throw `InvalidStateError`,
|
|
170
|
+
* which surfaces as a stall with no obvious cause.
|
|
188
171
|
*/
|
|
172
|
+
/** Consecutive 503s, so a source that is slow to produce backs off instead of hammering. */
|
|
173
|
+
let waits = 0;
|
|
174
|
+
let waitTimer = null;
|
|
175
|
+
/**
|
|
176
|
+
* Come back for a segment the host has not finished producing.
|
|
177
|
+
*
|
|
178
|
+
* ⚠ Bounded, and it says so when it gives up: a wait that retries for ever is the same permanent
|
|
179
|
+
* spinner by a slower route. The cap is generous because the host's own 503 already means it is
|
|
180
|
+
* working — this is not a failure budget, it is a politeness one.
|
|
181
|
+
*/
|
|
182
|
+
const retryLater = (uri) => {
|
|
183
|
+
if (disposed)
|
|
184
|
+
return;
|
|
185
|
+
if (++waits > 40) {
|
|
186
|
+
fail(`segments: ${uri} still answering 503 after ${waits} attempts — giving up`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const delay = Math.min(2000, 100 * waits);
|
|
190
|
+
say(`segments: ${uri} not ready (503) — retrying in ${delay}ms`);
|
|
191
|
+
if (waitTimer !== null)
|
|
192
|
+
clearTimeout(waitTimer);
|
|
193
|
+
waitTimer = setTimeout(() => {
|
|
194
|
+
waitTimer = null;
|
|
195
|
+
if (!disposed)
|
|
196
|
+
void pump();
|
|
197
|
+
}, delay);
|
|
198
|
+
};
|
|
189
199
|
const pump = async () => {
|
|
190
200
|
if (pumping || disposed)
|
|
191
201
|
return;
|
|
@@ -201,12 +211,20 @@ export async function bindSegmentStream(options) {
|
|
|
201
211
|
const response = await doFetch(resolve(manifestUrl, entry.uri));
|
|
202
212
|
if (disposed)
|
|
203
213
|
return;
|
|
214
|
+
if (response.status === 503) {
|
|
215
|
+
// 🔴 503 IS THE HOST SAYING "still producing" — a WAIT, and it must actually be waited FOR.
|
|
216
|
+
// This used to say exactly that in a comment and then `fail()` and break, with nothing
|
|
217
|
+
// scheduled to resume: a permanent spinner, on the one path the segment route uses to mean
|
|
218
|
+
// "ask again". The producing side waits its own budget before answering, so both halves said
|
|
219
|
+
// "wait" and neither came back.
|
|
220
|
+
retryLater(entry.uri);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
204
223
|
if (!response.ok) {
|
|
205
|
-
// 503 is the host saying "still producing", which is a WAIT and not a failure — the route
|
|
206
|
-
// answers it deliberately rather than 404ing a source that is merely not ready.
|
|
207
224
|
fail(`segments: ${entry.uri} answered ${response.status}`);
|
|
208
225
|
break;
|
|
209
226
|
}
|
|
227
|
+
waits = 0; // progress: the next stall starts its backoff from the bottom again
|
|
210
228
|
await append(new Uint8Array(await response.arrayBuffer()));
|
|
211
229
|
appended.add(index);
|
|
212
230
|
if (disposed)
|
|
@@ -244,6 +262,11 @@ export async function bindSegmentStream(options) {
|
|
|
244
262
|
if (disposed)
|
|
245
263
|
return;
|
|
246
264
|
disposed = true;
|
|
265
|
+
// The 503 retry must not outlive the binding — it would re-enter a pump over a torn-down source.
|
|
266
|
+
if (waitTimer !== null) {
|
|
267
|
+
clearTimeout(waitTimer);
|
|
268
|
+
waitTimer = null;
|
|
269
|
+
}
|
|
247
270
|
source.removeEventListener('startstreaming', onStart);
|
|
248
271
|
source.removeEventListener('endstreaming', onEnd);
|
|
249
272
|
element.removeEventListener('timeupdate', wake);
|